Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
676 changes: 555 additions & 121 deletions codex-rs/tui/src/app.rs

Large diffs are not rendered by default.

44 changes: 41 additions & 3 deletions codex-rs/tui/src/app_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ use codex_utils_approval_presets::ApprovalPreset;

use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::StatusLineItem;
use crate::chatwidget::UserMessage;
use crate::history_cell::HistoryCell;

use codex_core::config::types::ApprovalsReviewer;
Expand Down Expand Up @@ -97,6 +98,12 @@ pub(crate) enum AppEvent {
/// Open the resume picker inside the running TUI session.
OpenResumePicker,

/// Resume a saved session by thread id.
ResumeSession(ThreadId),

/// Resume a saved session using the exact picker-selected rollout target.
ResumeSessionTarget(crate::resume_picker::SessionTarget),

/// Fork the current session into a new thread.
ForkCurrentSession,

Expand Down Expand Up @@ -182,6 +189,14 @@ pub(crate) enum AppEvent {
/// Update the current model slug in the running app and widget.
UpdateModel(String),

/// Evaluate a serialized built-in slash-command draft. If a task is currently running, the
/// draft is queued and replayed later through the same path as queued composer input.
HandleSlashCommandDraft(UserMessage),

/// Notify the app that an interactive bottom-pane view finished, so queued replay can resume
/// once the UI is idle again.
BottomPaneViewCompleted,

/// Update the active collaboration mask in the running app and widget.
UpdateCollaborationMode(CollaborationModeMask),

Expand Down Expand Up @@ -253,6 +268,7 @@ pub(crate) enum AppEvent {
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
OpenWorldWritableWarningConfirmation {
preset: Option<ApprovalPreset>,
approvals_reviewer: Option<ApprovalsReviewer>,
/// Up to 3 sample world-writable directories to display in the warning.
sample_paths: Vec<String>,
/// If there are more than `sample_paths`, this carries the remaining count.
Expand All @@ -265,24 +281,44 @@ pub(crate) enum AppEvent {
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
OpenWindowsSandboxEnablePrompt {
preset: ApprovalPreset,
approvals_reviewer: ApprovalsReviewer,
},

/// Open the Windows sandbox fallback prompt after declining or failing elevation.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
OpenWindowsSandboxFallbackPrompt {
preset: ApprovalPreset,
approvals_reviewer: ApprovalsReviewer,
},

/// Begin the elevated Windows sandbox setup flow.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
BeginWindowsSandboxElevatedSetup {
preset: ApprovalPreset,
approvals_reviewer: ApprovalsReviewer,
},

/// Result of the elevated Windows sandbox setup flow.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
WindowsSandboxElevatedSetupCompleted {
preset: ApprovalPreset,
approvals_reviewer: ApprovalsReviewer,
setup_succeeded: bool,
},

/// Begin the non-elevated Windows sandbox setup flow.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
BeginWindowsSandboxLegacySetup {
preset: ApprovalPreset,
approvals_reviewer: ApprovalsReviewer,
},

/// Result of the non-elevated Windows sandbox setup flow.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
WindowsSandboxLegacySetupCompleted {
preset: ApprovalPreset,
approvals_reviewer: ApprovalsReviewer,
error: Option<String>,
},

/// Begin a non-elevated grant of read access for an additional directory.
Expand All @@ -298,11 +334,16 @@ pub(crate) enum AppEvent {
error: Option<String>,
},

/// Result of the asynchronous Windows world-writable scan.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
WorldWritableScanCompleted,

/// Enable the Windows sandbox feature and switch to Agent mode.
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
EnableWindowsSandboxForAgentMode {
preset: ApprovalPreset,
mode: WindowsSandboxEnableMode,
approvals_reviewer: ApprovalsReviewer,
},

/// Update the Windows sandbox feature mode without changing approval presets.
Expand Down Expand Up @@ -361,9 +402,6 @@ pub(crate) enum AppEvent {
/// Re-open the approval presets popup.
OpenApprovalsPopup,

/// Open the skills list popup.
OpenSkillsList,

/// Open the skills enable/disable picker.
OpenManageSkillsPopup,

Expand Down
146 changes: 80 additions & 66 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@
//!
//! The numeric auto-submit path used by the slash popup performs the same pending-paste expansion
//! and attachment pruning, and clears pending paste state on success.
//! Slash commands with arguments (like `/plan` and `/review`) reuse the same preparation path so
//! pasted content and text elements are preserved when extracting args.
//! Slash commands with arguments (like `/model`, `/plan`, and `/review`) reuse the same
//! preparation path so pasted content and text elements are preserved when extracting args.
//!
//! # Remote Image Rows (Up/Down/Delete)
//!
Expand Down Expand Up @@ -572,23 +572,6 @@ impl ChatComposer {
self.sync_popups();
}

pub(crate) fn take_mention_bindings(&mut self) -> Vec<MentionBinding> {
let elements = self.current_mention_elements();
let mut ordered = Vec::new();
for (id, mention) in elements {
if let Some(binding) = self.mention_bindings.remove(&id)
&& binding.mention == mention
{
ordered.push(MentionBinding {
mention: binding.mention,
path: binding.path,
});
}
}
self.mention_bindings.clear();
ordered
}

pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) {
self.collaboration_modes_enabled = enabled;
}
Expand Down Expand Up @@ -2532,9 +2515,6 @@ impl ChatComposer {
&& let Some(cmd) =
slash_commands::find_builtin_command(name, self.builtin_command_flags())
{
if self.reject_slash_command_if_unavailable(cmd) {
return Some(InputResult::None);
}
self.textarea.set_text_clearing_elements("");
Some(InputResult::Command(cmd))
} else {
Expand All @@ -2560,13 +2540,6 @@ impl ChatComposer {

let cmd = slash_commands::find_builtin_command(name, self.builtin_command_flags())?;

if !cmd.supports_inline_args() {
return None;
}
if self.reject_slash_command_if_unavailable(cmd) {
return Some(InputResult::None);
}

let mut args_elements =
Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements());
let trimmed_rest = rest.trim();
Expand All @@ -2580,10 +2553,10 @@ impl ChatComposer {

/// Expand pending placeholders and extract normalized inline-command args.
///
/// Inline-arg commands are initially dispatched using the raw draft so command rejection does
/// not consume user input. Once a command is accepted, this helper performs the usual
/// submission preparation (paste expansion, element trimming) and rebases element ranges from
/// full-text offsets to command-arg offsets.
/// Inline-arg commands are initially dispatched using the raw draft so command-specific
/// handling can decide whether to consume the input. Once a command is accepted, this helper
/// performs the usual submission preparation (paste expansion, element trimming) and rebases
/// element ranges from full-text offsets to command-arg offsets.
pub(crate) fn prepare_inline_args_submission(
&mut self,
record_history: bool,
Expand All @@ -2600,20 +2573,6 @@ impl ChatComposer {
Some((trimmed_rest.to_string(), args_elements))
}

fn reject_slash_command_if_unavailable(&self, cmd: SlashCommand) -> bool {
if !self.is_task_running || cmd.available_during_task() {
return false;
}
let message = format!(
"'/{}' is disabled while a task is in progress.",
cmd.command()
);
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_error_event(message),
)));
true
}

/// Translate full-text element ranges into command-argument ranges.
///
/// `rest_offset` is the byte offset where `rest` begins in the full text.
Expand Down Expand Up @@ -6432,6 +6391,69 @@ mod tests {
});
}

#[test]
fn slash_popup_help_first_for_root_ui() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;

let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);

let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);

type_chars_humanlike(&mut composer, &['/']);

let mut terminal = match Terminal::new(TestBackend::new(60, 8)) {
Ok(t) => t,
Err(e) => panic!("Failed to create terminal: {e}"),
};
terminal
.draw(|f| composer.render(f.area(), f.buffer_mut()))
.unwrap_or_else(|e| panic!("Failed to draw composer: {e}"));

if cfg!(target_os = "windows") {
insta::with_settings!({ snapshot_suffix => "windows" }, {
insta::assert_snapshot!("slash_popup_root", terminal.backend());
});
} else {
insta::assert_snapshot!("slash_popup_root", terminal.backend());
}
}

#[test]
fn slash_popup_help_first_for_root_logic() {
use super::super::command_popup::CommandItem;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/']);

match &composer.active_popup {
ActivePopup::Command(popup) => match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => {
assert_eq!(cmd.command(), "help")
}
Some(CommandItem::UserPrompt(_)) => {
panic!("unexpected prompt selected for '/'")
}
None => panic!("no selected command for '/'"),
},
_ => panic!("slash popup not active after typing '/'"),
}
}

#[test]
fn slash_popup_model_first_for_mo_ui() {
use ratatui::Terminal;
Expand Down Expand Up @@ -6688,7 +6710,7 @@ mod tests {
}

#[test]
fn slash_command_disabled_while_task_running_keeps_text() {
fn slash_command_while_task_running_still_dispatches() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
Expand All @@ -6710,24 +6732,16 @@ mod tests {
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));

assert_eq!(InputResult::None, result);
assert_eq!(
InputResult::CommandWithArgs(
SlashCommand::Review,
"these changes".to_string(),
Vec::new(),
),
result
);
assert_eq!("/review these changes", composer.textarea.text());

let mut found_error = false;
while let Ok(event) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
let message = cell
.display_lines(80)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(message.contains("disabled while a task is in progress"));
found_error = true;
break;
}
}
assert!(found_error, "expected error history cell to be sent");
assert!(rx.try_recv().is_err(), "no error should be emitted");
}

#[test]
Expand Down Expand Up @@ -7636,7 +7650,7 @@ mod tests {
composer.take_recent_submission_mention_bindings(),
mention_bindings
);
assert!(composer.take_mention_bindings().is_empty());
assert!(composer.mention_bindings().is_empty());
}

#[test]
Expand Down
Loading
Loading