tui: canonicalize interactive slash drafts

Route interactive slash-command pickers through canonical serialized drafts so live dispatch and queued replay share one parser/executor path.

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

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Charles Cunningham
2026-03-15 23:32:50 -07:00
co-authored by Codex
parent 217da0c113
commit 8c45c1acfc
11 changed files with 778 additions and 326 deletions
+120 -88
View File
@@ -34,6 +34,7 @@ use crate::pager_overlay::Overlay;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::Renderable;
use crate::resume_picker::SessionSelection;
use crate::resume_picker::SessionTarget;
use crate::tui;
use crate::tui::TuiEvent;
use crate::update_action::UpdateAction;
@@ -53,6 +54,7 @@ use codex_core::config::types::ApprovalsReviewer;
use codex_core::config::types::ModelAvailabilityNuxConfig;
use codex_core::config_loader::ConfigLayerStackOrdering;
use codex_core::features::Feature;
use codex_core::find_thread_path_by_id_str;
use codex_core::models_manager::manager::RefreshStrategy;
use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
@@ -834,6 +836,99 @@ impl App {
}
}
async fn resume_session_target(
&mut self,
tui: &mut tui::Tui,
target_session: SessionTarget,
) -> Result<()> {
let current_cwd = self.config.cwd.clone();
let resume_cwd = match crate::resolve_cwd_for_resume_or_fork(
tui,
&self.config,
&current_cwd,
target_session.thread_id,
&target_session.path,
CwdPromptAction::Resume,
true,
)
.await?
{
crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd,
crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(),
crate::ResolveCwdOutcome::Exit => return Ok(()),
};
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(());
}
};
self.apply_runtime_policy_overrides(&mut resume_config);
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.thread_id(),
self.chat_widget.thread_name(),
);
match self
.server
.resume_thread_from_rollout(
resume_config.clone(),
target_session.path.clone(),
self.auth_manager.clone(),
)
.await
{
Ok(resumed) => {
self.shutdown_current_thread().await;
self.config = resume_config;
tui.set_notification_method(self.config.tui_notification_method);
self.file_search.update_search_dir(self.config.cwd.clone());
let init =
self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone());
self.chat_widget =
ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured);
self.reset_thread_event_state();
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> = vec![summary.usage_line.clone().into()];
if let Some(command) = summary.resume_command {
let spans = vec!["To continue this session, run ".into(), command.cyan()];
lines.push(spans.into());
}
self.chat_widget.add_plain_history_lines(lines);
}
}
Err(err) => {
let path_display = target_session.path.display();
self.chat_widget.add_error_message(format!(
"Failed to resume session from {path_display}: {err}"
));
}
}
Ok(())
}
async fn resume_session_by_thread_id(
&mut self,
tui: &mut tui::Tui,
thread_id: ThreadId,
) -> Result<()> {
let Some(path) =
find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string()).await?
else {
self.chat_widget
.add_error_message(format!("No saved session found for thread {thread_id}."));
return Ok(());
};
self.resume_session_target(tui, SessionTarget { path, thread_id })
.await
}
fn apply_runtime_policy_overrides(&mut self, config: &mut Config) {
if let Some(policy) = self.runtime_approval_policy_override.as_ref()
&& let Err(err) = config.permissions.approval_policy.set(*policy)
@@ -1656,7 +1751,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(
format!(
"/{} {id}",
crate::slash_command::SlashCommand::Agent.command()
)
.into(),
));
})],
dismiss_on_select: true,
search_value: Some(format!("{name} {uuid}")),
@@ -2414,87 +2515,15 @@ impl App {
AppEvent::OpenResumePicker => {
match crate::resume_picker::run_resume_picker(tui, &self.config, false).await? {
SessionSelection::Resume(target_session) => {
let current_cwd = self.config.cwd.clone();
let resume_cwd = match crate::resolve_cwd_for_resume_or_fork(
tui,
&self.config,
&current_cwd,
target_session.thread_id,
&target_session.path,
CwdPromptAction::Resume,
true,
)
.await?
{
crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd,
crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(),
crate::ResolveCwdOutcome::Exit => {
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
}
};
let mut resume_config = match self
.rebuild_config_for_resume_or_fallback(&current_cwd, resume_cwd)
.await
{
Ok(cfg) => cfg,
Err(err) => {
self.chat_widget.add_error_message(format!(
"Failed to rebuild configuration for resume: {err}"
));
return Ok(AppRunControl::Continue);
}
};
self.apply_runtime_policy_overrides(&mut resume_config);
let summary = session_summary(
self.chat_widget.token_usage(),
self.chat_widget.thread_id(),
self.chat_widget.thread_name(),
);
match self
.server
.resume_thread_from_rollout(
resume_config.clone(),
target_session.path.clone(),
self.auth_manager.clone(),
None,
self.chat_widget.handle_serialized_slash_command(
format!(
"/{} {}",
crate::slash_command::SlashCommand::Resume.command(),
target_session.thread_id
)
.await
{
Ok(resumed) => {
self.shutdown_current_thread().await;
self.config = resume_config;
tui.set_notification_method(self.config.tui_notification_method);
self.file_search.update_search_dir(self.config.cwd.clone());
let init = self.chatwidget_init_for_forked_or_resumed_thread(
tui,
self.config.clone(),
);
self.chat_widget = ChatWidget::new_from_existing(
init,
resumed.thread,
resumed.session_configured,
);
self.reset_thread_event_state();
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> =
vec![summary.usage_line.clone().into()];
if let Some(command) = summary.resume_command {
let spans = vec![
"To continue this session, run ".into(),
command.cyan(),
];
lines.push(spans.into());
}
self.chat_widget.add_plain_history_lines(lines);
}
}
Err(err) => {
let path_display = target_session.path.display();
self.chat_widget.add_error_message(format!(
"Failed to resume session from {path_display}: {err}"
));
}
}
.into(),
);
self.refresh_status_line();
}
SessionSelection::Exit
| SessionSelection::StartFresh
@@ -2504,6 +2533,9 @@ impl App {
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
tui.frame_requester().schedule_frame();
}
AppEvent::ResumeSession(thread_id) => {
self.resume_session_by_thread_id(tui, thread_id).await?;
}
AppEvent::ForkCurrentSession => {
self.session_telemetry.counter(
"codex.thread.fork",
@@ -3482,9 +3514,6 @@ impl App {
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();
}
@@ -4182,6 +4211,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;
@@ -5925,10 +5955,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(())
}
+3 -3
View File
@@ -98,6 +98,9 @@ pub(crate) enum AppEvent {
/// Open the resume picker inside the running TUI session.
OpenResumePicker,
/// Resume a saved session by thread id.
ResumeSession(ThreadId),
/// Fork the current session into a new thread.
ForkCurrentSession,
@@ -366,9 +369,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,
@@ -2533,10 +2533,6 @@ impl ChatComposer {
let cmd = slash_commands::find_builtin_command(name, self.builtin_command_flags())?;
if !cmd.supports_inline_args() {
return None;
}
let mut args_elements =
Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements());
let trimmed_rest = rest.trim();
@@ -17,10 +17,9 @@ 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::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 +29,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 +197,16 @@ impl BottomPaneView for ExperimentalFeaturesView {
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
// Save the updates
if !self.features.is_empty() {
let updates = self
let args = self
.features
.iter()
.map(|item| (item.feature, item.enabled))
.collect();
self.app_event_tx
.send(AppEvent::UpdateFeatureFlags { updates });
.map(|item| format!("{}={}", item.key, if item.enabled { "on" } else { "off" }))
.collect::<Vec<_>>()
.join(" ");
self.app_event_tx.send(AppEvent::HandleSlashCommandDraft(
format!("/{} {args}", SlashCommand::Experimental.command()).into(),
));
}
self.complete = true;
+10 -1
View File
@@ -485,8 +485,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(
format!("/feedback {token}").into(),
));
});
super::SelectionItem {
name: name.to_string(),
@@ -34,6 +34,7 @@ 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;
/// Available items that can be displayed in the status line.
///
@@ -231,12 +232,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 args = if ids.is_empty() {
"none".to_string()
} else {
ids.join(" ")
};
app_event.send(AppEvent::HandleSlashCommandDraft(
format!("/{} {args}", SlashCommand::Statusline.command()).into(),
));
})
.on_cancel(|app_event| {
app_event.send(AppEvent::StatusLineSetupCancelled);
+550 -147
View File
@@ -40,6 +40,7 @@ use std::time::Instant;
use self::realtime::PendingSteerCompareKey;
use crate::app_event::RealtimeAudioDeviceKind;
use crate::app_event::WindowsSandboxEnableMode;
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
use crate::audio_device::list_realtime_audio_device_names;
use crate::bottom_pane::BuiltinCommandFlags;
@@ -4701,11 +4702,6 @@ impl ChatWidget {
args: String,
text_elements: Vec<TextElement>,
) {
if !cmd.supports_inline_args() {
self.dispatch_command(cmd);
return;
}
let trimmed = args.trim();
let should_queue = self.bottom_pane.is_task_running() && !cmd.available_during_task();
if trimmed.is_empty() {
@@ -4760,15 +4756,253 @@ impl ChatWidget {
self.queue_user_message(Self::inline_slash_command_draft(cmd, args_message));
return;
}
let _ = self.execute_inline_slash_command(cmd, args_message);
let _ = self.execute_slash_command_with_args(cmd, args_message);
}
fn execute_inline_slash_command(
fn execute_slash_command_with_args(
&mut self,
cmd: SlashCommand,
args_message: UserMessage,
) -> QueueReplayControl {
match cmd {
SlashCommand::Approvals | SlashCommand::Permissions => {
let args = args_message
.text
.split_whitespace()
.map(str::to_string)
.collect::<Vec<_>>();
if args.is_empty() {
return self.restore_invalid_inline_slash_command(
cmd,
args_message,
"Usage: /approvals <read-only|auto|full-access> [--confirm-full-access] [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] [--enable-windows-sandbox=elevated|legacy]".to_string(),
);
}
let preset_id = args[0].as_str();
let Some(preset) = builtin_approval_presets()
.into_iter()
.find(|preset| preset.id == preset_id)
else {
return self.restore_invalid_inline_slash_command(
cmd,
args_message,
format!("Unknown approval preset: {preset_id}"),
);
};
let mut confirm_full_access = false;
let mut remember_full_access = false;
let mut confirm_world_writable = false;
let mut remember_world_writable = false;
let mut windows_sandbox_mode = None;
for token in args.iter().skip(1) {
match token.as_str() {
"--confirm-full-access" => confirm_full_access = true,
"--remember-full-access" => remember_full_access = true,
"--confirm-world-writable" => confirm_world_writable = true,
"--remember-world-writable" => remember_world_writable = true,
"--enable-windows-sandbox=elevated" => {
windows_sandbox_mode = Some(WindowsSandboxEnableMode::Elevated);
}
"--enable-windows-sandbox=legacy" => {
windows_sandbox_mode = Some(WindowsSandboxEnableMode::Legacy);
}
_ => {
return self.restore_invalid_inline_slash_command(
cmd,
args_message,
format!("Unrecognized /approvals option: {token}"),
);
}
}
}
#[cfg(not(target_os = "windows"))]
let _ = windows_sandbox_mode;
if remember_full_access && !confirm_full_access {
return self.restore_invalid_inline_slash_command(
cmd,
args_message,
"--remember-full-access requires --confirm-full-access".to_string(),
);
}
if remember_world_writable && !confirm_world_writable {
return self.restore_invalid_inline_slash_command(
cmd,
args_message,
"--remember-world-writable requires --confirm-world-writable".to_string(),
);
}
#[cfg(target_os = "windows")]
let label = if preset.id == "auto"
&& matches!(
WindowsSandboxLevel::from_config(&self.config),
WindowsSandboxLevel::RestrictedToken
) {
"Default (non-admin sandbox)".to_string()
} else {
preset.label.to_string()
};
#[cfg(not(target_os = "windows"))]
let label = preset.label.to_string();
if preset.id == "full-access"
&& !confirm_full_access
&& !self
.config
.notices
.hide_full_access_warning
.unwrap_or(false)
{
return self.restore_invalid_inline_slash_command(
cmd,
args_message,
"Full access requires confirmation. Re-run with --confirm-full-access."
.to_string(),
);
}
#[cfg(target_os = "windows")]
{
if preset.id == "auto"
&& WindowsSandboxLevel::from_config(&self.config)
== WindowsSandboxLevel::Disabled
{
let Some(mode) = windows_sandbox_mode else {
return self.restore_invalid_inline_slash_command(
cmd,
args_message,
"Default permissions require Windows sandbox setup. Re-run with --enable-windows-sandbox=elevated or --enable-windows-sandbox=legacy.".to_string(),
);
};
self.app_event_tx
.send(AppEvent::EnableWindowsSandboxForAgentMode { preset, mode });
return QueueReplayControl::Stop;
}
if preset.id == "auto"
&& self.world_writable_warning_details().is_some()
&& !confirm_world_writable
{
return self.restore_invalid_inline_slash_command(
cmd,
args_message,
"Default permissions require confirming the Windows sandbox warning. Re-run with --confirm-world-writable.".to_string(),
);
}
if confirm_world_writable {
self.app_event_tx.send(AppEvent::SkipNextWorldWritableScan);
if remember_world_writable {
self.app_event_tx
.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true));
self.app_event_tx
.send(AppEvent::PersistWorldWritableWarningAcknowledged);
}
}
}
if confirm_full_access {
self.app_event_tx
.send(AppEvent::UpdateFullAccessWarningAcknowledged(true));
if remember_full_access {
self.app_event_tx
.send(AppEvent::PersistFullAccessWarningAcknowledged);
}
}
let sandbox = preset.sandbox.clone();
self.app_event_tx
.send(AppEvent::CodexOp(Op::OverrideTurnContext {
cwd: None,
approval_policy: Some(preset.approval),
sandbox_policy: Some(sandbox.clone()),
windows_sandbox_level: None,
model: None,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
}));
self.app_event_tx
.send(AppEvent::UpdateAskForApprovalPolicy(preset.approval));
self.app_event_tx
.send(AppEvent::UpdateSandboxPolicy(sandbox));
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_info_event(format!("Permissions updated to {label}"), None),
)));
QueueReplayControl::Stop
}
SlashCommand::Agent | SlashCommand::MultiAgents => {
match ThreadId::from_string(&args_message.text) {
Ok(thread_id) => {
self.app_event_tx
.send(AppEvent::SelectAgentThread(thread_id));
QueueReplayControl::Stop
}
Err(_) => self.restore_invalid_inline_slash_command(
cmd,
args_message,
"Usage: /agent <thread-id>".to_string(),
),
}
}
SlashCommand::Collab => {
let mode = args_message.text.trim().to_ascii_lowercase();
let Some(mask) = collaboration_modes::presets_for_tui(self.models_manager.as_ref())
.into_iter()
.find(|mask| mask.name.eq_ignore_ascii_case(&mode))
else {
return self.restore_invalid_inline_slash_command(
cmd,
args_message,
"Usage: /collab <default|plan>".to_string(),
);
};
self.app_event_tx
.send(AppEvent::UpdateCollaborationMode(mask.clone()));
self.set_collaboration_mask(mask);
QueueReplayControl::Continue
}
SlashCommand::Experimental => {
let tokens = args_message
.text
.split_whitespace()
.map(str::to_string)
.collect::<Vec<_>>();
let mut updates = Vec::new();
for token in tokens {
let Some((key, value)) = token.split_once('=') else {
return self.restore_invalid_inline_slash_command(
cmd,
args_message,
"Usage: /experimental <feature-key>=on|off ...".to_string(),
);
};
let Some(spec) = FEATURES.iter().find(|spec| spec.key == key) else {
return self.restore_invalid_inline_slash_command(
cmd,
args_message,
format!("Unknown experimental feature: {key}"),
);
};
let enabled = match value {
"on" => true,
"off" => false,
_ => {
return self.restore_invalid_inline_slash_command(
cmd,
args_message,
format!("Experimental feature {key} must be set to on or off."),
);
}
};
updates.push((spec.id, enabled));
}
self.app_event_tx
.send(AppEvent::UpdateFeatureFlags { updates });
QueueReplayControl::Stop
}
SlashCommand::Fast => match args_message.text.to_ascii_lowercase().as_str() {
"on" => {
self.set_service_tier_selection(Some(ServiceTier::Fast));
@@ -4793,6 +5027,26 @@ impl ChatWidget {
"Usage: /fast [on|off|status]".to_string(),
),
},
SlashCommand::Feedback => {
let category = match args_message.text.trim().to_ascii_lowercase().as_str() {
"bad-result" => crate::app_event::FeedbackCategory::BadResult,
"good-result" => crate::app_event::FeedbackCategory::GoodResult,
"bug" => crate::app_event::FeedbackCategory::Bug,
"safety-check" => crate::app_event::FeedbackCategory::SafetyCheck,
"other" => crate::app_event::FeedbackCategory::Other,
_ => {
return self.restore_invalid_inline_slash_command(
cmd,
args_message,
"Usage: /feedback <bug|bad-result|good-result|safety-check|other>"
.to_string(),
);
}
};
self.app_event_tx
.send(AppEvent::OpenFeedbackConsent { category });
QueueReplayControl::Stop
}
SlashCommand::Model => match Self::parse_model_selection_args(&args_message.text) {
Ok((model, effort, scope)) => {
self.apply_model_selection(model, effort, scope);
@@ -4802,6 +5056,48 @@ impl ChatWidget {
self.restore_invalid_inline_slash_command(cmd, args_message, message)
}
},
SlashCommand::Personality => {
let personality = match args_message.text.trim().to_ascii_lowercase().as_str() {
"none" => Personality::None,
"friendly" => Personality::Friendly,
"pragmatic" => Personality::Pragmatic,
_ => {
return self.restore_invalid_inline_slash_command(
cmd,
args_message,
"Usage: /personality <none|friendly|pragmatic>".to_string(),
);
}
};
if !self.current_model_supports_personality() {
let current_model = self.current_model();
return self.restore_invalid_inline_slash_command(
cmd,
args_message,
format!(
"Current model ({current_model}) doesn't support personalities. Try /model to pick a different model."
),
);
}
self.app_event_tx
.send(AppEvent::CodexOp(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
model: None,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
windows_sandbox_level: None,
personality: Some(personality),
}));
self.app_event_tx
.send(AppEvent::UpdatePersonality(personality));
self.app_event_tx
.send(AppEvent::PersistPersonalitySelection { personality });
QueueReplayControl::Continue
}
SlashCommand::Rename => {
self.session_telemetry
.counter("codex.thread.rename", 1, &[]);
@@ -4840,6 +5136,17 @@ impl ChatWidget {
self.restore_invalid_inline_slash_command(cmd, args_message, message)
}
},
SlashCommand::Resume => match ThreadId::from_string(args_message.text.trim()) {
Ok(thread_id) => {
self.app_event_tx.send(AppEvent::ResumeSession(thread_id));
QueueReplayControl::Stop
}
Err(_) => self.restore_invalid_inline_slash_command(
cmd,
args_message,
"Usage: /resume <thread-id>".to_string(),
),
},
SlashCommand::SandboxReadRoot => {
self.app_event_tx
.send(AppEvent::BeginWindowsSandboxGrantReadRoot {
@@ -4847,7 +5154,122 @@ impl ChatWidget {
});
QueueReplayControl::Stop
}
_ => self.dispatch_command(cmd),
SlashCommand::Settings => {
let mut tokens = args_message.text.trim().splitn(2, char::is_whitespace);
let Some(kind_name) = tokens.next() else {
return self.restore_invalid_inline_slash_command(
cmd,
args_message,
"Usage: /settings <microphone|speaker> [default|<device-name>]".to_string(),
);
};
let kind = match kind_name.to_ascii_lowercase().as_str() {
"microphone" => RealtimeAudioDeviceKind::Microphone,
"speaker" => RealtimeAudioDeviceKind::Speaker,
_ => {
return self.restore_invalid_inline_slash_command(
cmd,
args_message,
"Usage: /settings <microphone|speaker> [default|<device-name>]"
.to_string(),
);
}
};
let name = match tokens.next().map(str::trim) {
None | Some("") | Some("default") => None,
Some(other) => Some(other.to_string()),
};
self.app_event_tx
.send(AppEvent::PersistRealtimeAudioDeviceSelection { kind, name });
QueueReplayControl::Stop
}
SlashCommand::Skills => match args_message.text.trim().to_ascii_lowercase().as_str() {
"list" => {
self.open_skills_list();
QueueReplayControl::Stop
}
"manage" => {
self.app_event_tx.send(AppEvent::OpenManageSkillsPopup);
QueueReplayControl::Stop
}
_ => self.restore_invalid_inline_slash_command(
cmd,
args_message,
"Usage: /skills <list|manage>".to_string(),
),
},
SlashCommand::Statusline => {
let trimmed = args_message.text.trim();
let item_ids = if trimmed.eq_ignore_ascii_case("none") {
Vec::new()
} else {
trimmed
.split_whitespace()
.map(str::to_string)
.collect::<Vec<_>>()
};
let items = match item_ids
.iter()
.map(|item_id| item_id.parse::<StatusLineItem>())
.collect::<Result<Vec<_>, _>>()
{
Ok(items) => items,
Err(_) => {
return self.restore_invalid_inline_slash_command(
cmd,
args_message,
"Usage: /statusline <item-id>... | /statusline none".to_string(),
);
}
};
self.app_event_tx.send(AppEvent::StatusLineSetup { items });
QueueReplayControl::Stop
}
SlashCommand::Theme => {
if crate::render::highlight::resolve_theme_by_name(
args_message.text.trim(),
Some(&self.config.codex_home),
)
.is_none()
{
return self.restore_invalid_inline_slash_command(
cmd,
args_message,
"Usage: /theme <theme-name>".to_string(),
);
}
self.app_event_tx.send(AppEvent::SyntaxThemeSelected {
name: args_message.text.trim().to_string(),
});
QueueReplayControl::Stop
}
SlashCommand::New
| SlashCommand::Fork
| SlashCommand::Init
| SlashCommand::Compact
| SlashCommand::Diff
| SlashCommand::Copy
| SlashCommand::Mention
| SlashCommand::Status
| SlashCommand::DebugConfig
| SlashCommand::Mcp
| SlashCommand::Apps
| SlashCommand::Logout
| SlashCommand::Quit
| SlashCommand::Exit
| SlashCommand::Rollout
| SlashCommand::Ps
| SlashCommand::Clean
| SlashCommand::Clear
| SlashCommand::Realtime
| SlashCommand::TestApproval
| SlashCommand::MemoryDrop
| SlashCommand::MemoryUpdate
| SlashCommand::ElevateSandbox => self.restore_invalid_inline_slash_command(
cmd,
args_message,
format!("`/{}` does not accept inline arguments.", cmd.command()),
),
}
}
@@ -4919,6 +5341,32 @@ impl ChatWidget {
}
}
fn plain_slash_command_draft(cmd: SlashCommand, args: &str) -> UserMessage {
if args.is_empty() {
format!("/{}", cmd.command()).into()
} else {
format!("/{} {args}", cmd.command()).into()
}
}
fn approval_preset_draft(preset_id: &str, flags: &[&str]) -> UserMessage {
let mut args = vec![preset_id.to_string()];
args.extend(flags.iter().map(|flag| (*flag).to_string()));
Self::plain_slash_command_draft(SlashCommand::Approvals, &args.join(" "))
}
fn settings_device_draft(kind: RealtimeAudioDeviceKind, name: Option<&str>) -> UserMessage {
let kind_name = match kind {
RealtimeAudioDeviceKind::Microphone => "microphone",
RealtimeAudioDeviceKind::Speaker => "speaker",
};
let args = match name {
Some(name) => format!("{kind_name} {name}"),
None => format!("{kind_name} default"),
};
Self::plain_slash_command_draft(SlashCommand::Settings, &args)
}
fn queue_current_inline_bare_slash_command(&mut self, cmd: SlashCommand) {
let Some((prepared_args, prepared_elements)) =
self.bottom_pane.prepare_inline_args_submission(false)
@@ -5002,7 +5450,19 @@ impl ChatWidget {
}
pub(crate) fn handle_serialized_slash_command(&mut self, draft: UserMessage) {
if self.bottom_pane.is_task_running() {
let Some((cmd, _, _)) = self.parse_builtin_slash_command(&draft.text) else {
if let Some((name, _, _)) = parse_slash_name(&draft.text)
&& !name.contains('/')
&& SlashCommand::from_str(name).is_ok()
{
self.queue_user_message(draft);
return;
}
self.add_error_message(format!("Failed to handle slash command: {}", draft.text));
self.restore_user_message_to_composer(draft);
return;
};
if self.bottom_pane.is_task_running() && !cmd.available_during_task() {
self.queue_user_message(draft);
return;
}
@@ -5078,6 +5538,13 @@ impl ChatWidget {
fn execute_serialized_slash_command(&mut self, draft: UserMessage) -> QueueReplayControl {
let preview = draft.text.clone();
let Some((cmd, rest, rest_offset)) = self.parse_builtin_slash_command(&preview) else {
if let Some((name, _, _)) = parse_slash_name(&preview)
&& !name.contains('/')
&& SlashCommand::from_str(name).is_ok()
{
self.submit_user_message(draft);
return QueueReplayControl::Stop;
}
self.add_error_message(format!("Failed to replay queued slash command: {preview}"));
self.restore_user_message_to_composer(draft);
return QueueReplayControl::Stop;
@@ -5098,13 +5565,8 @@ impl ChatWidget {
}
return QueueReplayControl::Stop;
}
if !cmd.supports_inline_args() {
self.add_error_message(format!("Failed to replay queued slash command: {preview}"));
self.restore_user_message_to_composer(draft);
return QueueReplayControl::Stop;
}
let args_message = Self::slash_command_args_message_from_draft(draft, rest_offset);
self.execute_inline_slash_command(cmd, args_message)
self.execute_slash_command_with_args(cmd, args_message)
}
fn slash_command_args_message_from_draft(
@@ -6823,21 +7285,14 @@ impl ChatWidget {
let name = Self::personality_label(personality).to_string();
let description = Some(Self::personality_description(personality).to_string());
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
approvals_reviewer: None,
sandbox_policy: None,
model: None,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
windows_sandbox_level: None,
personality: Some(personality),
}));
tx.send(AppEvent::UpdatePersonality(personality));
tx.send(AppEvent::PersistPersonalitySelection { personality });
tx.send(AppEvent::HandleSlashCommandDraft(
Self::plain_slash_command_draft(
SlashCommand::Personality,
Self::personality_label(personality)
.to_ascii_lowercase()
.as_str(),
),
));
})];
SelectionItem {
name,
@@ -6931,7 +7386,9 @@ impl ChatWidget {
description: Some("Use your operating system default device.".to_string()),
is_current: current_selection.is_none(),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::PersistRealtimeAudioDeviceSelection { kind, name: None });
tx.send(AppEvent::HandleSlashCommandDraft(
Self::settings_device_draft(kind, None),
));
})],
dismiss_on_select: true,
..Default::default()
@@ -6953,10 +7410,9 @@ impl ChatWidget {
items.extend(device_names.into_iter().map(|device_name| {
let persisted_name = device_name.clone();
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::PersistRealtimeAudioDeviceSelection {
kind,
name: Some(persisted_name.clone()),
});
tx.send(AppEvent::HandleSlashCommandDraft(
Self::settings_device_draft(kind, Some(persisted_name.as_str())),
));
})];
SelectionItem {
is_current: current_selection.as_deref() == Some(device_name.as_str()),
@@ -7223,8 +7679,14 @@ impl ChatWidget {
.map(|mask| {
let name = mask.name.clone();
let is_current = current_kind == mask.mode;
let command_name = name.to_ascii_lowercase();
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::UpdateCollaborationMode(mask.clone()));
tx.send(AppEvent::HandleSlashCommandDraft(
Self::plain_slash_command_draft(
SlashCommand::Collab,
command_name.as_str(),
),
));
})];
SelectionItem {
name,
@@ -7667,30 +8129,15 @@ impl ChatWidget {
});
})]
} else {
Self::approval_preset_actions(
preset.approval,
preset.sandbox.clone(),
base_name.clone(),
ApprovalsReviewer::User,
)
Self::approval_preset_actions(preset.id, &[])
}
}
#[cfg(not(target_os = "windows"))]
{
Self::approval_preset_actions(
preset.approval,
preset.sandbox.clone(),
base_name.clone(),
ApprovalsReviewer::User,
)
Self::approval_preset_actions(preset.id, &[])
}
} else {
Self::approval_preset_actions(
preset.approval,
preset.sandbox.clone(),
base_name.clone(),
ApprovalsReviewer::User,
)
Self::approval_preset_actions(preset.id, &[])
};
if preset.id == "auto" {
items.push(SelectionItem {
@@ -7772,7 +8219,7 @@ impl ChatWidget {
let name = spec.stage.experimental_menu_name()?;
let description = spec.stage.experimental_menu_description()?;
Some(ExperimentalFeatureItem {
feature: spec.id,
key: spec.key.to_string(),
name: name.to_string(),
description: description.to_string(),
enabled: self.config.features.enabled(spec.id),
@@ -7785,32 +8232,13 @@ impl ChatWidget {
}
fn approval_preset_actions(
approval: AskForApproval,
sandbox: SandboxPolicy,
label: String,
approvals_reviewer: ApprovalsReviewer,
preset_id: &'static str,
flags: &'static [&'static str],
) -> Vec<SelectionAction> {
vec![Box::new(move |tx| {
let sandbox_clone = sandbox.clone();
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
cwd: None,
approval_policy: Some(approval),
approvals_reviewer: Some(approvals_reviewer),
sandbox_policy: Some(sandbox_clone.clone()),
windows_sandbox_level: None,
model: None,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
}));
tx.send(AppEvent::UpdateAskForApprovalPolicy(approval));
tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone));
tx.send(AppEvent::UpdateApprovalsReviewer(approvals_reviewer));
tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_info_event(format!("Permissions updated to {label}"), None),
)));
tx.send(AppEvent::HandleSlashCommandDraft(
Self::approval_preset_draft(preset_id, flags),
));
})]
}
@@ -7884,9 +8312,6 @@ impl ChatWidget {
preset: ApprovalPreset,
return_to_permissions: bool,
) {
let selected_name = preset.label.to_string();
let approval = preset.approval;
let sandbox = preset.sandbox;
let mut header_children: Vec<Box<dyn Renderable>> = Vec::new();
let title_line = Line::from("Enable full access?").bold();
let info_line = Line::from(vec![
@@ -7901,26 +8326,11 @@ impl ChatWidget {
));
let header = ColumnRenderable::with(header_children);
let mut accept_actions = Self::approval_preset_actions(
approval,
sandbox.clone(),
selected_name.clone(),
ApprovalsReviewer::User,
let accept_actions = Self::approval_preset_actions(preset.id, &["--confirm-full-access"]);
let accept_and_remember_actions = Self::approval_preset_actions(
preset.id,
&["--confirm-full-access", "--remember-full-access"],
);
accept_actions.push(Box::new(|tx| {
tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true));
}));
let mut accept_and_remember_actions = Self::approval_preset_actions(
approval,
sandbox,
selected_name,
ApprovalsReviewer::User,
);
accept_and_remember_actions.push(Box::new(|tx| {
tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true));
tx.send(AppEvent::PersistFullAccessWarningAcknowledged);
}));
let deny_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
if return_to_permissions {
@@ -7970,10 +8380,6 @@ impl ChatWidget {
extra_count: usize,
failed_scan: bool,
) {
let (approval, sandbox) = match &preset {
Some(p) => (Some(p.approval), Some(p.sandbox.clone())),
None => (None, None),
};
let mut header_children: Vec<Box<dyn Renderable>> = Vec::new();
let describe_policy = |policy: &SandboxPolicy| match policy {
SandboxPolicy::WorkspaceWrite { .. } => "Agent mode",
@@ -8017,36 +8423,21 @@ impl ChatWidget {
// Build actions ensuring acknowledgement happens before applying the new sandbox policy,
// so downstream policy-change hooks don't re-trigger the warning.
let mut accept_actions: Vec<SelectionAction> = Vec::new();
// Suppress the immediate re-scan only when a preset will be applied (i.e., via /approvals or
// /permissions), to avoid duplicate warnings from the ensuing policy change.
if preset.is_some() {
accept_actions.push(Box::new(|tx| {
tx.send(AppEvent::SkipNextWorldWritableScan);
}));
}
if let (Some(approval), Some(sandbox)) = (approval, sandbox.clone()) {
accept_actions.extend(Self::approval_preset_actions(
approval,
sandbox,
mode_label.to_string(),
ApprovalsReviewer::User,
));
}
let mut accept_and_remember_actions: Vec<SelectionAction> = Vec::new();
accept_and_remember_actions.push(Box::new(|tx| {
tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true));
tx.send(AppEvent::PersistWorldWritableWarningAcknowledged);
}));
if let (Some(approval), Some(sandbox)) = (approval, sandbox) {
accept_and_remember_actions.extend(Self::approval_preset_actions(
approval,
sandbox,
mode_label.to_string(),
ApprovalsReviewer::User,
));
}
let accept_actions = preset
.as_ref()
.map(|preset| Self::approval_preset_actions(preset.id, &["--confirm-world-writable"]))
.unwrap_or_default();
let accept_and_remember_actions = if let Some(preset) = preset.as_ref() {
Self::approval_preset_actions(
preset.id,
&["--confirm-world-writable", "--remember-world-writable"],
)
} else {
vec![Box::new(|tx| {
tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true));
tx.send(AppEvent::PersistWorldWritableWarningAcknowledged);
})]
};
let items = vec![
SelectionItem {
@@ -8155,9 +8546,12 @@ impl ChatWidget {
description: None,
actions: vec![Box::new(move |tx| {
accept_otel.counter("codex.windows_sandbox.elevated_prompt_accept", 1, &[]);
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
preset: preset.clone(),
});
tx.send(AppEvent::HandleSlashCommandDraft(
Self::approval_preset_draft(
preset.id,
&["--enable-windows-sandbox=elevated"],
),
));
})],
dismiss_on_select: true,
..Default::default()
@@ -8167,9 +8561,12 @@ impl ChatWidget {
description: None,
actions: vec![Box::new(move |tx| {
legacy_otel.counter("codex.windows_sandbox.elevated_prompt_use_legacy", 1, &[]);
tx.send(AppEvent::BeginWindowsSandboxLegacySetup {
preset: legacy_preset.clone(),
});
tx.send(AppEvent::HandleSlashCommandDraft(
Self::approval_preset_draft(
legacy_preset.id,
&["--enable-windows-sandbox=legacy"],
),
));
})],
dismiss_on_select: true,
..Default::default()
@@ -8229,9 +8626,12 @@ impl ChatWidget {
let preset = elevated_preset;
move |tx| {
otel.counter("codex.windows_sandbox.fallback_retry_elevated", 1, &[]);
tx.send(AppEvent::BeginWindowsSandboxElevatedSetup {
preset: preset.clone(),
});
tx.send(AppEvent::HandleSlashCommandDraft(
Self::approval_preset_draft(
preset.id,
&["--enable-windows-sandbox=elevated"],
),
));
}
})],
dismiss_on_select: true,
@@ -8245,9 +8645,12 @@ impl ChatWidget {
let preset = legacy_preset;
move |tx| {
otel.counter("codex.windows_sandbox.fallback_use_legacy", 1, &[]);
tx.send(AppEvent::BeginWindowsSandboxLegacySetup {
preset: preset.clone(),
});
tx.send(AppEvent::HandleSlashCommandDraft(
Self::approval_preset_draft(
preset.id,
&["--enable-windows-sandbox=legacy"],
),
));
}
})],
dismiss_on_select: true,
+7 -2
View File
@@ -12,6 +12,7 @@ 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 codex_chatgpt::connectors::AppInfo;
use codex_core::connectors::connector_mention_slug;
use codex_core::mention_syntax::TOOL_MENTION_SIGIL;
@@ -34,7 +35,9 @@ 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(
format!("/{} list", SlashCommand::Skills.command()).into(),
));
})],
dismiss_on_select: true,
..Default::default()
@@ -43,7 +46,9 @@ 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(
format!("/{} manage", SlashCommand::Skills.command()).into(),
));
})],
dismiss_on_select: true,
..Default::default()
+64 -13
View File
@@ -2034,6 +2034,19 @@ fn drain_insert_history(
out
}
fn run_next_serialized_slash_draft(
chat: &mut ChatWidget,
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
) {
while let Ok(event) = rx.try_recv() {
if let AppEvent::HandleSlashCommandDraft(draft) = event {
chat.handle_serialized_slash_command(draft);
return;
}
}
panic!("expected serialized slash draft event");
}
fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String {
let mut s = String::new();
for line in lines {
@@ -5538,11 +5551,7 @@ async fn collab_slash_command_opens_picker_and_updates_mode() {
);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let selected_mask = match rx.try_recv() {
Ok(AppEvent::UpdateCollaborationMode(mask)) => mask,
other => panic!("expected UpdateCollaborationMode event, got {other:?}"),
};
chat.set_collaboration_mask(selected_mask);
run_next_serialized_slash_draft(&mut chat, &mut rx);
chat.bottom_pane
.set_composer_text("hello".to_string(), Vec::new(), Vec::new());
@@ -7550,13 +7559,13 @@ async fn experimental_features_popup_snapshot() {
let features = vec![
ExperimentalFeatureItem {
feature: Feature::GhostCommit,
key: Feature::GhostCommit.key().to_string(),
name: "Ghost snapshots".to_string(),
description: "Capture undo snapshots each turn.".to_string(),
enabled: false,
},
ExperimentalFeatureItem {
feature: Feature::ShellTool,
key: Feature::ShellTool.key().to_string(),
name: "Shell tool".to_string(),
description: "Allow the model to run shell commands.".to_string(),
enabled: true,
@@ -7576,7 +7585,7 @@ async fn experimental_features_toggle_saves_on_exit() {
let expected_feature = Feature::GhostCommit;
let view = ExperimentalFeaturesView::new(
vec![ExperimentalFeatureItem {
feature: expected_feature,
key: expected_feature.key().to_string(),
name: "Ghost snapshots".to_string(),
description: "Capture undo snapshots each turn.".to_string(),
enabled: false,
@@ -7593,6 +7602,7 @@ async fn experimental_features_toggle_saves_on_exit() {
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
run_next_serialized_slash_draft(&mut chat, &mut rx);
let mut updates = None;
while let Ok(event) = rx.try_recv() {
@@ -7747,7 +7757,7 @@ async fn realtime_microphone_picker_popup_snapshot() {
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
#[tokio::test]
async fn realtime_audio_picker_emits_persist_event() {
async fn realtime_audio_picker_emits_serialized_slash_draft() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await;
chat.open_realtime_audio_device_selection_with_names(
RealtimeAudioDeviceKind::Speaker,
@@ -7760,10 +7770,8 @@ async fn realtime_audio_picker_emits_persist_event() {
assert_matches!(
rx.try_recv(),
Ok(AppEvent::PersistRealtimeAudioDeviceSelection {
kind: RealtimeAudioDeviceKind::Speaker,
name: Some(name),
}) if name == "Headphones"
Ok(AppEvent::HandleSlashCommandDraft(UserMessage { text, .. }))
if text == "/settings speaker Headphones"
);
}
@@ -8092,6 +8100,43 @@ async fn feedback_selection_popup_snapshot() {
assert_snapshot!("feedback_selection_popup", popup);
}
#[tokio::test]
async fn feedback_selection_popup_emits_serialized_slash_draft() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.dispatch_command(SlashCommand::Feedback);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::HandleSlashCommandDraft(UserMessage { text, .. })) if text == "/feedback bug"
);
}
#[tokio::test]
async fn skills_menu_emits_serialized_slash_drafts() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.open_skills_menu();
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::HandleSlashCommandDraft(UserMessage { text, .. }))
if text == "/skills list"
);
chat.open_skills_menu();
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::HandleSlashCommandDraft(UserMessage { text, .. }))
if text == "/skills manage"
);
}
#[tokio::test]
async fn feedback_upload_consent_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
@@ -8699,6 +8744,7 @@ async fn approvals_popup_navigation_skips_disabled() {
// Press Enter; selection should land on an enabled preset and dispatch updates.
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
run_next_serialized_slash_draft(&mut chat, &mut rx);
let mut app_events = Vec::new();
while let Ok(ev) = rx.try_recv() {
app_events.push(ev);
@@ -8740,6 +8786,7 @@ async fn permissions_selection_emits_history_cell_when_selection_changes() {
chat.open_permissions_popup();
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
run_next_serialized_slash_draft(&mut chat, &mut rx);
let cells = drain_insert_history(&mut rx);
assert_eq!(
@@ -8769,6 +8816,7 @@ async fn permissions_selection_history_snapshot_after_mode_switch() {
#[cfg(target_os = "windows")]
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
run_next_serialized_slash_draft(&mut chat, &mut rx);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one mode-switch history cell");
@@ -8805,6 +8853,7 @@ async fn permissions_selection_history_snapshot_full_access_to_default() {
chat.handle_key_event(KeyEvent::from(KeyCode::Up));
}
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
run_next_serialized_slash_draft(&mut chat, &mut rx);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one mode-switch history cell");
@@ -8843,6 +8892,7 @@ async fn permissions_selection_emits_history_cell_when_current_is_selected() {
chat.open_permissions_popup();
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
run_next_serialized_slash_draft(&mut chat, &mut rx);
let cells = drain_insert_history(&mut rx);
assert_eq!(
@@ -9163,6 +9213,7 @@ async fn permissions_full_access_history_cell_emitted_only_after_confirmation()
);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
run_next_serialized_slash_draft(&mut chat, &mut rx);
let cells_after_confirmation = drain_insert_history(&mut rx);
let total_history_cells = cells_before_confirmation.len() + cells_after_confirmation.len();
assert_eq!(
-48
View File
@@ -120,54 +120,6 @@ impl SlashCommand {
self.into()
}
/// Whether this command supports inline args (for example `/model ...` or `/review ...`).
pub fn supports_inline_args(self) -> bool {
match self {
SlashCommand::Model
| SlashCommand::Review
| SlashCommand::Rename
| SlashCommand::Plan
| SlashCommand::Fast
| SlashCommand::SandboxReadRoot => true,
SlashCommand::Approvals
| SlashCommand::Permissions
| SlashCommand::ElevateSandbox
| SlashCommand::Experimental
| SlashCommand::Skills
| SlashCommand::New
| SlashCommand::Resume
| SlashCommand::Fork
| SlashCommand::Init
| SlashCommand::Compact
| SlashCommand::Collab
| SlashCommand::Agent
| SlashCommand::Diff
| SlashCommand::Copy
| SlashCommand::Mention
| SlashCommand::Status
| SlashCommand::DebugConfig
| SlashCommand::Statusline
| SlashCommand::Theme
| SlashCommand::Mcp
| SlashCommand::Apps
| SlashCommand::Logout
| SlashCommand::Quit
| SlashCommand::Exit
| SlashCommand::Feedback
| SlashCommand::Rollout
| SlashCommand::Ps
| SlashCommand::Clean
| SlashCommand::Clear
| SlashCommand::Personality
| SlashCommand::Realtime
| SlashCommand::Settings
| SlashCommand::TestApproval
| SlashCommand::MultiAgents
| SlashCommand::MemoryDrop
| SlashCommand::MemoryUpdate => false,
}
}
/// Whether bare dispatch opens interactive UI that should be resolved before queueing.
pub fn requires_interaction(self) -> bool {
match self {
+6 -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,7 @@ 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::status::format_directory_display;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -344,9 +345,9 @@ 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(
format!("/{} {}", SlashCommand::Theme.command(), name_for_action).into(),
));
})],
..Default::default()
}