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()
}