Compare commits

...

4 Commits

Author SHA1 Message Date
Felipe Coury
4960e5b1db fix(tui): localize slash command history dispatch
Keep slash command dispatch as the boundary that commits or discards
staged composer history. This avoids leaking bool/outcome plumbing to
call sites while still preserving the two rejection paths needed for
visible drafts versus consumed submission state.

Inline command handling now uses private helpers to distinguish when to
keep draft metadata and when to drain prepared state, so `/plan` and
other command-derived submissions can avoid duplicating history entries.
2026-04-10 12:20:13 -03:00
Felipe Coury
28a5c6735a fix(tui): preserve inline slash drafts on rejection
Return a richer outcome for inline slash command dispatch so callers
can distinguish between failures that leave the draft visible and
failures that happen after submission prep already consumed it.

This prevents rejected commands like `/fast badarg ...` from silently
clearing attachments, remote images, or mention bindings while the
composer text remains on screen.
2026-04-03 18:44:45 -03:00
Felipe Coury
2a5ba61f1d fix(tui): encode slash command mentions in history
Store the staged slash-command `HistoryEntry` through the persistent
history path so mention bindings are encoded before `Op::AddToHistory`
submits the text.

This keeps slash commands with bound mentions recoverable across
sessions, matching the normal `UserMessage` history behavior, and adds
a targeted regression test for the inline-arg submission path.
2026-04-03 18:16:29 -03:00
Felipe Coury
5371cade71 fix(tui): include slash commands in composer history
Persist successful slash commands in both the composer recall ring
and cross-session history so commands like `/diff`, `/plan ...`,
and `/rename ...` can be reissued with Up-arrow.

Stage slash-command drafts in the composer, commit them only after
dispatch succeeds, and suppress persistence for derived side-effect
prompts to avoid duplicate entries.
2026-04-03 17:13:31 -03:00
7 changed files with 784 additions and 39 deletions

View File

@@ -29,6 +29,21 @@
//! Recalled entries move the cursor to end-of-line so repeated Up/Down presses keep shell-like
//! history traversal semantics instead of dropping to column 0.
//!
//! # Slash Command History Staging
//!
//! Slash commands clear the textarea before `ChatWidget` knows whether the command truly
//! succeeded, so the composer snapshots them into a pending [`HistoryEntry`] first.
//!
//! This pending entry is a short-lived handoff slot, not history yet. The composer stages the exact
//! draft while it still owns the raw text, element ranges, attachment lists, mention bindings, and
//! pending paste placeholders. `ChatWidget` then either commits the entry after a successful
//! command dispatch, or discards it if the command is rejected.
//!
//! The distinction matters most for inline-argument commands such as `/plan investigate this`.
//! Rejections that happen before submission preparation keep the visible draft and must only clear
//! the pending history slot. Rejections that happen after preparation must drain recent submission
//! state as well, or stale attachments and mention bindings can leak into the next draft.
//!
//! # Submission and Prompt Expansion
//!
//! `Enter` submits immediately. `Tab` requests queuing while a task is running; if no task is
@@ -277,6 +292,12 @@ impl ChatComposerConfig {
}
}
/// Owns the editable prompt draft and the local state needed to submit or recall it.
///
/// `ChatComposer` is the only component that can faithfully snapshot a draft because it owns the
/// textarea, element ranges, attachments, mention bindings, and pending paste expansion state.
/// Higher layers should treat its submission/history helpers as authority boundaries rather than
/// trying to reconstruct draft metadata from visible text alone.
pub(crate) struct ChatComposer {
textarea: TextArea,
textarea_state: RefCell<TextAreaState>,
@@ -311,6 +332,9 @@ pub(crate) struct ChatComposer {
/// Tracks keyboard selection for the remote-image rows so Up/Down + Delete/Backspace
/// can highlight and remove remote attachments from the composer UI.
selected_remote_image_index: Option<usize>,
/// Trimmed slash-command draft staged for local/persistent history after
/// the command has been accepted by `ChatWidget`.
pending_slash_command_history: Option<HistoryEntry>,
footer_flash: Option<FooterFlash>,
context_window_percent: Option<i64>,
// Monotonically increasing identifier for textarea elements we insert.
@@ -434,6 +458,7 @@ impl ChatComposer {
footer_hint_override: None,
remote_image_urls: Vec::new(),
selected_remote_image_index: None,
pending_slash_command_history: None,
footer_flash: None,
context_window_percent: None,
#[cfg(not(target_os = "linux"))]
@@ -1060,6 +1085,32 @@ impl ChatComposer {
std::mem::take(&mut self.recent_submission_mention_bindings)
}
/// Takes the staged slash-command history entry without recording it.
///
/// Use this when the command outcome says the invocation should not become recallable. The
/// caller must still decide separately whether to drain recent submission state; discarding the
/// staged entry alone intentionally preserves the visible draft and its metadata for
/// `RejectedKeepDraft` paths.
pub(crate) fn take_pending_slash_command_history(&mut self) -> Option<HistoryEntry> {
self.pending_slash_command_history.take()
}
/// Commits the staged slash-command entry into the local history ring.
///
/// This is the commit half of the two-phase slash-command history protocol. The caller
/// (`ChatWidget::commit_pending_slash_command_history`) should only call this after confirming
/// the command was accepted, then use the returned entry for persistent cross-session storage
/// via `Op::AddToHistory`.
///
/// Calling this speculatively would make a failed command recallable even though no command
/// actually ran. Returns `None` if no entry was staged, which happens when the command was
/// already drained or was never a slash command to begin with.
pub(crate) fn record_pending_slash_command_history(&mut self) -> Option<HistoryEntry> {
let entry = self.pending_slash_command_history.take()?;
self.history.record_local_submission(entry.clone());
Some(entry)
}
fn prune_attached_images_for_submission(&mut self, text: &str, text_elements: &[TextElement]) {
if self.attached_images.is_empty() {
return;
@@ -1278,6 +1329,7 @@ impl ChatComposer {
if let Some(sel) = popup.selected_item() {
let CommandItem::Builtin(cmd) = sel;
if cmd == SlashCommand::Skills {
self.stage_pending_selected_slash_command_history(cmd);
self.textarea.set_text_clearing_elements("");
return (InputResult::Command(cmd), true);
}
@@ -1302,6 +1354,7 @@ impl ChatComposer {
} => {
if let Some(sel) = popup.selected_item() {
let CommandItem::Builtin(cmd) = sel;
self.stage_pending_selected_slash_command_history(cmd);
self.textarea.set_text_clearing_elements("");
return (InputResult::Command(cmd), true);
}
@@ -2271,6 +2324,7 @@ impl ChatComposer {
if self.reject_slash_command_if_unavailable(cmd) {
return Some(InputResult::None);
}
self.stage_pending_slash_command_history();
self.textarea.set_text_clearing_elements("");
Some(InputResult::Command(cmd))
} else {
@@ -2303,6 +2357,8 @@ impl ChatComposer {
return Some(InputResult::None);
}
self.stage_pending_slash_command_history();
let mut args_elements =
Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements());
let trimmed_rest = rest.trim();
@@ -2350,6 +2406,52 @@ impl ChatComposer {
true
}
/// Snapshots the current composer draft into `pending_slash_command_history`.
///
/// Called just before the textarea is cleared for a slash command. The
/// snapshot captures the trimmed command text (e.g. "/plan investigate
/// this") along with all attachment and mention state, so that the entry
/// can later be recorded into both the local history ring (for Up-arrow
/// recall) and persistent storage (for cross-session recall).
///
/// If this is called but the command ultimately fails, the pending entry
/// is discarded by `drain_pending_submission_state` — nothing is persisted.
fn stage_pending_slash_command_history(&mut self) {
self.stage_pending_slash_command_history_text(self.textarea.text().trim().to_string());
}
/// Snapshots a popup-selected bare slash command into
/// `pending_slash_command_history`.
///
/// Popup dispatch should persist the command that actually ran (e.g.
/// "/diff"), not the partially typed filter text that selected it
/// (e.g. "/di").
fn stage_pending_selected_slash_command_history(&mut self, cmd: SlashCommand) {
self.stage_pending_slash_command_history_text(format!("/{}", cmd.command()));
}
/// Stores a prepared slash-command history entry in the pending handoff slot.
///
/// The caller chooses the text to persist because popup selection and typed commands differ:
/// typed commands should store the trimmed draft, while popup completion should store the
/// canonical command that actually dispatches. This helper always snapshots the current
/// metadata alongside that text, so calling it after metadata has been drained would create a
/// recall entry that looks right but cannot restore mentions or attachments.
fn stage_pending_slash_command_history_text(&mut self, text: String) {
self.pending_slash_command_history = Some(HistoryEntry {
text,
text_elements: self.textarea.text_elements(),
local_image_paths: self
.attached_images
.iter()
.map(|img| img.path.clone())
.collect(),
remote_image_urls: self.remote_image_urls.clone(),
mention_bindings: self.snapshot_mention_bindings(),
pending_pastes: self.pending_pastes.clone(),
});
}
/// Translate full-text element ranges into command-argument ranges.
///
/// `rest_offset` is the byte offset where `rest` begins in the full text.
@@ -2536,6 +2638,14 @@ impl ChatComposer {
}
}
#[cfg(test)]
pub(crate) fn handle_key_event_without_popup_for_test(
&mut self,
key_event: KeyEvent,
) -> (InputResult, bool) {
self.handle_key_event_without_popup(key_event)
}
fn is_bang_shell_command(&self) -> bool {
self.textarea.text().trim_start().starts_with('!')
}
@@ -7860,6 +7970,111 @@ mod tests {
);
}
#[test]
fn bare_slash_command_can_be_recalled_from_history() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_text_content("/diff".to_string(), Vec::new(), Vec::new());
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(result, InputResult::Command(SlashCommand::Diff));
assert_eq!(
composer.record_pending_slash_command_history(),
Some(HistoryEntry::new("/diff".to_string()))
);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert_eq!(composer.current_text(), "/diff");
assert_eq!(composer.textarea.cursor(), composer.current_text().len());
}
#[test]
fn popup_selected_bare_slash_command_recalls_canonical_command_from_history() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_text_content("/di".to_string(), Vec::new(), Vec::new());
assert!(matches!(composer.active_popup, ActivePopup::Command(_)));
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(result, InputResult::Command(SlashCommand::Diff));
assert_eq!(
composer.record_pending_slash_command_history(),
Some(HistoryEntry::new("/diff".to_string()))
);
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert_eq!(composer.current_text(), "/diff");
assert_eq!(composer.textarea.cursor(), composer.current_text().len());
}
#[test]
fn slash_command_with_args_can_be_recalled_exactly_from_history() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
/*has_input_focus*/ true,
sender,
/*enhanced_keys_supported*/ false,
"Ask Codex to do anything".to_string(),
/*disable_paste_burst*/ false,
);
composer.set_collaboration_modes_enabled(/*enabled*/ true);
composer.set_text_content("/plan investigate this".to_string(), Vec::new(), Vec::new());
composer.active_popup = ActivePopup::None;
let (result, _needs_redraw) = composer
.handle_key_event_without_popup(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::CommandWithArgs(cmd, args, text_elements) => {
assert_eq!(cmd, SlashCommand::Plan);
assert_eq!(args, "investigate this");
assert!(text_elements.is_empty());
}
other => panic!("expected /plan inline-args command, got {other:?}"),
}
let staged = composer
.record_pending_slash_command_history()
.expect("expected staged history entry");
assert_eq!(staged.text, "/plan investigate this");
assert!(staged.mention_bindings.is_empty());
let (prepared_args, prepared_elements) = composer
.prepare_inline_args_submission(/*record_history*/ false)
.expect("expected slash command args to prepare");
assert_eq!(prepared_args, "investigate this");
assert!(prepared_elements.is_empty());
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(result, InputResult::None);
assert_eq!(composer.current_text(), "/plan investigate this");
assert_eq!(composer.textarea.cursor(), composer.current_text().len());
}
#[test]
fn apply_external_edit_rebuilds_text_and_attachments() {
let (tx, _rx) = unbounded_channel::<AppEvent>();

View File

@@ -13,6 +13,11 @@
//!
//! Some UI is time-based rather than input-based, such as the transient "press again to quit"
//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle.
//!
//! The pane also exposes narrow cleanup boundaries for submission state. `ChatComposer` owns the
//! raw draft metadata, while `ChatWidget` decides whether a slash command succeeded; `BottomPane`
//! keeps those layers from reaching through each other by offering explicit methods to commit,
//! discard, or drain the staged command state.
use std::path::PathBuf;
use crate::app_event::ConnectorsSnapshot;
@@ -192,6 +197,12 @@ pub(crate) struct BottomPane {
context_window_used_tokens: Option<i64>,
}
/// Construction parameters for the bottom pane and its retained composer.
///
/// Callers pass session/UI capabilities at construction time so the pane can initialize the
/// composer once and preserve draft state across transient views. Runtime capability changes, such
/// as image paste support or plugin mentions, should go through the corresponding setters so popup
/// and redraw side effects stay centralized.
pub(crate) struct BottomPaneParams {
pub(crate) app_event_tx: AppEventSender,
pub(crate) frame_requester: FrameRequester,
@@ -280,14 +291,55 @@ impl BottomPane {
self.composer.take_recent_submission_mention_bindings()
}
/// Clear pending attachments and mention bindings e.g. when a slash command doesn't submit text.
pub(crate) fn drain_pending_submission_state(&mut self) {
/// Discards the staged slash-command history entry without recording it.
///
/// This is the lightweight rejection path for commands whose visible draft should remain in the
/// composer. Using `drain_pending_submission_state` for those cases would drop attachments,
/// remote images, and mention bindings that the user still expects to edit.
///
/// See [`ChatComposer::take_pending_slash_command_history`].
pub(crate) fn take_pending_slash_command_history(
&mut self,
) -> Option<chat_composer_history::HistoryEntry> {
self.composer.take_pending_slash_command_history()
}
/// Commits the staged slash-command entry to the local history ring.
///
/// The returned entry is intended for the persistent-history path in `ChatWidget`. Callers
/// should not call this until command dispatch has accepted the invocation; otherwise a rejected
/// command becomes recallable and can be persisted across sessions.
///
/// See [`ChatComposer::record_pending_slash_command_history`].
pub(crate) fn record_pending_slash_command_history(
&mut self,
) -> Option<chat_composer_history::HistoryEntry> {
self.composer.record_pending_slash_command_history()
}
/// Clears recent submission metadata without touching staged slash-command history.
///
/// Use this after an accepted inline command has consumed attachments, remote images, and
/// mention bindings but still needs the staged command entry for history commit. Calling the
/// broader pending-state drain here would make the command run successfully but disappear from
/// recall.
pub(crate) fn drain_recent_submission_state(&mut self) {
let _ = self.take_recent_submission_images_with_placeholders();
let _ = self.take_remote_image_urls();
let _ = self.take_recent_submission_mention_bindings();
let _ = self.take_mention_bindings();
}
/// Clears recent submission metadata and any staged slash-command history entry.
///
/// Use this only when the command path has already prepared or cleared the draft and the
/// invocation must not be committed. Using it for a rejection that keeps the visible draft would
/// silently strip attachments and mention bindings from the user's retry.
pub(crate) fn drain_pending_submission_state(&mut self) {
self.drain_recent_submission_state();
let _ = self.take_pending_slash_command_history();
}
pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) {
self.composer.set_collaboration_modes_enabled(enabled);
self.request_redraw();
@@ -447,6 +499,20 @@ impl BottomPane {
}
}
#[cfg(test)]
pub(crate) fn handle_composer_key_event_without_popup(
&mut self,
key_event: KeyEvent,
) -> InputResult {
let (input_result, needs_redraw) = self
.composer
.handle_key_event_without_popup_for_test(key_event);
if needs_redraw {
self.request_redraw();
}
input_result
}
/// Handles a Ctrl+C press within the bottom pane.
///
/// An active modal view is given the first chance to consume the key (typically to dismiss
@@ -1110,6 +1176,11 @@ impl BottomPane {
.take_recent_submission_images_with_placeholders()
}
/// Expands pending paste placeholders and returns normalized inline-command arguments.
///
/// `ChatWidget` calls this only after accepting an inline-argument command. Calling it before a
/// command has passed validation can consume and clear draft metadata even if the command later
/// reports an error.
pub(crate) fn prepare_inline_args_submission(
&mut self,
record_history: bool,

View File

@@ -25,6 +25,11 @@
//! the final answer. During streaming we hide the status row to avoid duplicate
//! progress indicators; once commentary completes and stream queues drain, we
//! re-show it so users still see turn-in-progress state between output bursts.
//!
//! Slash-command submission is split with the composer. `ChatComposer` snapshots a pending history
//! entry before clearing the visible draft, while `ChatWidget` decides whether command dispatch
//! accepted the invocation and whether that staged entry should be committed, discarded while
//! preserving the draft, or discarded together with prepared submission state.
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashMap;
@@ -975,6 +980,28 @@ enum CodexOpTarget {
AppEvent,
}
/// Outcome of dispatching a slash command that may have staged composer history.
///
/// This enum exists because a boolean cannot distinguish the two rejection modes that matter for
/// draft safety. Some invalid commands leave the user's draft visible and editable, while others
/// have already consumed prepared submission state and must drain attachments, remote images, and
/// mention bindings before returning control to the composer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SlashCommandDispatchOutcome {
/// The command was accepted and the staged history entry should be committed.
Committed,
/// The command was rejected before submission state was consumed.
///
/// The caller should discard only the staged history entry. Draining recent submission state in
/// this branch would lose draft metadata that the user still sees and may retry.
RejectedKeepDraft,
/// The command was rejected after prepared submission state may have been consumed.
///
/// The caller should drain both recent submission state and the staged history entry so stale
/// attachments or mention bindings cannot leak into the next draft.
RejectedDiscardPreparedState,
}
/// Snapshot of active-cell state that affects transcript overlay rendering.
///
/// The overlay keeps a cached "live tail" for the in-flight cell; this key lets
@@ -999,6 +1026,12 @@ pub(crate) struct ActiveCellTranscriptKey {
pub(crate) animation_tick: Option<u64>,
}
/// User-authored or command-derived input waiting to be submitted to a thread.
///
/// Most instances represent ordinary composer text and should be persisted to history. Slash
/// commands with side effects can also create derived messages, such as the argument text from
/// `/plan investigate this`; those messages must carry `persist_to_history = false` because the
/// visible slash-command draft is persisted through the staged command-history path instead.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct UserMessage {
text: String,
@@ -1011,6 +1044,17 @@ pub(crate) struct UserMessage {
remote_image_urls: Vec<String>,
text_elements: Vec<TextElement>,
mention_bindings: Vec<MentionBinding>,
/// Controls whether this message is recorded in cross-session persistent
/// history when submitted via `submit_user_message_with_history`.
///
/// Defaults to `true` for normal prose messages. Set to `false` for
/// derived text that a slash command submits as a side effect (e.g. the
/// "investigate this" portion of `/plan investigate this`), because the
/// full command string is already persisted separately by
/// `commit_pending_slash_command_history`. Without this flag, the derived
/// text would be recorded a second time, producing a confusing duplicate
/// in recall.
persist_to_history: bool,
}
#[derive(Debug, Clone, PartialEq, Default)]
@@ -1055,6 +1099,7 @@ impl From<String> for UserMessage {
// Plain text conversion has no UI element ranges.
text_elements: Vec::new(),
mention_bindings: Vec::new(),
persist_to_history: true,
}
}
}
@@ -1068,6 +1113,7 @@ impl From<&str> for UserMessage {
// Plain text conversion has no UI element ranges.
text_elements: Vec::new(),
mention_bindings: Vec::new(),
persist_to_history: true,
}
}
}
@@ -1100,6 +1146,7 @@ pub(crate) fn create_initial_user_message(
remote_image_urls: Vec::new(),
text_elements,
mention_bindings: Vec::new(),
persist_to_history: true,
})
}
}
@@ -1130,6 +1177,7 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize)
local_images,
remote_image_urls,
mention_bindings,
persist_to_history,
} = message;
if local_images.is_empty() {
return UserMessage {
@@ -1138,6 +1186,7 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize)
local_images,
remote_image_urls,
mention_bindings,
persist_to_history,
};
}
@@ -1194,16 +1243,19 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize)
remote_image_urls,
text_elements: rebuilt_elements,
mention_bindings,
persist_to_history,
}
}
fn merge_user_messages(messages: Vec<UserMessage>) -> UserMessage {
let persist_to_history = messages.iter().all(|message| message.persist_to_history);
let mut combined = UserMessage {
text: String::new(),
text_elements: Vec::new(),
local_images: Vec::new(),
remote_image_urls: Vec::new(),
mention_bindings: Vec::new(),
persist_to_history,
};
let total_remote_images = messages
.iter()
@@ -1221,6 +1273,7 @@ fn merge_user_messages(messages: Vec<UserMessage>) -> UserMessage {
local_images,
remote_image_urls,
mention_bindings,
persist_to_history: _,
} = remap_placeholders_for_message(message, &mut next_image_label);
append_text_with_rebased_elements(
&mut combined.text,
@@ -3076,6 +3129,7 @@ impl ChatWidget {
local_images: self.bottom_pane.composer_local_images(),
remote_image_urls: self.bottom_pane.remote_image_urls(),
mention_bindings: self.bottom_pane.composer_mention_bindings(),
persist_to_history: true,
};
let mut to_merge: Vec<UserMessage> = self.rejected_steers_queue.drain(..).collect();
@@ -3102,6 +3156,7 @@ impl ChatWidget {
remote_image_urls,
text_elements,
mention_bindings,
persist_to_history: _,
} = user_message;
let local_image_paths = local_images.into_iter().map(|img| img.path).collect();
self.set_remote_image_urls(remote_image_urls);
@@ -4864,6 +4919,7 @@ impl ChatWidget {
mention_bindings: self
.bottom_pane
.take_recent_submission_mention_bindings(),
persist_to_history: true,
};
if user_message.text.is_empty()
&& user_message.local_images.is_empty()
@@ -4905,6 +4961,7 @@ impl ChatWidget {
mention_bindings: self
.bottom_pane
.take_recent_submission_mention_bindings(),
persist_to_history: true,
};
let Some(user_message) =
self.maybe_defer_user_message_for_realtime(user_message)
@@ -4988,16 +5045,31 @@ impl ChatWidget {
false
}
/// Executes a bare slash command that may have already been staged by the composer.
///
/// Accepted commands commit the staged entry to local and persistent history. Rejected,
/// unavailable, or no-op/help-only commands discard staged submission state before returning.
///
/// A future command that submits derived user text should prefer
/// `submit_user_message_without_history`, or it can persist both the slash command and the
/// derived text as separate recall entries.
fn dispatch_command(&mut self, cmd: SlashCommand) {
if self.dispatch_command_inner(cmd) {
self.commit_pending_slash_command_history();
} else {
self.bottom_pane.drain_pending_submission_state();
}
}
fn dispatch_command_inner(&mut self, cmd: SlashCommand) -> bool {
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
let message = format!(
"'/{}' is disabled while a task is in progress.",
cmd.command()
);
self.add_to_history(history_cell::new_error_event(message));
self.bottom_pane.drain_pending_submission_state();
self.request_redraw();
return;
return false;
}
match cmd {
SlashCommand::Feedback => {
@@ -5005,25 +5077,30 @@ impl ChatWidget {
let params = crate::bottom_pane::feedback_disabled_params();
self.bottom_pane.show_selection_view(params);
self.request_redraw();
return;
return false;
}
// Step 1: pick a category (UI built in feedback_view)
let params =
crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone());
self.bottom_pane.show_selection_view(params);
self.request_redraw();
true
}
SlashCommand::New => {
self.app_event_tx.send(AppEvent::NewSession);
true
}
SlashCommand::Clear => {
self.app_event_tx.send(AppEvent::ClearUi);
true
}
SlashCommand::Resume => {
self.app_event_tx.send(AppEvent::OpenResumePicker);
true
}
SlashCommand::Fork => {
self.app_event_tx.send(AppEvent::ForkCurrentSession);
true
}
SlashCommand::Init => {
let init_target = match self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME) {
@@ -5032,7 +5109,7 @@ impl ChatWidget {
self.add_error_message(format!(
"Failed to prepare {DEFAULT_PROJECT_DOC_FILENAME}: {err}",
));
return;
return false;
}
};
if init_target.exists() {
@@ -5040,10 +5117,11 @@ impl ChatWidget {
"{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it."
);
self.add_info_message(message, /*hint*/ None);
return;
return false;
}
const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md");
self.submit_user_message(INIT_PROMPT.to_string().into());
self.submit_user_message_without_history(INIT_PROMPT.to_string().into());
true
}
SlashCommand::Compact => {
self.clear_token_usage();
@@ -5051,17 +5129,21 @@ impl ChatWidget {
self.bottom_pane.set_task_running(/*running*/ true);
}
self.app_event_tx.compact();
true
}
SlashCommand::Review => {
self.open_review_popup();
true
}
SlashCommand::Rename => {
self.session_telemetry
.counter("codex.thread.rename", /*inc*/ 1, &[]);
self.show_rename_prompt();
true
}
SlashCommand::Model => {
self.open_model_popup();
true
}
SlashCommand::Fast => {
let next_tier = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) {
@@ -5070,25 +5152,29 @@ impl ChatWidget {
Some(ServiceTier::Fast)
};
self.set_service_tier_selection(next_tier);
true
}
SlashCommand::Realtime => {
if !self.realtime_conversation_enabled() {
return;
return false;
}
if self.realtime_conversation.is_live() {
self.stop_realtime_conversation_from_ui();
} else {
self.start_realtime_conversation();
}
true
}
SlashCommand::Settings => {
if !self.realtime_audio_device_selection_enabled() {
return;
return false;
}
self.open_realtime_audio_popup();
true
}
SlashCommand::Personality => {
self.open_personality_popup();
true
}
SlashCommand::Plan => {
if !self.collaboration_modes_enabled() {
@@ -5096,15 +5182,17 @@ impl ChatWidget {
"Collaboration modes are disabled.".to_string(),
Some("Enable collaboration modes to use /plan.".to_string()),
);
return;
return false;
}
if let Some(mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) {
self.set_collaboration_mask(mask);
true
} else {
self.add_info_message(
"Plan mode unavailable right now.".to_string(),
/*hint*/ None,
);
false
}
}
SlashCommand::Collab => {
@@ -5113,18 +5201,22 @@ impl ChatWidget {
"Collaboration modes are disabled.".to_string(),
Some("Enable collaboration modes to use /collab.".to_string()),
);
return;
return false;
}
self.open_collaboration_modes_popup();
true
}
SlashCommand::Agent | SlashCommand::MultiAgents => {
self.app_event_tx.send(AppEvent::OpenAgentPicker);
true
}
SlashCommand::Approvals => {
self.open_permissions_popup();
true
}
SlashCommand::Permissions => {
self.open_permissions_popup();
true
}
SlashCommand::ElevateSandbox => {
#[cfg(target_os = "windows")]
@@ -5137,7 +5229,7 @@ impl ChatWidget {
{
// This command should not be visible/recognized outside degraded mode,
// but guard anyway in case something dispatches it directly.
return;
return false;
}
let Some(preset) = builtin_approval_presets()
@@ -5149,7 +5241,7 @@ impl ChatWidget {
self.add_error_message(
"Internal error: missing the 'auto' approval preset.".to_string(),
);
return;
return false;
};
if let Err(err) = self
@@ -5159,7 +5251,7 @@ impl ChatWidget {
.can_set(&preset.approval)
{
self.add_error_message(err.to_string());
return;
return false;
}
self.session_telemetry.counter(
@@ -5169,23 +5261,29 @@ impl ChatWidget {
);
self.app_event_tx
.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset });
true
}
#[cfg(not(target_os = "windows"))]
{
let _ = &self.session_telemetry;
// Not supported; on non-Windows this command should never be reachable.
};
false
}
}
SlashCommand::SandboxReadRoot => {
self.add_error_message(
"Usage: /sandbox-add-read-dir <absolute-directory-path>".to_string(),
);
false
}
SlashCommand::Experimental => {
self.open_experimental_popup();
true
}
SlashCommand::Quit | SlashCommand::Exit => {
self.commit_pending_slash_command_history();
self.request_quit_without_confirmation();
true
}
SlashCommand::Logout => {
if let Err(e) = codex_login::logout(
@@ -5194,7 +5292,9 @@ impl ChatWidget {
) {
tracing::error!("failed to logout: {e}");
}
self.commit_pending_slash_command_history();
self.request_quit_without_confirmation();
true
}
// SlashCommand::Undo => {
// self.app_event_tx.send(AppEvent::CodexOp(Op::Undo));
@@ -5215,6 +5315,7 @@ impl ChatWidget {
};
tx.send(AppEvent::DiffResult(text));
});
true
}
SlashCommand::Copy => {
let Some(text) = self.last_copyable_output.as_deref() else {
@@ -5223,7 +5324,7 @@ impl ChatWidget {
.to_string(),
/*hint*/ None,
);
return;
return false;
};
let copy_result = clipboard_text::copy_text_to_clipboard(text);
@@ -5243,12 +5344,15 @@ impl ChatWidget {
self.add_error_message(format!("Failed to copy to clipboard: {err}"))
}
}
true
}
SlashCommand::Mention => {
self.insert_str("@");
true
}
SlashCommand::Skills => {
self.open_skills_menu();
true
}
SlashCommand::Status => {
if self.should_prefetch_rate_limits() {
@@ -5263,39 +5367,51 @@ impl ChatWidget {
/*refreshing_rate_limits*/ false, /*request_id*/ None,
);
}
true
}
SlashCommand::DebugConfig => {
self.add_debug_config_output();
true
}
SlashCommand::Title => {
self.open_terminal_title_setup();
true
}
SlashCommand::Statusline => {
self.open_status_line_setup();
true
}
SlashCommand::Theme => {
self.open_theme_picker();
true
}
SlashCommand::Ps => {
self.add_ps_output();
true
}
SlashCommand::Stop => {
self.clean_background_terminals();
true
}
SlashCommand::MemoryDrop => {
self.add_app_server_stub_message("Memory maintenance");
false
}
SlashCommand::MemoryUpdate => {
self.add_app_server_stub_message("Memory maintenance");
false
}
SlashCommand::Mcp => {
self.add_mcp_output();
true
}
SlashCommand::Apps => {
self.add_connectors_output();
true
}
SlashCommand::Plugins => {
self.add_plugins_output();
true
}
SlashCommand::Rollout => {
if let Some(path) = self.rollout_path() {
@@ -5309,6 +5425,7 @@ impl ChatWidget {
/*hint*/ None,
);
}
true
}
SlashCommand::TestApproval => {
use std::collections::HashMap;
@@ -5340,19 +5457,54 @@ impl ChatWidget {
grant_root: Some(PathBuf::from("/tmp")),
},
);
true
}
}
}
/// Executes a slash command that was submitted with inline arguments.
///
/// Inline commands are validated in two phases. First, this method checks whether the command
/// accepts the provided arguments without consuming the visible draft. Once accepted, it asks
/// the bottom pane to prepare submission text, which may expand paste placeholders, trim text,
/// and drain draft metadata.
///
fn dispatch_command_with_args(
&mut self,
cmd: SlashCommand,
args: String,
_text_elements: Vec<TextElement>,
text_elements: Vec<TextElement>,
) {
match self.dispatch_command_with_args_inner(cmd, args, text_elements) {
SlashCommandDispatchOutcome::Committed => {
self.commit_pending_slash_command_history();
}
SlashCommandDispatchOutcome::RejectedKeepDraft => {
let _ = self.bottom_pane.take_pending_slash_command_history();
}
SlashCommandDispatchOutcome::RejectedDiscardPreparedState => {
self.bottom_pane.drain_pending_submission_state();
}
}
}
/// Returns how the staged slash-command history entry should be handled.
///
/// Returning `RejectedKeepDraft` after calling `prepare_inline_args_submission` would leave the
/// UI looking recoverable while metadata had already been consumed, so accepted command arms
/// should prepare only after their user-facing validation has passed.
fn dispatch_command_with_args_inner(
&mut self,
cmd: SlashCommand,
args: String,
_text_elements: Vec<TextElement>,
) -> SlashCommandDispatchOutcome {
if !cmd.supports_inline_args() {
self.dispatch_command(cmd);
return;
return if self.dispatch_command_inner(cmd) {
SlashCommandDispatchOutcome::Committed
} else {
SlashCommandDispatchOutcome::RejectedDiscardPreparedState
};
}
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
let message = format!(
@@ -5361,15 +5513,18 @@ impl ChatWidget {
);
self.add_to_history(history_cell::new_error_event(message));
self.request_redraw();
return;
return SlashCommandDispatchOutcome::RejectedKeepDraft;
}
let trimmed = args.trim();
match cmd {
SlashCommand::Fast => {
if trimmed.is_empty() {
self.dispatch_command(cmd);
return;
return if self.dispatch_command_inner(cmd) {
SlashCommandDispatchOutcome::Committed
} else {
SlashCommandDispatchOutcome::RejectedDiscardPreparedState
};
}
match trimmed.to_ascii_lowercase().as_str() {
"on" => self.set_service_tier_selection(Some(ServiceTier::Fast)),
@@ -5388,8 +5543,10 @@ impl ChatWidget {
}
_ => {
self.add_error_message("Usage: /fast [on|off|status]".to_string());
return SlashCommandDispatchOutcome::RejectedKeepDraft;
}
}
};
SlashCommandDispatchOutcome::Committed
}
SlashCommand::Rename if !trimmed.is_empty() => {
self.session_telemetry
@@ -5398,28 +5555,28 @@ impl ChatWidget {
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
return SlashCommandDispatchOutcome::RejectedKeepDraft;
};
let Some(name) = codex_core::util::normalize_thread_name(&prepared_args) else {
self.add_error_message("Thread name cannot be empty.".to_string());
return;
return SlashCommandDispatchOutcome::RejectedDiscardPreparedState;
};
let cell = Self::rename_confirmation_cell(&name, self.thread_id);
self.add_boxed_history(Box::new(cell));
self.request_redraw();
self.app_event_tx.set_thread_name(name);
self.bottom_pane.drain_pending_submission_state();
self.bottom_pane.drain_recent_submission_state();
SlashCommandDispatchOutcome::Committed
}
SlashCommand::Plan if !trimmed.is_empty() => {
self.dispatch_command(cmd);
if self.active_mode_kind() != ModeKind::Plan {
return;
if !self.dispatch_command_inner(cmd) || self.active_mode_kind() != ModeKind::Plan {
return SlashCommandDispatchOutcome::RejectedKeepDraft;
}
let Some((prepared_args, prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ true)
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
return SlashCommandDispatchOutcome::RejectedKeepDraft;
};
let local_images = self
.bottom_pane
@@ -5431,22 +5588,24 @@ impl ChatWidget {
remote_image_urls,
text_elements: prepared_elements,
mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(),
persist_to_history: false,
};
if self.is_session_configured() {
self.reasoning_buffer.clear();
self.full_reasoning_buffer.clear();
self.set_status_header(String::from("Working"));
self.submit_user_message(user_message);
self.submit_user_message_without_history(user_message);
} else {
self.queue_user_message(user_message);
}
SlashCommandDispatchOutcome::Committed
}
SlashCommand::Review if !trimmed.is_empty() => {
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
return SlashCommandDispatchOutcome::RejectedKeepDraft;
};
self.submit_op(AppCommand::review(ReviewRequest {
target: ReviewTarget::Custom {
@@ -5454,22 +5613,30 @@ impl ChatWidget {
},
user_facing_hint: None,
}));
self.bottom_pane.drain_pending_submission_state();
self.bottom_pane.drain_recent_submission_state();
SlashCommandDispatchOutcome::Committed
}
SlashCommand::SandboxReadRoot if !trimmed.is_empty() => {
let Some((prepared_args, _prepared_elements)) = self
.bottom_pane
.prepare_inline_args_submission(/*record_history*/ false)
else {
return;
return SlashCommandDispatchOutcome::RejectedKeepDraft;
};
self.app_event_tx
.send(AppEvent::BeginWindowsSandboxGrantReadRoot {
path: prepared_args,
});
self.bottom_pane.drain_pending_submission_state();
self.bottom_pane.drain_recent_submission_state();
SlashCommandDispatchOutcome::Committed
}
_ => {
if self.dispatch_command_inner(cmd) {
SlashCommandDispatchOutcome::Committed
} else {
SlashCommandDispatchOutcome::RejectedDiscardPreparedState
}
}
_ => self.dispatch_command(cmd),
}
}
@@ -5565,6 +5732,35 @@ impl ChatWidget {
}
fn submit_user_message(&mut self, user_message: UserMessage) {
self.submit_user_message_with_history(user_message, /*persist_to_history*/ true);
}
/// Submits a user message while suppressing cross-session history persistence.
///
/// Used for derived text that a slash command sends as a side effect (e.g.
/// `/init`'s generated prompt, `/plan <args>`'s argument text). The full
/// slash-command string is already persisted by the separate
/// `commit_pending_slash_command_history` path; recording the derived text
/// would create a confusing duplicate.
fn submit_user_message_without_history(&mut self, user_message: UserMessage) {
self.submit_user_message_with_history(user_message, /*persist_to_history*/ false);
}
/// Submits a user message and optionally records it in cross-session history.
///
/// The method applies both the call-site flag and the message's own `persist_to_history` flag.
/// This lets command-derived messages remain non-persistent while still flowing through the
/// ordinary queueing, pending-steer, rendering, and model-submission paths.
///
/// Passing `true` here does not override a message that already opted out. Accidentally
/// constructing a derived slash-command message with `persist_to_history = true` would make the
/// arguments persist in addition to the full slash command.
fn submit_user_message_with_history(
&mut self,
mut user_message: UserMessage,
persist_to_history: bool,
) {
user_message.persist_to_history &= persist_to_history;
if !self.is_session_configured() {
tracing::warn!("cannot submit user message before session is configured; queueing");
self.queued_user_messages.push_front(user_message);
@@ -5577,6 +5773,7 @@ impl ChatWidget {
remote_image_urls,
text_elements,
mention_bindings,
persist_to_history: message_persist_to_history,
} = user_message;
if text.is_empty() && local_images.is_empty() && remote_image_urls.is_empty() {
return;
@@ -5759,6 +5956,7 @@ impl ChatWidget {
remote_image_urls: remote_image_urls.clone(),
text_elements: text_elements.clone(),
mention_bindings: mention_bindings.clone(),
persist_to_history: message_persist_to_history,
},
compare_key: Self::pending_steer_compare_key_from_items(&items),
});
@@ -5789,7 +5987,7 @@ impl ChatWidget {
// Persist the text to cross-session message history. Mentions are
// encoded into placeholder syntax so recall can reconstruct the
// mention bindings in a future session.
if !text.is_empty() {
if persist_to_history && message_persist_to_history && !text.is_empty() {
let encoded_mentions = mention_bindings
.iter()
.map(|binding| LinkedMention {
@@ -5845,6 +6043,30 @@ impl ChatWidget {
self.needs_final_message_separator = false;
}
/// Finalizes the staged slash-command history entry after a successful dispatch.
///
/// Moves the entry from the composer's pending slot into the local history
/// ring (enabling Up-arrow recall) and emits `Op::AddToHistory` so the
/// backend persists it across sessions. Does nothing if no entry was staged.
fn commit_pending_slash_command_history(&mut self) {
let Some(history_entry) = self.bottom_pane.record_pending_slash_command_history() else {
return;
};
if history_entry.text.is_empty() {
return;
}
let encoded_mentions = history_entry
.mention_bindings
.iter()
.map(|binding| LinkedMention {
mention: binding.mention.clone(),
path: binding.path.clone(),
})
.collect::<Vec<_>>();
let history_text = encode_history_mentions(&history_entry.text, &encoded_mentions);
self.submit_op(Op::AddToHistory { text: history_text });
}
/// Restore the blocked submission draft without losing mention resolution state.
///
/// The blocked-image path intentionally keeps the draft in the composer so
@@ -10332,6 +10554,7 @@ impl ChatWidget {
remote_image_urls: Vec::new(),
text_elements: Vec::new(),
mention_bindings: Vec::new(),
persist_to_history: true,
};
if should_queue {
self.queue_user_message(user_message);

View File

@@ -582,6 +582,7 @@ async fn queued_restore_with_remote_images_keeps_local_placeholder_mapping() {
remote_image_urls: remote_image_urls.clone(),
text_elements: text_elements.clone(),
mention_bindings: Vec::new(),
persist_to_history: true,
});
assert_eq!(chat.bottom_pane.composer_text(), text);
@@ -610,6 +611,7 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() {
remote_image_urls: Vec::new(),
text_elements: Vec::new(),
mention_bindings: Vec::new(),
persist_to_history: true,
});
chat.refresh_pending_input_preview();
@@ -672,6 +674,7 @@ async fn remap_placeholders_uses_attachment_labels() {
local_images: attachments,
remote_image_urls: vec!["https://example.com/a.png".to_string()],
mention_bindings: Vec::new(),
persist_to_history: true,
};
let mut next_label = 3usize;
let remapped = remap_placeholders_for_message(message, &mut next_label);
@@ -738,6 +741,7 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() {
local_images: attachments,
remote_image_urls: Vec::new(),
mention_bindings: Vec::new(),
persist_to_history: true,
};
let mut next_label = 3usize;
let remapped = remap_placeholders_for_message(message, &mut next_label);

View File

@@ -958,6 +958,7 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() {
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}],
persist_to_history: true,
});
let Op::UserTurn { items, .. } = next_submit_op(&mut op_rx) else {

View File

@@ -38,6 +38,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
remote_image_urls: Vec::new(),
text_elements: first_elements,
mention_bindings: Vec::new(),
persist_to_history: true,
});
chat.queued_user_messages.push_back(UserMessage {
text: second_text,
@@ -48,6 +49,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() {
remote_image_urls: Vec::new(),
text_elements: second_elements,
mention_bindings: Vec::new(),
persist_to_history: true,
});
chat.refresh_pending_input_preview();
@@ -539,6 +541,7 @@ async fn item_completed_pops_pending_steer_with_local_image_and_text_elements()
remote_image_urls: Vec::new(),
text_elements,
mention_bindings: Vec::new(),
persist_to_history: true,
});
match next_submit_op(&mut op_rx) {

View File

@@ -1,6 +1,53 @@
use super::*;
use crate::mention_codec::LinkedMention;
use crate::mention_codec::encode_history_mentions;
use pretty_assertions::assert_eq;
fn configure_session(chat: &mut ChatWidget) {
let configured = codex_protocol::protocol::SessionConfiguredEvent {
session_id: ThreadId::new(),
forked_from_id: None,
thread_name: None,
model: "test-model".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
cwd: PathBuf::from("/home/user/project"),
reasoning_effort: Some(ReasoningEffortConfig::default()),
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: None,
};
chat.handle_codex_event(Event {
id: "session-configured".to_string(),
msg: EventMsg::SessionConfigured(configured),
});
}
fn drain_history_and_user_turn_ops(
op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>,
) -> (Vec<String>, Option<String>) {
let mut history_texts = Vec::new();
let mut user_turn_text = None;
while let Ok(op) = op_rx.try_recv() {
match op {
Op::AddToHistory { text } => history_texts.push(text),
Op::UserTurn { items, .. } => {
user_turn_text = items.into_iter().find_map(|item| match item {
UserInput::Text { text, .. } => Some(text),
_ => None,
});
}
_ => {}
}
}
(history_texts, user_turn_text)
}
#[tokio::test]
async fn slash_compact_eagerly_queues_follow_up_before_turn_start() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -79,6 +126,187 @@ async fn slash_init_skips_when_project_doc_exists() {
);
}
#[tokio::test]
async fn slash_init_before_session_configured_persists_command_but_not_generated_prompt() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let _ = drain_insert_history(&mut rx);
chat.bottom_pane
.set_composer_text("/init".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let (history_texts, user_turn_text) = drain_history_and_user_turn_ops(&mut op_rx);
assert_eq!(history_texts, vec!["/init".to_string()]);
assert_eq!(user_turn_text, None);
assert_eq!(chat.queued_user_messages.len(), 1);
let queued_prompt = chat
.queued_user_messages
.front()
.expect("expected /init to queue its generated prompt before session config");
assert!(!queued_prompt.text.is_empty());
assert!(!queued_prompt.persist_to_history);
}
#[tokio::test]
async fn bare_slash_command_is_added_to_persistent_history_and_recall() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.bottom_pane
.set_composer_text("/diff".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let (history_texts, user_turn_text) = drain_history_and_user_turn_ops(&mut op_rx);
assert_eq!(history_texts, vec!["/diff".to_string()]);
assert_eq!(user_turn_text, None);
let _ = drain_insert_history(&mut rx);
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(chat.bottom_pane.composer_text(), "/diff");
}
#[tokio::test]
async fn slash_plan_with_args_persists_exact_command_once_and_recalls_it() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
configure_session(&mut chat);
let _ = drain_insert_history(&mut rx);
chat.bottom_pane.set_composer_text(
"/plan investigate this".to_string(),
Vec::new(),
Vec::new(),
);
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let (history_texts, user_turn_text) = drain_history_and_user_turn_ops(&mut op_rx);
assert_eq!(user_turn_text, Some("investigate this".to_string()));
assert_eq!(history_texts, vec!["/plan investigate this".to_string()]);
let _ = drain_insert_history(&mut rx);
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(chat.bottom_pane.composer_text(), "/plan investigate this");
}
#[tokio::test]
async fn slash_plan_with_bound_mention_persists_encoded_history_text() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
configure_session(&mut chat);
let _ = drain_insert_history(&mut rx);
chat.bottom_pane.set_composer_text_with_mention_bindings(
"/plan ask $sample".to_string(),
Vec::new(),
Vec::new(),
vec![MentionBinding {
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}],
);
let result = chat
.bottom_pane
.handle_composer_key_event_without_popup(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::CommandWithArgs(cmd, args, text_elements) => {
chat.dispatch_command_with_args(cmd, args, text_elements);
}
other => panic!("expected inline-arg slash command, got {other:?}"),
}
let (history_texts, _user_turn_text) = drain_history_and_user_turn_ops(&mut op_rx);
assert_eq!(
history_texts,
vec![encode_history_mentions(
"/plan ask $sample",
&[LinkedMention {
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}],
)]
);
}
#[tokio::test]
async fn slash_quit_is_added_to_history_before_exit() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
configure_session(&mut chat);
let _ = drain_insert_history(&mut rx);
chat.bottom_pane
.set_composer_text("/quit".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let (history_texts, user_turn_text) = drain_history_and_user_turn_ops(&mut op_rx);
assert_eq!(history_texts, vec!["/quit".to_string()]);
assert_eq!(user_turn_text, None);
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
}
#[tokio::test]
async fn slash_rename_with_args_persists_exact_command_once_and_recalls_it() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
configure_session(&mut chat);
let _ = drain_insert_history(&mut rx);
chat.bottom_pane
.set_composer_text("/rename Better title".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let (history_texts, user_turn_text) = drain_history_and_user_turn_ops(&mut op_rx);
assert_eq!(history_texts, vec!["/rename Better title".to_string()]);
assert_eq!(user_turn_text, None);
let _ = drain_insert_history(&mut rx);
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(chat.bottom_pane.composer_text(), "/rename Better title");
}
#[tokio::test]
async fn rejected_inline_slash_command_keeps_visible_draft_metadata() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true);
let image_path = PathBuf::from("/tmp/plan.png");
chat.bottom_pane.set_composer_text_with_mention_bindings(
"/fast badarg $sample".to_string(),
Vec::new(),
vec![image_path.clone()],
vec![MentionBinding {
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}],
);
chat.set_remote_image_urls(vec!["https://example.com/one.png".to_string()]);
let result = chat
.bottom_pane
.handle_composer_key_event_without_popup(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::CommandWithArgs(cmd, args, text_elements) => {
chat.dispatch_command_with_args(cmd, args, text_elements);
}
other => panic!("expected inline-arg slash command, got {other:?}"),
}
assert_eq!(chat.bottom_pane.composer_text(), "/fast badarg $sample");
assert_eq!(
chat.bottom_pane.composer_local_image_paths(),
vec![image_path]
);
assert_eq!(
chat.bottom_pane.remote_image_urls(),
vec!["https://example.com/one.png".to_string()]
);
assert_eq!(
chat.bottom_pane.composer_mention_bindings(),
vec![MentionBinding {
mention: "sample".to_string(),
path: "plugin://sample@test".to_string(),
}]
);
assert!(drain_history_and_user_turn_ops(&mut op_rx).0.is_empty());
}
#[tokio::test]
async fn slash_quit_requests_exit() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;