tui: queue slash commands in order

Queue slash drafts while a turn is running, replay them through the same serialized slash-command path, and keep interactive command flows like /model, /review, /permissions, and /help consistent with that ordering model. Preserve queued slash command order when replay drains follow-up input.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Charles Cunningham
2026-03-16 20:48:58 -07:00
co-authored by Codex
parent b231db7dfa
commit 5bdcd2a8bc
27 changed files with 6175 additions and 954 deletions
+555 -121
View File
@@ -34,6 +34,7 @@ use crate::pager_overlay::Overlay;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::Renderable;
use crate::resume_picker::SessionSelection;
use crate::resume_picker::SessionTarget;
use crate::tui;
use crate::tui::TuiEvent;
use crate::update_action::UpdateAction;
@@ -53,6 +54,7 @@ use codex_core::config::types::ApprovalsReviewer;
use codex_core::config::types::ModelAvailabilityNuxConfig;
use codex_core::config_loader::ConfigLayerStackOrdering;
use codex_core::features::Feature;
use codex_core::find_thread_path_by_id_str;
use codex_core::models_manager::manager::RefreshStrategy;
use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
@@ -728,6 +730,7 @@ pub(crate) struct App {
primary_thread_id: Option<ThreadId>,
primary_session_configured: Option<SessionConfiguredEvent>,
pending_primary_events: VecDeque<Event>,
pending_async_queue_resume_barriers: usize,
}
#[derive(Default)]
@@ -755,6 +758,28 @@ fn normalize_harness_overrides_for_cwd(
}
impl App {
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn begin_async_queue_resume_barrier(&mut self) {
self.pending_async_queue_resume_barriers += 1;
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn finish_async_queue_resume_barrier(&mut self) {
if self.pending_async_queue_resume_barriers == 0 {
tracing::warn!("finished async queue-resume barrier with no pending barrier");
return;
}
self.pending_async_queue_resume_barriers -= 1;
}
fn maybe_resume_queued_inputs_after_app_events(&mut self, app_events_drained: bool) {
if !app_events_drained || self.pending_async_queue_resume_barriers != 0 {
return;
}
self.chat_widget.maybe_resume_queued_inputs_when_idle();
}
pub fn chatwidget_init_for_forked_or_resumed_thread(
&self,
tui: &mut tui::Tui,
@@ -834,6 +859,104 @@ impl App {
}
}
async fn resume_session_target(
&mut self,
tui: &mut tui::Tui,
target_session: SessionTarget,
) -> Result<AppRunControl> {
let current_cwd = self.config.cwd.clone();
let resume_cwd = match crate::resolve_cwd_for_resume_or_fork(
tui,
&self.config,
&current_cwd,
target_session.thread_id,
&target_session.path,
CwdPromptAction::Resume,
true,
)
.await?
{
crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd,
crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(),
crate::ResolveCwdOutcome::Exit => {
return Ok(self.handle_exit_mode(ExitMode::ShutdownFirst));
}
};
let mut resume_config = match self
.rebuild_config_for_resume_or_fallback(&current_cwd, resume_cwd)
.await
{
Ok(cfg) => cfg,
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to rebuild configuration for resume: {err}"
));
return Ok(AppRunControl::Continue);
}
};
self.apply_runtime_policy_overrides(&mut resume_config);
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.thread_id(),
self.chat_widget.thread_name(),
);
match self
.server
.resume_thread_from_rollout(
resume_config.clone(),
target_session.path.clone(),
self.auth_manager.clone(),
None,
)
.await
{
Ok(resumed) => {
let input_state = self.chat_widget.capture_thread_input_state();
self.shutdown_current_thread().await;
self.config = resume_config;
tui.set_notification_method(self.config.tui_notification_method);
self.file_search.update_search_dir(self.config.cwd.clone());
let init =
self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone());
self.chat_widget =
ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured);
self.reset_thread_event_state();
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> = vec![summary.usage_line.clone().into()];
if let Some(command) = summary.resume_command {
let spans = vec!["To continue this session, run ".into(), command.cyan()];
lines.push(spans.into());
}
self.chat_widget.add_plain_history_lines(lines);
}
self.restore_input_state_after_thread_switch(input_state);
}
Err(err) => {
let path_display = target_session.path.display();
self.chat_widget.add_error_message(format!(
"Failed to resume session from {path_display}: {err}"
));
}
}
Ok(AppRunControl::Continue)
}
async fn resume_session_by_thread_id(
&mut self,
tui: &mut tui::Tui,
thread_id: ThreadId,
) -> Result<AppRunControl> {
let Some(path) =
find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string()).await?
else {
self.chat_widget
.add_error_message(format!("No saved session found for thread {thread_id}."));
return Ok(AppRunControl::Continue);
};
self.resume_session_target(tui, SessionTarget { path, thread_id })
.await
}
fn apply_runtime_policy_overrides(&mut self, config: &mut Config) {
if let Some(policy) = self.runtime_approval_policy_override.as_ref()
&& let Err(err) = config.permissions.approval_policy.set(*policy)
@@ -1661,7 +1784,13 @@ impl App {
description: Some(uuid.clone()),
is_current: self.active_thread_id == Some(*thread_id),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::SelectAgentThread(id));
tx.send(AppEvent::HandleSlashCommandDraft(
crate::slash_command_invocation::SlashCommandInvocation::with_args(
crate::slash_command::SlashCommand::Agent,
[id.to_string()],
)
.into_user_message(),
));
})],
dismiss_on_select: true,
search_value: Some(format!("{name} {uuid}")),
@@ -1789,6 +1918,11 @@ impl App {
self.sync_active_agent_label();
}
fn restore_input_state_after_thread_switch(&mut self, input_state: Option<ThreadInputState>) {
self.chat_widget.restore_thread_input_state(input_state);
self.chat_widget.drain_queued_inputs_until_blocked();
}
async fn start_fresh_session_with_summary_hint(&mut self, tui: &mut tui::Tui) {
// Start a fresh in-memory session while preserving resumability via persisted rollout
// history.
@@ -1801,6 +1935,7 @@ impl App {
self.chat_widget.thread_id(),
self.chat_widget.thread_name(),
);
let input_state = self.chat_widget.capture_thread_input_state();
self.shutdown_current_thread().await;
let report = self
.server
@@ -1840,6 +1975,7 @@ impl App {
}
self.chat_widget.add_plain_history_lines(lines);
}
self.restore_input_state_after_thread_switch(input_state);
tui.frame_requester().schedule_frame();
}
@@ -1919,7 +2055,7 @@ impl App {
self.chat_widget
.set_queue_autosend_suppressed(/*suppressed*/ false);
if resume_restored_queue {
self.chat_widget.maybe_send_next_queued_input();
self.chat_widget.drain_queued_inputs_until_blocked();
}
self.refresh_status_line();
}
@@ -2191,6 +2327,7 @@ impl App {
primary_thread_id: None,
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_async_queue_resume_barriers: 0,
};
// On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows.
@@ -2209,6 +2346,7 @@ impl App {
.hide_world_writable_warning
.unwrap_or(false);
if should_check {
app.begin_async_queue_resume_barrier();
let cwd = app.config.cwd.clone();
let env_map: std::collections::HashMap<String, String> = std::env::vars().collect();
let tx = app.app_event_tx.clone();
@@ -2319,6 +2457,11 @@ impl App {
) {
waiting_for_initial_session_configured = false;
}
// Some replayed slash commands pause queue draining until their app-side updates,
// popup flows, or async follow-up work settle. Only resume once the app-event
// queue is fully drained and no background slash-command completions are still
// pending, so later queued input cannot interleave with those updates.
app.maybe_resume_queued_inputs_after_app_events(app_event_rx.is_empty());
match control {
AppRunControl::Continue => {}
AppRunControl::Exit(reason) => break Ok(reason),
@@ -2431,87 +2574,10 @@ impl App {
.await?
{
SessionSelection::Resume(target_session) => {
let current_cwd = self.config.cwd.clone();
let resume_cwd = match crate::resolve_cwd_for_resume_or_fork(
tui,
&self.config,
&current_cwd,
target_session.thread_id,
&target_session.path,
CwdPromptAction::Resume,
/*allow_prompt*/ true,
)
.await?
{
crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd,
crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(),
crate::ResolveCwdOutcome::Exit => {
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
}
};
let mut resume_config = match self
.rebuild_config_for_resume_or_fallback(&current_cwd, resume_cwd)
.await
{
Ok(cfg) => cfg,
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to rebuild configuration for resume: {err}"
));
return Ok(AppRunControl::Continue);
}
};
self.apply_runtime_policy_overrides(&mut resume_config);
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.thread_id(),
self.chat_widget.thread_name(),
self.chat_widget.handle_serialized_slash_command(
ChatWidget::resume_selection_draft(&target_session),
);
match self
.server
.resume_thread_from_rollout(
resume_config.clone(),
target_session.path.clone(),
self.auth_manager.clone(),
/*parent_trace*/ None,
)
.await
{
Ok(resumed) => {
self.shutdown_current_thread().await;
self.config = resume_config;
tui.set_notification_method(self.config.tui_notification_method);
self.file_search.update_search_dir(self.config.cwd.clone());
let init = self.chatwidget_init_for_forked_or_resumed_thread(
tui,
self.config.clone(),
);
self.chat_widget = ChatWidget::new_from_existing(
init,
resumed.thread,
resumed.session_configured,
);
self.reset_thread_event_state();
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> =
vec![summary.usage_line.clone().into()];
if let Some(command) = summary.resume_command {
let spans = vec![
"To continue this session, run ".into(),
command.cyan(),
];
lines.push(spans.into());
}
self.chat_widget.add_plain_history_lines(lines);
}
}
Err(err) => {
let path_display = target_session.path.display();
self.chat_widget.add_error_message(format!(
"Failed to resume session from {path_display}: {err}"
));
}
}
self.refresh_status_line();
}
SessionSelection::Exit
| SessionSelection::StartFresh
@@ -2521,6 +2587,12 @@ impl App {
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
tui.frame_requester().schedule_frame();
}
AppEvent::ResumeSession(thread_id) => {
return self.resume_session_by_thread_id(tui, thread_id).await;
}
AppEvent::ResumeSessionTarget(target_session) => {
return self.resume_session_target(tui, target_session).await;
}
AppEvent::ForkCurrentSession => {
self.session_telemetry.counter(
"codex.thread.fork",
@@ -2552,6 +2624,7 @@ impl App {
.await
{
Ok(forked) => {
let input_state = self.chat_widget.capture_thread_input_state();
self.shutdown_current_thread().await;
let init = self.chatwidget_init_for_forked_or_resumed_thread(
tui,
@@ -2575,6 +2648,7 @@ impl App {
}
self.chat_widget.add_plain_history_lines(lines);
}
self.restore_input_state_after_thread_switch(input_state);
}
Err(err) => {
let path_display = path.display();
@@ -2740,6 +2814,11 @@ impl App {
self.chat_widget.set_model(&model);
self.refresh_status_line();
}
AppEvent::HandleSlashCommandDraft(draft) => {
self.chat_widget.handle_serialized_slash_command(draft);
self.refresh_status_line();
}
AppEvent::BottomPaneViewCompleted => {}
AppEvent::UpdateCollaborationMode(mask) => {
self.chat_widget.set_collaboration_mask(mask);
self.refresh_status_line();
@@ -2769,12 +2848,14 @@ impl App {
}
AppEvent::OpenWorldWritableWarningConfirmation {
preset,
approvals_reviewer,
sample_paths,
extra_count,
failed_scan,
} => {
self.chat_widget.open_world_writable_warning_confirmation(
preset,
approvals_reviewer,
sample_paths,
extra_count,
failed_scan,
@@ -2794,10 +2875,17 @@ impl App {
self.launch_external_editor(tui).await;
}
}
AppEvent::OpenWindowsSandboxEnablePrompt { preset } => {
self.chat_widget.open_windows_sandbox_enable_prompt(preset);
AppEvent::OpenWindowsSandboxEnablePrompt {
preset,
approvals_reviewer,
} => {
self.chat_widget
.open_windows_sandbox_enable_prompt(preset, approvals_reviewer);
}
AppEvent::OpenWindowsSandboxFallbackPrompt { preset } => {
AppEvent::OpenWindowsSandboxFallbackPrompt {
preset,
approvals_reviewer,
} => {
self.session_telemetry.counter(
"codex.windows_sandbox.fallback_prompt_shown",
/*inc*/ 1,
@@ -2812,9 +2900,12 @@ impl App {
);
}
self.chat_widget
.open_windows_sandbox_fallback_prompt(preset);
.open_windows_sandbox_fallback_prompt(preset, approvals_reviewer);
}
AppEvent::BeginWindowsSandboxElevatedSetup { preset } => {
AppEvent::BeginWindowsSandboxElevatedSetup {
preset,
approvals_reviewer,
} => {
#[cfg(target_os = "windows")]
{
let policy = preset.sandbox.clone();
@@ -2832,12 +2923,14 @@ impl App {
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset,
mode: WindowsSandboxEnableMode::Elevated,
approvals_reviewer,
});
return Ok(AppRunControl::Continue);
}
self.chat_widget.show_windows_sandbox_setup_status();
self.windows_sandbox.setup_started_at = Some(Instant::now());
self.begin_async_queue_resume_barrier();
let session_telemetry = self.session_telemetry.clone();
tokio::task::spawn_blocking(move || {
let result = codex_core::windows_sandbox::run_elevated_setup(
@@ -2854,9 +2947,10 @@ impl App {
1,
&[],
);
AppEvent::EnableWindowsSandboxForAgentMode {
AppEvent::WindowsSandboxElevatedSetupCompleted {
preset: preset.clone(),
mode: WindowsSandboxEnableMode::Elevated,
approvals_reviewer,
setup_succeeded: true,
}
}
Err(err) => {
@@ -2888,7 +2982,11 @@ impl App {
error = %err,
"failed to run elevated Windows sandbox setup"
);
AppEvent::OpenWindowsSandboxFallbackPrompt { preset }
AppEvent::WindowsSandboxElevatedSetupCompleted {
preset,
approvals_reviewer,
setup_succeeded: false,
}
}
};
tx.send(event);
@@ -2896,10 +2994,40 @@ impl App {
}
#[cfg(not(target_os = "windows"))]
{
let _ = preset;
let _ = (preset, approvals_reviewer);
}
}
AppEvent::BeginWindowsSandboxLegacySetup { preset } => {
AppEvent::WindowsSandboxElevatedSetupCompleted {
preset,
approvals_reviewer,
setup_succeeded,
} => {
#[cfg(target_os = "windows")]
{
self.finish_async_queue_resume_barrier();
let event = if setup_succeeded {
AppEvent::EnableWindowsSandboxForAgentMode {
preset,
mode: WindowsSandboxEnableMode::Elevated,
approvals_reviewer,
}
} else {
AppEvent::OpenWindowsSandboxFallbackPrompt {
preset,
approvals_reviewer,
}
};
self.app_event_tx.send(event);
}
#[cfg(not(target_os = "windows"))]
{
let _ = (preset, approvals_reviewer, setup_succeeded);
}
}
AppEvent::BeginWindowsSandboxLegacySetup {
preset,
approvals_reviewer,
} => {
#[cfg(target_os = "windows")]
{
let policy = preset.sandbox.clone();
@@ -2909,36 +3037,70 @@ impl App {
std::env::vars().collect();
let codex_home = self.config.codex_home.clone();
let tx = self.app_event_tx.clone();
let session_telemetry = self.session_telemetry.clone();
self.chat_widget.show_windows_sandbox_setup_status();
self.begin_async_queue_resume_barrier();
tokio::task::spawn_blocking(move || {
if let Err(err) = codex_core::windows_sandbox::run_legacy_setup_preflight(
let preset_for_error = preset.clone();
let result = codex_core::windows_sandbox::run_legacy_setup_preflight(
&policy,
policy_cwd.as_path(),
command_cwd.as_path(),
&env_map,
codex_home.as_path(),
) {
session_telemetry.counter(
"codex.windows_sandbox.legacy_setup_preflight_failed",
1,
&[],
);
tracing::warn!(
error = %err,
"failed to preflight non-admin Windows sandbox setup"
);
}
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset,
mode: WindowsSandboxEnableMode::Legacy,
});
);
let event = match result {
Ok(()) => AppEvent::WindowsSandboxLegacySetupCompleted {
preset,
approvals_reviewer,
error: None,
},
Err(err) => {
tracing::error!(
error = %err,
"failed to run legacy Windows sandbox setup preflight"
);
AppEvent::WindowsSandboxLegacySetupCompleted {
preset: preset_for_error,
approvals_reviewer,
error: Some(err.to_string()),
}
}
};
tx.send(event);
});
}
#[cfg(not(target_os = "windows"))]
{
let _ = preset;
let _ = (preset, approvals_reviewer);
}
}
AppEvent::WindowsSandboxLegacySetupCompleted {
preset,
approvals_reviewer,
error,
} => {
#[cfg(target_os = "windows")]
{
self.finish_async_queue_resume_barrier();
match error {
None => {
self.app_event_tx
.send(AppEvent::EnableWindowsSandboxForAgentMode {
preset,
mode: WindowsSandboxEnableMode::Legacy,
approvals_reviewer,
});
}
Some(err) => {
self.chat_widget.add_error_message(format!(
"Failed to enable the Windows sandbox feature: {err}"
));
}
}
}
#[cfg(not(target_os = "windows"))]
{
let _ = (preset, approvals_reviewer, error);
}
}
AppEvent::BeginWindowsSandboxGrantReadRoot { path } => {
@@ -2998,7 +3160,11 @@ impl App {
));
}
},
AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => {
AppEvent::EnableWindowsSandboxForAgentMode {
preset,
mode,
approvals_reviewer,
} => {
#[cfg(target_os = "windows")]
{
self.chat_widget.clear_windows_sandbox_setup_status();
@@ -3054,6 +3220,7 @@ impl App {
self.app_event_tx.send(
AppEvent::OpenWorldWritableWarningConfirmation {
preset: Some(preset.clone()),
approvals_reviewer: Some(approvals_reviewer),
sample_paths,
extra_count,
failed_scan,
@@ -3064,7 +3231,7 @@ impl App {
Op::OverrideTurnContext {
cwd: None,
approval_policy: Some(preset.approval),
approvals_reviewer: Some(self.config.approvals_reviewer),
approvals_reviewer: Some(approvals_reviewer),
sandbox_policy: Some(preset.sandbox.clone()),
windows_sandbox_level: Some(windows_sandbox_level),
model: None,
@@ -3079,6 +3246,8 @@ impl App {
.send(AppEvent::UpdateAskForApprovalPolicy(preset.approval));
self.app_event_tx
.send(AppEvent::UpdateSandboxPolicy(preset.sandbox.clone()));
self.app_event_tx
.send(AppEvent::UpdateApprovalsReviewer(approvals_reviewer));
let _ = mode;
self.chat_widget.add_plain_history_lines(vec![
Line::from(vec!["• ".dim(), "Sandbox ready".into()]),
@@ -3103,7 +3272,7 @@ impl App {
}
#[cfg(not(target_os = "windows"))]
{
let _ = (preset, mode);
let _ = (preset, mode, approvals_reviewer);
}
}
AppEvent::PersistModelSelection { model, effort } => {
@@ -3323,6 +3492,7 @@ impl App {
&& policy_is_workspace_write_or_ro
&& !self.chat_widget.world_writable_warning_hidden();
if should_check {
self.begin_async_queue_resume_barrier();
let cwd = self.config.cwd.clone();
let env_map: std::collections::HashMap<String, String> =
std::env::vars().collect();
@@ -3495,15 +3665,16 @@ impl App {
AppEvent::OpenApprovalsPopup => {
self.chat_widget.open_approvals_popup();
}
AppEvent::WorldWritableScanCompleted => {
#[cfg(target_os = "windows")]
self.finish_async_queue_resume_barrier();
}
AppEvent::OpenAgentPicker => {
self.open_agent_picker().await;
}
AppEvent::SelectAgentThread(thread_id) => {
self.select_agent_thread(tui, thread_id).await?;
}
AppEvent::OpenSkillsList => {
self.chat_widget.open_skills_list();
}
AppEvent::OpenManageSkillsPopup => {
self.chat_widget.open_manage_skills_popup();
}
@@ -4186,11 +4357,13 @@ impl App {
// Scan failed: warn without examples.
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
preset: None,
approvals_reviewer: None,
sample_paths: Vec::new(),
extra_count: 0usize,
failed_scan: true,
});
}
tx.send(AppEvent::WorldWritableScanCompleted);
});
}
}
@@ -4201,6 +4374,7 @@ mod tests {
use crate::app_backtrack::BacktrackSelection;
use crate::app_backtrack::BacktrackState;
use crate::app_backtrack::user_count;
use crate::chatwidget::UserMessage;
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
use crate::chatwidget::tests::set_chatgpt_auth;
use crate::file_search::FileSearchManager;
@@ -4701,6 +4875,60 @@ mod tests {
}
}
#[tokio::test]
async fn thread_switch_restores_and_drains_queued_follow_up() {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let session_configured = Event {
id: "session-configured".to_string(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: thread_id,
forked_from_id: None,
thread_name: None,
model: "gpt-test".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("/tmp/project"),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
network_proxy: None,
rollout_path: Some(PathBuf::new()),
}),
};
app.chat_widget
.apply_external_edit("queued follow-up".to_string());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let input_state = app
.chat_widget
.capture_thread_input_state()
.expect("expected queued follow-up state");
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
make_chatwidget_manual_with_sender().await;
app.chat_widget = chat_widget;
app.chat_widget.handle_codex_event(session_configured);
while new_op_rx.try_recv().is_ok() {}
app.restore_input_state_after_thread_switch(Some(input_state));
match next_user_turn_op(&mut new_op_rx) {
Op::UserTurn { items, .. } => assert_eq!(
items,
vec![UserInput::Text {
text: "queued follow-up".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected queued follow-up submission, got {other:?}"),
}
}
#[tokio::test]
async fn replay_only_thread_keeps_restored_queue_visible() {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
@@ -5944,10 +6172,12 @@ guardian_approval = true
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
app_event_rx.try_recv(),
Ok(AppEvent::SelectAgentThread(selected_thread_id)) if selected_thread_id == thread_id
);
match app_event_rx.try_recv() {
Ok(AppEvent::HandleSlashCommandDraft(draft)) => {
assert_eq!(draft, UserMessage::from(format!("/agent {thread_id}")));
}
other => panic!("expected serialized agent slash draft, got {other:?}"),
}
Ok(())
}
@@ -6404,6 +6634,7 @@ guardian_approval = true
primary_thread_id: None,
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_async_queue_resume_barriers: 0,
}
}
@@ -6464,6 +6695,7 @@ guardian_approval = true
primary_thread_id: None,
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_async_queue_resume_barriers: 0,
},
rx,
op_rx,
@@ -7478,6 +7710,208 @@ guardian_approval = true
}
}
#[tokio::test]
async fn queued_personality_selection_resumes_followup_after_app_events_drain() {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
app.chat_widget.set_model("gpt-5.2-codex");
app.chat_widget
.set_feature_enabled(Feature::Personality, true);
app.chat_widget.handle_codex_event(Event {
id: "configured".into(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: ThreadId::new(),
forked_from_id: None,
thread_name: None,
model: "gpt-5.2-codex".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,
}),
});
app.chat_widget.handle_codex_event(Event {
id: "turn-started".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
model_context_window: None,
collaboration_mode_kind: Default::default(),
}),
});
while app_event_rx.try_recv().is_ok() {}
while op_rx.try_recv().is_ok() {}
app.chat_widget
.handle_serialized_slash_command(UserMessage::from("/personality pragmatic"));
app.chat_widget
.set_composer_text("followup".to_string(), Vec::new(), Vec::new());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
app.chat_widget.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: None,
}),
});
assert_eq!(
app.chat_widget.queued_user_message_texts(),
vec!["followup".to_string()]
);
assert!(
op_rx.try_recv().is_err(),
"queued follow-up should not submit before app events drain"
);
loop {
match app_event_rx.try_recv() {
Ok(AppEvent::CodexOp(Op::OverrideTurnContext {
personality: Some(Personality::Pragmatic),
..
})) => continue,
Ok(AppEvent::UpdatePersonality(Personality::Pragmatic)) => {
app.on_update_personality(Personality::Pragmatic);
break;
}
Ok(AppEvent::PersistPersonalitySelection {
personality: Personality::Pragmatic,
}) => continue,
Ok(_) => continue,
Err(TryRecvError::Empty) => panic!("expected personality update events"),
Err(TryRecvError::Disconnected) => panic!("expected personality update events"),
}
}
app.maybe_resume_queued_inputs_after_app_events(true);
match next_user_turn_op(&mut op_rx) {
Op::UserTurn {
items,
personality: Some(Personality::Pragmatic),
..
} => assert_eq!(
items,
vec![UserInput::Text {
text: "followup".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected Op::UserTurn with pragmatic personality, got {other:?}"),
}
}
#[tokio::test]
async fn queued_followup_waits_for_pending_async_resume_barrier() {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
app.chat_widget.set_model("gpt-5.2-codex");
app.chat_widget
.set_feature_enabled(Feature::Personality, true);
app.chat_widget.handle_codex_event(Event {
id: "configured".into(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: ThreadId::new(),
forked_from_id: None,
thread_name: None,
model: "gpt-5.2-codex".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,
}),
});
app.chat_widget.handle_codex_event(Event {
id: "turn-started".into(),
msg: EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
model_context_window: None,
collaboration_mode_kind: Default::default(),
}),
});
while app_event_rx.try_recv().is_ok() {}
while op_rx.try_recv().is_ok() {}
app.chat_widget
.handle_serialized_slash_command(UserMessage::from("/personality pragmatic"));
app.chat_widget
.set_composer_text("followup".to_string(), Vec::new(), Vec::new());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
app.chat_widget.handle_codex_event(Event {
id: "turn-complete".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: None,
}),
});
loop {
match app_event_rx.try_recv() {
Ok(AppEvent::CodexOp(Op::OverrideTurnContext {
personality: Some(Personality::Pragmatic),
..
})) => continue,
Ok(AppEvent::UpdatePersonality(Personality::Pragmatic)) => {
app.on_update_personality(Personality::Pragmatic);
break;
}
Ok(AppEvent::PersistPersonalitySelection {
personality: Personality::Pragmatic,
}) => continue,
Ok(_) => continue,
Err(TryRecvError::Empty) => panic!("expected personality update events"),
Err(TryRecvError::Disconnected) => panic!("expected personality update events"),
}
}
app.pending_async_queue_resume_barriers = 1;
app.maybe_resume_queued_inputs_after_app_events(true);
assert_eq!(
app.chat_widget.queued_user_message_texts(),
vec!["followup".to_string()]
);
assert!(
op_rx.try_recv().is_err(),
"queued follow-up should stay queued while async replay barriers are pending"
);
app.pending_async_queue_resume_barriers = 0;
app.maybe_resume_queued_inputs_after_app_events(true);
match next_user_turn_op(&mut op_rx) {
Op::UserTurn {
items,
personality: Some(Personality::Pragmatic),
..
} => assert_eq!(
items,
vec![UserInput::Text {
text: "followup".to_string(),
text_elements: Vec::new(),
}]
),
other => panic!("expected Op::UserTurn with pragmatic personality, got {other:?}"),
}
}
#[tokio::test]
async fn shutdown_first_exit_returns_immediate_exit_when_shutdown_submit_fails() {
let mut app = make_test_app().await;
+41 -3
View File
@@ -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;
@@ -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,
@@ -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),
@@ -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.
@@ -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.
@@ -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.
@@ -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,
+84 -72
View File
@@ -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)
//!
@@ -192,7 +192,6 @@ use crate::render::Insets;
use crate::render::RectExt;
use crate::render::renderable::Renderable;
use crate::slash_command::SlashCommand;
use crate::slash_command_invocation::SlashCommandInvocation;
use crate::style::user_message_style;
use codex_protocol::custom_prompts::CustomPrompt;
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
@@ -573,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;
}
@@ -1417,13 +1399,12 @@ impl ChatComposer {
return (InputResult::Command(cmd), true);
}
let bare_command =
SlashCommandInvocation::bare(cmd).into_prefixed_string();
let starts_with_cmd =
first_line.trim_start().starts_with(&bare_command);
let starts_with_cmd = first_line
.trim_start()
.starts_with(&format!("/{}", cmd.command()));
if !starts_with_cmd {
self.textarea
.set_text_clearing_elements(&format!("{bare_command} "));
.set_text_clearing_elements(&format!("/{} ", cmd.command()));
}
if !self.textarea.text().is_empty() {
cursor_target = Some(self.textarea.text().len());
@@ -2534,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 {
@@ -2562,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();
@@ -2582,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,
@@ -2602,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.
@@ -6434,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;
@@ -6690,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;
@@ -6712,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]
@@ -7638,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]
+102 -21
View File
@@ -12,7 +12,6 @@ use crate::render::RectExt;
use crate::slash_command::SlashCommand;
use codex_protocol::custom_prompts::CustomPrompt;
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
use std::collections::HashSet;
/// A selectable item in the popup: either a built-in command or a user prompt.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -24,7 +23,8 @@ pub(crate) enum CommandItem {
pub(crate) struct CommandPopup {
command_filter: String,
builtins: Vec<(&'static str, SlashCommand)>,
builtins: Vec<SlashCommand>,
reserved_builtin_names: std::collections::HashSet<String>,
prompts: Vec<CustomPrompt>,
state: ScrollState,
}
@@ -57,30 +57,27 @@ impl From<CommandPopupFlags> for slash_commands::BuiltinCommandFlags {
impl CommandPopup {
pub(crate) fn new(mut prompts: Vec<CustomPrompt>, flags: CommandPopupFlags) -> Self {
// Keep built-in availability in sync with the composer.
let builtins: Vec<(&'static str, SlashCommand)> =
slash_commands::builtins_for_input(flags.into())
.into_iter()
.filter(|(name, _)| !name.starts_with("debug"))
.collect();
let builtin_flags = flags.into();
let builtins = slash_commands::visible_builtins_for_input(builtin_flags)
.into_iter()
.filter(|cmd| !cmd.command().starts_with("debug"))
.collect();
// Exclude prompts that collide with builtin command names and sort by name.
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
prompts.retain(|p| !exclude.contains(&p.name));
let reserved_builtin_names =
slash_commands::reserved_builtin_names_for_input(builtin_flags);
prompts.retain(|p| !reserved_builtin_names.contains(&p.name));
prompts.sort_by(|a, b| a.name.cmp(&b.name));
Self {
command_filter: String::new(),
builtins,
reserved_builtin_names,
prompts,
state: ScrollState::new(),
}
}
pub(crate) fn set_prompts(&mut self, mut prompts: Vec<CustomPrompt>) {
let exclude: HashSet<String> = self
.builtins
.iter()
.map(|(n, _)| (*n).to_string())
.collect();
prompts.retain(|p| !exclude.contains(&p.name));
prompts.retain(|p| !self.reserved_builtin_names.contains(&p.name));
prompts.sort_by(|a, b| a.name.cmp(&b.name));
self.prompts = prompts;
}
@@ -137,7 +134,7 @@ impl CommandPopup {
let mut out: Vec<(CommandItem, Option<Vec<usize>>)> = Vec::new();
if filter.is_empty() {
// Built-ins first, in presentation order.
for (_, cmd) in self.builtins.iter() {
for cmd in self.builtins.iter() {
if !cmd.show_in_command_popup() {
continue;
}
@@ -157,6 +154,29 @@ impl CommandPopup {
let prompt_prefix_len = PROMPTS_CMD_PREFIX.chars().count() + 1;
let indices_for = |offset| Some((offset..offset + filter_chars).collect());
for cmd in self.builtins.iter() {
if cmd.command() == filter_lower.as_str() {
exact.push((CommandItem::Builtin(*cmd), indices_for(0)));
continue;
}
if cmd.command().starts_with(&filter_lower) {
prefix.push((CommandItem::Builtin(*cmd), indices_for(0)));
continue;
}
// Keep the popup searchable by accepted aliases, but keep rendering the
// canonical command name so the list stays deduplicated and stable.
if cmd.command_aliases().contains(&filter_lower.as_str()) {
exact.push((CommandItem::Builtin(*cmd), None));
continue;
}
if cmd
.command_aliases()
.iter()
.any(|alias| alias.starts_with(&filter_lower))
{
prefix.push((CommandItem::Builtin(*cmd), None));
}
}
let mut push_match =
|item: CommandItem, display: &str, name: Option<&str>, name_offset: usize| {
let display_lower = display.to_lowercase();
@@ -177,10 +197,6 @@ impl CommandPopup {
prefix.push((item, indices_for(offset)));
}
};
for (_, cmd) in self.builtins.iter() {
push_match(CommandItem::Builtin(*cmd), cmd.command(), None, 0);
}
// Support both search styles:
// - Typing "name" should surface "/prompts:name" results.
// - Typing "prompts:name" should also work.
@@ -335,6 +351,20 @@ mod tests {
}
}
#[test]
fn help_is_first_suggestion_for_root_popup() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
popup.on_composer_text_change("/".to_string());
let matches = popup.filtered_items();
match matches.first() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "help"),
Some(CommandItem::UserPrompt(_)) => {
panic!("unexpected prompt ranked before '/help' for '/'")
}
None => panic!("expected at least one match for '/'"),
}
}
#[test]
fn filtered_commands_keep_presentation_order_for_prefix() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
@@ -348,7 +378,7 @@ mod tests {
CommandItem::UserPrompt(_) => None,
})
.collect();
assert_eq!(cmds, vec!["model", "mention", "mcp"]);
assert_eq!(cmds, vec!["model", "mention", "mcp", "subagents"]);
}
#[test]
@@ -406,6 +436,31 @@ mod tests {
);
}
#[test]
fn prompt_name_collision_with_builtin_alias_is_ignored() {
let popup = CommandPopup::new(
vec![CustomPrompt {
name: "multi-agents".to_string(),
path: "/tmp/multi-agents.md".to_string().into(),
content: "should be ignored".to_string(),
description: None,
argument_hint: None,
}],
CommandPopupFlags::default(),
);
let items = popup.filtered_items();
let has_collision_prompt = items.into_iter().any(|it| match it {
CommandItem::UserPrompt(i) => popup
.prompt(i)
.is_some_and(|prompt| prompt.name == "multi-agents"),
CommandItem::Builtin(_) => false,
});
assert!(
!has_collision_prompt,
"prompt with builtin alias should be ignored"
);
}
#[test]
fn prompt_description_uses_frontmatter_metadata() {
let popup = CommandPopup::new(
@@ -474,6 +529,32 @@ mod tests {
assert!(items.contains(&CommandItem::Builtin(SlashCommand::Quit)));
}
#[test]
fn multi_agents_alias_matches_subagents_entry() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
popup.on_composer_text_change("/multi".to_string());
assert_eq!(
popup.selected_item(),
Some(CommandItem::Builtin(SlashCommand::MultiAgents))
);
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
})
.collect();
assert_eq!(cmds, vec!["subagents"]);
popup.on_composer_text_change("/multi-agents".to_string());
assert_eq!(
popup.selected_item(),
Some(CommandItem::Builtin(SlashCommand::MultiAgents))
);
}
#[test]
fn collab_command_hidden_when_collaboration_modes_disabled() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
@@ -17,10 +17,10 @@ use crate::render::Insets;
use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::slash_command::SlashCommand;
use crate::slash_command_invocation::SlashCommandInvocation;
use crate::style::user_message_style;
use codex_core::features::Feature;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use super::popup_consts::MAX_POPUP_ROWS;
@@ -30,7 +30,7 @@ use super::selection_popup_common::measure_rows_height;
use super::selection_popup_common::render_rows;
pub(crate) struct ExperimentalFeatureItem {
pub feature: Feature,
pub key: String,
pub name: String,
pub description: String,
pub enabled: bool,
@@ -198,15 +198,16 @@ impl BottomPaneView for ExperimentalFeaturesView {
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
// Save the updates
if !self.features.is_empty() {
let updates = self
.features
.iter()
.map(|item| (item.feature, item.enabled))
.collect();
self.app_event_tx
.send(AppEvent::UpdateFeatureFlags { updates });
let invocation = SlashCommandInvocation::with_args(
SlashCommand::Experimental,
self.features.iter().map(|item| {
format!("{}={}", item.key, if item.enabled { "on" } else { "off" })
}),
);
self.app_event_tx.send(AppEvent::HandleSlashCommandDraft(
invocation.into_user_message(),
));
}
self.complete = true;
+12 -1
View File
@@ -21,6 +21,8 @@ use crate::app_event::FeedbackCategory;
use crate::app_event_sender::AppEventSender;
use crate::history_cell;
use crate::render::renderable::Renderable;
use crate::slash_command::SlashCommand;
use crate::slash_command_invocation::SlashCommandInvocation;
use codex_protocol::protocol::SessionSource;
use super::CancellationEvent;
@@ -485,8 +487,17 @@ fn make_feedback_item(
description: &str,
category: FeedbackCategory,
) -> super::SelectionItem {
let token = match category {
FeedbackCategory::Bug => "bug",
FeedbackCategory::BadResult => "bad-result",
FeedbackCategory::GoodResult => "good-result",
FeedbackCategory::SafetyCheck => "safety-check",
FeedbackCategory::Other => "other",
};
let action: super::SelectionAction = Box::new(move |_sender: &AppEventSender| {
app_event_tx.send(AppEvent::OpenFeedbackConsent { category });
app_event_tx.send(AppEvent::HandleSlashCommandDraft(
SlashCommandInvocation::with_args(SlashCommand::Feedback, [token]).into_user_message(),
));
});
super::SelectionItem {
name: name.to_string(),
+495
View File
@@ -0,0 +1,495 @@
use std::cell::Cell;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use unicode_width::UnicodeWidthStr;
use crate::bottom_pane::BuiltinCommandFlags;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::bottom_pane_view::BottomPaneView;
use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS;
use crate::bottom_pane::selection_popup_common::render_menu_surface;
use crate::bottom_pane::visible_builtins_for_input;
use crate::key_hint;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_lines;
const HELP_VIEW_MIN_BODY_ROWS: u16 = 6;
#[derive(Clone, Copy)]
enum HelpRowWrap {
None,
Note,
Description,
Usage,
}
#[derive(Clone)]
struct HelpRow {
plain_text: String,
line: Line<'static>,
wrap: HelpRowWrap,
}
#[derive(Default)]
struct HelpSearch {
active_query: String,
input: Option<String>,
selected_match: usize,
}
pub(crate) struct SlashHelpView {
complete: bool,
rows: Vec<HelpRow>,
scroll_top: Cell<usize>,
follow_selected_match: Cell<bool>,
search: HelpSearch,
}
impl SlashHelpView {
pub(crate) fn new(flags: BuiltinCommandFlags) -> Self {
Self {
complete: false,
rows: Self::build_document(flags),
scroll_top: Cell::new(0),
follow_selected_match: Cell::new(false),
search: HelpSearch::default(),
}
}
fn visible_body_rows(area_height: u16) -> usize {
area_height
.saturating_sub(3)
.max(HELP_VIEW_MIN_BODY_ROWS)
.into()
}
fn build_document(flags: BuiltinCommandFlags) -> Vec<HelpRow> {
let mut rows = vec![
HelpRow {
plain_text: "Slash Commands".to_string(),
line: Line::from("Slash Commands".bold()),
wrap: HelpRowWrap::None,
},
HelpRow {
plain_text: String::new(),
line: Line::from(""),
wrap: HelpRowWrap::None,
},
HelpRow {
plain_text: "Type / to open the command popup. For commands with both a picker and an arg form, bare /command opens the picker and /command ... runs directly.".to_string(),
line: Line::from(
"Type / to open the command popup. For commands with both a picker and an arg form, bare /command opens the picker and /command ... runs directly."
.dim(),
),
wrap: HelpRowWrap::Note,
},
HelpRow {
plain_text: "Args use shell-style quoting; quote values with spaces.".to_string(),
line: Line::from("Args use shell-style quoting; quote values with spaces.".dim()),
wrap: HelpRowWrap::Note,
},
HelpRow {
plain_text: String::new(),
line: Line::from(""),
wrap: HelpRowWrap::None,
},
];
for cmd in visible_builtins_for_input(flags) {
rows.push(HelpRow {
plain_text: format!("/{}", cmd.command()),
line: Line::from(format!("/{}", cmd.command()).cyan().bold()),
wrap: HelpRowWrap::None,
});
rows.push(HelpRow {
plain_text: format!(" {}", cmd.description()),
line: Line::from(format!(" {}", cmd.description()).dim()),
wrap: HelpRowWrap::Description,
});
rows.push(HelpRow {
plain_text: " Usage:".to_string(),
line: Line::from(" Usage:".dim()),
wrap: HelpRowWrap::None,
});
for form in cmd.help_forms() {
let plain_text = if form.is_empty() {
format!("/{}", cmd.command())
} else {
format!("/{} {}", cmd.command(), form)
};
rows.push(HelpRow {
plain_text: plain_text.clone(),
line: Line::from(plain_text.cyan()),
wrap: HelpRowWrap::Usage,
});
}
rows.push(HelpRow {
plain_text: String::new(),
line: Line::from(""),
wrap: HelpRowWrap::None,
});
}
while rows.last().is_some_and(|row| row.plain_text.is_empty()) {
rows.pop();
}
rows
}
fn scroll_by(&mut self, delta: isize) {
self.scroll_top
.set(self.scroll_top.get().saturating_add_signed(delta));
self.follow_selected_match.set(false);
}
fn matching_logical_rows(rows: &[HelpRow], query: &str) -> Vec<usize> {
let query = query.to_ascii_lowercase();
rows.iter()
.enumerate()
.filter_map(|(idx, row)| {
row.plain_text
.to_ascii_lowercase()
.contains(query.as_str())
.then_some(idx)
})
.collect()
}
fn current_query(&self) -> Option<&str> {
if let Some(input) = self.search.input.as_deref() {
return (!input.is_empty()).then_some(input);
}
(!self.search.active_query.is_empty()).then_some(self.search.active_query.as_str())
}
fn search_indicator(
&self,
rows: &[HelpRow],
total_rows: usize,
visible_rows: usize,
scroll_top: usize,
) -> String {
let start_row = if total_rows == 0 { 0 } else { scroll_top + 1 };
let end_row = (scroll_top + visible_rows).min(total_rows);
let viewport = format!("{start_row}-{end_row}/{total_rows}");
let Some(query) = self.current_query() else {
return viewport;
};
let match_count = Self::matching_logical_rows(rows, query).len();
if self.search.input.is_some() {
return format!(
"{} match{} | {viewport}",
match_count,
if match_count == 1 { "" } else { "es" }
);
}
if match_count == 0 {
return format!("0/0 | {viewport}");
}
let current_match = self.search.selected_match.min(match_count - 1) + 1;
format!("{current_match}/{match_count} | {viewport}")
}
fn footer_line(&self) -> Line<'static> {
if let Some(input) = self.search.input.as_deref() {
return Line::from(vec![
"Search: ".dim(),
format!("/{input}").cyan(),
" | ".dim(),
key_hint::plain(KeyCode::Enter).into(),
" apply | ".dim(),
key_hint::plain(KeyCode::Esc).into(),
" cancel".dim(),
]);
}
let mut spans = vec![
key_hint::plain(KeyCode::Up).into(),
"/".into(),
key_hint::plain(KeyCode::Down).into(),
" scroll | [".dim(),
key_hint::ctrl(KeyCode::Char('p')).into(),
" / ".dim(),
key_hint::ctrl(KeyCode::Char('n')).into(),
"] page | ".dim(),
"/ search".dim(),
];
if !self.search.active_query.is_empty() {
spans.push(" | ".dim());
spans.push("n/p match".dim());
}
spans.extend([
" | ".dim(),
key_hint::plain(KeyCode::Esc).into(),
" close".dim(),
]);
Line::from(spans)
}
fn wrap_rows(rows: &[HelpRow], width: u16) -> (Vec<Line<'static>>, Vec<usize>, Vec<usize>) {
let width = width.max(24);
let note_opts = RtOptions::new(width as usize)
.initial_indent(Line::from(""))
.subsequent_indent(Line::from(""));
let description_opts = RtOptions::new(width as usize)
.initial_indent(Line::from(""))
.subsequent_indent(Line::from(" "));
let usage_opts = RtOptions::new(width as usize)
.initial_indent(Line::from(" "))
.subsequent_indent(Line::from(" "));
let mut wrapped_rows = Vec::new();
let mut row_starts = Vec::with_capacity(rows.len());
let mut row_ends = Vec::with_capacity(rows.len());
for row in rows {
row_starts.push(wrapped_rows.len());
let wrapped = match row.wrap {
HelpRowWrap::None => vec![row.line.clone()],
HelpRowWrap::Note => word_wrap_lines([row.line.clone()], note_opts.clone()),
HelpRowWrap::Description => {
word_wrap_lines([row.line.clone()], description_opts.clone())
}
HelpRowWrap::Usage => word_wrap_lines([row.line.clone()], usage_opts.clone()),
};
wrapped_rows.extend(wrapped);
row_ends.push(wrapped_rows.len());
}
(wrapped_rows, row_starts, row_ends)
}
fn move_to_match(&mut self, delta: isize) {
if self.search.input.is_some() || self.search.active_query.is_empty() {
return;
}
let matches = Self::matching_logical_rows(&self.rows, &self.search.active_query);
if matches.is_empty() {
return;
}
let next = (self.search.selected_match as isize + delta).rem_euclid(matches.len() as isize);
self.search.selected_match = next as usize;
self.follow_selected_match.set(true);
}
}
impl BottomPaneView for SlashHelpView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if let Some(input) = self.search.input.as_mut() {
match key_event {
KeyEvent {
code: KeyCode::Esc, ..
} => {
self.search.input = None;
}
KeyEvent {
code: KeyCode::Enter,
..
} => {
self.search.active_query = self.search.input.take().unwrap_or_default();
self.search.selected_match = 0;
self.follow_selected_match
.set(!self.search.active_query.is_empty());
}
KeyEvent {
code: KeyCode::Backspace,
..
} => {
input.pop();
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
input.push(c);
}
_ => {}
}
return;
}
match key_event {
KeyEvent {
code: KeyCode::Char('/'),
modifiers: KeyModifiers::NONE,
..
} => {
self.search.active_query.clear();
self.search.selected_match = 0;
self.follow_selected_match.set(false);
self.search.input = Some(String::new());
}
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('k'),
modifiers: KeyModifiers::NONE,
..
} => self.scroll_by(-1),
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
..
} => self.scroll_by(1),
KeyEvent {
code: KeyCode::PageUp,
..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
} => self.scroll_by(-(MAX_POPUP_ROWS as isize)),
KeyEvent {
code: KeyCode::PageDown,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
} => self.scroll_by(MAX_POPUP_ROWS as isize),
KeyEvent {
code: KeyCode::Esc, ..
} if !self.search.active_query.is_empty() => {
self.search.active_query.clear();
self.search.selected_match = 0;
self.follow_selected_match.set(false);
}
KeyEvent {
code: KeyCode::Char('q'),
modifiers: KeyModifiers::NONE,
..
} => {
self.on_ctrl_c();
}
KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::NONE,
..
} => self.move_to_match(1),
KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::NONE,
..
}
| KeyEvent {
code: KeyCode::Char('N'),
modifiers: KeyModifiers::SHIFT,
..
} => self.move_to_match(-1),
_ => {}
}
}
fn is_complete(&self) -> bool {
self.complete
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.complete = true;
CancellationEvent::Handled
}
fn prefer_esc_to_handle_key_event(&self) -> bool {
self.search.input.is_some() || !self.search.active_query.is_empty()
}
}
impl crate::render::renderable::Renderable for SlashHelpView {
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
let content_area = render_menu_surface(area, buf);
let [header_area, body_area, footer_area] = Layout::vertical([
Constraint::Length(2),
Constraint::Fill(1),
Constraint::Length(2),
])
.areas(content_area);
let [_footer_gap_area, footer_line_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(footer_area);
let (lines, row_starts, row_ends) = Self::wrap_rows(&self.rows, body_area.width);
let header_lines = lines.iter().take(2).cloned().collect::<Vec<_>>();
let mut body_lines = lines.iter().skip(2).cloned().collect::<Vec<_>>();
let visible_rows = Self::visible_body_rows(body_area.height);
let max_scroll = body_lines.len().saturating_sub(visible_rows);
let mut scroll_top = self.scroll_top.get().min(max_scroll);
if self.search.input.is_none()
&& !self.search.active_query.is_empty()
&& let Some(selected_row_idx) =
Self::matching_logical_rows(&self.rows, &self.search.active_query)
.get(self.search.selected_match)
.copied()
{
let start = row_starts[selected_row_idx].saturating_sub(2);
let end = row_ends[selected_row_idx].saturating_sub(2);
if self.follow_selected_match.get() || start < scroll_top {
scroll_top = start;
} else if end > scroll_top + visible_rows {
scroll_top = end.saturating_sub(visible_rows);
}
scroll_top = scroll_top.min(max_scroll);
self.scroll_top.set(scroll_top);
self.follow_selected_match.set(false);
for line in body_lines.iter_mut().take(end).skip(start) {
*line = line.clone().patch_style(Style::new().reversed());
}
}
self.scroll_top.set(scroll_top);
Paragraph::new(header_lines).render(header_area, buf);
Paragraph::new(body_lines.clone())
.scroll((scroll_top as u16, 0))
.render(body_area, buf);
let footer_line = self.footer_line();
Paragraph::new(footer_line.clone()).render(footer_line_area, buf);
let indicator =
self.search_indicator(&self.rows, body_lines.len(), visible_rows, scroll_top);
let indicator_width = UnicodeWidthStr::width(indicator.as_str()) as u16;
let footer_width = footer_line.width() as u16;
if footer_width + indicator_width + 2 <= footer_line_area.width {
Paragraph::new(indicator.dim()).render(
Rect::new(
footer_line_area.x + footer_line_area.width - indicator_width,
footer_line_area.y,
indicator_width,
footer_line_area.height,
),
buf,
);
}
}
fn desired_height(&self, width: u16) -> u16 {
let (wrapped_rows, _, _) = Self::wrap_rows(&self.rows, width.saturating_sub(4));
let content_rows = wrapped_rows.len() as u16;
content_rows.max(HELP_VIEW_MIN_BODY_ROWS + 4)
}
}
+75 -64
View File
@@ -15,6 +15,7 @@
//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle.
use std::path::PathBuf;
use crate::app_event::AppEvent;
use crate::app_event::ConnectorsSnapshot;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::pending_input_preview::PendingInputPreview;
@@ -31,6 +32,7 @@ use codex_core::features::Features;
use codex_core::plugins::PluginCapabilitySummary;
use codex_core::skills::model::SkillMetadata;
use codex_file_search::FileMatch;
use codex_protocol::protocol::Op;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_protocol::user_input::TextElement;
use crossterm::event::KeyCode;
@@ -79,6 +81,7 @@ pub mod custom_prompt_view;
mod experimental_features_view;
mod file_search_popup;
mod footer;
mod help_view;
mod list_selection_view;
mod prompt_args;
mod skill_popup;
@@ -95,6 +98,7 @@ pub(crate) use feedback_view::FeedbackAudience;
pub(crate) use feedback_view::feedback_disabled_params;
pub(crate) use feedback_view::feedback_selection_params;
pub(crate) use feedback_view::feedback_upload_consent_params;
pub(crate) use help_view::SlashHelpView;
pub(crate) use skills_toggle_view::SkillsToggleItem;
pub(crate) use skills_toggle_view::SkillsToggleView;
pub(crate) use status_line_setup::StatusLineItem;
@@ -137,18 +141,20 @@ pub(crate) enum CancellationEvent {
NotHandled,
}
use crate::bottom_pane::prompt_args::parse_slash_name;
use crate::status_indicator_widget::StatusDetailsCapitalization;
use crate::status_indicator_widget::StatusIndicatorWidget;
pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::ChatComposerConfig;
pub(crate) use chat_composer::InputResult;
use codex_protocol::custom_prompts::CustomPrompt;
use crate::status_indicator_widget::StatusDetailsCapitalization;
use crate::status_indicator_widget::StatusIndicatorWidget;
pub(crate) use experimental_features_view::ExperimentalFeatureItem;
pub(crate) use experimental_features_view::ExperimentalFeaturesView;
pub(crate) use list_selection_view::SelectionAction;
pub(crate) use list_selection_view::SelectionItem;
pub(crate) use prompt_args::parse_slash_name;
pub(crate) use slash_commands::BuiltinCommandFlags;
pub(crate) use slash_commands::find_builtin_command;
pub(crate) use slash_commands::visible_builtins_for_input;
/// Pane displayed in the lower half of the chat UI.
///
@@ -263,22 +269,10 @@ impl BottomPane {
self.request_redraw();
}
pub fn take_mention_bindings(&mut self) -> Vec<MentionBinding> {
self.composer.take_mention_bindings()
}
pub fn take_recent_submission_mention_bindings(&mut self) -> Vec<MentionBinding> {
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) {
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();
}
pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) {
self.composer.set_collaboration_modes_enabled(enabled);
self.request_redraw();
@@ -421,25 +415,15 @@ impl BottomPane {
self.request_redraw();
InputResult::None
} else {
let is_agent_command = self
.composer_text()
.lines()
.next()
.and_then(parse_slash_name)
.is_some_and(|(name, _, _)| name == "agent");
// If a task is running and a status line is visible, allow Esc to
// send an interrupt even while the composer has focus.
// When a popup is active, prefer dismissing it over interrupting the task.
// If a task is running, allow Esc to send an interrupt when no popup is active.
// Final-message streaming can temporarily hide the status widget, but that should not
// disable interrupt.
if key_event.code == KeyCode::Esc
&& matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
&& self.is_task_running
&& !is_agent_command
&& !self.composer.popup_active()
&& let Some(status) = &self.status
{
// Send Op::Interrupt
status.interrupt();
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
self.request_redraw();
return InputResult::None;
}
@@ -723,7 +707,6 @@ impl BottomPane {
if !was_running {
if self.status.is_none() {
self.status = Some(StatusIndicatorWidget::new(
self.app_event_tx.clone(),
self.frame_requester.clone(),
self.animations_enabled,
));
@@ -750,7 +733,6 @@ impl BottomPane {
pub(crate) fn ensure_status_indicator(&mut self) {
if self.status.is_none() {
self.status = Some(StatusIndicatorWidget::new(
self.app_event_tx.clone(),
self.frame_requester.clone(),
self.animations_enabled,
));
@@ -1029,6 +1011,7 @@ impl BottomPane {
fn on_active_view_complete(&mut self) {
self.resume_status_timer_after_modal();
self.set_composer_input_enabled(/*enabled*/ true, /*placeholder*/ None);
self.app_event_tx.send(AppEvent::BottomPaneViewCompleted);
}
fn pause_status_timer_for_modal(&mut self) {
@@ -1641,29 +1624,6 @@ mod tests {
assert!(snapshot.contains("[Image #2]"));
}
#[test]
fn drain_pending_submission_state_clears_remote_image_urls() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
skills: Some(Vec::new()),
});
pane.set_remote_image_urls(vec!["https://example.com/one.png".to_string()]);
assert_eq!(pane.remote_image_urls().len(), 1);
pane.drain_pending_submission_state();
assert!(pane.remote_image_urls().is_empty());
}
#[test]
fn esc_with_skill_popup_does_not_interrupt_task() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
@@ -1749,7 +1709,7 @@ mod tests {
}
#[test]
fn esc_with_agent_command_without_popup_does_not_interrupt_task() {
fn esc_with_agent_command_without_popup_interrupts_task() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
@@ -1765,8 +1725,8 @@ mod tests {
pane.set_task_running(true);
// Repro: `/agent ` hides the popup (cursor past command name). Esc should
// keep editing command text instead of interrupting the running task.
// `/agent ` hides the popup once the cursor moves past the command name.
// Without an active popup, Esc should interrupt even though the composer has text.
pane.insert_str("/agent ");
assert!(
!pane.composer.popup_active(),
@@ -1775,12 +1735,10 @@ mod tests {
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
while let Ok(ev) = rx.try_recv() {
assert!(
!matches!(ev, AppEvent::CodexOp(Op::Interrupt)),
"expected Esc to not send Op::Interrupt while typing `/agent`"
);
}
assert!(
matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))),
"expected Esc to send Op::Interrupt while typing `/agent` with no popup"
);
assert_eq!(pane.composer_text(), "/agent ");
}
@@ -1857,6 +1815,59 @@ mod tests {
);
}
#[test]
fn esc_with_nonempty_composer_interrupts_task_when_no_popup() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
skills: Some(Vec::new()),
});
pane.set_task_running(true);
pane.insert_str("still editing");
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(
matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))),
"expected Esc to send Op::Interrupt while composer has text and no popup is active"
);
assert_eq!(pane.composer_text(), "still editing");
}
#[test]
fn esc_interrupts_running_task_when_status_indicator_hidden() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
frame_requester: FrameRequester::test_dummy(),
has_input_focus: true,
enhanced_keys_supported: false,
placeholder_text: "Ask Codex to do anything".to_string(),
disable_paste_burst: false,
animations_enabled: true,
skills: Some(Vec::new()),
});
pane.set_task_running(true);
pane.hide_status_indicator();
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(
matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))),
"expected Esc to send Op::Interrupt even when the status indicator is hidden"
);
}
#[test]
fn esc_routes_to_handle_key_event_when_requested() {
#[derive(Default)]
+72 -14
View File
@@ -3,12 +3,13 @@
//! The same sandbox- and feature-gating rules are used by both the composer
//! and the command popup. Centralizing them here keeps those call sites small
//! and ensures they stay in sync.
use std::str::FromStr;
use std::collections::HashSet;
use codex_utils_fuzzy_match::fuzzy_match;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
use crate::slash_command::visible_built_in_slash_commands;
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct BuiltinCommandFlags {
@@ -21,30 +22,64 @@ pub(crate) struct BuiltinCommandFlags {
pub(crate) allow_elevate_sandbox: bool,
}
fn command_enabled_for_input(cmd: SlashCommand, flags: BuiltinCommandFlags) -> bool {
if !flags.allow_elevate_sandbox && cmd == SlashCommand::ElevateSandbox {
return false;
}
if !flags.collaboration_modes_enabled
&& matches!(cmd, SlashCommand::Collab | SlashCommand::Plan)
{
return false;
}
if !flags.connectors_enabled && cmd == SlashCommand::Apps {
return false;
}
if !flags.fast_command_enabled && cmd == SlashCommand::Fast {
return false;
}
if !flags.personality_command_enabled && cmd == SlashCommand::Personality {
return false;
}
if !flags.realtime_conversation_enabled && cmd == SlashCommand::Realtime {
return false;
}
if !flags.audio_device_selection_enabled && cmd == SlashCommand::Settings {
return false;
}
true
}
/// Return the built-ins that should be visible/usable for the current input.
pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static str, SlashCommand)> {
built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| flags.allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
.filter(|(_, cmd)| {
flags.collaboration_modes_enabled
|| !matches!(*cmd, SlashCommand::Collab | SlashCommand::Plan)
})
.filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Apps)
.filter(|(_, cmd)| flags.fast_command_enabled || *cmd != SlashCommand::Fast)
.filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality)
.filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime)
.filter(|(_, cmd)| flags.audio_device_selection_enabled || *cmd != SlashCommand::Settings)
.filter(|(_, cmd)| command_enabled_for_input(*cmd, flags))
.collect()
}
/// Return the visible built-ins once each, in popup presentation order.
pub(crate) fn visible_builtins_for_input(flags: BuiltinCommandFlags) -> Vec<SlashCommand> {
visible_built_in_slash_commands()
.into_iter()
.filter(|cmd| command_enabled_for_input(*cmd, flags))
.collect()
}
/// Find a single built-in command by exact name, after applying the gating rules.
pub(crate) fn find_builtin_command(name: &str, flags: BuiltinCommandFlags) -> Option<SlashCommand> {
let cmd = SlashCommand::from_str(name).ok()?;
builtins_for_input(flags)
.into_iter()
.any(|(_, visible_cmd)| visible_cmd == cmd)
.then_some(cmd)
.find(|(command_name, _)| *command_name == name)
.map(|(_, cmd)| cmd)
}
/// Return every builtin name that should be reserved against custom prompt collisions.
pub(crate) fn reserved_builtin_names_for_input(flags: BuiltinCommandFlags) -> HashSet<String> {
visible_builtins_for_input(flags)
.into_iter()
.flat_map(SlashCommand::all_command_names)
.map(str::to_string)
.collect()
}
/// Whether any visible built-in fuzzily matches the provided prefix.
@@ -101,6 +136,29 @@ mod tests {
);
}
#[test]
fn multi_agents_alias_still_resolves_for_dispatch() {
assert_eq!(
find_builtin_command("multi-agents", all_enabled_flags()),
Some(SlashCommand::MultiAgents)
);
assert_eq!(
find_builtin_command("subagents", all_enabled_flags()),
Some(SlashCommand::MultiAgents)
);
assert_eq!(SlashCommand::MultiAgents.command(), "subagents");
}
#[test]
fn visible_builtins_keep_multi_agents_deduplicated() {
let builtins = visible_builtins_for_input(all_enabled_flags());
let multi_agents: Vec<_> = builtins
.into_iter()
.filter(|cmd| *cmd == SlashCommand::MultiAgents)
.collect();
assert_eq!(multi_agents, vec![SlashCommand::MultiAgents]);
}
#[test]
fn fast_command_is_hidden_when_disabled() {
let mut flags = all_enabled_flags();
@@ -0,0 +1,12 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› / "
" "
" /help show slash command help "
" /model choose what model and reasoning effort to "
" use "
" /permissions choose what Codex is allowed to do "
" /experimental toggle experimental features "
@@ -0,0 +1,12 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› / "
" "
" /help show slash command help "
" /model choose what model and reasoning "
" effort to use "
" /permissions choose what Codex is allowed to do "
" /sandbox-add-read-dir let sandbox read a directory: / "
@@ -34,6 +34,8 @@ use crate::bottom_pane::bottom_pane_view::BottomPaneView;
use crate::bottom_pane::multi_select_picker::MultiSelectItem;
use crate::bottom_pane::multi_select_picker::MultiSelectPicker;
use crate::render::renderable::Renderable;
use crate::slash_command::SlashCommand;
use crate::slash_command_invocation::SlashCommandInvocation;
/// Available items that can be displayed in the status line.
///
@@ -231,12 +233,14 @@ impl StatusLineSetupView {
.enable_ordering()
.on_preview(move |items| preview_data.line_for_items(items))
.on_confirm(|ids, app_event| {
let items = ids
.iter()
.map(|id| id.parse::<StatusLineItem>())
.collect::<Result<Vec<_>, _>>()
.unwrap_or_default();
app_event.send(AppEvent::StatusLineSetup { items });
let invocation = if ids.is_empty() {
SlashCommandInvocation::with_args(SlashCommand::Statusline, ["none"])
} else {
SlashCommandInvocation::with_args(SlashCommand::Statusline, ids)
};
app_event.send(AppEvent::HandleSlashCommandDraft(
invocation.into_user_message(),
));
})
.on_cancel(|app_event| {
app_event.send(AppEvent::StatusLineSetupCancelled);
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -12,6 +12,8 @@ use crate::bottom_pane::SkillsToggleView;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::skills_helpers::skill_description;
use crate::skills_helpers::skill_display_name;
use crate::slash_command::SlashCommand;
use crate::slash_command_invocation::SlashCommandInvocation;
use codex_chatgpt::connectors::AppInfo;
use codex_core::connectors::connector_mention_slug;
use codex_core::mention_syntax::TOOL_MENTION_SIGIL;
@@ -34,7 +36,10 @@ impl ChatWidget {
name: "List skills".to_string(),
description: Some("Tip: press $ to open this list directly.".to_string()),
actions: vec![Box::new(|tx| {
tx.send(AppEvent::OpenSkillsList);
tx.send(AppEvent::HandleSlashCommandDraft(
SlashCommandInvocation::with_args(SlashCommand::Skills, ["list"])
.into_user_message(),
));
})],
dismiss_on_select: true,
..Default::default()
@@ -43,7 +48,10 @@ impl ChatWidget {
name: "Enable/Disable Skills".to_string(),
description: Some("Enable or disable skills.".to_string()),
actions: vec![Box::new(|tx| {
tx.send(AppEvent::OpenManageSkillsPopup);
tx.send(AppEvent::HandleSlashCommandDraft(
SlashCommandInvocation::with_args(SlashCommand::Skills, ["manage"])
.into_user_message(),
));
})],
dismiss_on_select: true,
..Default::default()
@@ -0,0 +1,22 @@
---
source: tui/src/chatwidget/tests.rs
expression: term.backend().vt100().screen().contents()
---
Select Model and Effort
Access legacy models by running codex -m <model_name> or in your config.toml
› 1. gpt-5.3-codex (default) Latest frontier agentic coding model.
2. gpt-5.4 Latest frontier agentic coding model.
3. gpt-5.2-codex Frontier agentic coding model.
4. gpt-5.1-codex-max Codex-optimized flagship for deep and fast
reasoning.
5. gpt-5.2 Latest frontier model with improvements across
knowledge, reasoning and coding
6. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less
capable.
Press enter to select reasoning effort, or esc to dismiss.
@@ -0,0 +1,223 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Slash Commands
Type / to open the command popup. For commands with both a picker and an arg form, bare /command
opens the picker and /command ... runs directly.
Args use shell-style quoting; quote values with spaces.
/help
show slash command help
Usage:
/help
/model
choose what model and reasoning effort to use
Usage:
/model
/model <model> [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]
/fast
toggle Fast mode to enable fastest inference at 2X plan usage
Usage:
/fast
/fast <on|off|status>
/approvals
choose what Codex is allowed to do
Usage:
/approvals
/approvals <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
[--enable-windows-sandbox=elevated|legacy]
/permissions
choose what Codex is allowed to do
Usage:
/permissions
/permissions <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
[--enable-windows-sandbox=elevated|legacy]
/experimental
toggle experimental features
Usage:
/experimental
/experimental <feature-key>=on|off ...
/skills
use skills to improve how Codex performs specific tasks
Usage:
/skills
/skills <list|manage>
/review
review my current changes and find issues
Usage:
/review
/review uncommitted
/review branch <name>
/review commit <sha> [title]
/review <instructions>
/rename
rename the current thread
Usage:
/rename
/rename <title...>
/new
start a new chat during a conversation
Usage:
/new
/resume
resume a saved chat
Usage:
/resume
/resume <thread-id>
/resume <thread-id> --path <rollout-path>
/fork
fork the current chat
Usage:
/fork
/init
create an AGENTS.md file with instructions for Codex
Usage:
/init
/compact
summarize conversation to prevent hitting the context limit
Usage:
/compact
/plan
switch to Plan mode
Usage:
/plan
/plan <prompt...>
/collab
change collaboration mode (experimental)
Usage:
/collab
/collab <default|plan>
/agent
switch the active agent thread
Usage:
/agent
/agent <thread-id>
/diff
show git diff (including untracked files)
Usage:
/diff
/copy
copy the latest Codex output to your clipboard
Usage:
/copy
/mention
mention a file
Usage:
/mention
/status
show current session configuration and token usage
Usage:
/status
/debug-config
show config layers and requirement sources for debugging
Usage:
/debug-config
/statusline
configure which items appear in the status line
Usage:
/statusline
/statusline <item-id>...
/statusline none
/theme
choose a syntax highlighting theme
Usage:
/theme
/theme <theme-name>
/mcp
list configured MCP tools
Usage:
/mcp
/logout
log out of Codex
Usage:
/logout
/quit
exit Codex
Usage:
/quit
/exit
exit Codex
Usage:
/exit
/feedback
send logs to maintainers
Usage:
/feedback
/feedback <bug|bad-result|good-result|safety-check|other>
/rollout
print the rollout file path
Usage:
/rollout
/ps
list background terminals
Usage:
/ps
/stop
stop all background terminals
Usage:
/stop
/clear
clear the terminal and start a new chat
Usage:
/clear
/personality
choose a communication style for Codex
Usage:
/personality
/personality <none|friendly|pragmatic>
/test-approval
test approval request
Usage:
/test-approval
/subagents
switch the active agent thread
Usage:
/subagents
/subagents <thread-id>
/debug-m-drop
DO NOT USE
Usage:
/debug-m-drop
↑/↓ scroll | [ctrl + p / ctrl + n] page | / search | esc close 1-212/219
@@ -0,0 +1,228 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Slash Commands
Type / to open the command popup. For commands with both a picker and an arg form, bare /command
opens the picker and /command ... runs directly.
Args use shell-style quoting; quote values with spaces.
/help
show slash command help
Usage:
/help
/model
choose what model and reasoning effort to use
Usage:
/model
/model <model> [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]
/fast
toggle Fast mode to enable fastest inference at 2X plan usage
Usage:
/fast
/fast <on|off|status>
/approvals
choose what Codex is allowed to do
Usage:
/approvals
/approvals <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
[--enable-windows-sandbox=elevated|legacy]
/permissions
choose what Codex is allowed to do
Usage:
/permissions
/permissions <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
[--enable-windows-sandbox=elevated|legacy]
/sandbox-add-read-dir
let sandbox read a directory: /sandbox-add-read-dir <absolute_path>
Usage:
/sandbox-add-read-dir <absolute-directory-path>
/experimental
toggle experimental features
Usage:
/experimental
/experimental <feature-key>=on|off ...
/skills
use skills to improve how Codex performs specific tasks
Usage:
/skills
/skills <list|manage>
/review
review my current changes and find issues
Usage:
/review
/review uncommitted
/review branch <name>
/review commit <sha> [title]
/review <instructions>
/rename
rename the current thread
Usage:
/rename
/rename <title...>
/new
start a new chat during a conversation
Usage:
/new
/resume
resume a saved chat
Usage:
/resume
/resume <thread-id>
/resume <thread-id> --path <rollout-path>
/fork
fork the current chat
Usage:
/fork
/init
create an AGENTS.md file with instructions for Codex
Usage:
/init
/compact
summarize conversation to prevent hitting the context limit
Usage:
/compact
/plan
switch to Plan mode
Usage:
/plan
/plan <prompt...>
/collab
change collaboration mode (experimental)
Usage:
/collab
/collab <default|plan>
/agent
switch the active agent thread
Usage:
/agent
/agent <thread-id>
/diff
show git diff (including untracked files)
Usage:
/diff
/copy
copy the latest Codex output to your clipboard
Usage:
/copy
/mention
mention a file
Usage:
/mention
/status
show current session configuration and token usage
Usage:
/status
/debug-config
show config layers and requirement sources for debugging
Usage:
/debug-config
/statusline
configure which items appear in the status line
Usage:
/statusline
/statusline <item-id>...
/statusline none
/theme
choose a syntax highlighting theme
Usage:
/theme
/theme <theme-name>
/mcp
list configured MCP tools
Usage:
/mcp
/logout
log out of Codex
Usage:
/logout
/quit
exit Codex
Usage:
/quit
/exit
exit Codex
Usage:
/exit
/feedback
send logs to maintainers
Usage:
/feedback
/feedback <bug|bad-result|good-result|safety-check|other>
/rollout
print the rollout file path
Usage:
/rollout
/ps
list background terminals
Usage:
/ps
/clean
stop all background terminals
Usage:
/clean
/clear
clear the terminal and start a new chat
Usage:
/clear
/personality
choose a communication style for Codex
Usage:
/personality
/personality <none|friendly|pragmatic>
/test-approval
test approval request
Usage:
/test-approval
/subagents
switch the active agent thread
Usage:
/subagents
/subagents <thread-id>
/debug-m-drop
DO NOT USE
Usage:
/debug-m-drop
↑/↓ scroll | [ctrl + p / ctrl + n] page | / search | esc close 1-217/224
@@ -0,0 +1,223 @@
---
source: tui/src/chatwidget/tests.rs
expression: searching
---
Slash Commands
Type / to open the command popup. For commands with both a picker and an arg form, bare /command
opens the picker and /command ... runs directly.
Args use shell-style quoting; quote values with spaces.
/help
show slash command help
Usage:
/help
/model
choose what model and reasoning effort to use
Usage:
/model
/model <model> [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]
/fast
toggle Fast mode to enable fastest inference at 2X plan usage
Usage:
/fast
/fast <on|off|status>
/approvals
choose what Codex is allowed to do
Usage:
/approvals
/approvals <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
[--enable-windows-sandbox=elevated|legacy]
/permissions
choose what Codex is allowed to do
Usage:
/permissions
/permissions <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
[--enable-windows-sandbox=elevated|legacy]
/experimental
toggle experimental features
Usage:
/experimental
/experimental <feature-key>=on|off ...
/skills
use skills to improve how Codex performs specific tasks
Usage:
/skills
/skills <list|manage>
/review
review my current changes and find issues
Usage:
/review
/review uncommitted
/review branch <name>
/review commit <sha> [title]
/review <instructions>
/rename
rename the current thread
Usage:
/rename
/rename <title...>
/new
start a new chat during a conversation
Usage:
/new
/resume
resume a saved chat
Usage:
/resume
/resume <thread-id>
/resume <thread-id> --path <rollout-path>
/fork
fork the current chat
Usage:
/fork
/init
create an AGENTS.md file with instructions for Codex
Usage:
/init
/compact
summarize conversation to prevent hitting the context limit
Usage:
/compact
/plan
switch to Plan mode
Usage:
/plan
/plan <prompt...>
/collab
change collaboration mode (experimental)
Usage:
/collab
/collab <default|plan>
/agent
switch the active agent thread
Usage:
/agent
/agent <thread-id>
/diff
show git diff (including untracked files)
Usage:
/diff
/copy
copy the latest Codex output to your clipboard
Usage:
/copy
/mention
mention a file
Usage:
/mention
/status
show current session configuration and token usage
Usage:
/status
/debug-config
show config layers and requirement sources for debugging
Usage:
/debug-config
/statusline
configure which items appear in the status line
Usage:
/statusline
/statusline <item-id>...
/statusline none
/theme
choose a syntax highlighting theme
Usage:
/theme
/theme <theme-name>
/mcp
list configured MCP tools
Usage:
/mcp
/logout
log out of Codex
Usage:
/logout
/quit
exit Codex
Usage:
/quit
/exit
exit Codex
Usage:
/exit
/feedback
send logs to maintainers
Usage:
/feedback
/feedback <bug|bad-result|good-result|safety-check|other>
/rollout
print the rollout file path
Usage:
/rollout
/ps
list background terminals
Usage:
/ps
/stop
stop all background terminals
Usage:
/stop
/clear
clear the terminal and start a new chat
Usage:
/clear
/personality
choose a communication style for Codex
Usage:
/personality
/personality <none|friendly|pragmatic>
/test-approval
test approval request
Usage:
/test-approval
/subagents
switch the active agent thread
Usage:
/subagents
/subagents <thread-id>
/debug-m-drop
DO NOT USE
Usage:
/debug-m-drop
Search: /maintainers | enter apply | esc cancel 1 match | 1-212/219
@@ -0,0 +1,228 @@
---
source: tui/src/chatwidget/tests.rs
expression: searching
---
Slash Commands
Type / to open the command popup. For commands with both a picker and an arg form, bare /command
opens the picker and /command ... runs directly.
Args use shell-style quoting; quote values with spaces.
/help
show slash command help
Usage:
/help
/model
choose what model and reasoning effort to use
Usage:
/model
/model <model> [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]
/fast
toggle Fast mode to enable fastest inference at 2X plan usage
Usage:
/fast
/fast <on|off|status>
/approvals
choose what Codex is allowed to do
Usage:
/approvals
/approvals <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
[--enable-windows-sandbox=elevated|legacy]
/permissions
choose what Codex is allowed to do
Usage:
/permissions
/permissions <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
[--enable-windows-sandbox=elevated|legacy]
/sandbox-add-read-dir
let sandbox read a directory: /sandbox-add-read-dir <absolute_path>
Usage:
/sandbox-add-read-dir <absolute-directory-path>
/experimental
toggle experimental features
Usage:
/experimental
/experimental <feature-key>=on|off ...
/skills
use skills to improve how Codex performs specific tasks
Usage:
/skills
/skills <list|manage>
/review
review my current changes and find issues
Usage:
/review
/review uncommitted
/review branch <name>
/review commit <sha> [title]
/review <instructions>
/rename
rename the current thread
Usage:
/rename
/rename <title...>
/new
start a new chat during a conversation
Usage:
/new
/resume
resume a saved chat
Usage:
/resume
/resume <thread-id>
/resume <thread-id> --path <rollout-path>
/fork
fork the current chat
Usage:
/fork
/init
create an AGENTS.md file with instructions for Codex
Usage:
/init
/compact
summarize conversation to prevent hitting the context limit
Usage:
/compact
/plan
switch to Plan mode
Usage:
/plan
/plan <prompt...>
/collab
change collaboration mode (experimental)
Usage:
/collab
/collab <default|plan>
/agent
switch the active agent thread
Usage:
/agent
/agent <thread-id>
/diff
show git diff (including untracked files)
Usage:
/diff
/copy
copy the latest Codex output to your clipboard
Usage:
/copy
/mention
mention a file
Usage:
/mention
/status
show current session configuration and token usage
Usage:
/status
/debug-config
show config layers and requirement sources for debugging
Usage:
/debug-config
/statusline
configure which items appear in the status line
Usage:
/statusline
/statusline <item-id>...
/statusline none
/theme
choose a syntax highlighting theme
Usage:
/theme
/theme <theme-name>
/mcp
list configured MCP tools
Usage:
/mcp
/logout
log out of Codex
Usage:
/logout
/quit
exit Codex
Usage:
/quit
/exit
exit Codex
Usage:
/exit
/feedback
send logs to maintainers
Usage:
/feedback
/feedback <bug|bad-result|good-result|safety-check|other>
/rollout
print the rollout file path
Usage:
/rollout
/ps
list background terminals
Usage:
/ps
/clean
stop all background terminals
Usage:
/clean
/clear
clear the terminal and start a new chat
Usage:
/clear
/personality
choose a communication style for Codex
Usage:
/personality
/personality <none|friendly|pragmatic>
/test-approval
test approval request
Usage:
/test-approval
/subagents
switch the active agent thread
Usage:
/subagents
/subagents <thread-id>
/debug-m-drop
DO NOT USE
Usage:
/debug-m-drop
Search: /maintainers | enter apply | esc cancel 1 match | 1-217/224
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -40,7 +40,7 @@ use unicode_width::UnicodeWidthStr;
const PAGE_SIZE: usize = 25;
const LOAD_NEAR_THRESHOLD: usize = 5;
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionTarget {
pub path: PathBuf,
pub thread_id: ThreadId,
+263 -150
View File
@@ -12,6 +12,7 @@ use strum_macros::IntoStaticStr;
pub enum SlashCommand {
// DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so
// more frequently used commands should be listed first.
Help,
Model,
Fast,
Approvals,
@@ -55,7 +56,7 @@ pub enum SlashCommand {
Realtime,
Settings,
TestApproval,
#[strum(serialize = "subagents")]
#[strum(serialize = "subagents", serialize = "multi-agents")]
MultiAgents,
// Debugging commands.
#[strum(serialize = "debug-m-drop")]
@@ -67,298 +68,320 @@ pub enum SlashCommand {
impl SlashCommand {
fn spec(self) -> SlashCommandSpec {
match self {
SlashCommand::Help => SlashCommandSpec {
description: "show slash command help",
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::Model => SlashCommandSpec {
description: "choose what model and reasoning effort to use",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
help_forms: &[
"",
"<model> [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]",
],
requires_interaction: true,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::Fast => SlashCommandSpec {
description: "toggle Fast mode to enable fastest inference at 2X plan usage",
supports_inline_args: true,
available_during_task: false,
is_visible: true,
help_forms: &["", "<on|off|status>"],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::Approvals => SlashCommandSpec {
description: "choose what Codex is allowed to do",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
help_forms: &[
"",
"<read-only|auto|full-access> [--smart-approvals] [--confirm-full-access] [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] [--enable-windows-sandbox=elevated|legacy]",
],
requires_interaction: true,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: false,
},
SlashCommand::Permissions => SlashCommandSpec {
description: "choose what Codex is allowed to do",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
help_forms: &[
"",
"<read-only|auto|full-access> [--smart-approvals] [--confirm-full-access] [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] [--enable-windows-sandbox=elevated|legacy]",
],
requires_interaction: true,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::ElevateSandbox => SlashCommandSpec {
description: "set up elevated agent sandbox",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::SandboxReadRoot => SlashCommandSpec {
description: "let sandbox read a directory: /sandbox-add-read-dir <absolute_path>",
supports_inline_args: true,
available_during_task: false,
is_visible: cfg!(target_os = "windows"),
help_forms: &["<absolute-directory-path>"],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::Experimental => SlashCommandSpec {
description: "toggle experimental features",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
help_forms: &["", "<feature-key>=on|off ..."],
requires_interaction: true,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::Skills => SlashCommandSpec {
description: "use skills to improve how Codex performs specific tasks",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
help_forms: &["", "<list|manage>"],
requires_interaction: true,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::Review => SlashCommandSpec {
description: "review my current changes and find issues",
supports_inline_args: true,
available_during_task: false,
is_visible: true,
help_forms: &[
"",
"uncommitted",
"branch <name>",
"commit <sha> [title]",
"<instructions>",
],
requires_interaction: true,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::Rename => SlashCommandSpec {
description: "rename the current thread",
supports_inline_args: true,
available_during_task: true,
is_visible: true,
help_forms: &["", "<title...>"],
requires_interaction: true,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::New => SlashCommandSpec {
description: "start a new chat during a conversation",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::Resume => SlashCommandSpec {
description: "resume a saved chat",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
help_forms: &["", "<thread-id>", "<thread-id> --path <rollout-path>"],
requires_interaction: true,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::Fork => SlashCommandSpec {
description: "fork the current chat",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::Init => SlashCommandSpec {
description: "create an AGENTS.md file with instructions for Codex",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::JustLikeUserMessage,
show_in_command_popup: true,
},
SlashCommand::Compact => SlashCommandSpec {
description: "summarize conversation to prevent hitting the context limit",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::Plan => SlashCommandSpec {
description: "switch to Plan mode",
supports_inline_args: true,
available_during_task: false,
is_visible: true,
help_forms: &["", "<prompt...>"],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::JustLikeUserMessage,
show_in_command_popup: true,
},
SlashCommand::Collab => SlashCommandSpec {
description: "change collaboration mode (experimental)",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
help_forms: &["", "<default|plan>"],
requires_interaction: true,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::Agent => SlashCommandSpec {
description: "switch the active agent thread",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
help_forms: &["", "<thread-id>"],
requires_interaction: true,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::Diff => SlashCommandSpec {
description: "show git diff (including untracked files)",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::Copy => SlashCommandSpec {
description: "copy the latest Codex output to your clipboard",
supports_inline_args: false,
available_during_task: true,
is_visible: !cfg!(target_os = "android"),
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::Mention => SlashCommandSpec {
description: "mention a file",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::Status => SlashCommandSpec {
description: "show current session configuration and token usage",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::DebugConfig => SlashCommandSpec {
description: "show config layers and requirement sources for debugging",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::Statusline => SlashCommandSpec {
description: "configure which items appear in the status line",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
help_forms: &["", "<item-id>...", "none"],
requires_interaction: true,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::Theme => SlashCommandSpec {
description: "choose a syntax highlighting theme",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
help_forms: &["", "<theme-name>"],
requires_interaction: true,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::Mcp => SlashCommandSpec {
description: "list configured MCP tools",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::Apps => SlashCommandSpec {
description: "manage apps",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::Logout => SlashCommandSpec {
description: "log out of Codex",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::Quit => SlashCommandSpec {
description: "exit Codex",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: false,
},
SlashCommand::Exit => SlashCommandSpec {
description: "exit Codex",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::Feedback => SlashCommandSpec {
description: "send logs to maintainers",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
help_forms: &["", "<bug|bad-result|good-result|safety-check|other>"],
requires_interaction: true,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::Rollout => SlashCommandSpec {
description: "print the rollout file path",
supports_inline_args: false,
available_during_task: true,
is_visible: cfg!(debug_assertions),
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::Ps => SlashCommandSpec {
description: "list background terminals",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::Stop => SlashCommandSpec {
description: "stop all background terminals",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::Clear => SlashCommandSpec {
description: "clear the terminal and start a new chat",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::Personality => SlashCommandSpec {
description: "choose a communication style for Codex",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
help_forms: &["", "<none|friendly|pragmatic>"],
requires_interaction: true,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::Realtime => SlashCommandSpec {
description: "toggle realtime voice mode (experimental)",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::Settings => SlashCommandSpec {
description: "configure realtime microphone/speaker",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
help_forms: &["", "<microphone|speaker> [default|<device-name>]"],
requires_interaction: true,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::TestApproval => SlashCommandSpec {
description: "test approval request",
supports_inline_args: false,
available_during_task: true,
is_visible: cfg!(debug_assertions),
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::MultiAgents => SlashCommandSpec {
description: "switch the active agent thread",
supports_inline_args: false,
available_during_task: true,
is_visible: true,
help_forms: &["", "<thread-id>"],
requires_interaction: true,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::MemoryDrop => SlashCommandSpec {
description: "DO NOT USE",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::MemoryUpdate => SlashCommandSpec {
description: "DO NOT USE",
supports_inline_args: false,
available_during_task: false,
is_visible: true,
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
}
@@ -372,42 +395,137 @@ impl SlashCommand {
/// Command string without the leading '/'. Provided for compatibility with
/// existing code that expects a method named `command()`.
pub fn command(self) -> &'static str {
self.into()
match self {
SlashCommand::MultiAgents => "subagents",
_ => self.into(),
}
}
/// Whether this command supports inline args (for example `/review ...`).
pub fn supports_inline_args(self) -> bool {
self.spec().supports_inline_args
/// Additional accepted built-in names besides `command()`.
pub fn command_aliases(self) -> &'static [&'static str] {
match self {
SlashCommand::Help
| SlashCommand::Model
| SlashCommand::Fast
| SlashCommand::Approvals
| SlashCommand::Permissions
| SlashCommand::ElevateSandbox
| SlashCommand::SandboxReadRoot
| SlashCommand::Experimental
| SlashCommand::Skills
| SlashCommand::Review
| SlashCommand::Rename
| SlashCommand::New
| SlashCommand::Resume
| SlashCommand::Fork
| SlashCommand::Init
| SlashCommand::Compact
| SlashCommand::Plan
| SlashCommand::Collab
| SlashCommand::Agent
| SlashCommand::Diff
| SlashCommand::Copy
| SlashCommand::Mention
| SlashCommand::Status
| SlashCommand::DebugConfig
| SlashCommand::Statusline
| SlashCommand::Theme
| SlashCommand::Mcp
| SlashCommand::Apps
| SlashCommand::Logout
| SlashCommand::Quit
| SlashCommand::Exit
| SlashCommand::Feedback
| SlashCommand::Rollout
| SlashCommand::Ps
| SlashCommand::Clear
| SlashCommand::Personality
| SlashCommand::Realtime
| SlashCommand::Settings
| SlashCommand::TestApproval
| SlashCommand::MemoryDrop
| SlashCommand::MemoryUpdate => &[],
SlashCommand::Stop => &["clean"],
SlashCommand::MultiAgents => &["multi-agents"],
}
}
/// Whether this command can be run while a task is in progress.
pub fn available_during_task(self) -> bool {
self.spec().available_during_task
pub fn all_command_names(self) -> impl Iterator<Item = &'static str> {
std::iter::once(self.command()).chain(self.command_aliases().iter().copied())
}
pub(crate) fn show_in_command_popup(self) -> bool {
/// Human-facing forms accepted by the TUI.
///
/// An empty string represents the bare `/command` form.
pub fn help_forms(self) -> &'static [&'static str] {
self.spec().help_forms
}
/// Whether bare dispatch opens interactive UI that should be resolved before queueing.
pub fn requires_interaction(self) -> bool {
self.spec().requires_interaction
}
/// How this command should behave when dispatched while another turn is running.
pub fn execution_kind(self) -> SlashCommandExecutionKind {
self.spec().execution_kind
}
pub fn show_in_command_popup(self) -> bool {
self.spec().show_in_command_popup
}
fn is_visible(self) -> bool {
self.spec().is_visible
match self {
SlashCommand::SandboxReadRoot => cfg!(target_os = "windows"),
SlashCommand::Copy => !cfg!(target_os = "android"),
SlashCommand::Rollout | SlashCommand::TestApproval => cfg!(debug_assertions),
_ => true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SlashCommandExecutionKind {
/// Behaves like a normal user message.
///
/// Enter should submit immediately when idle, and queue while a turn is running.
/// Use this for commands whose effect is "ask the model to do work now".
JustLikeUserMessage,
/// Does not become a user message, but changes state that affects future turns.
///
/// While a turn is running, it must queue and apply later in order.
ChangesTurnContext,
/// Does not submit model work and does not need to wait for the current turn.
///
/// Run it immediately, even while a turn is in progress.
Immediate,
}
#[derive(Clone, Copy)]
struct SlashCommandSpec {
description: &'static str,
help_forms: &'static [&'static str],
requires_interaction: bool,
execution_kind: SlashCommandExecutionKind,
show_in_command_popup: bool,
}
/// Return all built-in commands in a Vec paired with their command string.
pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
SlashCommand::iter()
.filter(|command| command.is_visible())
.map(|c| (c.command(), c))
.flat_map(|command| command.all_command_names().map(move |name| (name, command)))
.collect()
}
struct SlashCommandSpec {
description: &'static str,
supports_inline_args: bool,
available_during_task: bool,
is_visible: bool,
show_in_command_popup: bool,
/// Return all visible built-in commands once each, in presentation order.
pub fn visible_built_in_slash_commands() -> Vec<SlashCommand> {
SlashCommand::iter()
.filter(|command| command.is_visible())
.collect()
}
#[cfg(test)]
@@ -418,17 +536,12 @@ mod tests {
use super::SlashCommand;
#[test]
fn approvals_alias_is_hidden_from_command_popup() {
assert_eq!(SlashCommand::Approvals.show_in_command_popup(), false);
fn stop_command_is_canonical_name() {
assert_eq!(SlashCommand::Stop.command(), "stop");
}
#[test]
fn clean_alias_parses_to_stop_command() {
assert_eq!(SlashCommand::from_str("clean"), Ok(SlashCommand::Stop));
}
#[test]
fn stop_command_is_canonical_name() {
assert_eq!(SlashCommand::Stop.command(), "stop");
}
}
+45 -7
View File
@@ -1,9 +1,10 @@
use crate::chatwidget::UserMessage;
use crate::slash_command::SlashCommand;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SlashCommandInvocation {
command: SlashCommand,
args: Vec<String>,
pub(crate) command: SlashCommand,
pub(crate) args: Vec<String>,
}
impl SlashCommandInvocation {
@@ -14,7 +15,22 @@ impl SlashCommandInvocation {
}
}
pub(crate) fn into_prefixed_string(self) -> String {
pub(crate) fn with_args<I, S>(command: SlashCommand, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
command,
args: args.into_iter().map(Into::into).collect(),
}
}
pub(crate) fn parse_args(args: &str, usage: &str) -> Result<Vec<String>, String> {
shlex::split(args).ok_or_else(|| usage.to_string())
}
pub(crate) fn into_user_message(self) -> UserMessage {
let command = self.command.command();
let joined = match shlex::try_join(
std::iter::once(command).chain(self.args.iter().map(String::as_str)),
@@ -22,7 +38,7 @@ impl SlashCommandInvocation {
Ok(joined) => joined,
Err(err) => panic!("slash command invocation should serialize: {err}"),
};
format!("/{joined}")
format!("/{joined}").into()
}
}
@@ -32,9 +48,31 @@ mod tests {
use pretty_assertions::assert_eq;
#[test]
fn bare_invocation_serializes_with_leading_slash() {
let invocation = SlashCommandInvocation::bare(SlashCommand::Model);
fn serializes_quoted_args() {
let draft = SlashCommandInvocation::with_args(
SlashCommand::Review,
["branch main needs coverage".to_string()],
)
.into_user_message();
assert_eq!(invocation.into_prefixed_string(), "/model");
assert_eq!(
draft,
UserMessage::from("/review 'branch main needs coverage'")
);
}
#[test]
fn parses_shlex_args() {
let parsed = SlashCommandInvocation::parse_args("'branch main' --flag key=value", "usage")
.expect("quoted args should parse");
assert_eq!(
parsed,
vec![
"branch main".to_string(),
"--flag".to_string(),
"key=value".to_string()
]
);
}
}
+7 -36
View File
@@ -7,7 +7,6 @@
use std::time::Duration;
use std::time::Instant;
use codex_protocol::protocol::Op;
use crossterm::event::KeyCode;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -19,8 +18,6 @@ use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::exec_cell::spinner;
use crate::key_hint;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
@@ -53,7 +50,6 @@ pub(crate) struct StatusIndicatorWidget {
elapsed_running: Duration,
last_resume_at: Instant,
is_paused: bool,
app_event_tx: AppEventSender,
frame_requester: FrameRequester,
animations_enabled: bool,
}
@@ -76,11 +72,7 @@ pub fn fmt_elapsed_compact(elapsed_secs: u64) -> String {
}
impl StatusIndicatorWidget {
pub(crate) fn new(
app_event_tx: AppEventSender,
frame_requester: FrameRequester,
animations_enabled: bool,
) -> Self {
pub(crate) fn new(frame_requester: FrameRequester, animations_enabled: bool) -> Self {
Self {
header: String::from("Working"),
details: None,
@@ -91,16 +83,11 @@ impl StatusIndicatorWidget {
last_resume_at: Instant::now(),
is_paused: false,
app_event_tx,
frame_requester,
animations_enabled,
}
}
pub(crate) fn interrupt(&self) {
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
}
/// Update the animated header label (left of the brackets).
pub(crate) fn update_header(&mut self, header: String) {
self.header = header;
@@ -292,13 +279,10 @@ impl Renderable for StatusIndicatorWidget {
#[cfg(test)]
mod tests {
use super::*;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use std::time::Duration;
use std::time::Instant;
use tokio::sync::mpsc::unbounded_channel;
use pretty_assertions::assert_eq;
@@ -318,9 +302,7 @@ mod tests {
#[test]
fn renders_with_working_header() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
let w = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), true);
// Render into a fixed-size test terminal and snapshot the backend.
let mut terminal = Terminal::new(TestBackend::new(80, 2)).expect("terminal");
@@ -332,9 +314,7 @@ mod tests {
#[test]
fn renders_truncated() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
let w = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), true);
// Render into a fixed-size test terminal and snapshot the backend.
let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal");
@@ -346,9 +326,7 @@ mod tests {
#[test]
fn renders_wrapped_details_panama_two_lines() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), false);
let mut w = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), false);
w.update_details(
Some("A man a plan a canal panama".to_string()),
StatusDetailsCapitalization::CapitalizeFirst,
@@ -371,10 +349,7 @@ mod tests {
#[test]
fn timer_pauses_when_requested() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut widget =
StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
let mut widget = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), true);
let baseline = Instant::now();
widget.last_resume_at = baseline;
@@ -393,9 +368,7 @@ mod tests {
#[test]
fn details_overflow_adds_ellipsis() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
let mut w = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), true);
w.update_details(
Some("abcd abcd abcd abcd".to_string()),
StatusDetailsCapitalization::CapitalizeFirst,
@@ -413,9 +386,7 @@ mod tests {
#[test]
fn details_args_can_disable_capitalization_and_limit_lines() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
let mut w = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), true);
w.update_details(
Some("cargo test -p codex-core and then cargo test -p codex-tui".to_string()),
StatusDetailsCapitalization::Preserve,
+11 -5
View File
@@ -8,8 +8,8 @@
//! the preview panel and any visible code blocks.
//! - **Cancel-restore:** on dismiss (Esc / Ctrl+C) the `on_cancel` callback
//! restores the theme snapshot taken when the picker opened.
//! - **Persist on confirm:** the `AppEvent::SyntaxThemeSelected` action persists
//! `[tui] theme = "..."` to `config.toml` via `ConfigEditsBuilder`.
//! - **Persist on confirm:** the picker emits a canonical `/theme <name>` draft,
//! which then persists `[tui] theme = "..."` through normal slash-command handling.
//!
//! Two preview renderables adapt to terminal width:
//!
@@ -35,6 +35,8 @@ use crate::diff_render::push_wrapped_diff_line_with_style_context;
use crate::diff_render::push_wrapped_diff_line_with_syntax_and_style_context;
use crate::render::highlight;
use crate::render::renderable::Renderable;
use crate::slash_command::SlashCommand;
use crate::slash_command_invocation::SlashCommandInvocation;
use crate::status::format_directory_display;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -356,9 +358,13 @@ pub(crate) fn build_theme_picker_params(
dismiss_on_select: true,
search_value: Some(entry.name.clone()),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::SyntaxThemeSelected {
name: name_for_action.clone(),
});
tx.send(AppEvent::HandleSlashCommandDraft(
SlashCommandInvocation::with_args(
SlashCommand::Theme,
[name_for_action.clone()],
)
.into_user_message(),
));
})],
..Default::default()
}
+10
View File
@@ -1,3 +1,13 @@
# Slash commands
For an overview of Codex CLI slash commands, see [this documentation](https://developers.openai.com/codex/cli/slash-commands).
## TUI
In the TUI, type `/` to open the slash-command popup. The popup uses the same command order as the
in-app `/help` page, with `/help` pinned at the top for discovery.
For commands that have both an interactive picker flow and a direct argument form, the bare
`/command` form opens the picker and `/command ...` runs the direct argument form instead. Use
`/help` inside the TUI for the current list of supported commands and argument syntax. Argument
parsing uses shell-style quoting, so quote values with spaces when needed.
+2 -2
View File
@@ -115,8 +115,8 @@ the input starts with `!` (shell command).
5. Clears pending pastes on success and suppresses submission if the final text is empty and there
are no attachments.
The same preparation path is reused for slash commands with arguments (for example `/plan` and
`/review`) so pasted content and text elements are preserved when extracting args.
The same preparation path is reused for slash commands with arguments (for example `/model`,
`/plan`, and `/review`) so pasted content and text elements are preserved when extracting args.
The composer also treats the textarea kill buffer as separate editing state from the visible draft.
After submit or slash-command dispatch clears the textarea, the most recent `Ctrl+K` payload is