Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 23 additions & 17 deletions crates/tui/src/commands/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions crates/tui/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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),
Expand Down
171 changes: 171 additions & 0 deletions crates/tui/src/commands/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The pattern match accepts "force" (without the -- prefix) as a valid flag, but the help text and usage string only document --force. A user who accidentally types /new force expecting an error will silently discard pending work. Accepting only the documented form avoids surprising behaviour.

Suggested change
Some("--force" | "force") => true,
Some("--force") => true,

Fix in Codex Fix in Claude Code Fix in Cursor

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)
),
Comment on lines +166 to +174
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The session title "New Session" and the full success message are hardcoded English strings that bypass the locale system. Every other command-level user-visible string goes through tr(locale, MessageId::...). A Japanese or Simplified-Chinese user who runs /new will see English text in both the status bar and the sessions picker. Adding MessageId::NewSessionTitle and MessageId::NewSessionConfirm (analogous to ClearConversation) and wiring them through localization.rs would make this consistent.

Suggested change
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)
),
app.current_session_id = Some(new_id.clone());
let locale = app.ui_locale;
app.session_title = Some(tr(locale, MessageId::NewSessionTitle).to_string());
app.scroll_to_bottom();
CommandResult::with_message_and_action(
tr(locale, MessageId::NewSessionConfirm)
.replace("{id}", &crate::session_manager::truncate_id(&new_id).to_string()),

Fix in Codex Fix in Claude Code Fix in Cursor

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
}
Comment on lines +150 to +203
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Critical vs Discardable Blockers

Allowing --force to bypass active operations like an in-progress turn or running context compaction is highly dangerous. When these background operations eventually complete, they will write back to the App state (e.g., appending messages or updating compaction results), which will corrupt the newly started session.

We should split the blockers into:

  1. Critical Blockers (active turn, compaction, running background tasks) which cannot be bypassed even with --force.
  2. Discardable Blockers (unsent composer text, queued messages) which can be safely discarded with --force.
    let critical_blockers = new_session_critical_blockers(app);
    if !critical_blockers.is_empty() {
        return CommandResult::error(format!(
            "Cannot start a new session while {}. Please wait for these operations to complete.",
            critical_blockers.join(", ")
        ));
    }

    if !force {
        let discardable_blockers = new_session_discardable_blockers(app);
        if !discardable_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.",
                discardable_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_critical_blockers(app: &App) -> Vec<&'static str> {
    let mut blockers = Vec::new();
    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
}

fn new_session_discardable_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");
    }
    blockers
}


/// Load session from file
pub fn load(app: &mut App, path: Option<&str>) -> CommandResult {
let load_path = if let Some(p) = path {
Expand Down Expand Up @@ -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();
Expand Down
7 changes: 7 additions & 0 deletions crates/tui/src/localization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ pub enum MessageId {
CmdRlmDescription,
CmdSaveDescription,
CmdForkDescription,
CmdNewDescription,
CmdSessionsDescription,
CmdSettingsDescription,
CmdSkillDescription,
Expand Down Expand Up @@ -527,6 +528,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::CmdReviewDescription,
MessageId::CmdRlmDescription,
MessageId::CmdSaveDescription,
MessageId::CmdNewDescription,
MessageId::CmdSessionsDescription,
MessageId::CmdSettingsDescription,
MessageId::CmdSkillDescription,
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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 => "激活技能,或安装/更新/卸载/信任社区技能",
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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 => {
Expand Down
Loading