tui_app_server: mirror queued slash command changes

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Charles Cunningham
2026-03-16 21:21:40 -07:00
co-authored by Codex
parent 5bdcd2a8bc
commit c7a64b5d6c
28 changed files with 5543 additions and 891 deletions
+358 -120
View File
@@ -39,6 +39,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;
@@ -54,6 +55,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::collaboration_mode_presets::CollaborationModesConfig;
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;
@@ -758,6 +760,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,
pending_app_server_requests: PendingAppServerRequests,
}
@@ -786,6 +789,33 @@ 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();
}
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();
}
pub fn chatwidget_init_for_forked_or_resumed_thread(
&self,
tui: &mut tui::Tui,
@@ -867,6 +897,106 @@ impl App {
}
}
async fn resume_session_target(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
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.as_deref(),
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(app_server, ExitMode::ShutdownFirst)
.await);
}
};
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 app_server
.resume_thread(resume_config.clone(), target_session.thread_id)
.await
{
Ok(resumed) => {
let input_state = self.chat_widget.capture_thread_input_state();
self.shutdown_current_thread(app_server).await;
self.config = resume_config;
tui.set_notification_method(self.config.tui_notification_method);
self.file_search.update_search_dir(self.config.cwd.clone());
match self
.replace_chat_widget_with_app_server_thread(tui, resumed)
.await
{
Ok(()) => {
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) => {
self.chat_widget.add_error_message(format!(
"Failed to attach to resumed app-server thread: {err}"
));
}
}
self.restore_input_state_after_thread_switch(input_state);
}
Err(err) => {
let path_display = target_session.display_label();
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,
app_server: &mut AppServerSession,
thread_id: ThreadId,
) -> Result<AppRunControl> {
let path =
find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string()).await?;
self.resume_session_target(tui, app_server, 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)
@@ -1939,7 +2069,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}")),
@@ -2072,6 +2208,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(app_server).await;
let tracked_thread_ids: Vec<ThreadId> =
self.thread_event_channels.keys().copied().collect();
@@ -2098,6 +2235,7 @@ impl App {
}
self.chat_widget.add_plain_history_lines(lines);
}
self.restore_input_state_after_thread_switch(input_state);
}
Err(err) => {
self.chat_widget.add_error_message(format!(
@@ -2200,7 +2338,7 @@ impl App {
self.chat_widget
.set_queue_autosend_suppressed(/*suppressed*/ false);
if resume_restored_queue {
self.chat_widget.maybe_send_next_queued_input();
self.chat_widget.drain_queued_inputs_until_blocked();
}
self.refresh_status_line();
}
@@ -2472,6 +2610,7 @@ impl App {
primary_thread_id: None,
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_async_queue_resume_barriers: 0,
pending_app_server_requests: PendingAppServerRequests::default(),
};
if let Some(session_configured) = initial_session_configured {
@@ -2498,6 +2637,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();
@@ -2590,6 +2730,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),
@@ -2727,87 +2872,10 @@ impl App {
.await?
{
SessionSelection::Resume(target_session) => {
let current_cwd = self.config.cwd.clone();
let resume_cwd = if self.remote_app_server_url.is_some() {
current_cwd.clone()
} else {
match crate::resolve_cwd_for_resume_or_fork(
tui,
&self.config,
&current_cwd,
target_session.thread_id,
target_session.path.as_deref(),
CwdPromptAction::Resume,
/*allow_prompt*/ true,
)
.await?
{
crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd,
crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(),
crate::ResolveCwdOutcome::Exit => {
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
}
}
};
let mut resume_config = match self
.rebuild_config_for_resume_or_fallback(&current_cwd, resume_cwd)
.await
{
Ok(cfg) => cfg,
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to rebuild configuration for resume: {err}"
));
return Ok(AppRunControl::Continue);
}
};
self.apply_runtime_policy_overrides(&mut resume_config);
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.thread_id(),
self.chat_widget.thread_name(),
self.chat_widget.handle_serialized_slash_command(
ChatWidget::resume_selection_draft(&target_session),
);
match app_server
.resume_thread(resume_config.clone(), target_session.thread_id)
.await
{
Ok(resumed) => {
self.shutdown_current_thread(app_server).await;
self.config = resume_config;
tui.set_notification_method(self.config.tui_notification_method);
self.file_search.update_search_dir(self.config.cwd.clone());
match self
.replace_chat_widget_with_app_server_thread(tui, resumed)
.await
{
Ok(()) => {
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) => {
self.chat_widget.add_error_message(format!(
"Failed to attach to resumed app-server thread: {err}"
));
}
}
}
Err(err) => {
let path_display = target_session.display_label();
self.chat_widget.add_error_message(format!(
"Failed to resume session from {path_display}: {err}"
));
}
}
self.refresh_status_line();
}
SessionSelection::Exit
| SessionSelection::StartFresh
@@ -2817,6 +2885,16 @@ 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, app_server, thread_id)
.await;
}
AppEvent::ResumeSessionTarget(target_session) => {
return self
.resume_session_target(tui, app_server, target_session)
.await;
}
AppEvent::ForkCurrentSession => {
self.session_telemetry.counter(
"codex.thread.fork",
@@ -2835,6 +2913,7 @@ impl App {
.await;
match app_server.fork_thread(self.config.clone(), thread_id).await {
Ok(forked) => {
let input_state = self.chat_widget.capture_thread_input_state();
self.shutdown_current_thread(app_server).await;
match self
.replace_chat_widget_with_app_server_thread(tui, forked)
@@ -2853,6 +2932,7 @@ impl App {
}
self.chat_widget.add_plain_history_lines(lines);
}
self.restore_input_state_after_thread_switch(input_state);
}
Err(err) => {
self.chat_widget.add_error_message(format!(
@@ -3019,6 +3099,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();
@@ -3048,12 +3133,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,
@@ -3073,10 +3160,17 @@ impl App {
self.launch_external_editor(tui).await;
}
}
AppEvent::OpenWindowsSandboxEnablePrompt { preset } => {
self.chat_widget.open_windows_sandbox_enable_prompt(preset);
AppEvent::OpenWindowsSandboxEnablePrompt {
preset,
approvals_reviewer,
} => {
self.chat_widget
.open_windows_sandbox_enable_prompt(preset, approvals_reviewer);
}
AppEvent::OpenWindowsSandboxFallbackPrompt { preset } => {
AppEvent::OpenWindowsSandboxFallbackPrompt {
preset,
approvals_reviewer,
} => {
self.session_telemetry.counter(
"codex.windows_sandbox.fallback_prompt_shown",
/*inc*/ 1,
@@ -3091,9 +3185,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();
@@ -3111,12 +3208,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(
@@ -3133,9 +3232,10 @@ impl App {
1,
&[],
);
AppEvent::EnableWindowsSandboxForAgentMode {
AppEvent::WindowsSandboxElevatedSetupCompleted {
preset: preset.clone(),
mode: WindowsSandboxEnableMode::Elevated,
approvals_reviewer,
setup_succeeded: true,
}
}
Err(err) => {
@@ -3167,7 +3267,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);
@@ -3175,10 +3279,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();
@@ -3188,36 +3322,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 } => {
@@ -3277,7 +3445,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();
@@ -3335,6 +3507,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,
@@ -3362,6 +3535,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()]),
@@ -3386,7 +3561,7 @@ impl App {
}
#[cfg(not(target_os = "windows"))]
{
let _ = (preset, mode);
let _ = (preset, mode, approvals_reviewer);
}
}
AppEvent::PersistModelSelection { model, effort } => {
@@ -3606,6 +3781,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();
@@ -3778,15 +3954,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();
}
@@ -4412,11 +4589,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);
});
}
}
@@ -4427,6 +4606,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;
@@ -4927,6 +5107,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;
@@ -6170,10 +6404,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(())
}
@@ -6621,6 +6857,7 @@ guardian_approval = true
primary_thread_id: None,
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_async_queue_resume_barriers: 0,
pending_app_server_requests: PendingAppServerRequests::default(),
}
}
@@ -6673,6 +6910,7 @@ guardian_approval = true
primary_thread_id: None,
primary_session_configured: None,
pending_primary_events: VecDeque::new(),
pending_async_queue_resume_barriers: 0,
pending_app_server_requests: PendingAppServerRequests::default(),
},
rx,
+41 -3
View File
@@ -21,6 +21,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;
@@ -99,6 +100,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,
@@ -186,6 +193,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),
@@ -257,6 +272,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.
@@ -269,24 +285,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.
@@ -302,11 +338,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.
@@ -365,9 +406,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,
@@ -6,7 +6,6 @@ use codex_protocol::approvals::ElicitationAction;
use codex_protocol::mcp::RequestId as McpRequestId;
use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::request_permissions::RequestPermissionsResponse;
use codex_protocol::request_user_input::RequestUserInputResponse;
use tokio::sync::mpsc::UnboundedSender;
@@ -51,12 +50,6 @@ impl AppEventSender {
));
}
pub(crate) fn review(&self, review_request: ReviewRequest) {
self.send(AppEvent::CodexOp(
AppCommand::review(review_request).into_core(),
));
}
pub(crate) fn list_skills(&self, cwds: Vec<PathBuf>, force_reload: bool) {
self.send(AppEvent::CodexOp(
AppCommand::list_skills(cwds, force_reload).into_core(),
@@ -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;
}
@@ -2546,9 +2529,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 {
@@ -2574,13 +2554,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();
@@ -2594,10 +2567,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,
@@ -2614,20 +2587,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.
@@ -6447,6 +6406,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;
@@ -6703,7 +6725,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;
@@ -6725,24 +6747,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]
@@ -7651,7 +7665,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]
@@ -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,9 @@ pub(crate) enum CommandItem {
pub(crate) struct CommandPopup {
command_filter: String,
builtins: Vec<(&'static str, SlashCommand)>,
builtins: Vec<SlashCommand>,
#[cfg(test)]
reserved_builtin_names: std::collections::HashSet<String>,
prompts: Vec<CustomPrompt>,
state: ScrollState,
}
@@ -62,18 +58,21 @@ 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,
#[cfg(test)]
reserved_builtin_names,
prompts,
state: ScrollState::new(),
}
@@ -81,12 +80,7 @@ impl CommandPopup {
#[cfg(test)]
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;
}
@@ -143,8 +137,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));
@@ -163,6 +157,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();
@@ -183,10 +200,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.
@@ -341,6 +354,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());
@@ -354,7 +381,7 @@ mod tests {
CommandItem::UserPrompt(_) => None,
})
.collect();
assert_eq!(cmds, vec!["model", "mention", "mcp"]);
assert_eq!(cmds, vec!["model", "mention", "mcp", "subagents"]);
}
#[test]
@@ -412,6 +439,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(
@@ -480,6 +532,32 @@ mod tests {
assert!(items.contains(&CommandItem::Builtin(SlashCommand::Quit)));
}
#[test]
fn multi_agents_alias_matches_subagents_entry() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
popup.on_composer_text_change("/multi".to_string());
assert_eq!(
popup.selected_item(),
Some(CommandItem::Builtin(SlashCommand::MultiAgents))
);
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
})
.collect();
assert_eq!(cmds, vec!["subagents"]);
popup.on_composer_text_change("/multi-agents".to_string());
assert_eq!(
popup.selected_item(),
Some(CommandItem::Builtin(SlashCommand::MultiAgents))
);
}
#[test]
fn collab_command_hidden_when_collaboration_modes_disabled() {
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
@@ -17,10 +17,10 @@ use crate::render::Insets;
use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::slash_command::SlashCommand;
use crate::slash_command_invocation::SlashCommandInvocation;
use crate::style::user_message_style;
use codex_core::features::Feature;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use super::popup_consts::MAX_POPUP_ROWS;
@@ -30,7 +30,7 @@ use super::selection_popup_common::measure_rows_height;
use super::selection_popup_common::render_rows;
pub(crate) struct ExperimentalFeatureItem {
pub feature: Feature,
pub key: String,
pub name: String,
pub description: String,
pub enabled: bool,
@@ -198,15 +198,16 @@ impl BottomPaneView for ExperimentalFeaturesView {
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
// Save the updates
if !self.features.is_empty() {
let updates = self
.features
.iter()
.map(|item| (item.feature, item.enabled))
.collect();
self.app_event_tx
.send(AppEvent::UpdateFeatureFlags { updates });
let invocation = SlashCommandInvocation::with_args(
SlashCommand::Experimental,
self.features.iter().map(|item| {
format!("{}={}", item.key, if item.enabled { "on" } else { "off" })
}),
);
self.app_event_tx.send(AppEvent::HandleSlashCommandDraft(
invocation.into_user_message(),
));
}
self.complete = true;
@@ -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(),
@@ -0,0 +1,495 @@
use std::cell::Cell;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use unicode_width::UnicodeWidthStr;
use crate::bottom_pane::BuiltinCommandFlags;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::bottom_pane_view::BottomPaneView;
use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS;
use crate::bottom_pane::selection_popup_common::render_menu_surface;
use crate::bottom_pane::visible_builtins_for_input;
use crate::key_hint;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_lines;
const HELP_VIEW_MIN_BODY_ROWS: u16 = 6;
#[derive(Clone, Copy)]
enum HelpRowWrap {
None,
Note,
Description,
Usage,
}
#[derive(Clone)]
struct HelpRow {
plain_text: String,
line: Line<'static>,
wrap: HelpRowWrap,
}
#[derive(Default)]
struct HelpSearch {
active_query: String,
input: Option<String>,
selected_match: usize,
}
pub(crate) struct SlashHelpView {
complete: bool,
rows: Vec<HelpRow>,
scroll_top: Cell<usize>,
follow_selected_match: Cell<bool>,
search: HelpSearch,
}
impl SlashHelpView {
pub(crate) fn new(flags: BuiltinCommandFlags) -> Self {
Self {
complete: false,
rows: Self::build_document(flags),
scroll_top: Cell::new(0),
follow_selected_match: Cell::new(false),
search: HelpSearch::default(),
}
}
fn visible_body_rows(area_height: u16) -> usize {
area_height
.saturating_sub(3)
.max(HELP_VIEW_MIN_BODY_ROWS)
.into()
}
fn build_document(flags: BuiltinCommandFlags) -> Vec<HelpRow> {
let mut rows = vec![
HelpRow {
plain_text: "Slash Commands".to_string(),
line: Line::from("Slash Commands".bold()),
wrap: HelpRowWrap::None,
},
HelpRow {
plain_text: String::new(),
line: Line::from(""),
wrap: HelpRowWrap::None,
},
HelpRow {
plain_text: "Type / to open the command popup. For commands with both a picker and an arg form, bare /command opens the picker and /command ... runs directly.".to_string(),
line: Line::from(
"Type / to open the command popup. For commands with both a picker and an arg form, bare /command opens the picker and /command ... runs directly."
.dim(),
),
wrap: HelpRowWrap::Note,
},
HelpRow {
plain_text: "Args use shell-style quoting; quote values with spaces.".to_string(),
line: Line::from("Args use shell-style quoting; quote values with spaces.".dim()),
wrap: HelpRowWrap::Note,
},
HelpRow {
plain_text: String::new(),
line: Line::from(""),
wrap: HelpRowWrap::None,
},
];
for cmd in visible_builtins_for_input(flags) {
rows.push(HelpRow {
plain_text: format!("/{}", cmd.command()),
line: Line::from(format!("/{}", cmd.command()).cyan().bold()),
wrap: HelpRowWrap::None,
});
rows.push(HelpRow {
plain_text: format!(" {}", cmd.description()),
line: Line::from(format!(" {}", cmd.description()).dim()),
wrap: HelpRowWrap::Description,
});
rows.push(HelpRow {
plain_text: " Usage:".to_string(),
line: Line::from(" Usage:".dim()),
wrap: HelpRowWrap::None,
});
for form in cmd.help_forms() {
let plain_text = if form.is_empty() {
format!("/{}", cmd.command())
} else {
format!("/{} {}", cmd.command(), form)
};
rows.push(HelpRow {
plain_text: plain_text.clone(),
line: Line::from(plain_text.cyan()),
wrap: HelpRowWrap::Usage,
});
}
rows.push(HelpRow {
plain_text: String::new(),
line: Line::from(""),
wrap: HelpRowWrap::None,
});
}
while rows.last().is_some_and(|row| row.plain_text.is_empty()) {
rows.pop();
}
rows
}
fn scroll_by(&mut self, delta: isize) {
self.scroll_top
.set(self.scroll_top.get().saturating_add_signed(delta));
self.follow_selected_match.set(false);
}
fn matching_logical_rows(rows: &[HelpRow], query: &str) -> Vec<usize> {
let query = query.to_ascii_lowercase();
rows.iter()
.enumerate()
.filter_map(|(idx, row)| {
row.plain_text
.to_ascii_lowercase()
.contains(query.as_str())
.then_some(idx)
})
.collect()
}
fn current_query(&self) -> Option<&str> {
if let Some(input) = self.search.input.as_deref() {
return (!input.is_empty()).then_some(input);
}
(!self.search.active_query.is_empty()).then_some(self.search.active_query.as_str())
}
fn search_indicator(
&self,
rows: &[HelpRow],
total_rows: usize,
visible_rows: usize,
scroll_top: usize,
) -> String {
let start_row = if total_rows == 0 { 0 } else { scroll_top + 1 };
let end_row = (scroll_top + visible_rows).min(total_rows);
let viewport = format!("{start_row}-{end_row}/{total_rows}");
let Some(query) = self.current_query() else {
return viewport;
};
let match_count = Self::matching_logical_rows(rows, query).len();
if self.search.input.is_some() {
return format!(
"{} match{} | {viewport}",
match_count,
if match_count == 1 { "" } else { "es" }
);
}
if match_count == 0 {
return format!("0/0 | {viewport}");
}
let current_match = self.search.selected_match.min(match_count - 1) + 1;
format!("{current_match}/{match_count} | {viewport}")
}
fn footer_line(&self) -> Line<'static> {
if let Some(input) = self.search.input.as_deref() {
return Line::from(vec![
"Search: ".dim(),
format!("/{input}").cyan(),
" | ".dim(),
key_hint::plain(KeyCode::Enter).into(),
" apply | ".dim(),
key_hint::plain(KeyCode::Esc).into(),
" cancel".dim(),
]);
}
let mut spans = vec![
key_hint::plain(KeyCode::Up).into(),
"/".into(),
key_hint::plain(KeyCode::Down).into(),
" scroll | [".dim(),
key_hint::ctrl(KeyCode::Char('p')).into(),
" / ".dim(),
key_hint::ctrl(KeyCode::Char('n')).into(),
"] page | ".dim(),
"/ search".dim(),
];
if !self.search.active_query.is_empty() {
spans.push(" | ".dim());
spans.push("n/p match".dim());
}
spans.extend([
" | ".dim(),
key_hint::plain(KeyCode::Esc).into(),
" close".dim(),
]);
Line::from(spans)
}
fn wrap_rows(rows: &[HelpRow], width: u16) -> (Vec<Line<'static>>, Vec<usize>, Vec<usize>) {
let width = width.max(24);
let note_opts = RtOptions::new(width as usize)
.initial_indent(Line::from(""))
.subsequent_indent(Line::from(""));
let description_opts = RtOptions::new(width as usize)
.initial_indent(Line::from(""))
.subsequent_indent(Line::from(" "));
let usage_opts = RtOptions::new(width as usize)
.initial_indent(Line::from(" "))
.subsequent_indent(Line::from(" "));
let mut wrapped_rows = Vec::new();
let mut row_starts = Vec::with_capacity(rows.len());
let mut row_ends = Vec::with_capacity(rows.len());
for row in rows {
row_starts.push(wrapped_rows.len());
let wrapped = match row.wrap {
HelpRowWrap::None => vec![row.line.clone()],
HelpRowWrap::Note => word_wrap_lines([row.line.clone()], note_opts.clone()),
HelpRowWrap::Description => {
word_wrap_lines([row.line.clone()], description_opts.clone())
}
HelpRowWrap::Usage => word_wrap_lines([row.line.clone()], usage_opts.clone()),
};
wrapped_rows.extend(wrapped);
row_ends.push(wrapped_rows.len());
}
(wrapped_rows, row_starts, row_ends)
}
fn move_to_match(&mut self, delta: isize) {
if self.search.input.is_some() || self.search.active_query.is_empty() {
return;
}
let matches = Self::matching_logical_rows(&self.rows, &self.search.active_query);
if matches.is_empty() {
return;
}
let next = (self.search.selected_match as isize + delta).rem_euclid(matches.len() as isize);
self.search.selected_match = next as usize;
self.follow_selected_match.set(true);
}
}
impl BottomPaneView for SlashHelpView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if let Some(input) = self.search.input.as_mut() {
match key_event {
KeyEvent {
code: KeyCode::Esc, ..
} => {
self.search.input = None;
}
KeyEvent {
code: KeyCode::Enter,
..
} => {
self.search.active_query = self.search.input.take().unwrap_or_default();
self.search.selected_match = 0;
self.follow_selected_match
.set(!self.search.active_query.is_empty());
}
KeyEvent {
code: KeyCode::Backspace,
..
} => {
input.pop();
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
input.push(c);
}
_ => {}
}
return;
}
match key_event {
KeyEvent {
code: KeyCode::Char('/'),
modifiers: KeyModifiers::NONE,
..
} => {
self.search.active_query.clear();
self.search.selected_match = 0;
self.follow_selected_match.set(false);
self.search.input = Some(String::new());
}
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('k'),
modifiers: KeyModifiers::NONE,
..
} => self.scroll_by(-1),
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
..
} => self.scroll_by(1),
KeyEvent {
code: KeyCode::PageUp,
..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
} => self.scroll_by(-(MAX_POPUP_ROWS as isize)),
KeyEvent {
code: KeyCode::PageDown,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
} => self.scroll_by(MAX_POPUP_ROWS as isize),
KeyEvent {
code: KeyCode::Esc, ..
} if !self.search.active_query.is_empty() => {
self.search.active_query.clear();
self.search.selected_match = 0;
self.follow_selected_match.set(false);
}
KeyEvent {
code: KeyCode::Char('q'),
modifiers: KeyModifiers::NONE,
..
} => {
self.on_ctrl_c();
}
KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::NONE,
..
} => self.move_to_match(1),
KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::NONE,
..
}
| KeyEvent {
code: KeyCode::Char('N'),
modifiers: KeyModifiers::SHIFT,
..
} => self.move_to_match(-1),
_ => {}
}
}
fn is_complete(&self) -> bool {
self.complete
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
self.complete = true;
CancellationEvent::Handled
}
fn prefer_esc_to_handle_key_event(&self) -> bool {
self.search.input.is_some() || !self.search.active_query.is_empty()
}
}
impl crate::render::renderable::Renderable for SlashHelpView {
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
let content_area = render_menu_surface(area, buf);
let [header_area, body_area, footer_area] = Layout::vertical([
Constraint::Length(2),
Constraint::Fill(1),
Constraint::Length(2),
])
.areas(content_area);
let [_footer_gap_area, footer_line_area] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(footer_area);
let (lines, row_starts, row_ends) = Self::wrap_rows(&self.rows, body_area.width);
let header_lines = lines.iter().take(2).cloned().collect::<Vec<_>>();
let mut body_lines = lines.iter().skip(2).cloned().collect::<Vec<_>>();
let visible_rows = Self::visible_body_rows(body_area.height);
let max_scroll = body_lines.len().saturating_sub(visible_rows);
let mut scroll_top = self.scroll_top.get().min(max_scroll);
if self.search.input.is_none()
&& !self.search.active_query.is_empty()
&& let Some(selected_row_idx) =
Self::matching_logical_rows(&self.rows, &self.search.active_query)
.get(self.search.selected_match)
.copied()
{
let start = row_starts[selected_row_idx].saturating_sub(2);
let end = row_ends[selected_row_idx].saturating_sub(2);
if self.follow_selected_match.get() || start < scroll_top {
scroll_top = start;
} else if end > scroll_top + visible_rows {
scroll_top = end.saturating_sub(visible_rows);
}
scroll_top = scroll_top.min(max_scroll);
self.scroll_top.set(scroll_top);
self.follow_selected_match.set(false);
for line in body_lines.iter_mut().take(end).skip(start) {
*line = line.clone().patch_style(Style::new().reversed());
}
}
self.scroll_top.set(scroll_top);
Paragraph::new(header_lines).render(header_area, buf);
Paragraph::new(body_lines.clone())
.scroll((scroll_top as u16, 0))
.render(body_area, buf);
let footer_line = self.footer_line();
Paragraph::new(footer_line.clone()).render(footer_line_area, buf);
let indicator =
self.search_indicator(&self.rows, body_lines.len(), visible_rows, scroll_top);
let indicator_width = UnicodeWidthStr::width(indicator.as_str()) as u16;
let footer_width = footer_line.width() as u16;
if footer_width + indicator_width + 2 <= footer_line_area.width {
Paragraph::new(indicator.dim()).render(
Rect::new(
footer_line_area.x + footer_line_area.width - indicator_width,
footer_line_area.y,
indicator_width,
footer_line_area.height,
),
buf,
);
}
}
fn desired_height(&self, width: u16) -> u16 {
let (wrapped_rows, _, _) = Self::wrap_rows(&self.rows, width.saturating_sub(4));
let content_rows = wrapped_rows.len() as u16;
content_rows.max(HELP_VIEW_MIN_BODY_ROWS + 4)
}
}
+75 -64
View File
@@ -15,6 +15,7 @@
//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle.
use std::path::PathBuf;
use crate::app_event::AppEvent;
use crate::app_event::ConnectorsSnapshot;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::pending_input_preview::PendingInputPreview;
@@ -31,6 +32,7 @@ use codex_core::features::Features;
use codex_core::plugins::PluginCapabilitySummary;
use codex_core::skills::model::SkillMetadata;
use codex_file_search::FileMatch;
use codex_protocol::protocol::Op;
use codex_protocol::request_user_input::RequestUserInputEvent;
use codex_protocol::user_input::TextElement;
use crossterm::event::KeyCode;
@@ -79,6 +81,7 @@ pub mod custom_prompt_view;
mod experimental_features_view;
mod file_search_popup;
mod footer;
mod help_view;
mod list_selection_view;
mod prompt_args;
mod skill_popup;
@@ -95,6 +98,7 @@ pub(crate) use feedback_view::FeedbackAudience;
pub(crate) use feedback_view::feedback_disabled_params;
pub(crate) use feedback_view::feedback_selection_params;
pub(crate) use feedback_view::feedback_upload_consent_params;
pub(crate) use help_view::SlashHelpView;
pub(crate) use skills_toggle_view::SkillsToggleItem;
pub(crate) use skills_toggle_view::SkillsToggleView;
pub(crate) use status_line_setup::StatusLineItem;
@@ -137,17 +141,19 @@ 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 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.
///
@@ -262,22 +268,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();
@@ -420,25 +414,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;
}
@@ -722,7 +706,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,
));
@@ -749,7 +732,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,
));
@@ -1022,6 +1004,7 @@ impl BottomPane {
fn on_active_view_complete(&mut self) {
self.resume_status_timer_after_modal();
self.set_composer_input_enabled(/*enabled*/ true, /*placeholder*/ None);
self.app_event_tx.send(AppEvent::BottomPaneViewCompleted);
}
fn pause_status_timer_for_modal(&mut self) {
@@ -1634,29 +1617,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>();
@@ -1742,7 +1702,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 {
@@ -1758,8 +1718,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(),
@@ -1768,12 +1728,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 ");
}
@@ -1850,6 +1808,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)]
@@ -3,12 +3,13 @@
//! The same sandbox- and feature-gating rules are used by both the composer
//! and the command popup. Centralizing them here keeps those call sites small
//! and ensures they stay in sync.
use std::str::FromStr;
use std::collections::HashSet;
use codex_utils_fuzzy_match::fuzzy_match;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
use crate::slash_command::visible_built_in_slash_commands;
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct BuiltinCommandFlags {
@@ -21,30 +22,64 @@ pub(crate) struct BuiltinCommandFlags {
pub(crate) allow_elevate_sandbox: bool,
}
fn command_enabled_for_input(cmd: SlashCommand, flags: BuiltinCommandFlags) -> bool {
if !flags.allow_elevate_sandbox && cmd == SlashCommand::ElevateSandbox {
return false;
}
if !flags.collaboration_modes_enabled
&& matches!(cmd, SlashCommand::Collab | SlashCommand::Plan)
{
return false;
}
if !flags.connectors_enabled && cmd == SlashCommand::Apps {
return false;
}
if !flags.fast_command_enabled && cmd == SlashCommand::Fast {
return false;
}
if !flags.personality_command_enabled && cmd == SlashCommand::Personality {
return false;
}
if !flags.realtime_conversation_enabled && cmd == SlashCommand::Realtime {
return false;
}
if !flags.audio_device_selection_enabled && cmd == SlashCommand::Settings {
return false;
}
true
}
/// Return the built-ins that should be visible/usable for the current input.
pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static str, SlashCommand)> {
built_in_slash_commands()
.into_iter()
.filter(|(_, cmd)| flags.allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
.filter(|(_, cmd)| {
flags.collaboration_modes_enabled
|| !matches!(*cmd, SlashCommand::Collab | SlashCommand::Plan)
})
.filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Apps)
.filter(|(_, cmd)| flags.fast_command_enabled || *cmd != SlashCommand::Fast)
.filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality)
.filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime)
.filter(|(_, cmd)| flags.audio_device_selection_enabled || *cmd != SlashCommand::Settings)
.filter(|(_, cmd)| command_enabled_for_input(*cmd, flags))
.collect()
}
/// Return the visible built-ins once each, in popup presentation order.
pub(crate) fn visible_builtins_for_input(flags: BuiltinCommandFlags) -> Vec<SlashCommand> {
visible_built_in_slash_commands()
.into_iter()
.filter(|cmd| command_enabled_for_input(*cmd, flags))
.collect()
}
/// Find a single built-in command by exact name, after applying the gating rules.
pub(crate) fn find_builtin_command(name: &str, flags: BuiltinCommandFlags) -> Option<SlashCommand> {
let cmd = SlashCommand::from_str(name).ok()?;
builtins_for_input(flags)
.into_iter()
.any(|(_, visible_cmd)| visible_cmd == cmd)
.then_some(cmd)
.find(|(command_name, _)| *command_name == name)
.map(|(_, cmd)| cmd)
}
/// Return every builtin name that should be reserved against custom prompt collisions.
pub(crate) fn reserved_builtin_names_for_input(flags: BuiltinCommandFlags) -> HashSet<String> {
visible_builtins_for_input(flags)
.into_iter()
.flat_map(SlashCommand::all_command_names)
.map(str::to_string)
.collect()
}
/// Whether any visible built-in fuzzily matches the provided prefix.
@@ -101,6 +136,29 @@ mod tests {
);
}
#[test]
fn multi_agents_alias_still_resolves_for_dispatch() {
assert_eq!(
find_builtin_command("multi-agents", all_enabled_flags()),
Some(SlashCommand::MultiAgents)
);
assert_eq!(
find_builtin_command("subagents", all_enabled_flags()),
Some(SlashCommand::MultiAgents)
);
assert_eq!(SlashCommand::MultiAgents.command(), "subagents");
}
#[test]
fn visible_builtins_keep_multi_agents_deduplicated() {
let builtins = visible_builtins_for_input(all_enabled_flags());
let multi_agents: Vec<_> = builtins
.into_iter()
.filter(|cmd| *cmd == SlashCommand::MultiAgents)
.collect();
assert_eq!(multi_agents, vec![SlashCommand::MultiAgents]);
}
#[test]
fn fast_command_is_hidden_when_disabled() {
let mut flags = all_enabled_flags();
@@ -0,0 +1,12 @@
---
source: tui_app_server/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› / "
" "
" /help show slash command help "
" /model choose what model and reasoning effort to "
" use "
" /permissions choose what Codex is allowed to do "
" /experimental toggle experimental features "
@@ -0,0 +1,12 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› / "
" "
" /help show slash command help "
" /model choose what model and reasoning "
" effort to use "
" /permissions choose what Codex is allowed to do "
" /sandbox-add-read-dir let sandbox read a directory: / "
@@ -34,6 +34,8 @@ use crate::bottom_pane::bottom_pane_view::BottomPaneView;
use crate::bottom_pane::multi_select_picker::MultiSelectItem;
use crate::bottom_pane::multi_select_picker::MultiSelectPicker;
use crate::render::renderable::Renderable;
use crate::slash_command::SlashCommand;
use crate::slash_command_invocation::SlashCommandInvocation;
/// Available items that can be displayed in the status line.
///
@@ -231,12 +233,14 @@ impl StatusLineSetupView {
.enable_ordering()
.on_preview(move |items| preview_data.line_for_items(items))
.on_confirm(|ids, app_event| {
let items = ids
.iter()
.map(|id| id.parse::<StatusLineItem>())
.collect::<Result<Vec<_>, _>>()
.unwrap_or_default();
app_event.send(AppEvent::StatusLineSetup { items });
let invocation = if ids.is_empty() {
SlashCommandInvocation::with_args(SlashCommand::Statusline, ["none"])
} else {
SlashCommandInvocation::with_args(SlashCommand::Statusline, ids)
};
app_event.send(AppEvent::HandleSlashCommandDraft(
invocation.into_user_message(),
));
})
.on_cancel(|app_event| {
app_event.send(AppEvent::StatusLineSetupCancelled);
File diff suppressed because it is too large Load Diff
@@ -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()
@@ -1,5 +0,0 @@
---
source: tui_app_server/src/chatwidget/tests.rs
expression: blob
---
■ '/model' is disabled while a task is in progress.
@@ -0,0 +1,22 @@
---
source: tui_app_server/src/chatwidget/tests.rs
expression: term.backend().vt100().screen().contents()
---
Select Model and Effort
Access legacy models by running codex -m <model_name> or in your config.toml
› 1. gpt-5.3-codex (default) Latest frontier agentic coding model.
2. gpt-5.4 Latest frontier agentic coding model.
3. gpt-5.2-codex Frontier agentic coding model.
4. gpt-5.1-codex-max Codex-optimized flagship for deep and fast
reasoning.
5. gpt-5.2 Latest frontier model with improvements across
knowledge, reasoning and coding
6. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less
capable.
Press enter to select reasoning effort, or esc to dismiss.
@@ -0,0 +1,223 @@
---
source: tui_app_server/src/chatwidget/tests.rs
expression: popup
---
Slash Commands
Type / to open the command popup. For commands with both a picker and an arg form, bare /command
opens the picker and /command ... runs directly.
Args use shell-style quoting; quote values with spaces.
/help
show slash command help
Usage:
/help
/model
choose what model and reasoning effort to use
Usage:
/model
/model <model> [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]
/fast
toggle Fast mode to enable fastest inference at 2X plan usage
Usage:
/fast
/fast <on|off|status>
/approvals
choose what Codex is allowed to do
Usage:
/approvals
/approvals <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
[--enable-windows-sandbox=elevated|legacy]
/permissions
choose what Codex is allowed to do
Usage:
/permissions
/permissions <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
[--enable-windows-sandbox=elevated|legacy]
/experimental
toggle experimental features
Usage:
/experimental
/experimental <feature-key>=on|off ...
/skills
use skills to improve how Codex performs specific tasks
Usage:
/skills
/skills <list|manage>
/review
review my current changes and find issues
Usage:
/review
/review uncommitted
/review branch <name>
/review commit <sha> [title]
/review <instructions>
/rename
rename the current thread
Usage:
/rename
/rename <title...>
/new
start a new chat during a conversation
Usage:
/new
/resume
resume a saved chat
Usage:
/resume
/resume <thread-id>
/resume <thread-id> --path <rollout-path>
/fork
fork the current chat
Usage:
/fork
/init
create an AGENTS.md file with instructions for Codex
Usage:
/init
/compact
summarize conversation to prevent hitting the context limit
Usage:
/compact
/plan
switch to Plan mode
Usage:
/plan
/plan <prompt...>
/collab
change collaboration mode (experimental)
Usage:
/collab
/collab <default|plan>
/agent
switch the active agent thread
Usage:
/agent
/agent <thread-id>
/diff
show git diff (including untracked files)
Usage:
/diff
/copy
copy the latest Codex output to your clipboard
Usage:
/copy
/mention
mention a file
Usage:
/mention
/status
show current session configuration and token usage
Usage:
/status
/debug-config
show config layers and requirement sources for debugging
Usage:
/debug-config
/statusline
configure which items appear in the status line
Usage:
/statusline
/statusline <item-id>...
/statusline none
/theme
choose a syntax highlighting theme
Usage:
/theme
/theme <theme-name>
/mcp
list configured MCP tools
Usage:
/mcp
/logout
log out of Codex
Usage:
/logout
/quit
exit Codex
Usage:
/quit
/exit
exit Codex
Usage:
/exit
/feedback
send logs to maintainers
Usage:
/feedback
/feedback <bug|bad-result|good-result|safety-check|other>
/rollout
print the rollout file path
Usage:
/rollout
/ps
list background terminals
Usage:
/ps
/stop
stop all background terminals
Usage:
/stop
/clear
clear the terminal and start a new chat
Usage:
/clear
/personality
choose a communication style for Codex
Usage:
/personality
/personality <none|friendly|pragmatic>
/test-approval
test approval request
Usage:
/test-approval
/subagents
switch the active agent thread
Usage:
/subagents
/subagents <thread-id>
/debug-m-drop
DO NOT USE
Usage:
/debug-m-drop
↑/↓ scroll | [ctrl + p / ctrl + n] page | / search | esc close 1-212/219
@@ -0,0 +1,228 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Slash Commands
Type / to open the command popup. For commands with both a picker and an arg form, bare /command
opens the picker and /command ... runs directly.
Args use shell-style quoting; quote values with spaces.
/help
show slash command help
Usage:
/help
/model
choose what model and reasoning effort to use
Usage:
/model
/model <model> [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]
/fast
toggle Fast mode to enable fastest inference at 2X plan usage
Usage:
/fast
/fast <on|off|status>
/approvals
choose what Codex is allowed to do
Usage:
/approvals
/approvals <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
[--enable-windows-sandbox=elevated|legacy]
/permissions
choose what Codex is allowed to do
Usage:
/permissions
/permissions <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
[--enable-windows-sandbox=elevated|legacy]
/sandbox-add-read-dir
let sandbox read a directory: /sandbox-add-read-dir <absolute_path>
Usage:
/sandbox-add-read-dir <absolute-directory-path>
/experimental
toggle experimental features
Usage:
/experimental
/experimental <feature-key>=on|off ...
/skills
use skills to improve how Codex performs specific tasks
Usage:
/skills
/skills <list|manage>
/review
review my current changes and find issues
Usage:
/review
/review uncommitted
/review branch <name>
/review commit <sha> [title]
/review <instructions>
/rename
rename the current thread
Usage:
/rename
/rename <title...>
/new
start a new chat during a conversation
Usage:
/new
/resume
resume a saved chat
Usage:
/resume
/resume <thread-id>
/resume <thread-id> --path <rollout-path>
/fork
fork the current chat
Usage:
/fork
/init
create an AGENTS.md file with instructions for Codex
Usage:
/init
/compact
summarize conversation to prevent hitting the context limit
Usage:
/compact
/plan
switch to Plan mode
Usage:
/plan
/plan <prompt...>
/collab
change collaboration mode (experimental)
Usage:
/collab
/collab <default|plan>
/agent
switch the active agent thread
Usage:
/agent
/agent <thread-id>
/diff
show git diff (including untracked files)
Usage:
/diff
/copy
copy the latest Codex output to your clipboard
Usage:
/copy
/mention
mention a file
Usage:
/mention
/status
show current session configuration and token usage
Usage:
/status
/debug-config
show config layers and requirement sources for debugging
Usage:
/debug-config
/statusline
configure which items appear in the status line
Usage:
/statusline
/statusline <item-id>...
/statusline none
/theme
choose a syntax highlighting theme
Usage:
/theme
/theme <theme-name>
/mcp
list configured MCP tools
Usage:
/mcp
/logout
log out of Codex
Usage:
/logout
/quit
exit Codex
Usage:
/quit
/exit
exit Codex
Usage:
/exit
/feedback
send logs to maintainers
Usage:
/feedback
/feedback <bug|bad-result|good-result|safety-check|other>
/rollout
print the rollout file path
Usage:
/rollout
/ps
list background terminals
Usage:
/ps
/clean
stop all background terminals
Usage:
/clean
/clear
clear the terminal and start a new chat
Usage:
/clear
/personality
choose a communication style for Codex
Usage:
/personality
/personality <none|friendly|pragmatic>
/test-approval
test approval request
Usage:
/test-approval
/subagents
switch the active agent thread
Usage:
/subagents
/subagents <thread-id>
/debug-m-drop
DO NOT USE
Usage:
/debug-m-drop
↑/↓ scroll | [ctrl + p / ctrl + n] page | / search | esc close 1-217/224
@@ -0,0 +1,223 @@
---
source: tui_app_server/src/chatwidget/tests.rs
expression: searching
---
Slash Commands
Type / to open the command popup. For commands with both a picker and an arg form, bare /command
opens the picker and /command ... runs directly.
Args use shell-style quoting; quote values with spaces.
/help
show slash command help
Usage:
/help
/model
choose what model and reasoning effort to use
Usage:
/model
/model <model> [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]
/fast
toggle Fast mode to enable fastest inference at 2X plan usage
Usage:
/fast
/fast <on|off|status>
/approvals
choose what Codex is allowed to do
Usage:
/approvals
/approvals <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
[--enable-windows-sandbox=elevated|legacy]
/permissions
choose what Codex is allowed to do
Usage:
/permissions
/permissions <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
[--enable-windows-sandbox=elevated|legacy]
/experimental
toggle experimental features
Usage:
/experimental
/experimental <feature-key>=on|off ...
/skills
use skills to improve how Codex performs specific tasks
Usage:
/skills
/skills <list|manage>
/review
review my current changes and find issues
Usage:
/review
/review uncommitted
/review branch <name>
/review commit <sha> [title]
/review <instructions>
/rename
rename the current thread
Usage:
/rename
/rename <title...>
/new
start a new chat during a conversation
Usage:
/new
/resume
resume a saved chat
Usage:
/resume
/resume <thread-id>
/resume <thread-id> --path <rollout-path>
/fork
fork the current chat
Usage:
/fork
/init
create an AGENTS.md file with instructions for Codex
Usage:
/init
/compact
summarize conversation to prevent hitting the context limit
Usage:
/compact
/plan
switch to Plan mode
Usage:
/plan
/plan <prompt...>
/collab
change collaboration mode (experimental)
Usage:
/collab
/collab <default|plan>
/agent
switch the active agent thread
Usage:
/agent
/agent <thread-id>
/diff
show git diff (including untracked files)
Usage:
/diff
/copy
copy the latest Codex output to your clipboard
Usage:
/copy
/mention
mention a file
Usage:
/mention
/status
show current session configuration and token usage
Usage:
/status
/debug-config
show config layers and requirement sources for debugging
Usage:
/debug-config
/statusline
configure which items appear in the status line
Usage:
/statusline
/statusline <item-id>...
/statusline none
/theme
choose a syntax highlighting theme
Usage:
/theme
/theme <theme-name>
/mcp
list configured MCP tools
Usage:
/mcp
/logout
log out of Codex
Usage:
/logout
/quit
exit Codex
Usage:
/quit
/exit
exit Codex
Usage:
/exit
/feedback
send logs to maintainers
Usage:
/feedback
/feedback <bug|bad-result|good-result|safety-check|other>
/rollout
print the rollout file path
Usage:
/rollout
/ps
list background terminals
Usage:
/ps
/stop
stop all background terminals
Usage:
/stop
/clear
clear the terminal and start a new chat
Usage:
/clear
/personality
choose a communication style for Codex
Usage:
/personality
/personality <none|friendly|pragmatic>
/test-approval
test approval request
Usage:
/test-approval
/subagents
switch the active agent thread
Usage:
/subagents
/subagents <thread-id>
/debug-m-drop
DO NOT USE
Usage:
/debug-m-drop
Search: /maintainers | enter apply | esc cancel 1 match | 1-212/219
@@ -0,0 +1,228 @@
---
source: tui/src/chatwidget/tests.rs
expression: searching
---
Slash Commands
Type / to open the command popup. For commands with both a picker and an arg form, bare /command
opens the picker and /command ... runs directly.
Args use shell-style quoting; quote values with spaces.
/help
show slash command help
Usage:
/help
/model
choose what model and reasoning effort to use
Usage:
/model
/model <model> [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]
/fast
toggle Fast mode to enable fastest inference at 2X plan usage
Usage:
/fast
/fast <on|off|status>
/approvals
choose what Codex is allowed to do
Usage:
/approvals
/approvals <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
[--enable-windows-sandbox=elevated|legacy]
/permissions
choose what Codex is allowed to do
Usage:
/permissions
/permissions <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
[--enable-windows-sandbox=elevated|legacy]
/sandbox-add-read-dir
let sandbox read a directory: /sandbox-add-read-dir <absolute_path>
Usage:
/sandbox-add-read-dir <absolute-directory-path>
/experimental
toggle experimental features
Usage:
/experimental
/experimental <feature-key>=on|off ...
/skills
use skills to improve how Codex performs specific tasks
Usage:
/skills
/skills <list|manage>
/review
review my current changes and find issues
Usage:
/review
/review uncommitted
/review branch <name>
/review commit <sha> [title]
/review <instructions>
/rename
rename the current thread
Usage:
/rename
/rename <title...>
/new
start a new chat during a conversation
Usage:
/new
/resume
resume a saved chat
Usage:
/resume
/resume <thread-id>
/resume <thread-id> --path <rollout-path>
/fork
fork the current chat
Usage:
/fork
/init
create an AGENTS.md file with instructions for Codex
Usage:
/init
/compact
summarize conversation to prevent hitting the context limit
Usage:
/compact
/plan
switch to Plan mode
Usage:
/plan
/plan <prompt...>
/collab
change collaboration mode (experimental)
Usage:
/collab
/collab <default|plan>
/agent
switch the active agent thread
Usage:
/agent
/agent <thread-id>
/diff
show git diff (including untracked files)
Usage:
/diff
/copy
copy the latest Codex output to your clipboard
Usage:
/copy
/mention
mention a file
Usage:
/mention
/status
show current session configuration and token usage
Usage:
/status
/debug-config
show config layers and requirement sources for debugging
Usage:
/debug-config
/statusline
configure which items appear in the status line
Usage:
/statusline
/statusline <item-id>...
/statusline none
/theme
choose a syntax highlighting theme
Usage:
/theme
/theme <theme-name>
/mcp
list configured MCP tools
Usage:
/mcp
/logout
log out of Codex
Usage:
/logout
/quit
exit Codex
Usage:
/quit
/exit
exit Codex
Usage:
/exit
/feedback
send logs to maintainers
Usage:
/feedback
/feedback <bug|bad-result|good-result|safety-check|other>
/rollout
print the rollout file path
Usage:
/rollout
/ps
list background terminals
Usage:
/ps
/clean
stop all background terminals
Usage:
/clean
/clear
clear the terminal and start a new chat
Usage:
/clear
/personality
choose a communication style for Codex
Usage:
/personality
/personality <none|friendly|pragmatic>
/test-approval
test approval request
Usage:
/test-approval
/subagents
switch the active agent thread
Usage:
/subagents
/subagents <thread-id>
/debug-m-drop
DO NOT USE
Usage:
/debug-m-drop
Search: /maintainers | enter apply | esc cancel 1 match | 1-217/224
File diff suppressed because it is too large Load Diff
+1
View File
@@ -119,6 +119,7 @@ mod session_log;
mod shimmer;
mod skills_helpers;
mod slash_command;
mod slash_command_invocation;
mod status;
mod status_indicator_widget;
mod streaming;
+1 -1
View File
@@ -46,7 +46,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: Option<PathBuf>,
pub thread_id: ThreadId,
+423 -93
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()
}
@@ -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()
]
);
}
}
@@ -18,7 +18,6 @@ use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
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;
@@ -51,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,
}
@@ -74,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,
@@ -88,17 +82,11 @@ impl StatusIndicatorWidget {
elapsed_running: Duration::ZERO,
last_resume_at: Instant::now(),
is_paused: false,
app_event_tx,
frame_requester,
animations_enabled,
}
}
pub(crate) fn interrupt(&self) {
self.app_event_tx.interrupt();
}
/// Update the animated header label (left of the brackets).
pub(crate) fn update_header(&mut self, header: String) {
self.header = header;
@@ -290,13 +278,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;
@@ -316,9 +301,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");
@@ -330,9 +313,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");
@@ -344,9 +325,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,
@@ -369,10 +348,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;
@@ -391,9 +367,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,
@@ -411,9 +385,7 @@ mod tests {
#[test]
fn details_args_can_disable_capitalization_and_limit_lines() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
let mut w = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), true);
w.update_details(
Some("cargo test -p codex-core and then cargo test -p codex-tui".to_string()),
StatusDetailsCapitalization::Preserve,
+11 -5
View File
@@ -8,8 +8,8 @@
//! the preview panel and any visible code blocks.
//! - **Cancel-restore:** on dismiss (Esc / Ctrl+C) the `on_cancel` callback
//! restores the theme snapshot taken when the picker opened.
//! - **Persist on confirm:** the `AppEvent::SyntaxThemeSelected` action persists
//! `[tui] theme = "..."` to `config.toml` via `ConfigEditsBuilder`.
//! - **Persist on confirm:** the picker emits a canonical `/theme <name>` draft,
//! which then persists `[tui] theme = "..."` through normal slash-command handling.
//!
//! Two preview renderables adapt to terminal width:
//!
@@ -35,6 +35,8 @@ use crate::diff_render::push_wrapped_diff_line_with_style_context;
use crate::diff_render::push_wrapped_diff_line_with_syntax_and_style_context;
use crate::render::highlight;
use crate::render::renderable::Renderable;
use crate::slash_command::SlashCommand;
use crate::slash_command_invocation::SlashCommandInvocation;
use crate::status::format_directory_display;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -356,9 +358,13 @@ pub(crate) fn build_theme_picker_params(
dismiss_on_select: true,
search_value: Some(entry.name.clone()),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::SyntaxThemeSelected {
name: name_for_action.clone(),
});
tx.send(AppEvent::HandleSlashCommandDraft(
SlashCommandInvocation::with_args(
SlashCommand::Theme,
[name_for_action.clone()],
)
.into_user_message(),
));
})],
..Default::default()
}