feat(/new): add new session command (#2235)
feat(/new): add new session command
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user