Skip to content
Closed
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
15 changes: 8 additions & 7 deletions PR_BODY.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions code-rs/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() }));
Expand Down Expand Up @@ -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();
Expand Down
68 changes: 62 additions & 6 deletions code-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion code-rs/tui/src/chatwidget/smoke_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ impl ChatWidgetHarness {
self.flush_into_widget();
}

pub(crate) fn drain_events(&self) -> Vec<AppEvent> {
pub fn drain_events(&self) -> Vec<AppEvent> {
let mut out = Vec::new();
while let Ok(ev) = self.events.try_recv() {
Comment on lines +205 to 207

Choose a reason for hiding this comment

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

P0 Badge Make drain_events public without exporting AppEvent

Changing ChatWidgetHarness::drain_events to pub exposes a return type of Vec<AppEvent> while AppEvent itself remains pub(crate). When the crate is built with the test-helpers feature (required for the newly added integration test), ChatWidgetHarness is re‑exported and this method becomes part of the public API, causing error[E0446]: private type AppEvent in public interface and the crate no longer compiles. Either keep the method crate‑visible or publicly re‑export AppEvent so the public signature uses a public type.

Useful? React with 👍 / 👎.

out.push(ev);
Expand Down
87 changes: 72 additions & 15 deletions code-rs/tui/src/slash_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ pub enum SlashCommand {
Solve,
Code,
Logout,
Quit,
#[strum(serialize = "exit", serialize = "quit")]
Exit,
#[cfg(debug_assertions)]
TestApproval,
}
Expand All @@ -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",
Expand Down Expand Up @@ -231,22 +232,18 @@ 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!("/{}", SlashCommand::Quit.command())
} else {
format!("/{} {}", SlashCommand::Quit.command(), args_raw)
};
let command_text = format!("/{}", canonical_command);

return ProcessedCommand::RegularCommand(SlashCommand::Quit, command_text);
}

if !has_slash {
return ProcessedCommand::NotCommand(message.to_string());
return ProcessedCommand::RegularCommand(SlashCommand::Exit, command_text);
}

// Try to parse the command
Expand Down Expand Up @@ -277,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
Expand All @@ -304,3 +311,53 @@ 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 /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") {
ProcessedCommand::NotCommand(original) => {
assert_eq!(original, "exit later");
}
other => panic!("expected \"exit later\" to be treated as message, got {other:?}"),
}
}
}
16 changes: 16 additions & 0 deletions code-rs/tui/tests/ui_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion docs/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading