From ed23a48e040302a571ebc9b07561e5558cf581a2 Mon Sep 17 00:00:00 2001 From: reidliu41 Date: Wed, 27 May 2026 07:33:27 +0800 Subject: [PATCH] feat: add new session command Add /new to start a fresh saved session from the TUI without overloading /clear. The command creates a distinct session id, resets conversation state, and keeps previous sessions available through /resume. Block unsafe switches when pending input or active work exists, with /new --force available for explicit discard. --- crates/tui/src/commands/core.rs | 40 ++++--- crates/tui/src/commands/mod.rs | 7 ++ crates/tui/src/commands/session.rs | 171 +++++++++++++++++++++++++++++ crates/tui/src/localization.rs | 7 ++ 4 files changed, 208 insertions(+), 17 deletions(-) diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index 0a50f1d8..44394485 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -46,6 +46,28 @@ pub fn help(app: &mut App, topic: Option<&str>) -> CommandResult { /// Clear conversation history pub fn clear(app: &mut App) -> CommandResult { + let todos_cleared = reset_conversation_state(app); + app.current_session_id = None; + let locale = app.ui_locale; + let message = if todos_cleared { + tr(locale, MessageId::ClearConversation).to_string() + } else { + tr(locale, MessageId::ClearConversationBusy).to_string() + }; + CommandResult::with_message_and_action( + message, + AppAction::SyncSession { + session_id: None, + messages: Vec::new(), + system_prompt: None, + model: app.model.clone(), + workspace: app.workspace.clone(), + }, + ) +} + +/// Reset the active conversation without choosing the next session id. +pub(crate) fn reset_conversation_state(app: &mut App) -> bool { app.clear_history(); app.mark_history_updated(); app.api_messages.clear(); @@ -78,23 +100,7 @@ pub fn clear(app: &mut App) -> CommandResult { app.session.last_reasoning_replay_tokens = None; app.session.turn_cache_history.clear(); app.session.last_cache_inspection = None; - app.current_session_id = None; - let locale = app.ui_locale; - let message = if todos_cleared { - tr(locale, MessageId::ClearConversation).to_string() - } else { - tr(locale, MessageId::ClearConversationBusy).to_string() - }; - CommandResult::with_message_and_action( - message, - AppAction::SyncSession { - session_id: None, - messages: Vec::new(), - system_prompt: None, - model: app.model.clone(), - workspace: app.workspace.clone(), - }, - ) + todos_cleared } /// Exit the application diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index f21df395..842625e3 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -298,6 +298,12 @@ pub const COMMANDS: &[CommandInfo] = &[ usage: "/fork", description_id: MessageId::CmdForkDescription, }, + CommandInfo { + name: "new", + aliases: &[], + usage: "/new [--force]", + description_id: MessageId::CmdNewDescription, + }, CommandInfo { name: "sessions", aliases: &["resume"], @@ -585,6 +591,7 @@ pub fn execute(cmd: &str, app: &mut App) -> CommandResult { "rename" | "gaiming" | "chongmingming" => rename::rename(app, arg), "save" => session::save(app, arg), "fork" | "branch" => session::fork(app), + "new" => session::new_session(app, arg), "sessions" | "resume" => session::sessions(app, arg), "relay" | "batonpass" | "接力" => relay(app, arg), "load" | "jiazai" => session::load(app, arg), diff --git a/crates/tui/src/commands/session.rs b/crates/tui/src/commands/session.rs index a54c4403..a54426c1 100644 --- a/crates/tui/src/commands/session.rs +++ b/crates/tui/src/commands/session.rs @@ -135,6 +135,73 @@ pub fn fork(app: &mut App) -> CommandResult { ) } +/// Start a fresh saved session from the current TUI state. +pub fn new_session(app: &mut App, arg: Option<&str>) -> CommandResult { + let force = match arg.map(str::trim).filter(|s| !s.is_empty()) { + None => false, + Some("--force" | "force") => true, + Some(other) => { + return CommandResult::error(format!( + "Usage: /new [--force]\n\nUnknown argument: {other}" + )); + } + }; + + if !force { + let blockers = new_session_blockers(app); + if !blockers.is_empty() { + return CommandResult::error(format!( + "Cannot start a new session while {}. Run `/new --force` to discard pending work and start a fresh session.", + blockers.join(", ") + )); + } + } + + let new_id = uuid::Uuid::new_v4().to_string(); + super::core::reset_conversation_state(app); + app.clear_input(); + app.session_artifacts.clear(); + app.session_context_references.clear(); + app.tool_evidence.clear(); + app.current_session_id = Some(new_id.clone()); + app.session_title = Some("New Session".to_string()); + app.scroll_to_bottom(); + + CommandResult::with_message_and_action( + format!( + "Started new session {} (New Session). Previous sessions remain available via /resume.", + crate::session_manager::truncate_id(&new_id) + ), + AppAction::SyncSession { + session_id: Some(new_id), + messages: Vec::new(), + system_prompt: None, + model: app.model.clone(), + workspace: app.workspace.clone(), + }, + ) +} + +fn new_session_blockers(app: &App) -> Vec<&'static str> { + let mut blockers = Vec::new(); + if !app.input.trim().is_empty() { + blockers.push("the composer has unsent text"); + } + if !app.queued_messages.is_empty() || app.queued_draft.is_some() { + blockers.push("queued messages are pending"); + } + if app.is_loading || app.runtime_turn_status.as_deref() == Some("in_progress") { + blockers.push("a turn is in progress"); + } + if app.is_compacting { + blockers.push("context compaction is running"); + } + if app.task_panel.iter().any(|task| task.status == "running") { + blockers.push("background tasks are running"); + } + blockers +} + /// Load session from file pub fn load(app: &mut App, path: Option<&str>) -> CommandResult { let load_path = if let Some(p) = path { @@ -489,6 +556,110 @@ mod tests { } } + #[test] + fn new_session_from_resumed_state_creates_distinct_empty_session() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.current_session_id = Some("old-session".to_string()); + app.session_title = Some("Old Session".to_string()); + app.api_messages.push(crate::models::Message { + role: "user".to_string(), + content: vec![crate::models::ContentBlock::Text { + text: "continue this thread".to_string(), + cache_control: None, + }], + }); + app.add_message(HistoryCell::System { + content: "old transcript".to_string(), + }); + app.system_prompt = Some(crate::models::SystemPrompt::Text("old prompt".to_string())); + app.session.total_tokens = 123; + app.session.session_cost = 1.25; + + let result = new_session(&mut app, None); + + assert!(!result.is_error, "{:?}", result.message); + let new_id = app.current_session_id.clone().expect("new session id"); + assert_ne!(new_id, "old-session"); + assert_eq!(app.session_title.as_deref(), Some("New Session")); + assert!(app.api_messages.is_empty()); + assert!(app.history.is_empty()); + assert!(app.system_prompt.is_none()); + assert_eq!(app.session.total_tokens, 0); + assert_eq!(app.session.session_cost, 0.0); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("/resume") + ); + match result.action { + Some(AppAction::SyncSession { + session_id, + messages, + system_prompt, + .. + }) => { + assert_eq!(session_id.as_deref(), Some(new_id.as_str())); + assert!(messages.is_empty()); + assert!(system_prompt.is_none()); + } + other => panic!("expected SyncSession action, got {other:?}"), + } + } + + #[test] + fn new_session_blocks_unsent_input_without_force() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.current_session_id = Some("old-session".to_string()); + app.input = "draft text".to_string(); + + let result = new_session(&mut app, None); + + assert!(result.is_error); + assert_eq!(app.current_session_id.as_deref(), Some("old-session")); + assert_eq!(app.input, "draft text"); + assert!(result.action.is_none()); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("/new --force") + ); + } + + #[test] + fn new_session_force_discards_unsent_input() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.current_session_id = Some("old-session".to_string()); + app.input = "draft text".to_string(); + + let result = new_session(&mut app, Some("--force")); + + assert!(!result.is_error, "{:?}", result.message); + assert_ne!(app.current_session_id.as_deref(), Some("old-session")); + assert!(app.input.is_empty()); + assert!(matches!(result.action, Some(AppAction::SyncSession { .. }))); + } + + #[test] + fn new_session_blocks_in_flight_turn_without_force() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.current_session_id = Some("old-session".to_string()); + app.is_loading = true; + + let result = new_session(&mut app, None); + + assert!(result.is_error); + assert_eq!(app.current_session_id.as_deref(), Some("old-session")); + assert!(result.action.is_none()); + } + #[test] fn test_save_with_default_path_uses_managed_sessions_dir() { let tmpdir = TempDir::new().unwrap(); diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 874bb2ec..21809260 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -294,6 +294,7 @@ pub enum MessageId { CmdRlmDescription, CmdSaveDescription, CmdForkDescription, + CmdNewDescription, CmdSessionsDescription, CmdSettingsDescription, CmdSkillDescription, @@ -527,6 +528,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CmdReviewDescription, MessageId::CmdRlmDescription, MessageId::CmdSaveDescription, + MessageId::CmdNewDescription, MessageId::CmdSessionsDescription, MessageId::CmdSettingsDescription, MessageId::CmdSkillDescription, @@ -971,6 +973,7 @@ fn english(id: MessageId) -> &'static str { MessageId::CmdRlmDescription => "Open a persistent RLM context: /rlm [0-3] ", MessageId::CmdSaveDescription => "Save session to file", MessageId::CmdForkDescription => "Fork the active conversation into a sibling session", + MessageId::CmdNewDescription => "Start a fresh saved session", MessageId::CmdSessionsDescription => "Open session history picker", MessageId::CmdSettingsDescription => "Show persistent settings", MessageId::CmdSkillDescription => { @@ -1359,6 +1362,7 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CmdRlmDescription => "永続 RLM コンテキストを開く: /rlm [0-3] ", MessageId::CmdSaveDescription => "セッションをファイルに保存", MessageId::CmdForkDescription => "現在の会話を兄弟セッションに fork", + MessageId::CmdNewDescription => "新しい保存済みセッションを開始", MessageId::CmdSessionsDescription => "セッション履歴ピッカーを開く", MessageId::CmdSettingsDescription => "永続化された設定を表示", MessageId::CmdSkillDescription => { @@ -1702,6 +1706,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CmdRlmDescription => "打开持久 RLM 上下文:/rlm [0-3] ", MessageId::CmdSaveDescription => "将会话保存到文件", MessageId::CmdForkDescription => "将当前对话分叉为兄弟会话", + MessageId::CmdNewDescription => "开始一个新的已保存会话", MessageId::CmdSessionsDescription => "打开会话历史选择器", MessageId::CmdSettingsDescription => "显示持久化设置", MessageId::CmdSkillDescription => "激活技能,或安装/更新/卸载/信任社区技能", @@ -2037,6 +2042,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { } MessageId::CmdSaveDescription => "Salvar a sessão em arquivo", MessageId::CmdForkDescription => "Bifurcar a conversa ativa para uma sessão irmã", + MessageId::CmdNewDescription => "Iniciar uma nova sessão salva", MessageId::CmdSessionsDescription => "Abrir seletor de histórico de sessões", MessageId::CmdSettingsDescription => "Exibir as configurações persistidas", MessageId::CmdSkillDescription => { @@ -2428,6 +2434,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { } MessageId::CmdSaveDescription => "Guardar la sesión en archivo", MessageId::CmdForkDescription => "Bifurcar la conversación activa a una sesión hermana", + MessageId::CmdNewDescription => "Iniciar una nueva sesión guardada", MessageId::CmdSessionsDescription => "Abrir el selector de sesiones", MessageId::CmdSettingsDescription => "Mostrar las configuraciones persistidas", MessageId::CmdSkillDescription => {