mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
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:
co-authored by
Codex
parent
217da0c113
commit
8c45c1acfc
+120
-88
@@ -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,
|
||||
¤t_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(¤t_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,
|
||||
¤t_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(¤t_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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user