feat(/new): add new session command#2235
Conversation
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.
There was a problem hiding this comment.
Code Review
This pull request introduces a new /new command to start a fresh saved session from the current TUI state, including support for a --force flag to bypass blockers. A critical issue was identified where using --force to bypass active background operations (such as an in-progress turn, context compaction, or running background tasks) could lead to state corruption when those operations eventually complete and write back to the App state. It is recommended to split the blockers into critical blockers (which cannot be bypassed) and discardable blockers (which can be bypassed with --force).
| 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 | ||
| } |
There was a problem hiding this comment.
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:
- Critical Blockers (active turn, compaction, running background tasks) which cannot be bypassed even with
--force. - 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
}| 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) | ||
| ), |
There was a problem hiding this comment.
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.
| 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()), |
| 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, |
There was a problem hiding this comment.
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.
| Some("--force" | "force") => true, | |
| Some("--force") => true, |
|
hi @Hmbown please take a look when you have time. thanks. |
- Merged #2235 (/new session command from @reidliu41, all CI green) - Added Xiaomi MiMo to provider examples and env-var table - Added verification gate paragraph in harness section - Updated @reidliu41 entries with #2235 across all READMEs - CHANGELOG: added /new command entry
Summary
Fixes #2234
Adds
/new [--force]as an explicit way to start a fresh saved session from inside the TUI.Previously,
/clearcould reset conversation state, but that made session lifecycle behavior ambiguous: clearing atranscript is not the same user intent as starting a new saved session after
resume./newnow gives that action its own command.This change:
/new [--force]to the command registry, help, and completions/resume/new --force/clearas a cleanup commandTesting
cargo fmt --all -- --checkcargo clippy --workspace --all-targets --all-featurescargo test --workspace --all-featuresChecklist
Greptile Summary
This PR introduces
/new [--force]as a first-class TUI command for starting a fresh saved session, distinct from/clear(which only resets transcript state). It achieves this by extracting the shared state-reset logic fromclear()into a newreset_conversation_state()helper incore.rs, then buildingnew_session()on top of it with its own blocker guards, a pre-generated UUID session ID, and aSyncSessiondispatch that persists an empty snapshot.core.rs: Refactorsclear()intoclear()+reset_conversation_state(); the observable behaviour of/clearis unchanged.session.rs: Addsnew_session()with five blocker conditions (unsent input, queued messages, in-flight turn, compaction, running tasks),--forceoverride, and four new unit tests; the session title and success message are hardcoded English strings rather than going through the locale system.localization.rs: AddsCmdNewDescriptionto the enum,ALL_MESSAGE_IDS, and all five locale translation functions.Confidence Score: 4/5
Safe to merge; the new command is additive, the /clear refactor is a pure extraction with no behavioural change, and the blocker logic is well-tested.
The refactor of clear() into reset_conversation_state() is low-risk and covered by existing tests. The new_session() logic is straightforward and has four targeted unit tests. The two findings — a hardcoded English session title/message that bypasses the locale system, and an undocumented 'force' shorthand — are cosmetic and do not affect correctness or data integrity.
crates/tui/src/commands/session.rs — the session title and confirmation message are hardcoded English and the undocumented 'force' shorthand is accepted; all other changed files are clean.
Important Files Changed
Sequence Diagram
sequenceDiagram participant User participant TUI as TUI (ui.rs) participant Cmd as new_session() participant Core as reset_conversation_state() participant Engine as EngineHandle participant Persist as persistence_actor User->>TUI: /new [--force] TUI->>Cmd: new_session(app, arg) alt !force Cmd->>Cmd: new_session_blockers(app) Note over Cmd: checks unsent input,<br/>queued msgs, is_loading,<br/>is_compacting, running tasks Cmd-->>TUI: CommandResult::error (if blocked) end Cmd->>Core: reset_conversation_state(app) Core-->>Cmd: todos_cleared (bool) Cmd->>Cmd: app.clear_input() / session_artifacts / context_references / tool_evidence cleared Cmd->>Cmd: "app.current_session_id = Some(new_uuid)" Cmd-->>TUI: "CommandResult::SyncSession{session_id: Some(new_uuid), messages: []}" TUI->>Engine: "Op::SyncSession{session_id: Some(new_uuid), messages: []}" TUI->>Persist: PersistRequest::SessionSnapshot (empty, new_uuid) TUI->>Persist: PersistRequest::ClearCheckpointReviews (1): Last reviewed commit: "feat: add new session command" | Re-trigger Greptile