Skip to content

Commit 0bdeab3

Browse files
authored
fix(tui): recall accepted slash commands locally (openai#17336)
# TL;DR - Adds recognized slash commands to the TUI's local in-session recall history. - This is the MVP of the whole feature: it keeps slash-command recall local only: nothing is written to persistent history, app-server history, or core history storage. - Treats slash commands like submitted text once they parse as a known built-in command, regardless of whether command dispatch later succeeds. # Problem Slash commands are handled outside the normal message submission path, so they could clear the composer without becoming part of the local Up-arrow recall list. That made command-heavy workflows awkward: after running `/diff`, `/rename Better title`, `/plan investigate this`, or even a valid command that reports a usage error, users had to retype the command instead of recalling and editing it like a normal prompt. The goal of this PR is to make slash commands feel like submitted input inside the current TUI session while keeping the change deliberately local. This is not persistent history yet; it only affects the composer's in-memory recall behavior. # Mental model The composer owns draft state and local recall. When slash input parses as a recognized built-in command, the composer stages the submitted command text before returning `InputResult::Command` or `InputResult::CommandWithArgs`. `ChatWidget` then dispatches the command and records the staged entry once dispatch returns to the input-result path. Command-name recognition is the only validation before local recall. A valid slash command is recallable whether it succeeds, fails with a usage error, no-ops, is unavailable while a task is running, or is skipped by command-specific logic. An unrecognized slash command is different: it is restored as a draft, surfaces the existing unrecognized-command message, and is not added to recall. Bare commands recalled from typed text use the trimmed submitted draft. Commands selected from the popup record the canonical command text, such as `/diff`, rather than the partial filter text the user typed. Inline commands with arguments keep the original command invocation available locally even when their arguments are later prepared through the normal submission pipeline. # Non-goals Persisting slash commands across sessions is intentionally out of scope. This change does not modify app-server history, core history storage, protocol events, or message submission semantics. This does not change command availability, command side effects, popup filtering, command parsing, or the semantics of unsupported commands. It only changes whether recognized slash-command invocations are available through local Up-arrow recall after the user submits them. # Tradeoffs The main tradeoff is that recall is based on command recognition, not command outcome. This intentionally favors a simpler user model: if the TUI accepted the input as a slash command, the user can recall and edit that input just like plain text. That means valid-but-unsuccessful invocations such as usage errors are recallable, which is useful when the next action is usually to edit and retry. The previous accept/reject design required command dispatch to report a boolean outcome, which made the dispatcher API noisier and forced every branch to decide history behavior. This version keeps the dispatch APIs as side-effect-only methods and localizes history recording to the slash-command input path. Inline command handling still avoids double-recording by preparing inline arguments without using the normal message-submission history path. The staged slash-command entry remains the single local recall record for the command invocation. # Architecture `ChatComposer` stages a pending `HistoryEntry` when recognized slash-command input is promoted into an input result. The pending entry mirrors the existing local history payload shape so recall can restore text elements, local images, remote images, mention bindings, and pending paste state when those are present. `BottomPane` exposes a narrow method for recording that staged command entry because it owns the composer. `ChatWidget` records the staged entry after dispatching a recognized command from the input-result match. Valid commands rejected before they reach `ChatWidget`, such as commands unavailable while a task is running, are staged and recorded in the composer path that detects the rejection. Slash-command dispatch itself now lives in `chatwidget/slash_dispatch.rs` so the behavior is reviewable without adding more weight to `chatwidget.rs`. The extraction is behavior-preserving: the dispatch match arms stay intact, while the input flow in `chatwidget.rs` remains the single place that connects submitted slash-command input to dispatch. # Observability There is no new logging because this is a local UI recall behavior and the result is directly visible through Up-arrow recall. The practical debug path is to trace Enter through `ChatComposer::try_dispatch_bare_slash_command`, `ChatComposer::try_dispatch_slash_command_with_args`, or popup Enter/Tab handling, then confirm the recognized command is staged before dispatch and recorded exactly once afterward. If a valid command unexpectedly does not appear in recall, check whether the input path staged slash history before clearing the composer and whether it used the `ChatWidget` slash-dispatch wrapper. If an unrecognized command unexpectedly appears in recall, check the parser branch that should restore the draft instead of staging history. # Tests Composer-level tests cover staging and recording for a bare typed slash command, a popup-selected command, and an inline command with arguments. Chat-widget tests cover valid commands being recallable after normal dispatch, inline dispatch, usage errors, task-running unavailability, no-op stub dispatch, and command-specific skip behavior such as `/init` when an instructions file already exists. They also cover the negative case: unrecognized slash commands are not added to local recall.
1 parent be13f03 commit 0bdeab3

5 files changed

Lines changed: 817 additions & 466 deletions

File tree

codex-rs/tui/src/bottom_pane/chat_composer.rs

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929
//! Recalled entries move the cursor to end-of-line so repeated Up/Down presses keep shell-like
3030
//! history traversal semantics instead of dropping to column 0.
3131
//!
32+
//! Slash commands are staged for local history instead of being recorded immediately. Command
33+
//! recall is a two-phase handoff: stage the submitted slash text here, then record it after
34+
//! `ChatWidget` dispatches the command.
35+
//!
3236
//! # Submission and Prompt Expansion
3337
//!
3438
//! `Enter` submits immediately. `Tab` requests queuing while a task is running; if no task is
@@ -228,7 +232,16 @@ pub enum InputResult {
228232
text: String,
229233
text_elements: Vec<TextElement>,
230234
},
235+
/// A bare slash command parsed by the composer.
236+
///
237+
/// Callers that dispatch this variant are also responsible for resolving any pending local
238+
/// command-history entry that the composer staged before clearing the visible input.
231239
Command(SlashCommand),
240+
/// An inline slash command and its trimmed argument text.
241+
///
242+
/// The `TextElement` ranges are rebased into the argument string, while any pending local
243+
/// command-history entry still represents the original command invocation that should be
244+
/// committed only if dispatch accepts it.
232245
CommandWithArgs(SlashCommand, String, Vec<TextElement>),
233246
None,
234247
}
@@ -311,6 +324,11 @@ pub(crate) struct ChatComposer {
311324
/// Tracks keyboard selection for the remote-image rows so Up/Down + Delete/Backspace
312325
/// can highlight and remove remote attachments from the composer UI.
313326
selected_remote_image_index: Option<usize>,
327+
/// Slash-command draft staged for local recall after application-level dispatch.
328+
///
329+
/// This slot is intentionally separate from `ChatComposerHistory` so inline slash commands can
330+
/// prepare their argument text without also double-recording the full command invocation.
331+
pending_slash_command_history: Option<HistoryEntry>,
314332
footer_flash: Option<FooterFlash>,
315333
context_window_percent: Option<i64>,
316334
// Monotonically increasing identifier for textarea elements we insert.
@@ -434,6 +452,7 @@ impl ChatComposer {
434452
footer_hint_override: None,
435453
remote_image_urls: Vec::new(),
436454
selected_remote_image_index: None,
455+
pending_slash_command_history: None,
437456
footer_flash: None,
438457
context_window_percent: None,
439458
#[cfg(not(target_os = "linux"))]
@@ -1063,6 +1082,16 @@ impl ChatComposer {
10631082
std::mem::take(&mut self.recent_submission_mention_bindings)
10641083
}
10651084

1085+
/// Commit the staged slash-command draft to local Up-arrow recall.
1086+
///
1087+
/// Call this after command dispatch. Calling it more than once is harmless because the pending
1088+
/// slot is consumed on the first call.
1089+
pub(crate) fn record_pending_slash_command_history(&mut self) {
1090+
if let Some(entry) = self.pending_slash_command_history.take() {
1091+
self.history.record_local_submission(entry);
1092+
}
1093+
}
1094+
10661095
fn prune_attached_images_for_submission(&mut self, text: &str, text_elements: &[TextElement]) {
10671096
if self.attached_images.is_empty() {
10681097
return;
@@ -1281,6 +1310,7 @@ impl ChatComposer {
12811310
if let Some(sel) = popup.selected_item() {
12821311
let CommandItem::Builtin(cmd) = sel;
12831312
if cmd == SlashCommand::Skills {
1313+
self.stage_selected_slash_command_history(cmd);
12841314
self.textarea.set_text_clearing_elements("");
12851315
return (InputResult::Command(cmd), true);
12861316
}
@@ -1305,6 +1335,7 @@ impl ChatComposer {
13051335
} => {
13061336
if let Some(sel) = popup.selected_item() {
13071337
let CommandItem::Builtin(cmd) = sel;
1338+
self.stage_selected_slash_command_history(cmd);
13081339
self.textarea.set_text_clearing_elements("");
13091340
return (InputResult::Command(cmd), true);
13101341
}
@@ -2272,8 +2303,11 @@ impl ChatComposer {
22722303
slash_commands::find_builtin_command(name, self.builtin_command_flags())
22732304
{
22742305
if self.reject_slash_command_if_unavailable(cmd) {
2306+
self.stage_slash_command_history();
2307+
self.record_pending_slash_command_history();
22752308
return Some(InputResult::None);
22762309
}
2310+
self.stage_slash_command_history();
22772311
self.textarea.set_text_clearing_elements("");
22782312
Some(InputResult::Command(cmd))
22792313
} else {
@@ -2303,9 +2337,13 @@ impl ChatComposer {
23032337
return None;
23042338
}
23052339
if self.reject_slash_command_if_unavailable(cmd) {
2340+
self.stage_slash_command_history();
2341+
self.record_pending_slash_command_history();
23062342
return Some(InputResult::None);
23072343
}
23082344

2345+
self.stage_slash_command_history();
2346+
23092347
let mut args_elements =
23102348
Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements());
23112349
let trimmed_rest = rest.trim();
@@ -2320,9 +2358,13 @@ impl ChatComposer {
23202358
/// Expand pending placeholders and extract normalized inline-command args.
23212359
///
23222360
/// Inline-arg commands are initially dispatched using the raw draft so command rejection does
2323-
/// not consume user input. Once a command is accepted, this helper performs the usual
2361+
/// not consume user input. Once a command needs its args, this helper performs the usual
23242362
/// submission preparation (paste expansion, element trimming) and rebases element ranges from
23252363
/// full-text offsets to command-arg offsets.
2364+
///
2365+
/// Callers that already staged slash-command history should normally pass `false` for
2366+
/// `record_history`; otherwise a command such as `/plan investigate` would be entered into
2367+
/// local recall through both the slash-command path and the message-submission path.
23262368
pub(crate) fn prepare_inline_args_submission(
23272369
&mut self,
23282370
record_history: bool,
@@ -2353,6 +2395,43 @@ impl ChatComposer {
23532395
true
23542396
}
23552397

2398+
/// Stage the current slash-command text for later local recall.
2399+
///
2400+
/// Staging snapshots the rich composer state before the textarea is cleared. `ChatWidget`
2401+
/// commits the staged entry after dispatch so command recall follows the submitted text, not
2402+
/// the command outcome.
2403+
fn stage_slash_command_history(&mut self) {
2404+
self.stage_slash_command_history_text(self.textarea.text().trim().to_string());
2405+
}
2406+
2407+
/// Stage a popup-selected command using its canonical command text.
2408+
///
2409+
/// Popup filtering text can be partial, so recording the selected command avoids recalling
2410+
/// `/di` after the user actually accepted `/diff`.
2411+
fn stage_selected_slash_command_history(&mut self, cmd: SlashCommand) {
2412+
self.stage_slash_command_history_text(format!("/{}", cmd.command()));
2413+
}
2414+
2415+
/// Store the provided command text and the current composer adornments in the pending slot.
2416+
///
2417+
/// The pending entry intentionally has the same shape as other local history entries so recall
2418+
/// can rehydrate attachments, mention bindings, and pending paste placeholders if command
2419+
/// workflows start carrying those through in the future.
2420+
fn stage_slash_command_history_text(&mut self, text: String) {
2421+
self.pending_slash_command_history = Some(HistoryEntry {
2422+
text,
2423+
text_elements: self.textarea.text_elements(),
2424+
local_image_paths: self
2425+
.attached_images
2426+
.iter()
2427+
.map(|img| img.path.clone())
2428+
.collect(),
2429+
remote_image_urls: self.remote_image_urls.clone(),
2430+
mention_bindings: self.snapshot_mention_bindings(),
2431+
pending_pastes: self.pending_pastes.clone(),
2432+
});
2433+
}
2434+
23562435
/// Translate full-text element ranges into command-argument ranges.
23572436
///
23582437
/// `rest_offset` is the byte offset where `rest` begins in the full text.
@@ -7863,6 +7942,90 @@ mod tests {
78637942
);
78647943
}
78657944

7945+
#[test]
7946+
fn bare_slash_command_can_be_recalled_after_recording_pending_history() {
7947+
let (tx, _rx) = unbounded_channel::<AppEvent>();
7948+
let sender = AppEventSender::new(tx);
7949+
let mut composer = ChatComposer::new(
7950+
/*has_input_focus*/ true,
7951+
sender,
7952+
/*enhanced_keys_supported*/ false,
7953+
"Ask Codex to do anything".to_string(),
7954+
/*disable_paste_burst*/ false,
7955+
);
7956+
7957+
composer.set_text_content("/diff".to_string(), Vec::new(), Vec::new());
7958+
let (result, _needs_redraw) =
7959+
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7960+
7961+
assert_eq!(result, InputResult::Command(SlashCommand::Diff));
7962+
composer.record_pending_slash_command_history();
7963+
7964+
let (result, _needs_redraw) =
7965+
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
7966+
assert_eq!(result, InputResult::None);
7967+
assert_eq!(composer.current_text(), "/diff");
7968+
}
7969+
7970+
#[test]
7971+
fn popup_selected_slash_command_records_canonical_command_history() {
7972+
let (tx, _rx) = unbounded_channel::<AppEvent>();
7973+
let sender = AppEventSender::new(tx);
7974+
let mut composer = ChatComposer::new(
7975+
/*has_input_focus*/ true,
7976+
sender,
7977+
/*enhanced_keys_supported*/ false,
7978+
"Ask Codex to do anything".to_string(),
7979+
/*disable_paste_burst*/ false,
7980+
);
7981+
7982+
composer.set_text_content("/di".to_string(), Vec::new(), Vec::new());
7983+
let (result, _needs_redraw) =
7984+
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7985+
7986+
assert_eq!(result, InputResult::Command(SlashCommand::Diff));
7987+
composer.record_pending_slash_command_history();
7988+
7989+
let (result, _needs_redraw) =
7990+
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
7991+
assert_eq!(result, InputResult::None);
7992+
assert_eq!(composer.current_text(), "/diff");
7993+
}
7994+
7995+
#[test]
7996+
fn inline_slash_command_can_be_recalled_after_recording_pending_history() {
7997+
let (tx, _rx) = unbounded_channel::<AppEvent>();
7998+
let sender = AppEventSender::new(tx);
7999+
let mut composer = ChatComposer::new(
8000+
/*has_input_focus*/ true,
8001+
sender,
8002+
/*enhanced_keys_supported*/ false,
8003+
"Ask Codex to do anything".to_string(),
8004+
/*disable_paste_burst*/ false,
8005+
);
8006+
composer.set_collaboration_modes_enabled(/*enabled*/ true);
8007+
8008+
composer.set_text_content("/plan investigate this".to_string(), Vec::new(), Vec::new());
8009+
composer.active_popup = ActivePopup::None;
8010+
let (result, _needs_redraw) =
8011+
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
8012+
8013+
match result {
8014+
InputResult::CommandWithArgs(cmd, args, text_elements) => {
8015+
assert_eq!(cmd, SlashCommand::Plan);
8016+
assert_eq!(args, "investigate this");
8017+
assert!(text_elements.is_empty());
8018+
}
8019+
other => panic!("expected inline /plan command, got {other:?}"),
8020+
}
8021+
composer.record_pending_slash_command_history();
8022+
8023+
let (result, _needs_redraw) =
8024+
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
8025+
assert_eq!(result, InputResult::None);
8026+
assert_eq!(composer.current_text(), "/plan investigate this");
8027+
}
8028+
78668029
#[test]
78678030
fn apply_external_edit_rebuilds_text_and_attachments() {
78688031
let (tx, _rx) = unbounded_channel::<AppEvent>();

codex-rs/tui/src/bottom_pane/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,14 @@ impl BottomPane {
280280
self.composer.take_recent_submission_mention_bindings()
281281
}
282282

283+
/// Add a staged slash-command draft to the composer's local recall list.
284+
///
285+
/// This should be called exactly once after `ChatWidget` dispatches a recognized command.
286+
/// Slash recall records the submitted command text regardless of whether the command succeeds.
287+
pub(crate) fn record_pending_slash_command_history(&mut self) {
288+
self.composer.record_pending_slash_command_history();
289+
}
290+
283291
/// Clear pending attachments and mention bindings e.g. when a slash command doesn't submit text.
284292
pub(crate) fn drain_pending_submission_state(&mut self) {
285293
let _ = self.take_recent_submission_images_with_placeholders();

0 commit comments

Comments
 (0)