Compare commits

...

67 Commits

Author SHA1 Message Date
Charles Cunningham
6c9bac8bdb tui: wait for async slash replay updates
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:36:33 -07:00
Charles Cunningham
ef28639b6e tui: accept plain p in help search
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:36:33 -07:00
Charles Cunningham
f701d9e056 tui: match slash popup aliases
Include builtin aliases when filtering the slash popup so valid
commands like /multi-agents still surface the canonical /subagents
entry. Reuse a shared command-name iterator for alias-aware builtin
lookup and reserved-name filtering.

Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:36:33 -07:00
Charles Cunningham
d9325105a1 tui: resume queued drafts after inline app events
Remove the extra app-side popup completion latch so queued replay resumes
whenever app events fully drain and ChatWidget is waiting to continue.
Add an app-level regression for queued /personality followed by a
queued user draft.

Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:36:33 -07:00
Charles Cunningham
9ec1452655 tui: simplify slash command policy wiring
Centralize slash command metadata, dedupe runtime builtin gating, and make popup-driven queued replay resume explicit.

Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:36:33 -07:00
Charles Cunningham
5d81980593 codex: fix windows help snapshots (#14170)
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:36:33 -07:00
Charles Cunningham
89f7c082cb codex: address PR review feedback (#14170)
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:36:33 -07:00
Charles Cunningham
3b8dbede74 codex: address PR review feedback (#14170)
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:36:33 -07:00
Charles Cunningham
11d49d9259 tui: queue smart approvals updates
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:36:33 -07:00
Charles Cunningham
8d3ed74adc tui: defer windows sandbox popup selection
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:36:06 -07:00
Charles Cunningham
6cc147624e tui: fix windows selection action inference
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:36:06 -07:00
Charles Cunningham
f34490a3f2 tui: gate inline feedback command
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:36:06 -07:00
Charles Cunningham
6d0be7f9d8 tui: preserve queued drafts across session switches
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:36:06 -07:00
Charles Cunningham
e0627c8f37 tui: reserve builtin aliases in command popup
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:36:06 -07:00
Charles Cunningham
a6ffd1c887 tui: gate slash help by runtime availability
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:36:06 -07:00
Charles Cunningham
1a54e52fd9 tui: dedupe alias-expanded popup commands
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:36:05 -07:00
Charles Cunningham
d65960d57b tui: restore multi-agents slash alias
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:53 -07:00
Charles Cunningham
90e964fed4 tui: pause replay after personality updates
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:53 -07:00
Charles Cunningham
eb31929288 tui: align slash help subagents naming
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:53 -07:00
Charles Cunningham
39f436fd69 tui: complete legacy sandbox preflight asynchronously
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:52 -07:00
Charles Cunningham
51d1e061f8 tui: encode queued resume paths losslessly
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:52 -07:00
Charles Cunningham
5ebd3486a8 tui: refresh windows slash help snapshots
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:52 -07:00
Charles Cunningham
7e91f70be0 tui: run legacy sandbox preflight before enable
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:52 -07:00
Charles Cunningham
027524dac5 tui: classify slash commands by execution kind
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:52 -07:00
Charles Cunningham
02f6733dea tui: clear empty-arg slash drafts on dispatch
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:52 -07:00
Charles Cunningham
a80a654b04 tui: honor resume cwd prompt exits
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:52 -07:00
Charles Cunningham
3b4b48320e tui: route elevated approvals through setup flow
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:52 -07:00
Charles Cunningham
295f7fe976 tui: serialize exact resume picker targets
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:52 -07:00
Charles Cunningham
7bcff58ea4 tui: reject unavailable queued slash drafts on replay
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:52 -07:00
Charles Cunningham
ef2b09c61a tui: preserve selected resume target path
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:52 -07:00
Charles Cunningham
648739854e codex: fix windows slash help snapshots (#14170)
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:52 -07:00
Charles Cunningham
3a6e3a95be tui: remove dead windows sandbox event
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:52 -07:00
Charles Cunningham
614936d113 tui: fix duplicate windows sandbox import
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:51 -07:00
Charles Cunningham
96fe896919 codex: fix CI failure on PR #14170
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:51 -07:00
Charles Cunningham
7250e82a9b tui: preserve world-writable warning opt-out
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:51 -07:00
Charles Cunningham
d8c0839fc2 tui: reject unavailable queued slash drafts
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:51 -07:00
Charles Cunningham
efa6715884 tui: cfg-gate realtime settings draft helper
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:51 -07:00
Charles Cunningham
93ead12235 tui: block queued replay behind open popups
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:51 -07:00
Charles Cunningham
2268f98488 tui: clean up slash help footer hints
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:51 -07:00
Charles Cunningham
e477780626 tui: clear slash help search before closing
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:51 -07:00
Charles Cunningham
541bc6ef34 tui: let shift-n go backward in slash help search
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:51 -07:00
Charles Cunningham
56c7646161 tui: shorten slash help close hint
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:51 -07:00
Charles Cunningham
0727144012 tui: space slash help footer
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:51 -07:00
Charles Cunningham
6e20c5af8c tui: refine slash help search controls
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:51 -07:00
Charles Cunningham
77e1e8029a tui: add slash help search and status
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:51 -07:00
Charles Cunningham
8fbc9d32fd tui: let q dismiss slash help
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:51 -07:00
Charles Cunningham
528e3df3f6 tui: let slash help grow with terminal
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:50 -07:00
Charles Cunningham
6a278e943f tui: make slash help dismissible
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:50 -07:00
Charles Cunningham
8fda4e0fc2 tui: add slash command help page
Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:50 -07:00
Charles Cunningham
ad586ba24c tui: share slash command shlex codec
Centralize slash command draft serialization on top of shlex so popup flows and queued replay use the same quoting and tokenization rules.

Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:50 -07:00
Charles Cunningham
7ea03de12b tui: resume queued replay after idle slash actions
Pause queued replay on local slash actions that need app-side updates or popup dismissal, then resume once the app event queue and bottom pane are idle. Add regression coverage for queued theme replay and popup-gated resume.

Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:50 -07:00
Charles Cunningham
bcb3555e70 tui: reject repeated model modifiers
Track whether /model effort and scope modifiers were provided so repeated sentinel values like default and global are rejected consistently.

Validation: cargo test -p codex-tui; just fix -p codex-tui; just fmt

Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:50 -07:00
Charles Cunningham
8c45c1acfc tui: canonicalize interactive slash drafts
Route interactive slash-command pickers through canonical serialized drafts so live dispatch and queued replay share one parser/executor path.

Validation: cargo test -p codex-tui; just fix -p codex-tui; just fmt

Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:50 -07:00
Charles Cunningham
217da0c113 tui: make slash command helpers exhaustive
Switch supports_inline_args() and requires_interaction() to exhaustive matches so newly added SlashCommand variants must be classified explicitly.

Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:50 -07:00
Charles Cunningham
bb3e61843a tui: clarify queue replay stop comment
Explain that the busy-path Stop return in dispatch_command() only matters for live dispatch because queued replay never reaches that branch.

Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:50 -07:00
Charles Cunningham
173d0ef4e3 tui: resolve interactive slash commands before queueing
Mark popup-opening slash commands as requiring interaction, open them immediately instead of queueing bare drafts while a task is running, and make queued replay restore any legacy bare interactive command draft instead of opening UI mid-drain.

Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:50 -07:00
Charles Cunningham
8267155494 tui: document slash dispatch replay contract
Explain above dispatch_command that live callers usually ignore its return value while queued replay uses QueueReplayControl to decide whether draining can continue.

Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:50 -07:00
Charles Cunningham
393740dbed tui: clarify live slash queueing branch
Document that the busy-path queueing in dispatch_command is only reached during live slash dispatch, not queued replay, so the returned drain-control value is effectively a throwaway for that branch.

Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:50 -07:00
Charles Cunningham
ed546bc217 tui: preserve queued slash draft intent
Serialize custom /review instructions with an explicit custom marker so queued replay cannot reinterpret branch-like text as a structured review target, and rename queued replay to better reflect that it drains inputs until blocked.

Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:49 -07:00
Charles Cunningham
122e1475d5 tui: simplify interrupt status plumbing
Send interrupt ops directly from the bottom pane instead of routing them through the status indicator widget, and remove the now-unused event wiring from the widget and its tests.

Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:49 -07:00
Charles Cunningham
fa50564579 tui: restore interactive slash queue behavior
Keep bare /model and /review interactive while preserving serialized queue replay, restore queued slash drafts into the composer on interrupt, and align queued slash parsing with the same feature-gated lookup used by the composer.

Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:49 -07:00
Charles Cunningham
35e8aa9ce0 Simplify queued slash command replay
Unify queued slash commands as serialized drafts, route popup actions through the same replay path, and stop replay after commands that submit a turn.

Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:49 -07:00
Charles Cunningham
9ab49d2c37 narrow esc interrupts in tui
Limit Esc interrupts to an empty composer when no popup is active, while preserving the pending-steer interrupt path.

This keeps dialog dismissal on Esc working normally without interrupting the running conversation.

Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:49 -07:00
Charles Cunningham
e5f1b8435d queue interactive slash command selections
Keep interactive slash flows usable while a turn is running, and queue the resulting action instead of the bare slash token.

Also let Esc interrupt through popup-active states so queued drafts restore without dropping queued slash actions.

Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:49 -07:00
Charles Cunningham
a8f1f43c1f preserve queued slash actions on interrupt
Restore only queued user-message drafts into the composer when a turn is interrupted, keep queued slash-command actions replayable, and cover the interrupt replay behavior in codex-tui tests.

Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:49 -07:00
Charles Cunningham
072d5d9e49 remove dead tui helper methods
Drop the unused bottom-pane mention-binding drain helpers introduced by the queueing refactor and switch the affected tests to the existing non-destructive composer mention accessor.

Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:49 -07:00
Charles Cunningham
9478b34e55 queue slash commands in tui
Allow slash commands entered during a running turn to be queued and replayed after the turn completes, including /review and inline slash-command variants tested in codex-tui.

Co-authored-by: Codex <noreply@openai.com>
2026-03-15 23:32:49 -07:00
29 changed files with 6341 additions and 888 deletions

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)
@@ -1656,7 +1779,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}")),
@@ -1784,6 +1913,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.
@@ -1796,6 +1930,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
@@ -1835,6 +1970,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();
}
@@ -1912,7 +2048,7 @@ impl App {
}
self.chat_widget.set_queue_autosend_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();
}
@@ -2180,6 +2316,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.
@@ -2198,6 +2335,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();
@@ -2308,6 +2446,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),
@@ -2414,87 +2557,10 @@ impl App {
AppEvent::OpenResumePicker => {
match crate::resume_picker::run_resume_picker(tui, &self.config, false).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,
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(),
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
@@ -2504,6 +2570,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",
@@ -2529,6 +2601,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,
@@ -2552,6 +2625,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();
@@ -2717,6 +2791,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();
@@ -2746,12 +2825,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,
@@ -2771,10 +2852,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",
1,
@@ -2789,9 +2877,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();
@@ -2809,12 +2900,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(
@@ -2831,9 +2924,10 @@ impl App {
1,
&[],
);
AppEvent::EnableWindowsSandboxForAgentMode {
AppEvent::WindowsSandboxElevatedSetupCompleted {
preset: preset.clone(),
mode: WindowsSandboxEnableMode::Elevated,
approvals_reviewer,
setup_succeeded: true,
}
}
Err(err) => {
@@ -2865,7 +2959,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);
@@ -2873,10 +2971,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();
@@ -2886,36 +3014,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 } => {
@@ -2975,7 +3137,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();
@@ -3031,6 +3197,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,
@@ -3041,7 +3208,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,
@@ -3056,6 +3223,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()]),
@@ -3080,7 +3249,7 @@ impl App {
}
#[cfg(not(target_os = "windows"))]
{
let _ = (preset, mode);
let _ = (preset, mode, approvals_reviewer);
}
}
AppEvent::PersistModelSelection { model, effort } => {
@@ -3300,6 +3469,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();
@@ -3472,15 +3642,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();
}
@@ -4163,11 +4334,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);
});
}
}
@@ -4178,6 +4351,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;
@@ -4678,6 +4852,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;
@@ -5921,10 +6149,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(())
}
@@ -6381,6 +6611,7 @@ guardian_approval = true
primary_thread_id: None,
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_async_queue_resume_barriers: 0,
}
}
@@ -6441,6 +6672,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,
@@ -7455,6 +7687,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;

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,

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)
//!
@@ -572,23 +572,6 @@ impl ChatComposer {
self.sync_popups();
}
pub(crate) fn take_mention_bindings(&mut self) -> Vec<MentionBinding> {
let elements = self.current_mention_elements();
let mut ordered = Vec::new();
for (id, mention) in elements {
if let Some(binding) = self.mention_bindings.remove(&id)
&& binding.mention == mention
{
ordered.push(MentionBinding {
mention: binding.mention,
path: binding.path,
});
}
}
self.mention_bindings.clear();
ordered
}
pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) {
self.collaboration_modes_enabled = enabled;
}
@@ -2525,9 +2508,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 {
@@ -2553,13 +2533,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();
@@ -2573,10 +2546,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,
@@ -2593,20 +2566,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.
@@ -6422,6 +6381,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;
@@ -6678,7 +6700,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;
@@ -6700,24 +6722,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]
@@ -7626,7 +7640,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]

View File

@@ -12,12 +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;
// Hide alias commands in the default popup list so each unique action appears once.
// `quit` is an alias of `exit`, so we skip `quit` here.
// `approvals` is an alias of `permissions`.
const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Approvals];
/// A selectable item in the popup: either a built-in command or a user prompt.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -29,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,
}
@@ -62,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;
}
@@ -142,8 +134,8 @@ 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() {
if ALIAS_COMMANDS.contains(cmd) {
for cmd in self.builtins.iter() {
if !cmd.show_in_command_popup() {
continue;
}
out.push((CommandItem::Builtin(*cmd), None));
@@ -162,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();
@@ -182,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.
@@ -338,6 +349,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());
@@ -351,7 +376,7 @@ mod tests {
CommandItem::UserPrompt(_) => None,
})
.collect();
assert_eq!(cmds, vec!["model", "mention", "mcp"]);
assert_eq!(cmds, vec!["model", "mention", "mcp", "subagents"]);
}
#[test]
@@ -409,6 +434,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(
@@ -477,6 +527,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());

View File

@@ -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;

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

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

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(true, None);
self.app_event_tx.send(AppEvent::BottomPaneViewCompleted);
}
fn pause_status_timer_for_modal(&mut self) {
@@ -1632,29 +1615,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>();
@@ -1740,7 +1700,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 {
@@ -1756,8 +1716,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(),
@@ -1766,12 +1726,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 ");
}
@@ -1848,6 +1806,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)]

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

View File

@@ -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 "

View File

@@ -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: / "

View File

@@ -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

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

View File

@@ -1,5 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: blob
---
■ '/model' is disabled while a task is in progress.

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -113,6 +113,7 @@ mod session_log;
mod shimmer;
mod skills_helpers;
mod slash_command;
mod slash_command_invocation;
mod status;
mod status_indicator_widget;
mod streaming;

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,

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")]
@@ -65,121 +66,415 @@ 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",
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",
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",
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",
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",
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>",
help_forms: &["<absolute-directory-path>"],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::Experimental => SlashCommandSpec {
description: "toggle experimental features",
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",
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",
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",
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",
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::Resume => SlashCommandSpec {
description: "resume a saved chat",
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",
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",
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",
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::Plan => SlashCommandSpec {
description: "switch to Plan mode",
help_forms: &["", "<prompt...>"],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::JustLikeUserMessage,
show_in_command_popup: true,
},
SlashCommand::Collab => SlashCommandSpec {
description: "change collaboration mode (experimental)",
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",
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)",
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",
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::Mention => SlashCommandSpec {
description: "mention a file",
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",
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",
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",
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",
help_forms: &["", "<theme-name>"],
requires_interaction: true,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::Mcp => SlashCommandSpec {
description: "list configured MCP tools",
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::Apps => SlashCommandSpec {
description: "manage apps",
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::Logout => SlashCommandSpec {
description: "log out of Codex",
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::Quit => SlashCommandSpec {
description: "exit Codex",
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: false,
},
SlashCommand::Exit => SlashCommandSpec {
description: "exit Codex",
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::Feedback => SlashCommandSpec {
description: "send logs to maintainers",
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",
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::Ps => SlashCommandSpec {
description: "list background terminals",
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::Stop => SlashCommandSpec {
description: "stop all background terminals",
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",
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::Personality => SlashCommandSpec {
description: "choose a communication style for Codex",
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)",
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::Settings => SlashCommandSpec {
description: "configure realtime microphone/speaker",
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",
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::MultiAgents => SlashCommandSpec {
description: "switch the active agent thread",
help_forms: &["", "<thread-id>"],
requires_interaction: true,
execution_kind: SlashCommandExecutionKind::Immediate,
show_in_command_popup: true,
},
SlashCommand::MemoryDrop => SlashCommandSpec {
description: "DO NOT USE",
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
SlashCommand::MemoryUpdate => SlashCommandSpec {
description: "DO NOT USE",
help_forms: &[""],
requires_interaction: false,
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
show_in_command_popup: true,
},
}
}
/// User-visible description shown in the popup.
pub fn description(self) -> &'static str {
match self {
SlashCommand::Feedback => "send logs to maintainers",
SlashCommand::New => "start a new chat during a conversation",
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
SlashCommand::Review => "review my current changes and find issues",
SlashCommand::Rename => "rename the current thread",
SlashCommand::Resume => "resume a saved chat",
SlashCommand::Clear => "clear the terminal and start a new chat",
SlashCommand::Fork => "fork the current chat",
// SlashCommand::Undo => "ask Codex to undo a turn",
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
SlashCommand::Diff => "show git diff (including untracked files)",
SlashCommand::Copy => "copy the latest Codex output to your clipboard",
SlashCommand::Mention => "mention a file",
SlashCommand::Skills => "use skills to improve how Codex performs specific tasks",
SlashCommand::Status => "show current session configuration and token usage",
SlashCommand::DebugConfig => "show config layers and requirement sources for debugging",
SlashCommand::Statusline => "configure which items appear in the status line",
SlashCommand::Theme => "choose a syntax highlighting theme",
SlashCommand::Ps => "list background terminals",
SlashCommand::Stop => "stop all background terminals",
SlashCommand::MemoryDrop => "DO NOT USE",
SlashCommand::MemoryUpdate => "DO NOT USE",
SlashCommand::Model => "choose what model and reasoning effort to use",
SlashCommand::Fast => "toggle Fast mode to enable fastest inference at 2X plan usage",
SlashCommand::Personality => "choose a communication style for Codex",
SlashCommand::Realtime => "toggle realtime voice mode (experimental)",
SlashCommand::Settings => "configure realtime microphone/speaker",
SlashCommand::Plan => "switch to Plan mode",
SlashCommand::Collab => "change collaboration mode (experimental)",
SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread",
SlashCommand::Approvals => "choose what Codex is allowed to do",
SlashCommand::Permissions => "choose what Codex is allowed to do",
SlashCommand::ElevateSandbox => "set up elevated agent sandbox",
SlashCommand::SandboxReadRoot => {
"let sandbox read a directory: /sandbox-add-read-dir <absolute_path>"
}
SlashCommand::Experimental => "toggle experimental features",
SlashCommand::Mcp => "list configured MCP tools",
SlashCommand::Apps => "manage apps",
SlashCommand::Logout => "log out of Codex",
SlashCommand::Rollout => "print the rollout file path",
SlashCommand::TestApproval => "test approval request",
}
self.spec().description
}
/// 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()
}
/// Whether this command supports inline args (for example `/review ...`).
pub fn supports_inline_args(self) -> bool {
matches!(
self,
SlashCommand::Review
| SlashCommand::Rename
| SlashCommand::Plan
| SlashCommand::Fast
| SlashCommand::SandboxReadRoot
)
}
/// Whether this command can be run while a task is in progress.
pub fn available_during_task(self) -> bool {
match self {
SlashCommand::New
| SlashCommand::Resume
| SlashCommand::Fork
| SlashCommand::Init
| SlashCommand::Compact
// | SlashCommand::Undo
SlashCommand::MultiAgents => "subagents",
_ => self.into(),
}
}
/// Additional accepted built-in names besides `command()`.
pub fn command_aliases(self) -> &'static [&'static str] {
match self {
SlashCommand::Help
| SlashCommand::Model
| SlashCommand::Fast
| SlashCommand::Personality
| SlashCommand::Approvals
| SlashCommand::Permissions
| SlashCommand::ElevateSandbox
| SlashCommand::SandboxReadRoot
| SlashCommand::Experimental
| SlashCommand::Review
| SlashCommand::Plan
| SlashCommand::Clear
| SlashCommand::Logout
| SlashCommand::MemoryDrop
| SlashCommand::MemoryUpdate => false,
SlashCommand::Diff
| SlashCommand::Copy
| SlashCommand::Rename
| SlashCommand::Mention
| 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::Ps
| SlashCommand::Stop
| SlashCommand::Statusline
| SlashCommand::Theme
| SlashCommand::Mcp
| SlashCommand::Apps
| SlashCommand::Feedback
| SlashCommand::Logout
| SlashCommand::Quit
| SlashCommand::Exit => true,
SlashCommand::Rollout => true,
SlashCommand::TestApproval => true,
SlashCommand::Realtime => true,
SlashCommand::Settings => true,
SlashCommand::Collab => true,
SlashCommand::Agent | SlashCommand::MultiAgents => true,
SlashCommand::Statusline => false,
SlashCommand::Theme => false,
| 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"],
}
}
pub fn all_command_names(self) -> impl Iterator<Item = &'static str> {
std::iter::once(self.command()).chain(self.command_aliases().iter().copied())
}
/// 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 {
match self {
SlashCommand::SandboxReadRoot => cfg!(target_os = "windows"),
@@ -190,11 +485,46 @@ impl SlashCommand {
}
}
#[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()
}
/// 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()
}

View File

@@ -0,0 +1,78 @@
use crate::chatwidget::UserMessage;
use crate::slash_command::SlashCommand;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SlashCommandInvocation {
pub(crate) command: SlashCommand,
pub(crate) args: Vec<String>,
}
impl SlashCommandInvocation {
pub(crate) fn bare(command: SlashCommand) -> Self {
Self {
command,
args: Vec::new(),
}
}
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)),
) {
Ok(joined) => joined,
Err(err) => panic!("slash command invocation should serialize: {err}"),
};
format!("/{joined}").into()
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn serializes_quoted_args() {
let draft = SlashCommandInvocation::with_args(
SlashCommand::Review,
["branch main needs coverage".to_string()],
)
.into_user_message();
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()
]
);
}
}

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,

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;
@@ -344,9 +346,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()
}

View File

@@ -1,3 +1,13 @@
# Slash commands
For an overview of Codex CLI slash commands, see [this documentation](https://developers.openai.com/codex/cli/slash-commands).
## TUI
In the TUI, type `/` to open the slash-command popup. The popup uses the same command order as the
in-app `/help` page, with `/help` pinned at the top for discovery.
For commands that have both an interactive picker flow and a direct argument form, the bare
`/command` form opens the picker and `/command ...` runs the direct argument form instead. Use
`/help` inside the TUI for the current list of supported commands and argument syntax. Argument
parsing uses shell-style quoting, so quote values with spaces when needed.

View File

@@ -115,8 +115,8 @@ the input starts with `!` (shell command).
5. Clears pending pastes on success and suppresses submission if the final text is empty and there
are no attachments.
The same preparation path is reused for slash commands with arguments (for example `/plan` and
`/review`) so pasted content and text elements are preserved when extracting args.
The same preparation path is reused for slash commands with arguments (for example `/model`,
`/plan`, and `/review`) so pasted content and text elements are preserved when extracting args.
The composer also treats the textarea kill buffer as separate editing state from the visible draft.
After submit or slash-command dispatch clears the textarea, the most recent `Ctrl+K` payload is