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.
This commit is contained in:
reidliu41
2026-05-27 07:33:27 +08:00
parent cdec3e8192
commit ed23a48e04
4 changed files with 208 additions and 17 deletions
+23 -17
View File
@@ -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
+7
View File
@@ -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),
+171
View File
@@ -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();
+7
View File
@@ -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] <file_or_text>",
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] <file_or_text>",
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] <file_or_text>",
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 => {