mirror of
https://github.com/openai/codex.git
synced 2026-05-13 07:42:40 +00:00
Compare commits
4 Commits
xli-codex/
...
fcoury/sla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4960e5b1db | ||
|
|
28a5c6735a | ||
|
|
2a5ba61f1d | ||
|
|
5371cade71 |
@@ -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>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user