From 184bbc71af8149984a7c52079aee5f9ba3931424 Mon Sep 17 00:00:00 2001 From: James Peter Date: Fri, 31 Oct 2025 01:54:31 +1000 Subject: [PATCH 1/2] feat(tui): add /exit slash command (#5932) --- PR_BODY.md | 15 ++++--- code-rs/tui/src/app.rs | 2 +- code-rs/tui/src/chatwidget/smoke_helpers.rs | 2 +- code-rs/tui/src/slash_command.rs | 47 ++++++++++++++++++--- code-rs/tui/tests/ui_smoke.rs | 16 +++++++ docs/slash-commands.md | 3 +- 6 files changed, 70 insertions(+), 15 deletions(-) diff --git a/PR_BODY.md b/PR_BODY.md index f9128ce12ac..6fad8413339 100644 --- a/PR_BODY.md +++ b/PR_BODY.md @@ -1,9 +1,10 @@ -## Overview -- AutoRunPhase now carries struct payloads; controller exposes helpers (`is_active`, `is_paused_manual`, `resume_after_submit`, `awaiting_coordinator_submit`, `awaiting_review`, `in_transient_recovery`). -- ChatWidget hot paths (manual pause, coordinator routing, ESC handling, review exit) rely on helpers/`matches!` instead of raw booleans. +## Summary +- promote `/exit` to a first-class slash command (aliasing `/quit`) and keep dispatch wiring intact +- normalize the parser so `/exit` preserves its spelling, while `/quit` remains supported +- add parser and ChatWidget harness coverage and document the new command in `docs/slash-commands.md` -## Tests -- `./build-fast.sh` +## Testing +- ./build-fast.sh +- cargo test -p code-tui slash_exit_and_quit_dispatch_exit_command *(fails: local cargo registry copy of `cc` 1.2.41 is missing generated modules; clear/update the crate and rerun)* -## Follow-ups -- See `docs/auto-drive-phase-migration-TODO.md` for remaining legacy-flag removals and snapshot coverage. +Closes #5932. diff --git a/code-rs/tui/src/app.rs b/code-rs/tui/src/app.rs index 0d30a8f706a..abfbd3cec7d 100644 --- a/code-rs/tui/src/app.rs +++ b/code-rs/tui/src/app.rs @@ -1994,7 +1994,7 @@ impl App<'_> { self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); } } - SlashCommand::Quit => { break 'main; } + SlashCommand::Exit => { break 'main; } SlashCommand::Login => { if let AppState::Chat { widget } = &mut self.app_state { widget.handle_login_command(); diff --git a/code-rs/tui/src/chatwidget/smoke_helpers.rs b/code-rs/tui/src/chatwidget/smoke_helpers.rs index 3f7522063d4..873fa1b8425 100644 --- a/code-rs/tui/src/chatwidget/smoke_helpers.rs +++ b/code-rs/tui/src/chatwidget/smoke_helpers.rs @@ -202,7 +202,7 @@ impl ChatWidgetHarness { self.flush_into_widget(); } - pub(crate) fn drain_events(&self) -> Vec { + pub fn drain_events(&self) -> Vec { let mut out = Vec::new(); while let Ok(ev) = self.events.try_recv() { out.push(ev); diff --git a/code-rs/tui/src/slash_command.rs b/code-rs/tui/src/slash_command.rs index 0aef953af4a..594e37d02ce 100644 --- a/code-rs/tui/src/slash_command.rs +++ b/code-rs/tui/src/slash_command.rs @@ -88,7 +88,8 @@ pub enum SlashCommand { Solve, Code, Logout, - Quit, + #[strum(serialize = "exit", serialize = "quit")] + Exit, #[cfg(debug_assertions)] TestApproval, } @@ -111,7 +112,7 @@ impl SlashCommand { SlashCommand::Undo => "restore the workspace to the last Code snapshot", SlashCommand::Review => "review your changes for potential issues", SlashCommand::Cloud => "browse, apply, and create cloud tasks", - SlashCommand::Quit => "exit Code", + SlashCommand::Exit => "exit Code", SlashCommand::Diff => "show git diff (including untracked files)", SlashCommand::Mention => "mention a file", SlashCommand::Cmd => "run a project command", @@ -237,12 +238,12 @@ pub fn process_slash_command_message(message: &str) -> ProcessedCommand { } let command_text = if args_raw.is_empty() { - format!("/{}", SlashCommand::Quit.command()) + format!("/{}", canonical_command) } else { - format!("/{} {}", SlashCommand::Quit.command(), args_raw) + format!("/{} {}", canonical_command, args_raw) }; - return ProcessedCommand::RegularCommand(SlashCommand::Quit, command_text); + return ProcessedCommand::RegularCommand(SlashCommand::Exit, command_text); } if !has_slash { @@ -304,3 +305,39 @@ pub enum ProcessedCommand { /// Error processing the command Error(String), } + +#[cfg(test)] +mod tests { + use super::{process_slash_command_message, ProcessedCommand, SlashCommand}; + + #[test] + fn slash_exit_and_quit_dispatch_exit_command() { + match process_slash_command_message("/exit") { + ProcessedCommand::RegularCommand(SlashCommand::Exit, command_text) => { + assert_eq!(command_text, "/exit"); + } + other => panic!("expected /exit to dispatch SlashCommand::Exit, got {other:?}"), + } + + match process_slash_command_message("/quit") { + ProcessedCommand::RegularCommand(SlashCommand::Exit, command_text) => { + assert_eq!(command_text, "/quit"); + } + other => panic!("expected /quit to map to SlashCommand::Exit, got {other:?}"), + } + + match process_slash_command_message("exit") { + ProcessedCommand::RegularCommand(SlashCommand::Exit, command_text) => { + assert_eq!(command_text, "/exit"); + } + other => panic!("expected bare exit to dispatch SlashCommand::Exit, got {other:?}"), + } + + match process_slash_command_message("exit later") { + ProcessedCommand::NotCommand(original) => { + assert_eq!(original, "exit later"); + } + other => panic!("expected \"exit later\" to be treated as message, got {other:?}"), + } + } +} diff --git a/code-rs/tui/tests/ui_smoke.rs b/code-rs/tui/tests/ui_smoke.rs index 5b8325f5540..72e9c272eeb 100644 --- a/code-rs/tui/tests/ui_smoke.rs +++ b/code-rs/tui/tests/ui_smoke.rs @@ -644,6 +644,22 @@ fn smoke_approval_flow() { } } +#[test] +fn slash_exit_dispatches_exit_command() { + let mut harness = ChatWidgetHarness::new(); + + harness.with_chat(|chat| chat.submit_text_message("/exit".to_string())); + + let events = harness.drain_events(); + let exit_dispatched = events.iter().any(|event| { + let debug = format!("{event:?}"); + debug.contains("DispatchCommand") + && debug.contains("Exit") + && debug.contains("/exit") + }); + assert!(exit_dispatched, "expected /exit to dispatch SlashCommand::Exit"); +} + #[test] fn smoke_custom_tool_call() { let mut harness = ChatWidgetHarness::new(); diff --git a/docs/slash-commands.md b/docs/slash-commands.md index b530033e95c..0d51bf50fa9 100644 --- a/docs/slash-commands.md +++ b/docs/slash-commands.md @@ -17,7 +17,8 @@ Notes - `/chrome`: connect to your Chrome browser. - `/new`: start a new chat during a conversation. - `/resume`: resume a past session for this folder. -- `/quit`: exit Codex. +- `/exit`: exit Codex. +- `/quit`: alias for `/exit`. - `/logout`: log out of Codex. - `/login`: manage Code sign-ins (select, add, or disconnect accounts). - `/settings [section]`: open the settings panel. Optional section argument From ee05790c1326967c4f06f9b42aa79f576b2110d0 Mon Sep 17 00:00:00 2001 From: James Peter Date: Fri, 7 Nov 2025 06:14:48 +1000 Subject: [PATCH 2/2] fix(tui/slash): guard exit command persistence --- code-rs/tui/src/app.rs | 2 +- code-rs/tui/src/chatwidget.rs | 68 +++++++++++++++++++++++++++++--- code-rs/tui/src/slash_command.rs | 48 +++++++++++++++------- 3 files changed, 97 insertions(+), 21 deletions(-) diff --git a/code-rs/tui/src/app.rs b/code-rs/tui/src/app.rs index abfbd3cec7d..1cd65e65480 100644 --- a/code-rs/tui/src/app.rs +++ b/code-rs/tui/src/app.rs @@ -1907,7 +1907,7 @@ impl App<'_> { // Persist UI-only slash commands to cross-session history. // For prompt-expanding commands (/plan, /solve, /code) we let the // expanded prompt be recorded by the normal submission path. - if !command.is_prompt_expanding() { + if !command.is_prompt_expanding() && command != SlashCommand::Exit { let _ = self .app_event_tx .send(AppEvent::CodexOp(Op::AddToHistory { text: command_text.clone() })); diff --git a/code-rs/tui/src/chatwidget.rs b/code-rs/tui/src/chatwidget.rs index 7a26b03c9a2..42d4c15fa87 100644 --- a/code-rs/tui/src/chatwidget.rs +++ b/code-rs/tui/src/chatwidget.rs @@ -7966,6 +7966,7 @@ impl ChatWidget<'_> { cell.trigger_fade(); } let mut message = user_message; + let message_suppress_persistence = message.suppress_persistence; // If our configured cwd no longer exists (e.g., a worktree folder was // deleted outside the app), try to automatically recover to the repo // root for worktrees and re-submit the same message there. @@ -8225,14 +8226,17 @@ impl ChatWidget<'_> { } } crate::slash_command::ProcessedCommand::RegularCommand(cmd, command_text) => { - if cmd == SlashCommand::Undo { + if cmd == SlashCommand::Exit && message_suppress_persistence { + // Treat synthetic/system messages as plain text to avoid accidental exits. + } else if cmd == SlashCommand::Undo { self.handle_undo_command(); return; + } else { + // This is a regular slash command, dispatch it normally + self.app_event_tx + .send(AppEvent::DispatchCommand(cmd, command_text)); + return; } - // This is a regular slash command, dispatch it normally - self.app_event_tx - .send(AppEvent::DispatchCommand(cmd, command_text)); - return; } crate::slash_command::ProcessedCommand::Error(error_msg) => { // Show error in history @@ -22917,6 +22921,8 @@ mod tests { use crate::bottom_pane::AutoCoordinatorViewModel; use crate::chatwidget::message::UserMessage; use crate::chatwidget::smoke_helpers::ChatWidgetHarness; + use crate::slash_command::SlashCommand; + use crate::app_event::AppEvent; use crate::history_cell::{self, ExploreAggregationCell, HistoryCellType}; use code_auto_drive_core::{ AutoContinueMode, @@ -22955,7 +22961,7 @@ mod tests { ExecCommandBeginEvent, TaskCompleteEvent, }; - use code_core::protocol::AgentInfo as CoreAgentInfo; + use code_core::protocol::{AgentInfo as CoreAgentInfo, Op}; use ratatui::backend::TestBackend; use ratatui::text::Line; use ratatui::Terminal; @@ -22985,6 +22991,56 @@ mod tests { } } + #[test] + fn exit_command_ignored_for_suppressed_message() { + let mut harness = ChatWidgetHarness::new(); + + harness.with_chat(|chat| { + let mut message = UserMessage::from("/exit".to_string()); + message.suppress_persistence = true; + chat.submit_user_message(message); + }); + + let events = harness.drain_events(); + let exit_dispatched = events.into_iter().any(|event| matches!( + event, + AppEvent::DispatchCommand(SlashCommand::Exit, _) + )); + assert!( + !exit_dispatched, + "suppressed messages should not dispatch the exit command" + ); + } + + #[test] + fn exit_command_dispatches_without_history_persistence() { + let mut harness = ChatWidgetHarness::new(); + + harness.with_chat(|chat| chat.submit_text_message("/exit".to_string())); + + let events = harness.drain_events(); + let mut exit_dispatched = false; + let mut history_persisted = false; + + for event in events { + match event { + AppEvent::DispatchCommand(SlashCommand::Exit, _) => { + exit_dispatched = true; + } + AppEvent::CodexOp(Op::AddToHistory { .. }) => { + history_persisted = true; + } + _ => {} + } + } + + assert!(exit_dispatched, "expected exit command to dispatch"); + assert!( + !history_persisted, + "/exit should not be persisted to history" + ); + } + impl Drop for CaptureCommitStubGuard { fn drop(&mut self) { match CAPTURE_AUTO_TURN_COMMIT_STUB.lock() { diff --git a/code-rs/tui/src/slash_command.rs b/code-rs/tui/src/slash_command.rs index 594e37d02ce..59e91dd674d 100644 --- a/code-rs/tui/src/slash_command.rs +++ b/code-rs/tui/src/slash_command.rs @@ -232,24 +232,20 @@ pub fn process_slash_command_message(message: &str) -> ProcessedCommand { let args_raw = parts.get(1).map(|s| s.trim()).unwrap_or(""); let canonical_command = command_str.to_ascii_lowercase(); + if !has_slash { + return ProcessedCommand::NotCommand(message.to_string()); + } + if matches!(canonical_command.as_str(), "quit" | "exit") { - if !has_slash && !args_raw.is_empty() { + if !args_raw.is_empty() { return ProcessedCommand::NotCommand(message.to_string()); } - let command_text = if args_raw.is_empty() { - format!("/{}", canonical_command) - } else { - format!("/{} {}", canonical_command, args_raw) - }; + let command_text = format!("/{}", canonical_command); return ProcessedCommand::RegularCommand(SlashCommand::Exit, command_text); } - if !has_slash { - return ProcessedCommand::NotCommand(message.to_string()); - } - // Try to parse the command if let Ok(command) = canonical_command.parse::() { if !command.is_available() { @@ -278,10 +274,20 @@ pub fn process_slash_command_message(message: &str) -> ProcessedCommand { } } + if command == SlashCommand::Exit && !args_raw.is_empty() { + return ProcessedCommand::NotCommand(message.to_string()); + } + + let command_name = if command == SlashCommand::Exit { + canonical_command.as_str() + } else { + command.command() + }; + let command_text = if args_raw.is_empty() { - format!("/{}", command.command()) + format!("/{}", command_name) } else { - format!("/{} {}", command.command(), args_raw) + format!("/{} {}", command_name, args_raw) }; // It's a regular command, return it as-is with the canonical text @@ -326,11 +332,25 @@ mod tests { other => panic!("expected /quit to map to SlashCommand::Exit, got {other:?}"), } - match process_slash_command_message("exit") { + match process_slash_command_message("/EXIT") { ProcessedCommand::RegularCommand(SlashCommand::Exit, command_text) => { assert_eq!(command_text, "/exit"); } - other => panic!("expected bare exit to dispatch SlashCommand::Exit, got {other:?}"), + other => panic!("expected /EXIT to dispatch SlashCommand::Exit, got {other:?}"), + } + + match process_slash_command_message("exit") { + ProcessedCommand::NotCommand(original) => { + assert_eq!(original, "exit"); + } + other => panic!("expected bare exit to be treated as message, got {other:?}"), + } + + match process_slash_command_message("/exit later") { + ProcessedCommand::NotCommand(original) => { + assert_eq!(original, "/exit later"); + } + other => panic!("expected '/exit later' to be treated as message, got {other:?}"), } match process_slash_command_message("exit later") {