mirror of
https://github.com/openai/codex.git
synced 2026-04-19 20:24:50 +00:00
Compare commits
67 Commits
dev/realti
...
cc/queue-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c9bac8bdb | ||
|
|
ef28639b6e | ||
|
|
f701d9e056 | ||
|
|
d9325105a1 | ||
|
|
9ec1452655 | ||
|
|
5d81980593 | ||
|
|
89f7c082cb | ||
|
|
3b8dbede74 | ||
|
|
11d49d9259 | ||
|
|
8d3ed74adc | ||
|
|
6cc147624e | ||
|
|
f34490a3f2 | ||
|
|
6d0be7f9d8 | ||
|
|
e0627c8f37 | ||
|
|
a6ffd1c887 | ||
|
|
1a54e52fd9 | ||
|
|
d65960d57b | ||
|
|
90e964fed4 | ||
|
|
eb31929288 | ||
|
|
39f436fd69 | ||
|
|
51d1e061f8 | ||
|
|
5ebd3486a8 | ||
|
|
7e91f70be0 | ||
|
|
027524dac5 | ||
|
|
02f6733dea | ||
|
|
a80a654b04 | ||
|
|
3b4b48320e | ||
|
|
295f7fe976 | ||
|
|
7bcff58ea4 | ||
|
|
ef2b09c61a | ||
|
|
648739854e | ||
|
|
3a6e3a95be | ||
|
|
614936d113 | ||
|
|
96fe896919 | ||
|
|
7250e82a9b | ||
|
|
d8c0839fc2 | ||
|
|
efa6715884 | ||
|
|
93ead12235 | ||
|
|
2268f98488 | ||
|
|
e477780626 | ||
|
|
541bc6ef34 | ||
|
|
56c7646161 | ||
|
|
0727144012 | ||
|
|
6e20c5af8c | ||
|
|
77e1e8029a | ||
|
|
8fbc9d32fd | ||
|
|
528e3df3f6 | ||
|
|
6a278e943f | ||
|
|
8fda4e0fc2 | ||
|
|
ad586ba24c | ||
|
|
7ea03de12b | ||
|
|
bcb3555e70 | ||
|
|
8c45c1acfc | ||
|
|
217da0c113 | ||
|
|
bb3e61843a | ||
|
|
173d0ef4e3 | ||
|
|
8267155494 | ||
|
|
393740dbed | ||
|
|
ed546bc217 | ||
|
|
122e1475d5 | ||
|
|
fa50564579 | ||
|
|
35e8aa9ce0 | ||
|
|
9ab49d2c37 | ||
|
|
e5f1b8435d | ||
|
|
a8f1f43c1f | ||
|
|
072d5d9e49 | ||
|
|
9478b34e55 |
@@ -34,6 +34,7 @@ use crate::pager_overlay::Overlay;
|
||||
use crate::render::highlight::highlight_bash_to_lines;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::resume_picker::SessionSelection;
|
||||
use crate::resume_picker::SessionTarget;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use crate::update_action::UpdateAction;
|
||||
@@ -53,6 +54,7 @@ use codex_core::config::types::ApprovalsReviewer;
|
||||
use codex_core::config::types::ModelAvailabilityNuxConfig;
|
||||
use codex_core::config_loader::ConfigLayerStackOrdering;
|
||||
use codex_core::features::Feature;
|
||||
use codex_core::find_thread_path_by_id_str;
|
||||
use codex_core::models_manager::manager::RefreshStrategy;
|
||||
use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
|
||||
use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
|
||||
@@ -728,6 +730,7 @@ pub(crate) struct App {
|
||||
primary_thread_id: Option<ThreadId>,
|
||||
primary_session_configured: Option<SessionConfiguredEvent>,
|
||||
pending_primary_events: VecDeque<Event>,
|
||||
pending_async_queue_resume_barriers: usize,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -755,6 +758,28 @@ fn normalize_harness_overrides_for_cwd(
|
||||
}
|
||||
|
||||
impl App {
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn begin_async_queue_resume_barrier(&mut self) {
|
||||
self.pending_async_queue_resume_barriers += 1;
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
fn finish_async_queue_resume_barrier(&mut self) {
|
||||
if self.pending_async_queue_resume_barriers == 0 {
|
||||
tracing::warn!("finished async queue-resume barrier with no pending barrier");
|
||||
return;
|
||||
}
|
||||
self.pending_async_queue_resume_barriers -= 1;
|
||||
}
|
||||
|
||||
fn maybe_resume_queued_inputs_after_app_events(&mut self, app_events_drained: bool) {
|
||||
if !app_events_drained || self.pending_async_queue_resume_barriers != 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
self.chat_widget.maybe_resume_queued_inputs_when_idle();
|
||||
}
|
||||
|
||||
pub fn chatwidget_init_for_forked_or_resumed_thread(
|
||||
&self,
|
||||
tui: &mut tui::Tui,
|
||||
@@ -834,6 +859,104 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
async fn resume_session_target(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
target_session: SessionTarget,
|
||||
) -> Result<AppRunControl> {
|
||||
let current_cwd = self.config.cwd.clone();
|
||||
let resume_cwd = match crate::resolve_cwd_for_resume_or_fork(
|
||||
tui,
|
||||
&self.config,
|
||||
¤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(self.handle_exit_mode(ExitMode::ShutdownFirst));
|
||||
}
|
||||
};
|
||||
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,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resumed) => {
|
||||
let input_state = self.chat_widget.capture_thread_input_state();
|
||||
self.shutdown_current_thread().await;
|
||||
self.config = resume_config;
|
||||
tui.set_notification_method(self.config.tui_notification_method);
|
||||
self.file_search.update_search_dir(self.config.cwd.clone());
|
||||
let init =
|
||||
self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone());
|
||||
self.chat_widget =
|
||||
ChatWidget::new_from_existing(init, resumed.thread, resumed.session_configured);
|
||||
self.reset_thread_event_state();
|
||||
if let Some(summary) = summary {
|
||||
let mut lines: Vec<Line<'static>> = vec![summary.usage_line.clone().into()];
|
||||
if let Some(command) = summary.resume_command {
|
||||
let spans = vec!["To continue this session, run ".into(), command.cyan()];
|
||||
lines.push(spans.into());
|
||||
}
|
||||
self.chat_widget.add_plain_history_lines(lines);
|
||||
}
|
||||
self.restore_input_state_after_thread_switch(input_state);
|
||||
}
|
||||
Err(err) => {
|
||||
let path_display = target_session.path.display();
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to resume session from {path_display}: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(AppRunControl::Continue)
|
||||
}
|
||||
|
||||
async fn resume_session_by_thread_id(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
thread_id: ThreadId,
|
||||
) -> Result<AppRunControl> {
|
||||
let Some(path) =
|
||||
find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string()).await?
|
||||
else {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("No saved session found for thread {thread_id}."));
|
||||
return Ok(AppRunControl::Continue);
|
||||
};
|
||||
self.resume_session_target(tui, SessionTarget { path, thread_id })
|
||||
.await
|
||||
}
|
||||
|
||||
fn apply_runtime_policy_overrides(&mut self, config: &mut Config) {
|
||||
if let Some(policy) = self.runtime_approval_policy_override.as_ref()
|
||||
&& let Err(err) = config.permissions.approval_policy.set(*policy)
|
||||
@@ -1656,7 +1779,13 @@ impl App {
|
||||
description: Some(uuid.clone()),
|
||||
is_current: self.active_thread_id == Some(*thread_id),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::SelectAgentThread(id));
|
||||
tx.send(AppEvent::HandleSlashCommandDraft(
|
||||
crate::slash_command_invocation::SlashCommandInvocation::with_args(
|
||||
crate::slash_command::SlashCommand::Agent,
|
||||
[id.to_string()],
|
||||
)
|
||||
.into_user_message(),
|
||||
));
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
search_value: Some(format!("{name} {uuid}")),
|
||||
@@ -1784,6 +1913,11 @@ impl App {
|
||||
self.sync_active_agent_label();
|
||||
}
|
||||
|
||||
fn restore_input_state_after_thread_switch(&mut self, input_state: Option<ThreadInputState>) {
|
||||
self.chat_widget.restore_thread_input_state(input_state);
|
||||
self.chat_widget.drain_queued_inputs_until_blocked();
|
||||
}
|
||||
|
||||
async fn start_fresh_session_with_summary_hint(&mut self, tui: &mut tui::Tui) {
|
||||
// Start a fresh in-memory session while preserving resumability via persisted rollout
|
||||
// history.
|
||||
@@ -1796,6 +1930,7 @@ impl App {
|
||||
self.chat_widget.thread_id(),
|
||||
self.chat_widget.thread_name(),
|
||||
);
|
||||
let input_state = self.chat_widget.capture_thread_input_state();
|
||||
self.shutdown_current_thread().await;
|
||||
let report = self
|
||||
.server
|
||||
@@ -1835,6 +1970,7 @@ impl App {
|
||||
}
|
||||
self.chat_widget.add_plain_history_lines(lines);
|
||||
}
|
||||
self.restore_input_state_after_thread_switch(input_state);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
@@ -1912,7 +2048,7 @@ impl App {
|
||||
}
|
||||
self.chat_widget.set_queue_autosend_suppressed(false);
|
||||
if resume_restored_queue {
|
||||
self.chat_widget.maybe_send_next_queued_input();
|
||||
self.chat_widget.drain_queued_inputs_until_blocked();
|
||||
}
|
||||
self.refresh_status_line();
|
||||
}
|
||||
@@ -2180,6 +2316,7 @@ impl App {
|
||||
primary_thread_id: None,
|
||||
primary_session_configured: None,
|
||||
pending_primary_events: VecDeque::new(),
|
||||
pending_async_queue_resume_barriers: 0,
|
||||
};
|
||||
|
||||
// On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows.
|
||||
@@ -2198,6 +2335,7 @@ impl App {
|
||||
.hide_world_writable_warning
|
||||
.unwrap_or(false);
|
||||
if should_check {
|
||||
app.begin_async_queue_resume_barrier();
|
||||
let cwd = app.config.cwd.clone();
|
||||
let env_map: std::collections::HashMap<String, String> = std::env::vars().collect();
|
||||
let tx = app.app_event_tx.clone();
|
||||
@@ -2308,6 +2446,11 @@ impl App {
|
||||
) {
|
||||
waiting_for_initial_session_configured = false;
|
||||
}
|
||||
// Some replayed slash commands pause queue draining until their app-side updates,
|
||||
// popup flows, or async follow-up work settle. Only resume once the app-event
|
||||
// queue is fully drained and no background slash-command completions are still
|
||||
// pending, so later queued input cannot interleave with those updates.
|
||||
app.maybe_resume_queued_inputs_after_app_events(app_event_rx.is_empty());
|
||||
match control {
|
||||
AppRunControl::Continue => {}
|
||||
AppRunControl::Exit(reason) => break Ok(reason),
|
||||
@@ -2414,87 +2557,10 @@ impl App {
|
||||
AppEvent::OpenResumePicker => {
|
||||
match crate::resume_picker::run_resume_picker(tui, &self.config, false).await? {
|
||||
SessionSelection::Resume(target_session) => {
|
||||
let current_cwd = self.config.cwd.clone();
|
||||
let resume_cwd = match crate::resolve_cwd_for_resume_or_fork(
|
||||
tui,
|
||||
&self.config,
|
||||
¤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(),
|
||||
self.chat_widget.handle_serialized_slash_command(
|
||||
ChatWidget::resume_selection_draft(&target_session),
|
||||
);
|
||||
match self
|
||||
.server
|
||||
.resume_thread_from_rollout(
|
||||
resume_config.clone(),
|
||||
target_session.path.clone(),
|
||||
self.auth_manager.clone(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resumed) => {
|
||||
self.shutdown_current_thread().await;
|
||||
self.config = resume_config;
|
||||
tui.set_notification_method(self.config.tui_notification_method);
|
||||
self.file_search.update_search_dir(self.config.cwd.clone());
|
||||
let init = self.chatwidget_init_for_forked_or_resumed_thread(
|
||||
tui,
|
||||
self.config.clone(),
|
||||
);
|
||||
self.chat_widget = ChatWidget::new_from_existing(
|
||||
init,
|
||||
resumed.thread,
|
||||
resumed.session_configured,
|
||||
);
|
||||
self.reset_thread_event_state();
|
||||
if let Some(summary) = summary {
|
||||
let mut lines: Vec<Line<'static>> =
|
||||
vec![summary.usage_line.clone().into()];
|
||||
if let Some(command) = summary.resume_command {
|
||||
let spans = vec![
|
||||
"To continue this session, run ".into(),
|
||||
command.cyan(),
|
||||
];
|
||||
lines.push(spans.into());
|
||||
}
|
||||
self.chat_widget.add_plain_history_lines(lines);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let path_display = target_session.path.display();
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to resume session from {path_display}: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
self.refresh_status_line();
|
||||
}
|
||||
SessionSelection::Exit
|
||||
| SessionSelection::StartFresh
|
||||
@@ -2504,6 +2570,12 @@ impl App {
|
||||
// Leaving alt-screen may blank the inline viewport; force a redraw either way.
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
AppEvent::ResumeSession(thread_id) => {
|
||||
return self.resume_session_by_thread_id(tui, thread_id).await;
|
||||
}
|
||||
AppEvent::ResumeSessionTarget(target_session) => {
|
||||
return self.resume_session_target(tui, target_session).await;
|
||||
}
|
||||
AppEvent::ForkCurrentSession => {
|
||||
self.session_telemetry.counter(
|
||||
"codex.thread.fork",
|
||||
@@ -2529,6 +2601,7 @@ impl App {
|
||||
.await
|
||||
{
|
||||
Ok(forked) => {
|
||||
let input_state = self.chat_widget.capture_thread_input_state();
|
||||
self.shutdown_current_thread().await;
|
||||
let init = self.chatwidget_init_for_forked_or_resumed_thread(
|
||||
tui,
|
||||
@@ -2552,6 +2625,7 @@ impl App {
|
||||
}
|
||||
self.chat_widget.add_plain_history_lines(lines);
|
||||
}
|
||||
self.restore_input_state_after_thread_switch(input_state);
|
||||
}
|
||||
Err(err) => {
|
||||
let path_display = path.display();
|
||||
@@ -2717,6 +2791,11 @@ impl App {
|
||||
self.chat_widget.set_model(&model);
|
||||
self.refresh_status_line();
|
||||
}
|
||||
AppEvent::HandleSlashCommandDraft(draft) => {
|
||||
self.chat_widget.handle_serialized_slash_command(draft);
|
||||
self.refresh_status_line();
|
||||
}
|
||||
AppEvent::BottomPaneViewCompleted => {}
|
||||
AppEvent::UpdateCollaborationMode(mask) => {
|
||||
self.chat_widget.set_collaboration_mask(mask);
|
||||
self.refresh_status_line();
|
||||
@@ -2746,12 +2825,14 @@ impl App {
|
||||
}
|
||||
AppEvent::OpenWorldWritableWarningConfirmation {
|
||||
preset,
|
||||
approvals_reviewer,
|
||||
sample_paths,
|
||||
extra_count,
|
||||
failed_scan,
|
||||
} => {
|
||||
self.chat_widget.open_world_writable_warning_confirmation(
|
||||
preset,
|
||||
approvals_reviewer,
|
||||
sample_paths,
|
||||
extra_count,
|
||||
failed_scan,
|
||||
@@ -2771,10 +2852,17 @@ impl App {
|
||||
self.launch_external_editor(tui).await;
|
||||
}
|
||||
}
|
||||
AppEvent::OpenWindowsSandboxEnablePrompt { preset } => {
|
||||
self.chat_widget.open_windows_sandbox_enable_prompt(preset);
|
||||
AppEvent::OpenWindowsSandboxEnablePrompt {
|
||||
preset,
|
||||
approvals_reviewer,
|
||||
} => {
|
||||
self.chat_widget
|
||||
.open_windows_sandbox_enable_prompt(preset, approvals_reviewer);
|
||||
}
|
||||
AppEvent::OpenWindowsSandboxFallbackPrompt { preset } => {
|
||||
AppEvent::OpenWindowsSandboxFallbackPrompt {
|
||||
preset,
|
||||
approvals_reviewer,
|
||||
} => {
|
||||
self.session_telemetry.counter(
|
||||
"codex.windows_sandbox.fallback_prompt_shown",
|
||||
1,
|
||||
@@ -2789,9 +2877,12 @@ impl App {
|
||||
);
|
||||
}
|
||||
self.chat_widget
|
||||
.open_windows_sandbox_fallback_prompt(preset);
|
||||
.open_windows_sandbox_fallback_prompt(preset, approvals_reviewer);
|
||||
}
|
||||
AppEvent::BeginWindowsSandboxElevatedSetup { preset } => {
|
||||
AppEvent::BeginWindowsSandboxElevatedSetup {
|
||||
preset,
|
||||
approvals_reviewer,
|
||||
} => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let policy = preset.sandbox.clone();
|
||||
@@ -2809,12 +2900,14 @@ impl App {
|
||||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset,
|
||||
mode: WindowsSandboxEnableMode::Elevated,
|
||||
approvals_reviewer,
|
||||
});
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
|
||||
self.chat_widget.show_windows_sandbox_setup_status();
|
||||
self.windows_sandbox.setup_started_at = Some(Instant::now());
|
||||
self.begin_async_queue_resume_barrier();
|
||||
let session_telemetry = self.session_telemetry.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = codex_core::windows_sandbox::run_elevated_setup(
|
||||
@@ -2831,9 +2924,10 @@ impl App {
|
||||
1,
|
||||
&[],
|
||||
);
|
||||
AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
AppEvent::WindowsSandboxElevatedSetupCompleted {
|
||||
preset: preset.clone(),
|
||||
mode: WindowsSandboxEnableMode::Elevated,
|
||||
approvals_reviewer,
|
||||
setup_succeeded: true,
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -2865,7 +2959,11 @@ impl App {
|
||||
error = %err,
|
||||
"failed to run elevated Windows sandbox setup"
|
||||
);
|
||||
AppEvent::OpenWindowsSandboxFallbackPrompt { preset }
|
||||
AppEvent::WindowsSandboxElevatedSetupCompleted {
|
||||
preset,
|
||||
approvals_reviewer,
|
||||
setup_succeeded: false,
|
||||
}
|
||||
}
|
||||
};
|
||||
tx.send(event);
|
||||
@@ -2873,10 +2971,40 @@ impl App {
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = preset;
|
||||
let _ = (preset, approvals_reviewer);
|
||||
}
|
||||
}
|
||||
AppEvent::BeginWindowsSandboxLegacySetup { preset } => {
|
||||
AppEvent::WindowsSandboxElevatedSetupCompleted {
|
||||
preset,
|
||||
approvals_reviewer,
|
||||
setup_succeeded,
|
||||
} => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
self.finish_async_queue_resume_barrier();
|
||||
let event = if setup_succeeded {
|
||||
AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset,
|
||||
mode: WindowsSandboxEnableMode::Elevated,
|
||||
approvals_reviewer,
|
||||
}
|
||||
} else {
|
||||
AppEvent::OpenWindowsSandboxFallbackPrompt {
|
||||
preset,
|
||||
approvals_reviewer,
|
||||
}
|
||||
};
|
||||
self.app_event_tx.send(event);
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = (preset, approvals_reviewer, setup_succeeded);
|
||||
}
|
||||
}
|
||||
AppEvent::BeginWindowsSandboxLegacySetup {
|
||||
preset,
|
||||
approvals_reviewer,
|
||||
} => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let policy = preset.sandbox.clone();
|
||||
@@ -2886,36 +3014,70 @@ impl App {
|
||||
std::env::vars().collect();
|
||||
let codex_home = self.config.codex_home.clone();
|
||||
let tx = self.app_event_tx.clone();
|
||||
let session_telemetry = self.session_telemetry.clone();
|
||||
|
||||
self.chat_widget.show_windows_sandbox_setup_status();
|
||||
self.begin_async_queue_resume_barrier();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if let Err(err) = codex_core::windows_sandbox::run_legacy_setup_preflight(
|
||||
let preset_for_error = preset.clone();
|
||||
let result = codex_core::windows_sandbox::run_legacy_setup_preflight(
|
||||
&policy,
|
||||
policy_cwd.as_path(),
|
||||
command_cwd.as_path(),
|
||||
&env_map,
|
||||
codex_home.as_path(),
|
||||
) {
|
||||
session_telemetry.counter(
|
||||
"codex.windows_sandbox.legacy_setup_preflight_failed",
|
||||
1,
|
||||
&[],
|
||||
);
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"failed to preflight non-admin Windows sandbox setup"
|
||||
);
|
||||
}
|
||||
tx.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset,
|
||||
mode: WindowsSandboxEnableMode::Legacy,
|
||||
});
|
||||
);
|
||||
let event = match result {
|
||||
Ok(()) => AppEvent::WindowsSandboxLegacySetupCompleted {
|
||||
preset,
|
||||
approvals_reviewer,
|
||||
error: None,
|
||||
},
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
"failed to run legacy Windows sandbox setup preflight"
|
||||
);
|
||||
AppEvent::WindowsSandboxLegacySetupCompleted {
|
||||
preset: preset_for_error,
|
||||
approvals_reviewer,
|
||||
error: Some(err.to_string()),
|
||||
}
|
||||
}
|
||||
};
|
||||
tx.send(event);
|
||||
});
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = preset;
|
||||
let _ = (preset, approvals_reviewer);
|
||||
}
|
||||
}
|
||||
AppEvent::WindowsSandboxLegacySetupCompleted {
|
||||
preset,
|
||||
approvals_reviewer,
|
||||
error,
|
||||
} => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
self.finish_async_queue_resume_barrier();
|
||||
match error {
|
||||
None => {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset,
|
||||
mode: WindowsSandboxEnableMode::Legacy,
|
||||
approvals_reviewer,
|
||||
});
|
||||
}
|
||||
Some(err) => {
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to enable the Windows sandbox feature: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = (preset, approvals_reviewer, error);
|
||||
}
|
||||
}
|
||||
AppEvent::BeginWindowsSandboxGrantReadRoot { path } => {
|
||||
@@ -2975,7 +3137,11 @@ impl App {
|
||||
));
|
||||
}
|
||||
},
|
||||
AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => {
|
||||
AppEvent::EnableWindowsSandboxForAgentMode {
|
||||
preset,
|
||||
mode,
|
||||
approvals_reviewer,
|
||||
} => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
self.chat_widget.clear_windows_sandbox_setup_status();
|
||||
@@ -3031,6 +3197,7 @@ impl App {
|
||||
self.app_event_tx.send(
|
||||
AppEvent::OpenWorldWritableWarningConfirmation {
|
||||
preset: Some(preset.clone()),
|
||||
approvals_reviewer: Some(approvals_reviewer),
|
||||
sample_paths,
|
||||
extra_count,
|
||||
failed_scan,
|
||||
@@ -3041,7 +3208,7 @@ impl App {
|
||||
Op::OverrideTurnContext {
|
||||
cwd: None,
|
||||
approval_policy: Some(preset.approval),
|
||||
approvals_reviewer: Some(self.config.approvals_reviewer),
|
||||
approvals_reviewer: Some(approvals_reviewer),
|
||||
sandbox_policy: Some(preset.sandbox.clone()),
|
||||
windows_sandbox_level: Some(windows_sandbox_level),
|
||||
model: None,
|
||||
@@ -3056,6 +3223,8 @@ impl App {
|
||||
.send(AppEvent::UpdateAskForApprovalPolicy(preset.approval));
|
||||
self.app_event_tx
|
||||
.send(AppEvent::UpdateSandboxPolicy(preset.sandbox.clone()));
|
||||
self.app_event_tx
|
||||
.send(AppEvent::UpdateApprovalsReviewer(approvals_reviewer));
|
||||
let _ = mode;
|
||||
self.chat_widget.add_plain_history_lines(vec![
|
||||
Line::from(vec!["• ".dim(), "Sandbox ready".into()]),
|
||||
@@ -3080,7 +3249,7 @@ impl App {
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = (preset, mode);
|
||||
let _ = (preset, mode, approvals_reviewer);
|
||||
}
|
||||
}
|
||||
AppEvent::PersistModelSelection { model, effort } => {
|
||||
@@ -3300,6 +3469,7 @@ impl App {
|
||||
&& policy_is_workspace_write_or_ro
|
||||
&& !self.chat_widget.world_writable_warning_hidden();
|
||||
if should_check {
|
||||
self.begin_async_queue_resume_barrier();
|
||||
let cwd = self.config.cwd.clone();
|
||||
let env_map: std::collections::HashMap<String, String> =
|
||||
std::env::vars().collect();
|
||||
@@ -3472,15 +3642,16 @@ impl App {
|
||||
AppEvent::OpenApprovalsPopup => {
|
||||
self.chat_widget.open_approvals_popup();
|
||||
}
|
||||
AppEvent::WorldWritableScanCompleted => {
|
||||
#[cfg(target_os = "windows")]
|
||||
self.finish_async_queue_resume_barrier();
|
||||
}
|
||||
AppEvent::OpenAgentPicker => {
|
||||
self.open_agent_picker().await;
|
||||
}
|
||||
AppEvent::SelectAgentThread(thread_id) => {
|
||||
self.select_agent_thread(tui, thread_id).await?;
|
||||
}
|
||||
AppEvent::OpenSkillsList => {
|
||||
self.chat_widget.open_skills_list();
|
||||
}
|
||||
AppEvent::OpenManageSkillsPopup => {
|
||||
self.chat_widget.open_manage_skills_popup();
|
||||
}
|
||||
@@ -4163,11 +4334,13 @@ impl App {
|
||||
// Scan failed: warn without examples.
|
||||
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
|
||||
preset: None,
|
||||
approvals_reviewer: None,
|
||||
sample_paths: Vec::new(),
|
||||
extra_count: 0usize,
|
||||
failed_scan: true,
|
||||
});
|
||||
}
|
||||
tx.send(AppEvent::WorldWritableScanCompleted);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4178,6 +4351,7 @@ mod tests {
|
||||
use crate::app_backtrack::BacktrackSelection;
|
||||
use crate::app_backtrack::BacktrackState;
|
||||
use crate::app_backtrack::user_count;
|
||||
use crate::chatwidget::UserMessage;
|
||||
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
|
||||
use crate::chatwidget::tests::set_chatgpt_auth;
|
||||
use crate::file_search::FileSearchManager;
|
||||
@@ -4678,6 +4852,60 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_switch_restores_and_drains_queued_follow_up() {
|
||||
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
let thread_id = ThreadId::new();
|
||||
let session_configured = Event {
|
||||
id: "session-configured".to_string(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: thread_id,
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "gpt-test".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
cwd: PathBuf::from("/tmp/project"),
|
||||
reasoning_effort: None,
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: Some(PathBuf::new()),
|
||||
}),
|
||||
};
|
||||
app.chat_widget
|
||||
.apply_external_edit("queued follow-up".to_string());
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
let input_state = app
|
||||
.chat_widget
|
||||
.capture_thread_input_state()
|
||||
.expect("expected queued follow-up state");
|
||||
|
||||
let (chat_widget, _app_event_tx, _rx, mut new_op_rx) =
|
||||
make_chatwidget_manual_with_sender().await;
|
||||
app.chat_widget = chat_widget;
|
||||
app.chat_widget.handle_codex_event(session_configured);
|
||||
while new_op_rx.try_recv().is_ok() {}
|
||||
|
||||
app.restore_input_state_after_thread_switch(Some(input_state));
|
||||
|
||||
match next_user_turn_op(&mut new_op_rx) {
|
||||
Op::UserTurn { items, .. } => assert_eq!(
|
||||
items,
|
||||
vec![UserInput::Text {
|
||||
text: "queued follow-up".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
),
|
||||
other => panic!("expected queued follow-up submission, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replay_only_thread_keeps_restored_queue_visible() {
|
||||
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
@@ -5921,10 +6149,12 @@ guardian_approval = true
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_matches!(
|
||||
app_event_rx.try_recv(),
|
||||
Ok(AppEvent::SelectAgentThread(selected_thread_id)) if selected_thread_id == thread_id
|
||||
);
|
||||
match app_event_rx.try_recv() {
|
||||
Ok(AppEvent::HandleSlashCommandDraft(draft)) => {
|
||||
assert_eq!(draft, UserMessage::from(format!("/agent {thread_id}")));
|
||||
}
|
||||
other => panic!("expected serialized agent slash draft, got {other:?}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -6381,6 +6611,7 @@ guardian_approval = true
|
||||
primary_thread_id: None,
|
||||
primary_session_configured: None,
|
||||
pending_primary_events: VecDeque::new(),
|
||||
pending_async_queue_resume_barriers: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6441,6 +6672,7 @@ guardian_approval = true
|
||||
primary_thread_id: None,
|
||||
primary_session_configured: None,
|
||||
pending_primary_events: VecDeque::new(),
|
||||
pending_async_queue_resume_barriers: 0,
|
||||
},
|
||||
rx,
|
||||
op_rx,
|
||||
@@ -7455,6 +7687,208 @@ guardian_approval = true
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_personality_selection_resumes_followup_after_app_events_drain() {
|
||||
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
||||
app.chat_widget.set_model("gpt-5.2-codex");
|
||||
app.chat_widget
|
||||
.set_feature_enabled(Feature::Personality, true);
|
||||
app.chat_widget.handle_codex_event(Event {
|
||||
id: "configured".into(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: ThreadId::new(),
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "gpt-5.2-codex".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
cwd: PathBuf::from("/home/user/project"),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: None,
|
||||
}),
|
||||
});
|
||||
app.chat_widget.handle_codex_event(Event {
|
||||
id: "turn-started".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: Default::default(),
|
||||
}),
|
||||
});
|
||||
while app_event_rx.try_recv().is_ok() {}
|
||||
while op_rx.try_recv().is_ok() {}
|
||||
|
||||
app.chat_widget
|
||||
.handle_serialized_slash_command(UserMessage::from("/personality pragmatic"));
|
||||
app.chat_widget
|
||||
.set_composer_text("followup".to_string(), Vec::new(), Vec::new());
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
|
||||
app.chat_widget.handle_codex_event(Event {
|
||||
id: "turn-complete".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
last_agent_message: None,
|
||||
}),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
app.chat_widget.queued_user_message_texts(),
|
||||
vec!["followup".to_string()]
|
||||
);
|
||||
assert!(
|
||||
op_rx.try_recv().is_err(),
|
||||
"queued follow-up should not submit before app events drain"
|
||||
);
|
||||
|
||||
loop {
|
||||
match app_event_rx.try_recv() {
|
||||
Ok(AppEvent::CodexOp(Op::OverrideTurnContext {
|
||||
personality: Some(Personality::Pragmatic),
|
||||
..
|
||||
})) => continue,
|
||||
Ok(AppEvent::UpdatePersonality(Personality::Pragmatic)) => {
|
||||
app.on_update_personality(Personality::Pragmatic);
|
||||
break;
|
||||
}
|
||||
Ok(AppEvent::PersistPersonalitySelection {
|
||||
personality: Personality::Pragmatic,
|
||||
}) => continue,
|
||||
Ok(_) => continue,
|
||||
Err(TryRecvError::Empty) => panic!("expected personality update events"),
|
||||
Err(TryRecvError::Disconnected) => panic!("expected personality update events"),
|
||||
}
|
||||
}
|
||||
|
||||
app.maybe_resume_queued_inputs_after_app_events(true);
|
||||
|
||||
match next_user_turn_op(&mut op_rx) {
|
||||
Op::UserTurn {
|
||||
items,
|
||||
personality: Some(Personality::Pragmatic),
|
||||
..
|
||||
} => assert_eq!(
|
||||
items,
|
||||
vec![UserInput::Text {
|
||||
text: "followup".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
),
|
||||
other => panic!("expected Op::UserTurn with pragmatic personality, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn queued_followup_waits_for_pending_async_resume_barrier() {
|
||||
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
||||
app.chat_widget.set_model("gpt-5.2-codex");
|
||||
app.chat_widget
|
||||
.set_feature_enabled(Feature::Personality, true);
|
||||
app.chat_widget.handle_codex_event(Event {
|
||||
id: "configured".into(),
|
||||
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
|
||||
session_id: ThreadId::new(),
|
||||
forked_from_id: None,
|
||||
thread_name: None,
|
||||
model: "gpt-5.2-codex".to_string(),
|
||||
model_provider_id: "test-provider".to_string(),
|
||||
service_tier: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: ApprovalsReviewer::User,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
cwd: PathBuf::from("/home/user/project"),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::default()),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
network_proxy: None,
|
||||
rollout_path: None,
|
||||
}),
|
||||
});
|
||||
app.chat_widget.handle_codex_event(Event {
|
||||
id: "turn-started".into(),
|
||||
msg: EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: Default::default(),
|
||||
}),
|
||||
});
|
||||
while app_event_rx.try_recv().is_ok() {}
|
||||
while op_rx.try_recv().is_ok() {}
|
||||
|
||||
app.chat_widget
|
||||
.handle_serialized_slash_command(UserMessage::from("/personality pragmatic"));
|
||||
app.chat_widget
|
||||
.set_composer_text("followup".to_string(), Vec::new(), Vec::new());
|
||||
app.chat_widget
|
||||
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
|
||||
app.chat_widget.handle_codex_event(Event {
|
||||
id: "turn-complete".into(),
|
||||
msg: EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: "turn-1".to_string(),
|
||||
last_agent_message: None,
|
||||
}),
|
||||
});
|
||||
|
||||
loop {
|
||||
match app_event_rx.try_recv() {
|
||||
Ok(AppEvent::CodexOp(Op::OverrideTurnContext {
|
||||
personality: Some(Personality::Pragmatic),
|
||||
..
|
||||
})) => continue,
|
||||
Ok(AppEvent::UpdatePersonality(Personality::Pragmatic)) => {
|
||||
app.on_update_personality(Personality::Pragmatic);
|
||||
break;
|
||||
}
|
||||
Ok(AppEvent::PersistPersonalitySelection {
|
||||
personality: Personality::Pragmatic,
|
||||
}) => continue,
|
||||
Ok(_) => continue,
|
||||
Err(TryRecvError::Empty) => panic!("expected personality update events"),
|
||||
Err(TryRecvError::Disconnected) => panic!("expected personality update events"),
|
||||
}
|
||||
}
|
||||
|
||||
app.pending_async_queue_resume_barriers = 1;
|
||||
app.maybe_resume_queued_inputs_after_app_events(true);
|
||||
|
||||
assert_eq!(
|
||||
app.chat_widget.queued_user_message_texts(),
|
||||
vec!["followup".to_string()]
|
||||
);
|
||||
assert!(
|
||||
op_rx.try_recv().is_err(),
|
||||
"queued follow-up should stay queued while async replay barriers are pending"
|
||||
);
|
||||
|
||||
app.pending_async_queue_resume_barriers = 0;
|
||||
app.maybe_resume_queued_inputs_after_app_events(true);
|
||||
|
||||
match next_user_turn_op(&mut op_rx) {
|
||||
Op::UserTurn {
|
||||
items,
|
||||
personality: Some(Personality::Pragmatic),
|
||||
..
|
||||
} => assert_eq!(
|
||||
items,
|
||||
vec![UserInput::Text {
|
||||
text: "followup".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
),
|
||||
other => panic!("expected Op::UserTurn with pragmatic personality, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_first_exit_returns_immediate_exit_when_shutdown_submit_fails() {
|
||||
let mut app = make_test_app().await;
|
||||
|
||||
@@ -20,6 +20,7 @@ use codex_utils_approval_presets::ApprovalPreset;
|
||||
|
||||
use crate::bottom_pane::ApprovalRequest;
|
||||
use crate::bottom_pane::StatusLineItem;
|
||||
use crate::chatwidget::UserMessage;
|
||||
use crate::history_cell::HistoryCell;
|
||||
|
||||
use codex_core::config::types::ApprovalsReviewer;
|
||||
@@ -97,6 +98,12 @@ pub(crate) enum AppEvent {
|
||||
/// Open the resume picker inside the running TUI session.
|
||||
OpenResumePicker,
|
||||
|
||||
/// Resume a saved session by thread id.
|
||||
ResumeSession(ThreadId),
|
||||
|
||||
/// Resume a saved session using the exact picker-selected rollout target.
|
||||
ResumeSessionTarget(crate::resume_picker::SessionTarget),
|
||||
|
||||
/// Fork the current session into a new thread.
|
||||
ForkCurrentSession,
|
||||
|
||||
@@ -182,6 +189,14 @@ pub(crate) enum AppEvent {
|
||||
/// Update the current model slug in the running app and widget.
|
||||
UpdateModel(String),
|
||||
|
||||
/// Evaluate a serialized built-in slash-command draft. If a task is currently running, the
|
||||
/// draft is queued and replayed later through the same path as queued composer input.
|
||||
HandleSlashCommandDraft(UserMessage),
|
||||
|
||||
/// Notify the app that an interactive bottom-pane view finished, so queued replay can resume
|
||||
/// once the UI is idle again.
|
||||
BottomPaneViewCompleted,
|
||||
|
||||
/// Update the active collaboration mask in the running app and widget.
|
||||
UpdateCollaborationMode(CollaborationModeMask),
|
||||
|
||||
@@ -253,6 +268,7 @@ pub(crate) enum AppEvent {
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
OpenWorldWritableWarningConfirmation {
|
||||
preset: Option<ApprovalPreset>,
|
||||
approvals_reviewer: Option<ApprovalsReviewer>,
|
||||
/// Up to 3 sample world-writable directories to display in the warning.
|
||||
sample_paths: Vec<String>,
|
||||
/// If there are more than `sample_paths`, this carries the remaining count.
|
||||
@@ -265,24 +281,44 @@ pub(crate) enum AppEvent {
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
OpenWindowsSandboxEnablePrompt {
|
||||
preset: ApprovalPreset,
|
||||
approvals_reviewer: ApprovalsReviewer,
|
||||
},
|
||||
|
||||
/// Open the Windows sandbox fallback prompt after declining or failing elevation.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
OpenWindowsSandboxFallbackPrompt {
|
||||
preset: ApprovalPreset,
|
||||
approvals_reviewer: ApprovalsReviewer,
|
||||
},
|
||||
|
||||
/// Begin the elevated Windows sandbox setup flow.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
BeginWindowsSandboxElevatedSetup {
|
||||
preset: ApprovalPreset,
|
||||
approvals_reviewer: ApprovalsReviewer,
|
||||
},
|
||||
|
||||
/// Result of the elevated Windows sandbox setup flow.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
WindowsSandboxElevatedSetupCompleted {
|
||||
preset: ApprovalPreset,
|
||||
approvals_reviewer: ApprovalsReviewer,
|
||||
setup_succeeded: bool,
|
||||
},
|
||||
|
||||
/// Begin the non-elevated Windows sandbox setup flow.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
BeginWindowsSandboxLegacySetup {
|
||||
preset: ApprovalPreset,
|
||||
approvals_reviewer: ApprovalsReviewer,
|
||||
},
|
||||
|
||||
/// Result of the non-elevated Windows sandbox setup flow.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
WindowsSandboxLegacySetupCompleted {
|
||||
preset: ApprovalPreset,
|
||||
approvals_reviewer: ApprovalsReviewer,
|
||||
error: Option<String>,
|
||||
},
|
||||
|
||||
/// Begin a non-elevated grant of read access for an additional directory.
|
||||
@@ -298,11 +334,16 @@ pub(crate) enum AppEvent {
|
||||
error: Option<String>,
|
||||
},
|
||||
|
||||
/// Result of the asynchronous Windows world-writable scan.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
WorldWritableScanCompleted,
|
||||
|
||||
/// Enable the Windows sandbox feature and switch to Agent mode.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
EnableWindowsSandboxForAgentMode {
|
||||
preset: ApprovalPreset,
|
||||
mode: WindowsSandboxEnableMode,
|
||||
approvals_reviewer: ApprovalsReviewer,
|
||||
},
|
||||
|
||||
/// Update the Windows sandbox feature mode without changing approval presets.
|
||||
@@ -361,9 +402,6 @@ pub(crate) enum AppEvent {
|
||||
/// Re-open the approval presets popup.
|
||||
OpenApprovalsPopup,
|
||||
|
||||
/// Open the skills list popup.
|
||||
OpenSkillsList,
|
||||
|
||||
/// Open the skills enable/disable picker.
|
||||
OpenManageSkillsPopup,
|
||||
|
||||
|
||||
@@ -50,8 +50,8 @@
|
||||
//!
|
||||
//! The numeric auto-submit path used by the slash popup performs the same pending-paste expansion
|
||||
//! and attachment pruning, and clears pending paste state on success.
|
||||
//! Slash commands with arguments (like `/plan` and `/review`) reuse the same preparation path so
|
||||
//! pasted content and text elements are preserved when extracting args.
|
||||
//! Slash commands with arguments (like `/model`, `/plan`, and `/review`) reuse the same
|
||||
//! preparation path so pasted content and text elements are preserved when extracting args.
|
||||
//!
|
||||
//! # Remote Image Rows (Up/Down/Delete)
|
||||
//!
|
||||
@@ -572,23 +572,6 @@ impl ChatComposer {
|
||||
self.sync_popups();
|
||||
}
|
||||
|
||||
pub(crate) fn take_mention_bindings(&mut self) -> Vec<MentionBinding> {
|
||||
let elements = self.current_mention_elements();
|
||||
let mut ordered = Vec::new();
|
||||
for (id, mention) in elements {
|
||||
if let Some(binding) = self.mention_bindings.remove(&id)
|
||||
&& binding.mention == mention
|
||||
{
|
||||
ordered.push(MentionBinding {
|
||||
mention: binding.mention,
|
||||
path: binding.path,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.mention_bindings.clear();
|
||||
ordered
|
||||
}
|
||||
|
||||
pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) {
|
||||
self.collaboration_modes_enabled = enabled;
|
||||
}
|
||||
@@ -2525,9 +2508,6 @@ impl ChatComposer {
|
||||
&& let Some(cmd) =
|
||||
slash_commands::find_builtin_command(name, self.builtin_command_flags())
|
||||
{
|
||||
if self.reject_slash_command_if_unavailable(cmd) {
|
||||
return Some(InputResult::None);
|
||||
}
|
||||
self.textarea.set_text_clearing_elements("");
|
||||
Some(InputResult::Command(cmd))
|
||||
} else {
|
||||
@@ -2553,13 +2533,6 @@ impl ChatComposer {
|
||||
|
||||
let cmd = slash_commands::find_builtin_command(name, self.builtin_command_flags())?;
|
||||
|
||||
if !cmd.supports_inline_args() {
|
||||
return None;
|
||||
}
|
||||
if self.reject_slash_command_if_unavailable(cmd) {
|
||||
return Some(InputResult::None);
|
||||
}
|
||||
|
||||
let mut args_elements =
|
||||
Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements());
|
||||
let trimmed_rest = rest.trim();
|
||||
@@ -2573,10 +2546,10 @@ impl ChatComposer {
|
||||
|
||||
/// Expand pending placeholders and extract normalized inline-command args.
|
||||
///
|
||||
/// Inline-arg commands are initially dispatched using the raw draft so command rejection does
|
||||
/// not consume user input. Once a command is accepted, this helper performs the usual
|
||||
/// submission preparation (paste expansion, element trimming) and rebases element ranges from
|
||||
/// full-text offsets to command-arg offsets.
|
||||
/// Inline-arg commands are initially dispatched using the raw draft so command-specific
|
||||
/// handling can decide whether to consume the input. Once a command is accepted, this helper
|
||||
/// performs the usual submission preparation (paste expansion, element trimming) and rebases
|
||||
/// element ranges from full-text offsets to command-arg offsets.
|
||||
pub(crate) fn prepare_inline_args_submission(
|
||||
&mut self,
|
||||
record_history: bool,
|
||||
@@ -2593,20 +2566,6 @@ impl ChatComposer {
|
||||
Some((trimmed_rest.to_string(), args_elements))
|
||||
}
|
||||
|
||||
fn reject_slash_command_if_unavailable(&self, cmd: SlashCommand) -> bool {
|
||||
if !self.is_task_running || cmd.available_during_task() {
|
||||
return false;
|
||||
}
|
||||
let message = format!(
|
||||
"'/{}' is disabled while a task is in progress.",
|
||||
cmd.command()
|
||||
);
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_error_event(message),
|
||||
)));
|
||||
true
|
||||
}
|
||||
|
||||
/// Translate full-text element ranges into command-argument ranges.
|
||||
///
|
||||
/// `rest_offset` is the byte offset where `rest` begins in the full text.
|
||||
@@ -6422,6 +6381,69 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_popup_help_first_for_root_ui() {
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
type_chars_humanlike(&mut composer, &['/']);
|
||||
|
||||
let mut terminal = match Terminal::new(TestBackend::new(60, 8)) {
|
||||
Ok(t) => t,
|
||||
Err(e) => panic!("Failed to create terminal: {e}"),
|
||||
};
|
||||
terminal
|
||||
.draw(|f| composer.render(f.area(), f.buffer_mut()))
|
||||
.unwrap_or_else(|e| panic!("Failed to draw composer: {e}"));
|
||||
|
||||
if cfg!(target_os = "windows") {
|
||||
insta::with_settings!({ snapshot_suffix => "windows" }, {
|
||||
insta::assert_snapshot!("slash_popup_root", terminal.backend());
|
||||
});
|
||||
} else {
|
||||
insta::assert_snapshot!("slash_popup_root", terminal.backend());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_popup_help_first_for_root_logic() {
|
||||
use super::super::command_popup::CommandItem;
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
type_chars_humanlike(&mut composer, &['/']);
|
||||
|
||||
match &composer.active_popup {
|
||||
ActivePopup::Command(popup) => match popup.selected_item() {
|
||||
Some(CommandItem::Builtin(cmd)) => {
|
||||
assert_eq!(cmd.command(), "help")
|
||||
}
|
||||
Some(CommandItem::UserPrompt(_)) => {
|
||||
panic!("unexpected prompt selected for '/'")
|
||||
}
|
||||
None => panic!("no selected command for '/'"),
|
||||
},
|
||||
_ => panic!("slash popup not active after typing '/'"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_popup_model_first_for_mo_ui() {
|
||||
use ratatui::Terminal;
|
||||
@@ -6678,7 +6700,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_command_disabled_while_task_running_keeps_text() {
|
||||
fn slash_command_while_task_running_still_dispatches() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
@@ -6700,24 +6722,16 @@ mod tests {
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_eq!(InputResult::None, result);
|
||||
assert_eq!(
|
||||
InputResult::CommandWithArgs(
|
||||
SlashCommand::Review,
|
||||
"these changes".to_string(),
|
||||
Vec::new(),
|
||||
),
|
||||
result
|
||||
);
|
||||
assert_eq!("/review these changes", composer.textarea.text());
|
||||
|
||||
let mut found_error = false;
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||||
let message = cell
|
||||
.display_lines(80)
|
||||
.into_iter()
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(message.contains("disabled while a task is in progress"));
|
||||
found_error = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(found_error, "expected error history cell to be sent");
|
||||
assert!(rx.try_recv().is_err(), "no error should be emitted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -7626,7 +7640,7 @@ mod tests {
|
||||
composer.take_recent_submission_mention_bindings(),
|
||||
mention_bindings
|
||||
);
|
||||
assert!(composer.take_mention_bindings().is_empty());
|
||||
assert!(composer.mention_bindings().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -12,12 +12,6 @@ use crate::render::RectExt;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
|
||||
use std::collections::HashSet;
|
||||
|
||||
// Hide alias commands in the default popup list so each unique action appears once.
|
||||
// `quit` is an alias of `exit`, so we skip `quit` here.
|
||||
// `approvals` is an alias of `permissions`.
|
||||
const ALIAS_COMMANDS: &[SlashCommand] = &[SlashCommand::Quit, SlashCommand::Approvals];
|
||||
|
||||
/// A selectable item in the popup: either a built-in command or a user prompt.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -29,7 +23,8 @@ pub(crate) enum CommandItem {
|
||||
|
||||
pub(crate) struct CommandPopup {
|
||||
command_filter: String,
|
||||
builtins: Vec<(&'static str, SlashCommand)>,
|
||||
builtins: Vec<SlashCommand>,
|
||||
reserved_builtin_names: std::collections::HashSet<String>,
|
||||
prompts: Vec<CustomPrompt>,
|
||||
state: ScrollState,
|
||||
}
|
||||
@@ -62,30 +57,27 @@ impl From<CommandPopupFlags> for slash_commands::BuiltinCommandFlags {
|
||||
impl CommandPopup {
|
||||
pub(crate) fn new(mut prompts: Vec<CustomPrompt>, flags: CommandPopupFlags) -> Self {
|
||||
// Keep built-in availability in sync with the composer.
|
||||
let builtins: Vec<(&'static str, SlashCommand)> =
|
||||
slash_commands::builtins_for_input(flags.into())
|
||||
.into_iter()
|
||||
.filter(|(name, _)| !name.starts_with("debug"))
|
||||
.collect();
|
||||
let builtin_flags = flags.into();
|
||||
let builtins = slash_commands::visible_builtins_for_input(builtin_flags)
|
||||
.into_iter()
|
||||
.filter(|cmd| !cmd.command().starts_with("debug"))
|
||||
.collect();
|
||||
// Exclude prompts that collide with builtin command names and sort by name.
|
||||
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
|
||||
prompts.retain(|p| !exclude.contains(&p.name));
|
||||
let reserved_builtin_names =
|
||||
slash_commands::reserved_builtin_names_for_input(builtin_flags);
|
||||
prompts.retain(|p| !reserved_builtin_names.contains(&p.name));
|
||||
prompts.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Self {
|
||||
command_filter: String::new(),
|
||||
builtins,
|
||||
reserved_builtin_names,
|
||||
prompts,
|
||||
state: ScrollState::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_prompts(&mut self, mut prompts: Vec<CustomPrompt>) {
|
||||
let exclude: HashSet<String> = self
|
||||
.builtins
|
||||
.iter()
|
||||
.map(|(n, _)| (*n).to_string())
|
||||
.collect();
|
||||
prompts.retain(|p| !exclude.contains(&p.name));
|
||||
prompts.retain(|p| !self.reserved_builtin_names.contains(&p.name));
|
||||
prompts.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
self.prompts = prompts;
|
||||
}
|
||||
@@ -142,8 +134,8 @@ impl CommandPopup {
|
||||
let mut out: Vec<(CommandItem, Option<Vec<usize>>)> = Vec::new();
|
||||
if filter.is_empty() {
|
||||
// Built-ins first, in presentation order.
|
||||
for (_, cmd) in self.builtins.iter() {
|
||||
if ALIAS_COMMANDS.contains(cmd) {
|
||||
for cmd in self.builtins.iter() {
|
||||
if !cmd.show_in_command_popup() {
|
||||
continue;
|
||||
}
|
||||
out.push((CommandItem::Builtin(*cmd), None));
|
||||
@@ -162,6 +154,29 @@ impl CommandPopup {
|
||||
let prompt_prefix_len = PROMPTS_CMD_PREFIX.chars().count() + 1;
|
||||
let indices_for = |offset| Some((offset..offset + filter_chars).collect());
|
||||
|
||||
for cmd in self.builtins.iter() {
|
||||
if cmd.command() == filter_lower.as_str() {
|
||||
exact.push((CommandItem::Builtin(*cmd), indices_for(0)));
|
||||
continue;
|
||||
}
|
||||
if cmd.command().starts_with(&filter_lower) {
|
||||
prefix.push((CommandItem::Builtin(*cmd), indices_for(0)));
|
||||
continue;
|
||||
}
|
||||
// Keep the popup searchable by accepted aliases, but keep rendering the
|
||||
// canonical command name so the list stays deduplicated and stable.
|
||||
if cmd.command_aliases().contains(&filter_lower.as_str()) {
|
||||
exact.push((CommandItem::Builtin(*cmd), None));
|
||||
continue;
|
||||
}
|
||||
if cmd
|
||||
.command_aliases()
|
||||
.iter()
|
||||
.any(|alias| alias.starts_with(&filter_lower))
|
||||
{
|
||||
prefix.push((CommandItem::Builtin(*cmd), None));
|
||||
}
|
||||
}
|
||||
let mut push_match =
|
||||
|item: CommandItem, display: &str, name: Option<&str>, name_offset: usize| {
|
||||
let display_lower = display.to_lowercase();
|
||||
@@ -182,10 +197,6 @@ impl CommandPopup {
|
||||
prefix.push((item, indices_for(offset)));
|
||||
}
|
||||
};
|
||||
|
||||
for (_, cmd) in self.builtins.iter() {
|
||||
push_match(CommandItem::Builtin(*cmd), cmd.command(), None, 0);
|
||||
}
|
||||
// Support both search styles:
|
||||
// - Typing "name" should surface "/prompts:name" results.
|
||||
// - Typing "prompts:name" should also work.
|
||||
@@ -338,6 +349,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_is_first_suggestion_for_root_popup() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
popup.on_composer_text_change("/".to_string());
|
||||
let matches = popup.filtered_items();
|
||||
match matches.first() {
|
||||
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "help"),
|
||||
Some(CommandItem::UserPrompt(_)) => {
|
||||
panic!("unexpected prompt ranked before '/help' for '/'")
|
||||
}
|
||||
None => panic!("expected at least one match for '/'"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_commands_keep_presentation_order_for_prefix() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
@@ -351,7 +376,7 @@ mod tests {
|
||||
CommandItem::UserPrompt(_) => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(cmds, vec!["model", "mention", "mcp"]);
|
||||
assert_eq!(cmds, vec!["model", "mention", "mcp", "subagents"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -409,6 +434,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_name_collision_with_builtin_alias_is_ignored() {
|
||||
let popup = CommandPopup::new(
|
||||
vec![CustomPrompt {
|
||||
name: "multi-agents".to_string(),
|
||||
path: "/tmp/multi-agents.md".to_string().into(),
|
||||
content: "should be ignored".to_string(),
|
||||
description: None,
|
||||
argument_hint: None,
|
||||
}],
|
||||
CommandPopupFlags::default(),
|
||||
);
|
||||
let items = popup.filtered_items();
|
||||
let has_collision_prompt = items.into_iter().any(|it| match it {
|
||||
CommandItem::UserPrompt(i) => popup
|
||||
.prompt(i)
|
||||
.is_some_and(|prompt| prompt.name == "multi-agents"),
|
||||
CommandItem::Builtin(_) => false,
|
||||
});
|
||||
assert!(
|
||||
!has_collision_prompt,
|
||||
"prompt with builtin alias should be ignored"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_description_uses_frontmatter_metadata() {
|
||||
let popup = CommandPopup::new(
|
||||
@@ -477,6 +527,32 @@ mod tests {
|
||||
assert!(items.contains(&CommandItem::Builtin(SlashCommand::Quit)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_agents_alias_matches_subagents_entry() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
popup.on_composer_text_change("/multi".to_string());
|
||||
assert_eq!(
|
||||
popup.selected_item(),
|
||||
Some(CommandItem::Builtin(SlashCommand::MultiAgents))
|
||||
);
|
||||
|
||||
let cmds: Vec<&str> = popup
|
||||
.filtered_items()
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
CommandItem::Builtin(cmd) => Some(cmd.command()),
|
||||
CommandItem::UserPrompt(_) => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(cmds, vec!["subagents"]);
|
||||
|
||||
popup.on_composer_text_change("/multi-agents".to_string());
|
||||
assert_eq!(
|
||||
popup.selected_item(),
|
||||
Some(CommandItem::Builtin(SlashCommand::MultiAgents))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collab_command_hidden_when_collaboration_modes_disabled() {
|
||||
let mut popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
|
||||
|
||||
@@ -17,10 +17,10 @@ use crate::render::Insets;
|
||||
use crate::render::RectExt as _;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::slash_command_invocation::SlashCommandInvocation;
|
||||
use crate::style::user_message_style;
|
||||
|
||||
use codex_core::features::Feature;
|
||||
|
||||
use super::CancellationEvent;
|
||||
use super::bottom_pane_view::BottomPaneView;
|
||||
use super::popup_consts::MAX_POPUP_ROWS;
|
||||
@@ -30,7 +30,7 @@ use super::selection_popup_common::measure_rows_height;
|
||||
use super::selection_popup_common::render_rows;
|
||||
|
||||
pub(crate) struct ExperimentalFeatureItem {
|
||||
pub feature: Feature,
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub enabled: bool,
|
||||
@@ -198,15 +198,16 @@ impl BottomPaneView for ExperimentalFeaturesView {
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
// Save the updates
|
||||
if !self.features.is_empty() {
|
||||
let updates = self
|
||||
.features
|
||||
.iter()
|
||||
.map(|item| (item.feature, item.enabled))
|
||||
.collect();
|
||||
self.app_event_tx
|
||||
.send(AppEvent::UpdateFeatureFlags { updates });
|
||||
let invocation = SlashCommandInvocation::with_args(
|
||||
SlashCommand::Experimental,
|
||||
self.features.iter().map(|item| {
|
||||
format!("{}={}", item.key, if item.enabled { "on" } else { "off" })
|
||||
}),
|
||||
);
|
||||
self.app_event_tx.send(AppEvent::HandleSlashCommandDraft(
|
||||
invocation.into_user_message(),
|
||||
));
|
||||
}
|
||||
|
||||
self.complete = true;
|
||||
|
||||
@@ -21,6 +21,8 @@ use crate::app_event::FeedbackCategory;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::history_cell;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::slash_command_invocation::SlashCommandInvocation;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
|
||||
use super::CancellationEvent;
|
||||
@@ -485,8 +487,17 @@ fn make_feedback_item(
|
||||
description: &str,
|
||||
category: FeedbackCategory,
|
||||
) -> super::SelectionItem {
|
||||
let token = match category {
|
||||
FeedbackCategory::Bug => "bug",
|
||||
FeedbackCategory::BadResult => "bad-result",
|
||||
FeedbackCategory::GoodResult => "good-result",
|
||||
FeedbackCategory::SafetyCheck => "safety-check",
|
||||
FeedbackCategory::Other => "other",
|
||||
};
|
||||
let action: super::SelectionAction = Box::new(move |_sender: &AppEventSender| {
|
||||
app_event_tx.send(AppEvent::OpenFeedbackConsent { category });
|
||||
app_event_tx.send(AppEvent::HandleSlashCommandDraft(
|
||||
SlashCommandInvocation::with_args(SlashCommand::Feedback, [token]).into_user_message(),
|
||||
));
|
||||
});
|
||||
super::SelectionItem {
|
||||
name: name.to_string(),
|
||||
|
||||
495
codex-rs/tui/src/bottom_pane/help_view.rs
Normal file
495
codex-rs/tui/src/bottom_pane/help_view.rs
Normal file
@@ -0,0 +1,495 @@
|
||||
use std::cell::Cell;
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::bottom_pane::BuiltinCommandFlags;
|
||||
use crate::bottom_pane::CancellationEvent;
|
||||
use crate::bottom_pane::bottom_pane_view::BottomPaneView;
|
||||
use crate::bottom_pane::popup_consts::MAX_POPUP_ROWS;
|
||||
use crate::bottom_pane::selection_popup_common::render_menu_surface;
|
||||
use crate::bottom_pane::visible_builtins_for_input;
|
||||
use crate::key_hint;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_lines;
|
||||
|
||||
const HELP_VIEW_MIN_BODY_ROWS: u16 = 6;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum HelpRowWrap {
|
||||
None,
|
||||
Note,
|
||||
Description,
|
||||
Usage,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct HelpRow {
|
||||
plain_text: String,
|
||||
line: Line<'static>,
|
||||
wrap: HelpRowWrap,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct HelpSearch {
|
||||
active_query: String,
|
||||
input: Option<String>,
|
||||
selected_match: usize,
|
||||
}
|
||||
|
||||
pub(crate) struct SlashHelpView {
|
||||
complete: bool,
|
||||
rows: Vec<HelpRow>,
|
||||
scroll_top: Cell<usize>,
|
||||
follow_selected_match: Cell<bool>,
|
||||
search: HelpSearch,
|
||||
}
|
||||
|
||||
impl SlashHelpView {
|
||||
pub(crate) fn new(flags: BuiltinCommandFlags) -> Self {
|
||||
Self {
|
||||
complete: false,
|
||||
rows: Self::build_document(flags),
|
||||
scroll_top: Cell::new(0),
|
||||
follow_selected_match: Cell::new(false),
|
||||
search: HelpSearch::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn visible_body_rows(area_height: u16) -> usize {
|
||||
area_height
|
||||
.saturating_sub(3)
|
||||
.max(HELP_VIEW_MIN_BODY_ROWS)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn build_document(flags: BuiltinCommandFlags) -> Vec<HelpRow> {
|
||||
let mut rows = vec![
|
||||
HelpRow {
|
||||
plain_text: "Slash Commands".to_string(),
|
||||
line: Line::from("Slash Commands".bold()),
|
||||
wrap: HelpRowWrap::None,
|
||||
},
|
||||
HelpRow {
|
||||
plain_text: String::new(),
|
||||
line: Line::from(""),
|
||||
wrap: HelpRowWrap::None,
|
||||
},
|
||||
HelpRow {
|
||||
plain_text: "Type / to open the command popup. For commands with both a picker and an arg form, bare /command opens the picker and /command ... runs directly.".to_string(),
|
||||
line: Line::from(
|
||||
"Type / to open the command popup. For commands with both a picker and an arg form, bare /command opens the picker and /command ... runs directly."
|
||||
.dim(),
|
||||
),
|
||||
wrap: HelpRowWrap::Note,
|
||||
},
|
||||
HelpRow {
|
||||
plain_text: "Args use shell-style quoting; quote values with spaces.".to_string(),
|
||||
line: Line::from("Args use shell-style quoting; quote values with spaces.".dim()),
|
||||
wrap: HelpRowWrap::Note,
|
||||
},
|
||||
HelpRow {
|
||||
plain_text: String::new(),
|
||||
line: Line::from(""),
|
||||
wrap: HelpRowWrap::None,
|
||||
},
|
||||
];
|
||||
|
||||
for cmd in visible_builtins_for_input(flags) {
|
||||
rows.push(HelpRow {
|
||||
plain_text: format!("/{}", cmd.command()),
|
||||
line: Line::from(format!("/{}", cmd.command()).cyan().bold()),
|
||||
wrap: HelpRowWrap::None,
|
||||
});
|
||||
rows.push(HelpRow {
|
||||
plain_text: format!(" {}", cmd.description()),
|
||||
line: Line::from(format!(" {}", cmd.description()).dim()),
|
||||
wrap: HelpRowWrap::Description,
|
||||
});
|
||||
rows.push(HelpRow {
|
||||
plain_text: " Usage:".to_string(),
|
||||
line: Line::from(" Usage:".dim()),
|
||||
wrap: HelpRowWrap::None,
|
||||
});
|
||||
for form in cmd.help_forms() {
|
||||
let plain_text = if form.is_empty() {
|
||||
format!("/{}", cmd.command())
|
||||
} else {
|
||||
format!("/{} {}", cmd.command(), form)
|
||||
};
|
||||
rows.push(HelpRow {
|
||||
plain_text: plain_text.clone(),
|
||||
line: Line::from(plain_text.cyan()),
|
||||
wrap: HelpRowWrap::Usage,
|
||||
});
|
||||
}
|
||||
rows.push(HelpRow {
|
||||
plain_text: String::new(),
|
||||
line: Line::from(""),
|
||||
wrap: HelpRowWrap::None,
|
||||
});
|
||||
}
|
||||
|
||||
while rows.last().is_some_and(|row| row.plain_text.is_empty()) {
|
||||
rows.pop();
|
||||
}
|
||||
|
||||
rows
|
||||
}
|
||||
|
||||
fn scroll_by(&mut self, delta: isize) {
|
||||
self.scroll_top
|
||||
.set(self.scroll_top.get().saturating_add_signed(delta));
|
||||
self.follow_selected_match.set(false);
|
||||
}
|
||||
|
||||
fn matching_logical_rows(rows: &[HelpRow], query: &str) -> Vec<usize> {
|
||||
let query = query.to_ascii_lowercase();
|
||||
rows.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, row)| {
|
||||
row.plain_text
|
||||
.to_ascii_lowercase()
|
||||
.contains(query.as_str())
|
||||
.then_some(idx)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn current_query(&self) -> Option<&str> {
|
||||
if let Some(input) = self.search.input.as_deref() {
|
||||
return (!input.is_empty()).then_some(input);
|
||||
}
|
||||
(!self.search.active_query.is_empty()).then_some(self.search.active_query.as_str())
|
||||
}
|
||||
|
||||
fn search_indicator(
|
||||
&self,
|
||||
rows: &[HelpRow],
|
||||
total_rows: usize,
|
||||
visible_rows: usize,
|
||||
scroll_top: usize,
|
||||
) -> String {
|
||||
let start_row = if total_rows == 0 { 0 } else { scroll_top + 1 };
|
||||
let end_row = (scroll_top + visible_rows).min(total_rows);
|
||||
let viewport = format!("{start_row}-{end_row}/{total_rows}");
|
||||
let Some(query) = self.current_query() else {
|
||||
return viewport;
|
||||
};
|
||||
let match_count = Self::matching_logical_rows(rows, query).len();
|
||||
if self.search.input.is_some() {
|
||||
return format!(
|
||||
"{} match{} | {viewport}",
|
||||
match_count,
|
||||
if match_count == 1 { "" } else { "es" }
|
||||
);
|
||||
}
|
||||
if match_count == 0 {
|
||||
return format!("0/0 | {viewport}");
|
||||
}
|
||||
let current_match = self.search.selected_match.min(match_count - 1) + 1;
|
||||
format!("{current_match}/{match_count} | {viewport}")
|
||||
}
|
||||
|
||||
fn footer_line(&self) -> Line<'static> {
|
||||
if let Some(input) = self.search.input.as_deref() {
|
||||
return Line::from(vec![
|
||||
"Search: ".dim(),
|
||||
format!("/{input}").cyan(),
|
||||
" | ".dim(),
|
||||
key_hint::plain(KeyCode::Enter).into(),
|
||||
" apply | ".dim(),
|
||||
key_hint::plain(KeyCode::Esc).into(),
|
||||
" cancel".dim(),
|
||||
]);
|
||||
}
|
||||
|
||||
let mut spans = vec![
|
||||
key_hint::plain(KeyCode::Up).into(),
|
||||
"/".into(),
|
||||
key_hint::plain(KeyCode::Down).into(),
|
||||
" scroll | [".dim(),
|
||||
key_hint::ctrl(KeyCode::Char('p')).into(),
|
||||
" / ".dim(),
|
||||
key_hint::ctrl(KeyCode::Char('n')).into(),
|
||||
"] page | ".dim(),
|
||||
"/ search".dim(),
|
||||
];
|
||||
if !self.search.active_query.is_empty() {
|
||||
spans.push(" | ".dim());
|
||||
spans.push("n/p match".dim());
|
||||
}
|
||||
spans.extend([
|
||||
" | ".dim(),
|
||||
key_hint::plain(KeyCode::Esc).into(),
|
||||
" close".dim(),
|
||||
]);
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn wrap_rows(rows: &[HelpRow], width: u16) -> (Vec<Line<'static>>, Vec<usize>, Vec<usize>) {
|
||||
let width = width.max(24);
|
||||
let note_opts = RtOptions::new(width as usize)
|
||||
.initial_indent(Line::from(""))
|
||||
.subsequent_indent(Line::from(""));
|
||||
let description_opts = RtOptions::new(width as usize)
|
||||
.initial_indent(Line::from(""))
|
||||
.subsequent_indent(Line::from(" "));
|
||||
let usage_opts = RtOptions::new(width as usize)
|
||||
.initial_indent(Line::from(" "))
|
||||
.subsequent_indent(Line::from(" "));
|
||||
|
||||
let mut wrapped_rows = Vec::new();
|
||||
let mut row_starts = Vec::with_capacity(rows.len());
|
||||
let mut row_ends = Vec::with_capacity(rows.len());
|
||||
|
||||
for row in rows {
|
||||
row_starts.push(wrapped_rows.len());
|
||||
let wrapped = match row.wrap {
|
||||
HelpRowWrap::None => vec![row.line.clone()],
|
||||
HelpRowWrap::Note => word_wrap_lines([row.line.clone()], note_opts.clone()),
|
||||
HelpRowWrap::Description => {
|
||||
word_wrap_lines([row.line.clone()], description_opts.clone())
|
||||
}
|
||||
HelpRowWrap::Usage => word_wrap_lines([row.line.clone()], usage_opts.clone()),
|
||||
};
|
||||
wrapped_rows.extend(wrapped);
|
||||
row_ends.push(wrapped_rows.len());
|
||||
}
|
||||
|
||||
(wrapped_rows, row_starts, row_ends)
|
||||
}
|
||||
|
||||
fn move_to_match(&mut self, delta: isize) {
|
||||
if self.search.input.is_some() || self.search.active_query.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let matches = Self::matching_logical_rows(&self.rows, &self.search.active_query);
|
||||
if matches.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let next = (self.search.selected_match as isize + delta).rem_euclid(matches.len() as isize);
|
||||
self.search.selected_match = next as usize;
|
||||
self.follow_selected_match.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView for SlashHelpView {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
if let Some(input) = self.search.input.as_mut() {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} => {
|
||||
self.search.input = None;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
..
|
||||
} => {
|
||||
self.search.active_query = self.search.input.take().unwrap_or_default();
|
||||
self.search.selected_match = 0;
|
||||
self.follow_selected_match
|
||||
.set(!self.search.active_query.is_empty());
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Backspace,
|
||||
..
|
||||
} => {
|
||||
input.pop();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
..
|
||||
} if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
||||
input.push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('/'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
self.search.active_query.clear();
|
||||
self.search.selected_match = 0;
|
||||
self.follow_selected_match.set(false);
|
||||
self.search.input = Some(String::new());
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Up, ..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('k'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.scroll_by(-1),
|
||||
KeyEvent {
|
||||
code: KeyCode::Down,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('j'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.scroll_by(1),
|
||||
KeyEvent {
|
||||
code: KeyCode::PageUp,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('p'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => self.scroll_by(-(MAX_POPUP_ROWS as isize)),
|
||||
KeyEvent {
|
||||
code: KeyCode::PageDown,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('n'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
..
|
||||
} => self.scroll_by(MAX_POPUP_ROWS as isize),
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} if !self.search.active_query.is_empty() => {
|
||||
self.search.active_query.clear();
|
||||
self.search.selected_match = 0;
|
||||
self.follow_selected_match.set(false);
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('q'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
self.on_ctrl_c();
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('n'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => self.move_to_match(1),
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('p'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('N'),
|
||||
modifiers: KeyModifiers::SHIFT,
|
||||
..
|
||||
} => self.move_to_match(-1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_complete(&self) -> bool {
|
||||
self.complete
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
self.complete = true;
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
|
||||
fn prefer_esc_to_handle_key_event(&self) -> bool {
|
||||
self.search.input.is_some() || !self.search.active_query.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::render::renderable::Renderable for SlashHelpView {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
if area.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let content_area = render_menu_surface(area, buf);
|
||||
let [header_area, body_area, footer_area] = Layout::vertical([
|
||||
Constraint::Length(2),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(2),
|
||||
])
|
||||
.areas(content_area);
|
||||
let [_footer_gap_area, footer_line_area] =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(footer_area);
|
||||
|
||||
let (lines, row_starts, row_ends) = Self::wrap_rows(&self.rows, body_area.width);
|
||||
let header_lines = lines.iter().take(2).cloned().collect::<Vec<_>>();
|
||||
let mut body_lines = lines.iter().skip(2).cloned().collect::<Vec<_>>();
|
||||
let visible_rows = Self::visible_body_rows(body_area.height);
|
||||
let max_scroll = body_lines.len().saturating_sub(visible_rows);
|
||||
let mut scroll_top = self.scroll_top.get().min(max_scroll);
|
||||
|
||||
if self.search.input.is_none()
|
||||
&& !self.search.active_query.is_empty()
|
||||
&& let Some(selected_row_idx) =
|
||||
Self::matching_logical_rows(&self.rows, &self.search.active_query)
|
||||
.get(self.search.selected_match)
|
||||
.copied()
|
||||
{
|
||||
let start = row_starts[selected_row_idx].saturating_sub(2);
|
||||
let end = row_ends[selected_row_idx].saturating_sub(2);
|
||||
if self.follow_selected_match.get() || start < scroll_top {
|
||||
scroll_top = start;
|
||||
} else if end > scroll_top + visible_rows {
|
||||
scroll_top = end.saturating_sub(visible_rows);
|
||||
}
|
||||
scroll_top = scroll_top.min(max_scroll);
|
||||
self.scroll_top.set(scroll_top);
|
||||
self.follow_selected_match.set(false);
|
||||
for line in body_lines.iter_mut().take(end).skip(start) {
|
||||
*line = line.clone().patch_style(Style::new().reversed());
|
||||
}
|
||||
}
|
||||
|
||||
self.scroll_top.set(scroll_top);
|
||||
|
||||
Paragraph::new(header_lines).render(header_area, buf);
|
||||
Paragraph::new(body_lines.clone())
|
||||
.scroll((scroll_top as u16, 0))
|
||||
.render(body_area, buf);
|
||||
|
||||
let footer_line = self.footer_line();
|
||||
Paragraph::new(footer_line.clone()).render(footer_line_area, buf);
|
||||
let indicator =
|
||||
self.search_indicator(&self.rows, body_lines.len(), visible_rows, scroll_top);
|
||||
let indicator_width = UnicodeWidthStr::width(indicator.as_str()) as u16;
|
||||
let footer_width = footer_line.width() as u16;
|
||||
if footer_width + indicator_width + 2 <= footer_line_area.width {
|
||||
Paragraph::new(indicator.dim()).render(
|
||||
Rect::new(
|
||||
footer_line_area.x + footer_line_area.width - indicator_width,
|
||||
footer_line_area.y,
|
||||
indicator_width,
|
||||
footer_line_area.height,
|
||||
),
|
||||
buf,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
let (wrapped_rows, _, _) = Self::wrap_rows(&self.rows, width.saturating_sub(4));
|
||||
let content_rows = wrapped_rows.len() as u16;
|
||||
content_rows.max(HELP_VIEW_MIN_BODY_ROWS + 4)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle.
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::ConnectorsSnapshot;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::pending_input_preview::PendingInputPreview;
|
||||
@@ -31,6 +32,7 @@ use codex_core::features::Features;
|
||||
use codex_core::plugins::PluginCapabilitySummary;
|
||||
use codex_core::skills::model::SkillMetadata;
|
||||
use codex_file_search::FileMatch;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::request_user_input::RequestUserInputEvent;
|
||||
use codex_protocol::user_input::TextElement;
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -79,6 +81,7 @@ pub mod custom_prompt_view;
|
||||
mod experimental_features_view;
|
||||
mod file_search_popup;
|
||||
mod footer;
|
||||
mod help_view;
|
||||
mod list_selection_view;
|
||||
mod prompt_args;
|
||||
mod skill_popup;
|
||||
@@ -95,6 +98,7 @@ pub(crate) use feedback_view::FeedbackAudience;
|
||||
pub(crate) use feedback_view::feedback_disabled_params;
|
||||
pub(crate) use feedback_view::feedback_selection_params;
|
||||
pub(crate) use feedback_view::feedback_upload_consent_params;
|
||||
pub(crate) use help_view::SlashHelpView;
|
||||
pub(crate) use skills_toggle_view::SkillsToggleItem;
|
||||
pub(crate) use skills_toggle_view::SkillsToggleView;
|
||||
pub(crate) use status_line_setup::StatusLineItem;
|
||||
@@ -137,18 +141,20 @@ pub(crate) enum CancellationEvent {
|
||||
NotHandled,
|
||||
}
|
||||
|
||||
use crate::bottom_pane::prompt_args::parse_slash_name;
|
||||
use crate::status_indicator_widget::StatusDetailsCapitalization;
|
||||
use crate::status_indicator_widget::StatusIndicatorWidget;
|
||||
pub(crate) use chat_composer::ChatComposer;
|
||||
pub(crate) use chat_composer::ChatComposerConfig;
|
||||
pub(crate) use chat_composer::InputResult;
|
||||
use codex_protocol::custom_prompts::CustomPrompt;
|
||||
|
||||
use crate::status_indicator_widget::StatusDetailsCapitalization;
|
||||
use crate::status_indicator_widget::StatusIndicatorWidget;
|
||||
pub(crate) use experimental_features_view::ExperimentalFeatureItem;
|
||||
pub(crate) use experimental_features_view::ExperimentalFeaturesView;
|
||||
pub(crate) use list_selection_view::SelectionAction;
|
||||
pub(crate) use list_selection_view::SelectionItem;
|
||||
pub(crate) use prompt_args::parse_slash_name;
|
||||
pub(crate) use slash_commands::BuiltinCommandFlags;
|
||||
pub(crate) use slash_commands::find_builtin_command;
|
||||
pub(crate) use slash_commands::visible_builtins_for_input;
|
||||
|
||||
/// Pane displayed in the lower half of the chat UI.
|
||||
///
|
||||
@@ -263,22 +269,10 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub fn take_mention_bindings(&mut self) -> Vec<MentionBinding> {
|
||||
self.composer.take_mention_bindings()
|
||||
}
|
||||
|
||||
pub fn take_recent_submission_mention_bindings(&mut self) -> Vec<MentionBinding> {
|
||||
self.composer.take_recent_submission_mention_bindings()
|
||||
}
|
||||
|
||||
/// Clear pending attachments and mention bindings e.g. when a slash command doesn't submit text.
|
||||
pub(crate) fn drain_pending_submission_state(&mut self) {
|
||||
let _ = self.take_recent_submission_images_with_placeholders();
|
||||
let _ = self.take_remote_image_urls();
|
||||
let _ = self.take_recent_submission_mention_bindings();
|
||||
let _ = self.take_mention_bindings();
|
||||
}
|
||||
|
||||
pub fn set_collaboration_modes_enabled(&mut self, enabled: bool) {
|
||||
self.composer.set_collaboration_modes_enabled(enabled);
|
||||
self.request_redraw();
|
||||
@@ -421,25 +415,15 @@ impl BottomPane {
|
||||
self.request_redraw();
|
||||
InputResult::None
|
||||
} else {
|
||||
let is_agent_command = self
|
||||
.composer_text()
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(parse_slash_name)
|
||||
.is_some_and(|(name, _, _)| name == "agent");
|
||||
|
||||
// If a task is running and a status line is visible, allow Esc to
|
||||
// send an interrupt even while the composer has focus.
|
||||
// When a popup is active, prefer dismissing it over interrupting the task.
|
||||
// If a task is running, allow Esc to send an interrupt when no popup is active.
|
||||
// Final-message streaming can temporarily hide the status widget, but that should not
|
||||
// disable interrupt.
|
||||
if key_event.code == KeyCode::Esc
|
||||
&& matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
||||
&& self.is_task_running
|
||||
&& !is_agent_command
|
||||
&& !self.composer.popup_active()
|
||||
&& let Some(status) = &self.status
|
||||
{
|
||||
// Send Op::Interrupt
|
||||
status.interrupt();
|
||||
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
|
||||
self.request_redraw();
|
||||
return InputResult::None;
|
||||
}
|
||||
@@ -723,7 +707,6 @@ impl BottomPane {
|
||||
if !was_running {
|
||||
if self.status.is_none() {
|
||||
self.status = Some(StatusIndicatorWidget::new(
|
||||
self.app_event_tx.clone(),
|
||||
self.frame_requester.clone(),
|
||||
self.animations_enabled,
|
||||
));
|
||||
@@ -750,7 +733,6 @@ impl BottomPane {
|
||||
pub(crate) fn ensure_status_indicator(&mut self) {
|
||||
if self.status.is_none() {
|
||||
self.status = Some(StatusIndicatorWidget::new(
|
||||
self.app_event_tx.clone(),
|
||||
self.frame_requester.clone(),
|
||||
self.animations_enabled,
|
||||
));
|
||||
@@ -1029,6 +1011,7 @@ impl BottomPane {
|
||||
fn on_active_view_complete(&mut self) {
|
||||
self.resume_status_timer_after_modal();
|
||||
self.set_composer_input_enabled(true, None);
|
||||
self.app_event_tx.send(AppEvent::BottomPaneViewCompleted);
|
||||
}
|
||||
|
||||
fn pause_status_timer_for_modal(&mut self) {
|
||||
@@ -1632,29 +1615,6 @@ mod tests {
|
||||
assert!(snapshot.contains("[Image #2]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drain_pending_submission_state_clears_remote_image_urls() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
animations_enabled: true,
|
||||
skills: Some(Vec::new()),
|
||||
});
|
||||
|
||||
pane.set_remote_image_urls(vec!["https://example.com/one.png".to_string()]);
|
||||
assert_eq!(pane.remote_image_urls().len(), 1);
|
||||
|
||||
pane.drain_pending_submission_state();
|
||||
|
||||
assert!(pane.remote_image_urls().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_with_skill_popup_does_not_interrupt_task() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
@@ -1740,7 +1700,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_with_agent_command_without_popup_does_not_interrupt_task() {
|
||||
fn esc_with_agent_command_without_popup_interrupts_task() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
@@ -1756,8 +1716,8 @@ mod tests {
|
||||
|
||||
pane.set_task_running(true);
|
||||
|
||||
// Repro: `/agent ` hides the popup (cursor past command name). Esc should
|
||||
// keep editing command text instead of interrupting the running task.
|
||||
// `/agent ` hides the popup once the cursor moves past the command name.
|
||||
// Without an active popup, Esc should interrupt even though the composer has text.
|
||||
pane.insert_str("/agent ");
|
||||
assert!(
|
||||
!pane.composer.popup_active(),
|
||||
@@ -1766,12 +1726,10 @@ mod tests {
|
||||
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
assert!(
|
||||
!matches!(ev, AppEvent::CodexOp(Op::Interrupt)),
|
||||
"expected Esc to not send Op::Interrupt while typing `/agent`"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))),
|
||||
"expected Esc to send Op::Interrupt while typing `/agent` with no popup"
|
||||
);
|
||||
assert_eq!(pane.composer_text(), "/agent ");
|
||||
}
|
||||
|
||||
@@ -1848,6 +1806,59 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_with_nonempty_composer_interrupts_task_when_no_popup() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
animations_enabled: true,
|
||||
skills: Some(Vec::new()),
|
||||
});
|
||||
|
||||
pane.set_task_running(true);
|
||||
pane.insert_str("still editing");
|
||||
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))),
|
||||
"expected Esc to send Op::Interrupt while composer has text and no popup is active"
|
||||
);
|
||||
assert_eq!(pane.composer_text(), "still editing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_interrupts_running_task_when_status_indicator_hidden() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = BottomPane::new(BottomPaneParams {
|
||||
app_event_tx: tx,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
has_input_focus: true,
|
||||
enhanced_keys_supported: false,
|
||||
placeholder_text: "Ask Codex to do anything".to_string(),
|
||||
disable_paste_burst: false,
|
||||
animations_enabled: true,
|
||||
skills: Some(Vec::new()),
|
||||
});
|
||||
|
||||
pane.set_task_running(true);
|
||||
pane.hide_status_indicator();
|
||||
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))),
|
||||
"expected Esc to send Op::Interrupt even when the status indicator is hidden"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn esc_routes_to_handle_key_event_when_requested() {
|
||||
#[derive(Default)]
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
//! The same sandbox- and feature-gating rules are used by both the composer
|
||||
//! and the command popup. Centralizing them here keeps those call sites small
|
||||
//! and ensures they stay in sync.
|
||||
use std::str::FromStr;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use codex_utils_fuzzy_match::fuzzy_match;
|
||||
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::slash_command::built_in_slash_commands;
|
||||
use crate::slash_command::visible_built_in_slash_commands;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct BuiltinCommandFlags {
|
||||
@@ -21,30 +22,64 @@ pub(crate) struct BuiltinCommandFlags {
|
||||
pub(crate) allow_elevate_sandbox: bool,
|
||||
}
|
||||
|
||||
fn command_enabled_for_input(cmd: SlashCommand, flags: BuiltinCommandFlags) -> bool {
|
||||
if !flags.allow_elevate_sandbox && cmd == SlashCommand::ElevateSandbox {
|
||||
return false;
|
||||
}
|
||||
if !flags.collaboration_modes_enabled
|
||||
&& matches!(cmd, SlashCommand::Collab | SlashCommand::Plan)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !flags.connectors_enabled && cmd == SlashCommand::Apps {
|
||||
return false;
|
||||
}
|
||||
if !flags.fast_command_enabled && cmd == SlashCommand::Fast {
|
||||
return false;
|
||||
}
|
||||
if !flags.personality_command_enabled && cmd == SlashCommand::Personality {
|
||||
return false;
|
||||
}
|
||||
if !flags.realtime_conversation_enabled && cmd == SlashCommand::Realtime {
|
||||
return false;
|
||||
}
|
||||
if !flags.audio_device_selection_enabled && cmd == SlashCommand::Settings {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Return the built-ins that should be visible/usable for the current input.
|
||||
pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static str, SlashCommand)> {
|
||||
built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|(_, cmd)| flags.allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
|
||||
.filter(|(_, cmd)| {
|
||||
flags.collaboration_modes_enabled
|
||||
|| !matches!(*cmd, SlashCommand::Collab | SlashCommand::Plan)
|
||||
})
|
||||
.filter(|(_, cmd)| flags.connectors_enabled || *cmd != SlashCommand::Apps)
|
||||
.filter(|(_, cmd)| flags.fast_command_enabled || *cmd != SlashCommand::Fast)
|
||||
.filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality)
|
||||
.filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime)
|
||||
.filter(|(_, cmd)| flags.audio_device_selection_enabled || *cmd != SlashCommand::Settings)
|
||||
.filter(|(_, cmd)| command_enabled_for_input(*cmd, flags))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Return the visible built-ins once each, in popup presentation order.
|
||||
pub(crate) fn visible_builtins_for_input(flags: BuiltinCommandFlags) -> Vec<SlashCommand> {
|
||||
visible_built_in_slash_commands()
|
||||
.into_iter()
|
||||
.filter(|cmd| command_enabled_for_input(*cmd, flags))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Find a single built-in command by exact name, after applying the gating rules.
|
||||
pub(crate) fn find_builtin_command(name: &str, flags: BuiltinCommandFlags) -> Option<SlashCommand> {
|
||||
let cmd = SlashCommand::from_str(name).ok()?;
|
||||
builtins_for_input(flags)
|
||||
.into_iter()
|
||||
.any(|(_, visible_cmd)| visible_cmd == cmd)
|
||||
.then_some(cmd)
|
||||
.find(|(command_name, _)| *command_name == name)
|
||||
.map(|(_, cmd)| cmd)
|
||||
}
|
||||
|
||||
/// Return every builtin name that should be reserved against custom prompt collisions.
|
||||
pub(crate) fn reserved_builtin_names_for_input(flags: BuiltinCommandFlags) -> HashSet<String> {
|
||||
visible_builtins_for_input(flags)
|
||||
.into_iter()
|
||||
.flat_map(SlashCommand::all_command_names)
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether any visible built-in fuzzily matches the provided prefix.
|
||||
@@ -101,6 +136,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_agents_alias_still_resolves_for_dispatch() {
|
||||
assert_eq!(
|
||||
find_builtin_command("multi-agents", all_enabled_flags()),
|
||||
Some(SlashCommand::MultiAgents)
|
||||
);
|
||||
assert_eq!(
|
||||
find_builtin_command("subagents", all_enabled_flags()),
|
||||
Some(SlashCommand::MultiAgents)
|
||||
);
|
||||
assert_eq!(SlashCommand::MultiAgents.command(), "subagents");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_builtins_keep_multi_agents_deduplicated() {
|
||||
let builtins = visible_builtins_for_input(all_enabled_flags());
|
||||
let multi_agents: Vec<_> = builtins
|
||||
.into_iter()
|
||||
.filter(|cmd| *cmd == SlashCommand::MultiAgents)
|
||||
.collect();
|
||||
assert_eq!(multi_agents, vec![SlashCommand::MultiAgents]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fast_command_is_hidden_when_disabled() {
|
||||
let mut flags = all_enabled_flags();
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› / "
|
||||
" "
|
||||
" /help show slash command help "
|
||||
" /model choose what model and reasoning effort to "
|
||||
" use "
|
||||
" /permissions choose what Codex is allowed to do "
|
||||
" /experimental toggle experimental features "
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
"› / "
|
||||
" "
|
||||
" /help show slash command help "
|
||||
" /model choose what model and reasoning "
|
||||
" effort to use "
|
||||
" /permissions choose what Codex is allowed to do "
|
||||
" /sandbox-add-read-dir let sandbox read a directory: / "
|
||||
@@ -34,6 +34,8 @@ use crate::bottom_pane::bottom_pane_view::BottomPaneView;
|
||||
use crate::bottom_pane::multi_select_picker::MultiSelectItem;
|
||||
use crate::bottom_pane::multi_select_picker::MultiSelectPicker;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::slash_command_invocation::SlashCommandInvocation;
|
||||
|
||||
/// Available items that can be displayed in the status line.
|
||||
///
|
||||
@@ -231,12 +233,14 @@ impl StatusLineSetupView {
|
||||
.enable_ordering()
|
||||
.on_preview(move |items| preview_data.line_for_items(items))
|
||||
.on_confirm(|ids, app_event| {
|
||||
let items = ids
|
||||
.iter()
|
||||
.map(|id| id.parse::<StatusLineItem>())
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap_or_default();
|
||||
app_event.send(AppEvent::StatusLineSetup { items });
|
||||
let invocation = if ids.is_empty() {
|
||||
SlashCommandInvocation::with_args(SlashCommand::Statusline, ["none"])
|
||||
} else {
|
||||
SlashCommandInvocation::with_args(SlashCommand::Statusline, ids)
|
||||
};
|
||||
app_event.send(AppEvent::HandleSlashCommandDraft(
|
||||
invocation.into_user_message(),
|
||||
));
|
||||
})
|
||||
.on_cancel(|app_event| {
|
||||
app_event.send(AppEvent::StatusLineSetupCancelled);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,8 @@ use crate::bottom_pane::SkillsToggleView;
|
||||
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
|
||||
use crate::skills_helpers::skill_description;
|
||||
use crate::skills_helpers::skill_display_name;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::slash_command_invocation::SlashCommandInvocation;
|
||||
use codex_chatgpt::connectors::AppInfo;
|
||||
use codex_core::connectors::connector_mention_slug;
|
||||
use codex_core::mention_syntax::TOOL_MENTION_SIGIL;
|
||||
@@ -34,7 +36,10 @@ impl ChatWidget {
|
||||
name: "List skills".to_string(),
|
||||
description: Some("Tip: press $ to open this list directly.".to_string()),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenSkillsList);
|
||||
tx.send(AppEvent::HandleSlashCommandDraft(
|
||||
SlashCommandInvocation::with_args(SlashCommand::Skills, ["list"])
|
||||
.into_user_message(),
|
||||
));
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
@@ -43,7 +48,10 @@ impl ChatWidget {
|
||||
name: "Enable/Disable Skills".to_string(),
|
||||
description: Some("Enable or disable skills.".to_string()),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenManageSkillsPopup);
|
||||
tx.send(AppEvent::HandleSlashCommandDraft(
|
||||
SlashCommandInvocation::with_args(SlashCommand::Skills, ["manage"])
|
||||
.into_user_message(),
|
||||
));
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: blob
|
||||
---
|
||||
■ '/model' is disabled while a task is in progress.
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: term.backend().vt100().screen().contents()
|
||||
---
|
||||
|
||||
|
||||
|
||||
|
||||
Select Model and Effort
|
||||
Access legacy models by running codex -m <model_name> or in your config.toml
|
||||
|
||||
› 1. gpt-5.3-codex (default) Latest frontier agentic coding model.
|
||||
2. gpt-5.4 Latest frontier agentic coding model.
|
||||
3. gpt-5.2-codex Frontier agentic coding model.
|
||||
4. gpt-5.1-codex-max Codex-optimized flagship for deep and fast
|
||||
reasoning.
|
||||
5. gpt-5.2 Latest frontier model with improvements across
|
||||
knowledge, reasoning and coding
|
||||
6. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less
|
||||
capable.
|
||||
|
||||
Press enter to select reasoning effort, or esc to dismiss.
|
||||
@@ -0,0 +1,223 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Slash Commands
|
||||
|
||||
Type / to open the command popup. For commands with both a picker and an arg form, bare /command
|
||||
opens the picker and /command ... runs directly.
|
||||
Args use shell-style quoting; quote values with spaces.
|
||||
|
||||
/help
|
||||
show slash command help
|
||||
Usage:
|
||||
/help
|
||||
|
||||
/model
|
||||
choose what model and reasoning effort to use
|
||||
Usage:
|
||||
/model
|
||||
/model <model> [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]
|
||||
|
||||
/fast
|
||||
toggle Fast mode to enable fastest inference at 2X plan usage
|
||||
Usage:
|
||||
/fast
|
||||
/fast <on|off|status>
|
||||
|
||||
/approvals
|
||||
choose what Codex is allowed to do
|
||||
Usage:
|
||||
/approvals
|
||||
/approvals <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
|
||||
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
|
||||
[--enable-windows-sandbox=elevated|legacy]
|
||||
|
||||
/permissions
|
||||
choose what Codex is allowed to do
|
||||
Usage:
|
||||
/permissions
|
||||
/permissions <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
|
||||
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
|
||||
[--enable-windows-sandbox=elevated|legacy]
|
||||
|
||||
/experimental
|
||||
toggle experimental features
|
||||
Usage:
|
||||
/experimental
|
||||
/experimental <feature-key>=on|off ...
|
||||
|
||||
/skills
|
||||
use skills to improve how Codex performs specific tasks
|
||||
Usage:
|
||||
/skills
|
||||
/skills <list|manage>
|
||||
|
||||
/review
|
||||
review my current changes and find issues
|
||||
Usage:
|
||||
/review
|
||||
/review uncommitted
|
||||
/review branch <name>
|
||||
/review commit <sha> [title]
|
||||
/review <instructions>
|
||||
|
||||
/rename
|
||||
rename the current thread
|
||||
Usage:
|
||||
/rename
|
||||
/rename <title...>
|
||||
|
||||
/new
|
||||
start a new chat during a conversation
|
||||
Usage:
|
||||
/new
|
||||
|
||||
/resume
|
||||
resume a saved chat
|
||||
Usage:
|
||||
/resume
|
||||
/resume <thread-id>
|
||||
/resume <thread-id> --path <rollout-path>
|
||||
|
||||
/fork
|
||||
fork the current chat
|
||||
Usage:
|
||||
/fork
|
||||
|
||||
/init
|
||||
create an AGENTS.md file with instructions for Codex
|
||||
Usage:
|
||||
/init
|
||||
|
||||
/compact
|
||||
summarize conversation to prevent hitting the context limit
|
||||
Usage:
|
||||
/compact
|
||||
|
||||
/plan
|
||||
switch to Plan mode
|
||||
Usage:
|
||||
/plan
|
||||
/plan <prompt...>
|
||||
|
||||
/collab
|
||||
change collaboration mode (experimental)
|
||||
Usage:
|
||||
/collab
|
||||
/collab <default|plan>
|
||||
|
||||
/agent
|
||||
switch the active agent thread
|
||||
Usage:
|
||||
/agent
|
||||
/agent <thread-id>
|
||||
|
||||
/diff
|
||||
show git diff (including untracked files)
|
||||
Usage:
|
||||
/diff
|
||||
|
||||
/copy
|
||||
copy the latest Codex output to your clipboard
|
||||
Usage:
|
||||
/copy
|
||||
|
||||
/mention
|
||||
mention a file
|
||||
Usage:
|
||||
/mention
|
||||
|
||||
/status
|
||||
show current session configuration and token usage
|
||||
Usage:
|
||||
/status
|
||||
|
||||
/debug-config
|
||||
show config layers and requirement sources for debugging
|
||||
Usage:
|
||||
/debug-config
|
||||
|
||||
/statusline
|
||||
configure which items appear in the status line
|
||||
Usage:
|
||||
/statusline
|
||||
/statusline <item-id>...
|
||||
/statusline none
|
||||
|
||||
/theme
|
||||
choose a syntax highlighting theme
|
||||
Usage:
|
||||
/theme
|
||||
/theme <theme-name>
|
||||
|
||||
/mcp
|
||||
list configured MCP tools
|
||||
Usage:
|
||||
/mcp
|
||||
|
||||
/logout
|
||||
log out of Codex
|
||||
Usage:
|
||||
/logout
|
||||
|
||||
/quit
|
||||
exit Codex
|
||||
Usage:
|
||||
/quit
|
||||
|
||||
/exit
|
||||
exit Codex
|
||||
Usage:
|
||||
/exit
|
||||
|
||||
/feedback
|
||||
send logs to maintainers
|
||||
Usage:
|
||||
/feedback
|
||||
/feedback <bug|bad-result|good-result|safety-check|other>
|
||||
|
||||
/rollout
|
||||
print the rollout file path
|
||||
Usage:
|
||||
/rollout
|
||||
|
||||
/ps
|
||||
list background terminals
|
||||
Usage:
|
||||
/ps
|
||||
|
||||
/stop
|
||||
stop all background terminals
|
||||
Usage:
|
||||
/stop
|
||||
|
||||
/clear
|
||||
clear the terminal and start a new chat
|
||||
Usage:
|
||||
/clear
|
||||
|
||||
/personality
|
||||
choose a communication style for Codex
|
||||
Usage:
|
||||
/personality
|
||||
/personality <none|friendly|pragmatic>
|
||||
|
||||
/test-approval
|
||||
test approval request
|
||||
Usage:
|
||||
/test-approval
|
||||
|
||||
/subagents
|
||||
switch the active agent thread
|
||||
Usage:
|
||||
/subagents
|
||||
/subagents <thread-id>
|
||||
|
||||
/debug-m-drop
|
||||
DO NOT USE
|
||||
Usage:
|
||||
/debug-m-drop
|
||||
|
||||
|
||||
↑/↓ scroll | [ctrl + p / ctrl + n] page | / search | esc close 1-212/219
|
||||
@@ -0,0 +1,228 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: popup
|
||||
---
|
||||
Slash Commands
|
||||
|
||||
Type / to open the command popup. For commands with both a picker and an arg form, bare /command
|
||||
opens the picker and /command ... runs directly.
|
||||
Args use shell-style quoting; quote values with spaces.
|
||||
|
||||
/help
|
||||
show slash command help
|
||||
Usage:
|
||||
/help
|
||||
|
||||
/model
|
||||
choose what model and reasoning effort to use
|
||||
Usage:
|
||||
/model
|
||||
/model <model> [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]
|
||||
|
||||
/fast
|
||||
toggle Fast mode to enable fastest inference at 2X plan usage
|
||||
Usage:
|
||||
/fast
|
||||
/fast <on|off|status>
|
||||
|
||||
/approvals
|
||||
choose what Codex is allowed to do
|
||||
Usage:
|
||||
/approvals
|
||||
/approvals <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
|
||||
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
|
||||
[--enable-windows-sandbox=elevated|legacy]
|
||||
|
||||
/permissions
|
||||
choose what Codex is allowed to do
|
||||
Usage:
|
||||
/permissions
|
||||
/permissions <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
|
||||
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
|
||||
[--enable-windows-sandbox=elevated|legacy]
|
||||
|
||||
/sandbox-add-read-dir
|
||||
let sandbox read a directory: /sandbox-add-read-dir <absolute_path>
|
||||
Usage:
|
||||
/sandbox-add-read-dir <absolute-directory-path>
|
||||
|
||||
/experimental
|
||||
toggle experimental features
|
||||
Usage:
|
||||
/experimental
|
||||
/experimental <feature-key>=on|off ...
|
||||
|
||||
/skills
|
||||
use skills to improve how Codex performs specific tasks
|
||||
Usage:
|
||||
/skills
|
||||
/skills <list|manage>
|
||||
|
||||
/review
|
||||
review my current changes and find issues
|
||||
Usage:
|
||||
/review
|
||||
/review uncommitted
|
||||
/review branch <name>
|
||||
/review commit <sha> [title]
|
||||
/review <instructions>
|
||||
|
||||
/rename
|
||||
rename the current thread
|
||||
Usage:
|
||||
/rename
|
||||
/rename <title...>
|
||||
|
||||
/new
|
||||
start a new chat during a conversation
|
||||
Usage:
|
||||
/new
|
||||
|
||||
/resume
|
||||
resume a saved chat
|
||||
Usage:
|
||||
/resume
|
||||
/resume <thread-id>
|
||||
/resume <thread-id> --path <rollout-path>
|
||||
|
||||
/fork
|
||||
fork the current chat
|
||||
Usage:
|
||||
/fork
|
||||
|
||||
/init
|
||||
create an AGENTS.md file with instructions for Codex
|
||||
Usage:
|
||||
/init
|
||||
|
||||
/compact
|
||||
summarize conversation to prevent hitting the context limit
|
||||
Usage:
|
||||
/compact
|
||||
|
||||
/plan
|
||||
switch to Plan mode
|
||||
Usage:
|
||||
/plan
|
||||
/plan <prompt...>
|
||||
|
||||
/collab
|
||||
change collaboration mode (experimental)
|
||||
Usage:
|
||||
/collab
|
||||
/collab <default|plan>
|
||||
|
||||
/agent
|
||||
switch the active agent thread
|
||||
Usage:
|
||||
/agent
|
||||
/agent <thread-id>
|
||||
|
||||
/diff
|
||||
show git diff (including untracked files)
|
||||
Usage:
|
||||
/diff
|
||||
|
||||
/copy
|
||||
copy the latest Codex output to your clipboard
|
||||
Usage:
|
||||
/copy
|
||||
|
||||
/mention
|
||||
mention a file
|
||||
Usage:
|
||||
/mention
|
||||
|
||||
/status
|
||||
show current session configuration and token usage
|
||||
Usage:
|
||||
/status
|
||||
|
||||
/debug-config
|
||||
show config layers and requirement sources for debugging
|
||||
Usage:
|
||||
/debug-config
|
||||
|
||||
/statusline
|
||||
configure which items appear in the status line
|
||||
Usage:
|
||||
/statusline
|
||||
/statusline <item-id>...
|
||||
/statusline none
|
||||
|
||||
/theme
|
||||
choose a syntax highlighting theme
|
||||
Usage:
|
||||
/theme
|
||||
/theme <theme-name>
|
||||
|
||||
/mcp
|
||||
list configured MCP tools
|
||||
Usage:
|
||||
/mcp
|
||||
|
||||
/logout
|
||||
log out of Codex
|
||||
Usage:
|
||||
/logout
|
||||
|
||||
/quit
|
||||
exit Codex
|
||||
Usage:
|
||||
/quit
|
||||
|
||||
/exit
|
||||
exit Codex
|
||||
Usage:
|
||||
/exit
|
||||
|
||||
/feedback
|
||||
send logs to maintainers
|
||||
Usage:
|
||||
/feedback
|
||||
/feedback <bug|bad-result|good-result|safety-check|other>
|
||||
|
||||
/rollout
|
||||
print the rollout file path
|
||||
Usage:
|
||||
/rollout
|
||||
|
||||
/ps
|
||||
list background terminals
|
||||
Usage:
|
||||
/ps
|
||||
|
||||
/clean
|
||||
stop all background terminals
|
||||
Usage:
|
||||
/clean
|
||||
|
||||
/clear
|
||||
clear the terminal and start a new chat
|
||||
Usage:
|
||||
/clear
|
||||
|
||||
/personality
|
||||
choose a communication style for Codex
|
||||
Usage:
|
||||
/personality
|
||||
/personality <none|friendly|pragmatic>
|
||||
|
||||
/test-approval
|
||||
test approval request
|
||||
Usage:
|
||||
/test-approval
|
||||
|
||||
/subagents
|
||||
switch the active agent thread
|
||||
Usage:
|
||||
/subagents
|
||||
/subagents <thread-id>
|
||||
|
||||
/debug-m-drop
|
||||
DO NOT USE
|
||||
Usage:
|
||||
/debug-m-drop
|
||||
|
||||
|
||||
↑/↓ scroll | [ctrl + p / ctrl + n] page | / search | esc close 1-217/224
|
||||
@@ -0,0 +1,223 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: searching
|
||||
---
|
||||
Slash Commands
|
||||
|
||||
Type / to open the command popup. For commands with both a picker and an arg form, bare /command
|
||||
opens the picker and /command ... runs directly.
|
||||
Args use shell-style quoting; quote values with spaces.
|
||||
|
||||
/help
|
||||
show slash command help
|
||||
Usage:
|
||||
/help
|
||||
|
||||
/model
|
||||
choose what model and reasoning effort to use
|
||||
Usage:
|
||||
/model
|
||||
/model <model> [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]
|
||||
|
||||
/fast
|
||||
toggle Fast mode to enable fastest inference at 2X plan usage
|
||||
Usage:
|
||||
/fast
|
||||
/fast <on|off|status>
|
||||
|
||||
/approvals
|
||||
choose what Codex is allowed to do
|
||||
Usage:
|
||||
/approvals
|
||||
/approvals <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
|
||||
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
|
||||
[--enable-windows-sandbox=elevated|legacy]
|
||||
|
||||
/permissions
|
||||
choose what Codex is allowed to do
|
||||
Usage:
|
||||
/permissions
|
||||
/permissions <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
|
||||
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
|
||||
[--enable-windows-sandbox=elevated|legacy]
|
||||
|
||||
/experimental
|
||||
toggle experimental features
|
||||
Usage:
|
||||
/experimental
|
||||
/experimental <feature-key>=on|off ...
|
||||
|
||||
/skills
|
||||
use skills to improve how Codex performs specific tasks
|
||||
Usage:
|
||||
/skills
|
||||
/skills <list|manage>
|
||||
|
||||
/review
|
||||
review my current changes and find issues
|
||||
Usage:
|
||||
/review
|
||||
/review uncommitted
|
||||
/review branch <name>
|
||||
/review commit <sha> [title]
|
||||
/review <instructions>
|
||||
|
||||
/rename
|
||||
rename the current thread
|
||||
Usage:
|
||||
/rename
|
||||
/rename <title...>
|
||||
|
||||
/new
|
||||
start a new chat during a conversation
|
||||
Usage:
|
||||
/new
|
||||
|
||||
/resume
|
||||
resume a saved chat
|
||||
Usage:
|
||||
/resume
|
||||
/resume <thread-id>
|
||||
/resume <thread-id> --path <rollout-path>
|
||||
|
||||
/fork
|
||||
fork the current chat
|
||||
Usage:
|
||||
/fork
|
||||
|
||||
/init
|
||||
create an AGENTS.md file with instructions for Codex
|
||||
Usage:
|
||||
/init
|
||||
|
||||
/compact
|
||||
summarize conversation to prevent hitting the context limit
|
||||
Usage:
|
||||
/compact
|
||||
|
||||
/plan
|
||||
switch to Plan mode
|
||||
Usage:
|
||||
/plan
|
||||
/plan <prompt...>
|
||||
|
||||
/collab
|
||||
change collaboration mode (experimental)
|
||||
Usage:
|
||||
/collab
|
||||
/collab <default|plan>
|
||||
|
||||
/agent
|
||||
switch the active agent thread
|
||||
Usage:
|
||||
/agent
|
||||
/agent <thread-id>
|
||||
|
||||
/diff
|
||||
show git diff (including untracked files)
|
||||
Usage:
|
||||
/diff
|
||||
|
||||
/copy
|
||||
copy the latest Codex output to your clipboard
|
||||
Usage:
|
||||
/copy
|
||||
|
||||
/mention
|
||||
mention a file
|
||||
Usage:
|
||||
/mention
|
||||
|
||||
/status
|
||||
show current session configuration and token usage
|
||||
Usage:
|
||||
/status
|
||||
|
||||
/debug-config
|
||||
show config layers and requirement sources for debugging
|
||||
Usage:
|
||||
/debug-config
|
||||
|
||||
/statusline
|
||||
configure which items appear in the status line
|
||||
Usage:
|
||||
/statusline
|
||||
/statusline <item-id>...
|
||||
/statusline none
|
||||
|
||||
/theme
|
||||
choose a syntax highlighting theme
|
||||
Usage:
|
||||
/theme
|
||||
/theme <theme-name>
|
||||
|
||||
/mcp
|
||||
list configured MCP tools
|
||||
Usage:
|
||||
/mcp
|
||||
|
||||
/logout
|
||||
log out of Codex
|
||||
Usage:
|
||||
/logout
|
||||
|
||||
/quit
|
||||
exit Codex
|
||||
Usage:
|
||||
/quit
|
||||
|
||||
/exit
|
||||
exit Codex
|
||||
Usage:
|
||||
/exit
|
||||
|
||||
/feedback
|
||||
send logs to maintainers
|
||||
Usage:
|
||||
/feedback
|
||||
/feedback <bug|bad-result|good-result|safety-check|other>
|
||||
|
||||
/rollout
|
||||
print the rollout file path
|
||||
Usage:
|
||||
/rollout
|
||||
|
||||
/ps
|
||||
list background terminals
|
||||
Usage:
|
||||
/ps
|
||||
|
||||
/stop
|
||||
stop all background terminals
|
||||
Usage:
|
||||
/stop
|
||||
|
||||
/clear
|
||||
clear the terminal and start a new chat
|
||||
Usage:
|
||||
/clear
|
||||
|
||||
/personality
|
||||
choose a communication style for Codex
|
||||
Usage:
|
||||
/personality
|
||||
/personality <none|friendly|pragmatic>
|
||||
|
||||
/test-approval
|
||||
test approval request
|
||||
Usage:
|
||||
/test-approval
|
||||
|
||||
/subagents
|
||||
switch the active agent thread
|
||||
Usage:
|
||||
/subagents
|
||||
/subagents <thread-id>
|
||||
|
||||
/debug-m-drop
|
||||
DO NOT USE
|
||||
Usage:
|
||||
/debug-m-drop
|
||||
|
||||
|
||||
Search: /maintainers | enter apply | esc cancel 1 match | 1-212/219
|
||||
@@ -0,0 +1,228 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: searching
|
||||
---
|
||||
Slash Commands
|
||||
|
||||
Type / to open the command popup. For commands with both a picker and an arg form, bare /command
|
||||
opens the picker and /command ... runs directly.
|
||||
Args use shell-style quoting; quote values with spaces.
|
||||
|
||||
/help
|
||||
show slash command help
|
||||
Usage:
|
||||
/help
|
||||
|
||||
/model
|
||||
choose what model and reasoning effort to use
|
||||
Usage:
|
||||
/model
|
||||
/model <model> [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]
|
||||
|
||||
/fast
|
||||
toggle Fast mode to enable fastest inference at 2X plan usage
|
||||
Usage:
|
||||
/fast
|
||||
/fast <on|off|status>
|
||||
|
||||
/approvals
|
||||
choose what Codex is allowed to do
|
||||
Usage:
|
||||
/approvals
|
||||
/approvals <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
|
||||
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
|
||||
[--enable-windows-sandbox=elevated|legacy]
|
||||
|
||||
/permissions
|
||||
choose what Codex is allowed to do
|
||||
Usage:
|
||||
/permissions
|
||||
/permissions <read-only|auto|full-access> [--smart-approvals] [--confirm-full-access]
|
||||
[--remember-full-access] [--confirm-world-writable] [--remember-world-writable]
|
||||
[--enable-windows-sandbox=elevated|legacy]
|
||||
|
||||
/sandbox-add-read-dir
|
||||
let sandbox read a directory: /sandbox-add-read-dir <absolute_path>
|
||||
Usage:
|
||||
/sandbox-add-read-dir <absolute-directory-path>
|
||||
|
||||
/experimental
|
||||
toggle experimental features
|
||||
Usage:
|
||||
/experimental
|
||||
/experimental <feature-key>=on|off ...
|
||||
|
||||
/skills
|
||||
use skills to improve how Codex performs specific tasks
|
||||
Usage:
|
||||
/skills
|
||||
/skills <list|manage>
|
||||
|
||||
/review
|
||||
review my current changes and find issues
|
||||
Usage:
|
||||
/review
|
||||
/review uncommitted
|
||||
/review branch <name>
|
||||
/review commit <sha> [title]
|
||||
/review <instructions>
|
||||
|
||||
/rename
|
||||
rename the current thread
|
||||
Usage:
|
||||
/rename
|
||||
/rename <title...>
|
||||
|
||||
/new
|
||||
start a new chat during a conversation
|
||||
Usage:
|
||||
/new
|
||||
|
||||
/resume
|
||||
resume a saved chat
|
||||
Usage:
|
||||
/resume
|
||||
/resume <thread-id>
|
||||
/resume <thread-id> --path <rollout-path>
|
||||
|
||||
/fork
|
||||
fork the current chat
|
||||
Usage:
|
||||
/fork
|
||||
|
||||
/init
|
||||
create an AGENTS.md file with instructions for Codex
|
||||
Usage:
|
||||
/init
|
||||
|
||||
/compact
|
||||
summarize conversation to prevent hitting the context limit
|
||||
Usage:
|
||||
/compact
|
||||
|
||||
/plan
|
||||
switch to Plan mode
|
||||
Usage:
|
||||
/plan
|
||||
/plan <prompt...>
|
||||
|
||||
/collab
|
||||
change collaboration mode (experimental)
|
||||
Usage:
|
||||
/collab
|
||||
/collab <default|plan>
|
||||
|
||||
/agent
|
||||
switch the active agent thread
|
||||
Usage:
|
||||
/agent
|
||||
/agent <thread-id>
|
||||
|
||||
/diff
|
||||
show git diff (including untracked files)
|
||||
Usage:
|
||||
/diff
|
||||
|
||||
/copy
|
||||
copy the latest Codex output to your clipboard
|
||||
Usage:
|
||||
/copy
|
||||
|
||||
/mention
|
||||
mention a file
|
||||
Usage:
|
||||
/mention
|
||||
|
||||
/status
|
||||
show current session configuration and token usage
|
||||
Usage:
|
||||
/status
|
||||
|
||||
/debug-config
|
||||
show config layers and requirement sources for debugging
|
||||
Usage:
|
||||
/debug-config
|
||||
|
||||
/statusline
|
||||
configure which items appear in the status line
|
||||
Usage:
|
||||
/statusline
|
||||
/statusline <item-id>...
|
||||
/statusline none
|
||||
|
||||
/theme
|
||||
choose a syntax highlighting theme
|
||||
Usage:
|
||||
/theme
|
||||
/theme <theme-name>
|
||||
|
||||
/mcp
|
||||
list configured MCP tools
|
||||
Usage:
|
||||
/mcp
|
||||
|
||||
/logout
|
||||
log out of Codex
|
||||
Usage:
|
||||
/logout
|
||||
|
||||
/quit
|
||||
exit Codex
|
||||
Usage:
|
||||
/quit
|
||||
|
||||
/exit
|
||||
exit Codex
|
||||
Usage:
|
||||
/exit
|
||||
|
||||
/feedback
|
||||
send logs to maintainers
|
||||
Usage:
|
||||
/feedback
|
||||
/feedback <bug|bad-result|good-result|safety-check|other>
|
||||
|
||||
/rollout
|
||||
print the rollout file path
|
||||
Usage:
|
||||
/rollout
|
||||
|
||||
/ps
|
||||
list background terminals
|
||||
Usage:
|
||||
/ps
|
||||
|
||||
/clean
|
||||
stop all background terminals
|
||||
Usage:
|
||||
/clean
|
||||
|
||||
/clear
|
||||
clear the terminal and start a new chat
|
||||
Usage:
|
||||
/clear
|
||||
|
||||
/personality
|
||||
choose a communication style for Codex
|
||||
Usage:
|
||||
/personality
|
||||
/personality <none|friendly|pragmatic>
|
||||
|
||||
/test-approval
|
||||
test approval request
|
||||
Usage:
|
||||
/test-approval
|
||||
|
||||
/subagents
|
||||
switch the active agent thread
|
||||
Usage:
|
||||
/subagents
|
||||
/subagents <thread-id>
|
||||
|
||||
/debug-m-drop
|
||||
DO NOT USE
|
||||
Usage:
|
||||
/debug-m-drop
|
||||
|
||||
|
||||
Search: /maintainers | enter apply | esc cancel 1 match | 1-217/224
|
||||
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,7 @@ mod session_log;
|
||||
mod shimmer;
|
||||
mod skills_helpers;
|
||||
mod slash_command;
|
||||
mod slash_command_invocation;
|
||||
mod status;
|
||||
mod status_indicator_widget;
|
||||
mod streaming;
|
||||
|
||||
@@ -40,7 +40,7 @@ use unicode_width::UnicodeWidthStr;
|
||||
const PAGE_SIZE: usize = 25;
|
||||
const LOAD_NEAR_THRESHOLD: usize = 5;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SessionTarget {
|
||||
pub path: PathBuf,
|
||||
pub thread_id: ThreadId,
|
||||
|
||||
@@ -12,6 +12,7 @@ use strum_macros::IntoStaticStr;
|
||||
pub enum SlashCommand {
|
||||
// DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so
|
||||
// more frequently used commands should be listed first.
|
||||
Help,
|
||||
Model,
|
||||
Fast,
|
||||
Approvals,
|
||||
@@ -55,7 +56,7 @@ pub enum SlashCommand {
|
||||
Realtime,
|
||||
Settings,
|
||||
TestApproval,
|
||||
#[strum(serialize = "subagents")]
|
||||
#[strum(serialize = "subagents", serialize = "multi-agents")]
|
||||
MultiAgents,
|
||||
// Debugging commands.
|
||||
#[strum(serialize = "debug-m-drop")]
|
||||
@@ -65,121 +66,415 @@ pub enum SlashCommand {
|
||||
}
|
||||
|
||||
impl SlashCommand {
|
||||
fn spec(self) -> SlashCommandSpec {
|
||||
match self {
|
||||
SlashCommand::Help => SlashCommandSpec {
|
||||
description: "show slash command help",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::Immediate,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Model => SlashCommandSpec {
|
||||
description: "choose what model and reasoning effort to use",
|
||||
help_forms: &[
|
||||
"",
|
||||
"<model> [default|none|minimal|low|medium|high|xhigh] [plan-only|all-modes]",
|
||||
],
|
||||
requires_interaction: true,
|
||||
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Fast => SlashCommandSpec {
|
||||
description: "toggle Fast mode to enable fastest inference at 2X plan usage",
|
||||
help_forms: &["", "<on|off|status>"],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Approvals => SlashCommandSpec {
|
||||
description: "choose what Codex is allowed to do",
|
||||
help_forms: &[
|
||||
"",
|
||||
"<read-only|auto|full-access> [--smart-approvals] [--confirm-full-access] [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] [--enable-windows-sandbox=elevated|legacy]",
|
||||
],
|
||||
requires_interaction: true,
|
||||
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
|
||||
show_in_command_popup: false,
|
||||
},
|
||||
SlashCommand::Permissions => SlashCommandSpec {
|
||||
description: "choose what Codex is allowed to do",
|
||||
help_forms: &[
|
||||
"",
|
||||
"<read-only|auto|full-access> [--smart-approvals] [--confirm-full-access] [--remember-full-access] [--confirm-world-writable] [--remember-world-writable] [--enable-windows-sandbox=elevated|legacy]",
|
||||
],
|
||||
requires_interaction: true,
|
||||
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::ElevateSandbox => SlashCommandSpec {
|
||||
description: "set up elevated agent sandbox",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::SandboxReadRoot => SlashCommandSpec {
|
||||
description: "let sandbox read a directory: /sandbox-add-read-dir <absolute_path>",
|
||||
help_forms: &["<absolute-directory-path>"],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Experimental => SlashCommandSpec {
|
||||
description: "toggle experimental features",
|
||||
help_forms: &["", "<feature-key>=on|off ..."],
|
||||
requires_interaction: true,
|
||||
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Skills => SlashCommandSpec {
|
||||
description: "use skills to improve how Codex performs specific tasks",
|
||||
help_forms: &["", "<list|manage>"],
|
||||
requires_interaction: true,
|
||||
execution_kind: SlashCommandExecutionKind::Immediate,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Review => SlashCommandSpec {
|
||||
description: "review my current changes and find issues",
|
||||
help_forms: &[
|
||||
"",
|
||||
"uncommitted",
|
||||
"branch <name>",
|
||||
"commit <sha> [title]",
|
||||
"<instructions>",
|
||||
],
|
||||
requires_interaction: true,
|
||||
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Rename => SlashCommandSpec {
|
||||
description: "rename the current thread",
|
||||
help_forms: &["", "<title...>"],
|
||||
requires_interaction: true,
|
||||
execution_kind: SlashCommandExecutionKind::Immediate,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::New => SlashCommandSpec {
|
||||
description: "start a new chat during a conversation",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Resume => SlashCommandSpec {
|
||||
description: "resume a saved chat",
|
||||
help_forms: &["", "<thread-id>", "<thread-id> --path <rollout-path>"],
|
||||
requires_interaction: true,
|
||||
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Fork => SlashCommandSpec {
|
||||
description: "fork the current chat",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Init => SlashCommandSpec {
|
||||
description: "create an AGENTS.md file with instructions for Codex",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::JustLikeUserMessage,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Compact => SlashCommandSpec {
|
||||
description: "summarize conversation to prevent hitting the context limit",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Plan => SlashCommandSpec {
|
||||
description: "switch to Plan mode",
|
||||
help_forms: &["", "<prompt...>"],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::JustLikeUserMessage,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Collab => SlashCommandSpec {
|
||||
description: "change collaboration mode (experimental)",
|
||||
help_forms: &["", "<default|plan>"],
|
||||
requires_interaction: true,
|
||||
execution_kind: SlashCommandExecutionKind::Immediate,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Agent => SlashCommandSpec {
|
||||
description: "switch the active agent thread",
|
||||
help_forms: &["", "<thread-id>"],
|
||||
requires_interaction: true,
|
||||
execution_kind: SlashCommandExecutionKind::Immediate,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Diff => SlashCommandSpec {
|
||||
description: "show git diff (including untracked files)",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::Immediate,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Copy => SlashCommandSpec {
|
||||
description: "copy the latest Codex output to your clipboard",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::Immediate,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Mention => SlashCommandSpec {
|
||||
description: "mention a file",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::Immediate,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Status => SlashCommandSpec {
|
||||
description: "show current session configuration and token usage",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::Immediate,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::DebugConfig => SlashCommandSpec {
|
||||
description: "show config layers and requirement sources for debugging",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::Immediate,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Statusline => SlashCommandSpec {
|
||||
description: "configure which items appear in the status line",
|
||||
help_forms: &["", "<item-id>...", "none"],
|
||||
requires_interaction: true,
|
||||
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Theme => SlashCommandSpec {
|
||||
description: "choose a syntax highlighting theme",
|
||||
help_forms: &["", "<theme-name>"],
|
||||
requires_interaction: true,
|
||||
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Mcp => SlashCommandSpec {
|
||||
description: "list configured MCP tools",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::Immediate,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Apps => SlashCommandSpec {
|
||||
description: "manage apps",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::Immediate,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Logout => SlashCommandSpec {
|
||||
description: "log out of Codex",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Quit => SlashCommandSpec {
|
||||
description: "exit Codex",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::Immediate,
|
||||
show_in_command_popup: false,
|
||||
},
|
||||
SlashCommand::Exit => SlashCommandSpec {
|
||||
description: "exit Codex",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::Immediate,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Feedback => SlashCommandSpec {
|
||||
description: "send logs to maintainers",
|
||||
help_forms: &["", "<bug|bad-result|good-result|safety-check|other>"],
|
||||
requires_interaction: true,
|
||||
execution_kind: SlashCommandExecutionKind::Immediate,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Rollout => SlashCommandSpec {
|
||||
description: "print the rollout file path",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::Immediate,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Ps => SlashCommandSpec {
|
||||
description: "list background terminals",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::Immediate,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Stop => SlashCommandSpec {
|
||||
description: "stop all background terminals",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::Immediate,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Clear => SlashCommandSpec {
|
||||
description: "clear the terminal and start a new chat",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Personality => SlashCommandSpec {
|
||||
description: "choose a communication style for Codex",
|
||||
help_forms: &["", "<none|friendly|pragmatic>"],
|
||||
requires_interaction: true,
|
||||
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Realtime => SlashCommandSpec {
|
||||
description: "toggle realtime voice mode (experimental)",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::Immediate,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::Settings => SlashCommandSpec {
|
||||
description: "configure realtime microphone/speaker",
|
||||
help_forms: &["", "<microphone|speaker> [default|<device-name>]"],
|
||||
requires_interaction: true,
|
||||
execution_kind: SlashCommandExecutionKind::Immediate,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::TestApproval => SlashCommandSpec {
|
||||
description: "test approval request",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::Immediate,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::MultiAgents => SlashCommandSpec {
|
||||
description: "switch the active agent thread",
|
||||
help_forms: &["", "<thread-id>"],
|
||||
requires_interaction: true,
|
||||
execution_kind: SlashCommandExecutionKind::Immediate,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::MemoryDrop => SlashCommandSpec {
|
||||
description: "DO NOT USE",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
SlashCommand::MemoryUpdate => SlashCommandSpec {
|
||||
description: "DO NOT USE",
|
||||
help_forms: &[""],
|
||||
requires_interaction: false,
|
||||
execution_kind: SlashCommandExecutionKind::ChangesTurnContext,
|
||||
show_in_command_popup: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// User-visible description shown in the popup.
|
||||
pub fn description(self) -> &'static str {
|
||||
match self {
|
||||
SlashCommand::Feedback => "send logs to maintainers",
|
||||
SlashCommand::New => "start a new chat during a conversation",
|
||||
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
|
||||
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
|
||||
SlashCommand::Review => "review my current changes and find issues",
|
||||
SlashCommand::Rename => "rename the current thread",
|
||||
SlashCommand::Resume => "resume a saved chat",
|
||||
SlashCommand::Clear => "clear the terminal and start a new chat",
|
||||
SlashCommand::Fork => "fork the current chat",
|
||||
// SlashCommand::Undo => "ask Codex to undo a turn",
|
||||
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
|
||||
SlashCommand::Diff => "show git diff (including untracked files)",
|
||||
SlashCommand::Copy => "copy the latest Codex output to your clipboard",
|
||||
SlashCommand::Mention => "mention a file",
|
||||
SlashCommand::Skills => "use skills to improve how Codex performs specific tasks",
|
||||
SlashCommand::Status => "show current session configuration and token usage",
|
||||
SlashCommand::DebugConfig => "show config layers and requirement sources for debugging",
|
||||
SlashCommand::Statusline => "configure which items appear in the status line",
|
||||
SlashCommand::Theme => "choose a syntax highlighting theme",
|
||||
SlashCommand::Ps => "list background terminals",
|
||||
SlashCommand::Stop => "stop all background terminals",
|
||||
SlashCommand::MemoryDrop => "DO NOT USE",
|
||||
SlashCommand::MemoryUpdate => "DO NOT USE",
|
||||
SlashCommand::Model => "choose what model and reasoning effort to use",
|
||||
SlashCommand::Fast => "toggle Fast mode to enable fastest inference at 2X plan usage",
|
||||
SlashCommand::Personality => "choose a communication style for Codex",
|
||||
SlashCommand::Realtime => "toggle realtime voice mode (experimental)",
|
||||
SlashCommand::Settings => "configure realtime microphone/speaker",
|
||||
SlashCommand::Plan => "switch to Plan mode",
|
||||
SlashCommand::Collab => "change collaboration mode (experimental)",
|
||||
SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread",
|
||||
SlashCommand::Approvals => "choose what Codex is allowed to do",
|
||||
SlashCommand::Permissions => "choose what Codex is allowed to do",
|
||||
SlashCommand::ElevateSandbox => "set up elevated agent sandbox",
|
||||
SlashCommand::SandboxReadRoot => {
|
||||
"let sandbox read a directory: /sandbox-add-read-dir <absolute_path>"
|
||||
}
|
||||
SlashCommand::Experimental => "toggle experimental features",
|
||||
SlashCommand::Mcp => "list configured MCP tools",
|
||||
SlashCommand::Apps => "manage apps",
|
||||
SlashCommand::Logout => "log out of Codex",
|
||||
SlashCommand::Rollout => "print the rollout file path",
|
||||
SlashCommand::TestApproval => "test approval request",
|
||||
}
|
||||
self.spec().description
|
||||
}
|
||||
|
||||
/// Command string without the leading '/'. Provided for compatibility with
|
||||
/// existing code that expects a method named `command()`.
|
||||
pub fn command(self) -> &'static str {
|
||||
self.into()
|
||||
}
|
||||
|
||||
/// Whether this command supports inline args (for example `/review ...`).
|
||||
pub fn supports_inline_args(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
SlashCommand::Review
|
||||
| SlashCommand::Rename
|
||||
| SlashCommand::Plan
|
||||
| SlashCommand::Fast
|
||||
| SlashCommand::SandboxReadRoot
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether this command can be run while a task is in progress.
|
||||
pub fn available_during_task(self) -> bool {
|
||||
match self {
|
||||
SlashCommand::New
|
||||
| SlashCommand::Resume
|
||||
| SlashCommand::Fork
|
||||
| SlashCommand::Init
|
||||
| SlashCommand::Compact
|
||||
// | SlashCommand::Undo
|
||||
SlashCommand::MultiAgents => "subagents",
|
||||
_ => self.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Additional accepted built-in names besides `command()`.
|
||||
pub fn command_aliases(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
SlashCommand::Help
|
||||
| SlashCommand::Model
|
||||
| SlashCommand::Fast
|
||||
| SlashCommand::Personality
|
||||
| SlashCommand::Approvals
|
||||
| SlashCommand::Permissions
|
||||
| SlashCommand::ElevateSandbox
|
||||
| SlashCommand::SandboxReadRoot
|
||||
| SlashCommand::Experimental
|
||||
| SlashCommand::Review
|
||||
| SlashCommand::Plan
|
||||
| SlashCommand::Clear
|
||||
| SlashCommand::Logout
|
||||
| SlashCommand::MemoryDrop
|
||||
| SlashCommand::MemoryUpdate => false,
|
||||
SlashCommand::Diff
|
||||
| SlashCommand::Copy
|
||||
| SlashCommand::Rename
|
||||
| SlashCommand::Mention
|
||||
| SlashCommand::Skills
|
||||
| SlashCommand::Review
|
||||
| SlashCommand::Rename
|
||||
| SlashCommand::New
|
||||
| SlashCommand::Resume
|
||||
| SlashCommand::Fork
|
||||
| SlashCommand::Init
|
||||
| SlashCommand::Compact
|
||||
| SlashCommand::Plan
|
||||
| SlashCommand::Collab
|
||||
| SlashCommand::Agent
|
||||
| SlashCommand::Diff
|
||||
| SlashCommand::Copy
|
||||
| SlashCommand::Mention
|
||||
| SlashCommand::Status
|
||||
| SlashCommand::DebugConfig
|
||||
| SlashCommand::Ps
|
||||
| SlashCommand::Stop
|
||||
| SlashCommand::Statusline
|
||||
| SlashCommand::Theme
|
||||
| SlashCommand::Mcp
|
||||
| SlashCommand::Apps
|
||||
| SlashCommand::Feedback
|
||||
| SlashCommand::Logout
|
||||
| SlashCommand::Quit
|
||||
| SlashCommand::Exit => true,
|
||||
SlashCommand::Rollout => true,
|
||||
SlashCommand::TestApproval => true,
|
||||
SlashCommand::Realtime => true,
|
||||
SlashCommand::Settings => true,
|
||||
SlashCommand::Collab => true,
|
||||
SlashCommand::Agent | SlashCommand::MultiAgents => true,
|
||||
SlashCommand::Statusline => false,
|
||||
SlashCommand::Theme => false,
|
||||
| SlashCommand::Exit
|
||||
| SlashCommand::Feedback
|
||||
| SlashCommand::Rollout
|
||||
| SlashCommand::Ps
|
||||
| SlashCommand::Clear
|
||||
| SlashCommand::Personality
|
||||
| SlashCommand::Realtime
|
||||
| SlashCommand::Settings
|
||||
| SlashCommand::TestApproval
|
||||
| SlashCommand::MemoryDrop
|
||||
| SlashCommand::MemoryUpdate => &[],
|
||||
SlashCommand::Stop => &["clean"],
|
||||
SlashCommand::MultiAgents => &["multi-agents"],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_command_names(self) -> impl Iterator<Item = &'static str> {
|
||||
std::iter::once(self.command()).chain(self.command_aliases().iter().copied())
|
||||
}
|
||||
|
||||
/// Human-facing forms accepted by the TUI.
|
||||
///
|
||||
/// An empty string represents the bare `/command` form.
|
||||
pub fn help_forms(self) -> &'static [&'static str] {
|
||||
self.spec().help_forms
|
||||
}
|
||||
|
||||
/// Whether bare dispatch opens interactive UI that should be resolved before queueing.
|
||||
pub fn requires_interaction(self) -> bool {
|
||||
self.spec().requires_interaction
|
||||
}
|
||||
|
||||
/// How this command should behave when dispatched while another turn is running.
|
||||
pub fn execution_kind(self) -> SlashCommandExecutionKind {
|
||||
self.spec().execution_kind
|
||||
}
|
||||
|
||||
pub fn show_in_command_popup(self) -> bool {
|
||||
self.spec().show_in_command_popup
|
||||
}
|
||||
|
||||
fn is_visible(self) -> bool {
|
||||
match self {
|
||||
SlashCommand::SandboxReadRoot => cfg!(target_os = "windows"),
|
||||
@@ -190,11 +485,46 @@ impl SlashCommand {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SlashCommandExecutionKind {
|
||||
/// Behaves like a normal user message.
|
||||
///
|
||||
/// Enter should submit immediately when idle, and queue while a turn is running.
|
||||
/// Use this for commands whose effect is "ask the model to do work now".
|
||||
JustLikeUserMessage,
|
||||
|
||||
/// Does not become a user message, but changes state that affects future turns.
|
||||
///
|
||||
/// While a turn is running, it must queue and apply later in order.
|
||||
ChangesTurnContext,
|
||||
|
||||
/// Does not submit model work and does not need to wait for the current turn.
|
||||
///
|
||||
/// Run it immediately, even while a turn is in progress.
|
||||
Immediate,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct SlashCommandSpec {
|
||||
description: &'static str,
|
||||
help_forms: &'static [&'static str],
|
||||
requires_interaction: bool,
|
||||
execution_kind: SlashCommandExecutionKind,
|
||||
show_in_command_popup: bool,
|
||||
}
|
||||
|
||||
/// Return all built-in commands in a Vec paired with their command string.
|
||||
pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
|
||||
SlashCommand::iter()
|
||||
.filter(|command| command.is_visible())
|
||||
.map(|c| (c.command(), c))
|
||||
.flat_map(|command| command.all_command_names().map(move |name| (name, command)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Return all visible built-in commands once each, in presentation order.
|
||||
pub fn visible_built_in_slash_commands() -> Vec<SlashCommand> {
|
||||
SlashCommand::iter()
|
||||
.filter(|command| command.is_visible())
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
78
codex-rs/tui/src/slash_command_invocation.rs
Normal file
78
codex-rs/tui/src/slash_command_invocation.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
use crate::chatwidget::UserMessage;
|
||||
use crate::slash_command::SlashCommand;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct SlashCommandInvocation {
|
||||
pub(crate) command: SlashCommand,
|
||||
pub(crate) args: Vec<String>,
|
||||
}
|
||||
|
||||
impl SlashCommandInvocation {
|
||||
pub(crate) fn bare(command: SlashCommand) -> Self {
|
||||
Self {
|
||||
command,
|
||||
args: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_args<I, S>(command: SlashCommand, args: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
Self {
|
||||
command,
|
||||
args: args.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_args(args: &str, usage: &str) -> Result<Vec<String>, String> {
|
||||
shlex::split(args).ok_or_else(|| usage.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn into_user_message(self) -> UserMessage {
|
||||
let command = self.command.command();
|
||||
let joined = match shlex::try_join(
|
||||
std::iter::once(command).chain(self.args.iter().map(String::as_str)),
|
||||
) {
|
||||
Ok(joined) => joined,
|
||||
Err(err) => panic!("slash command invocation should serialize: {err}"),
|
||||
};
|
||||
format!("/{joined}").into()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn serializes_quoted_args() {
|
||||
let draft = SlashCommandInvocation::with_args(
|
||||
SlashCommand::Review,
|
||||
["branch main needs coverage".to_string()],
|
||||
)
|
||||
.into_user_message();
|
||||
|
||||
assert_eq!(
|
||||
draft,
|
||||
UserMessage::from("/review 'branch main needs coverage'")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_shlex_args() {
|
||||
let parsed = SlashCommandInvocation::parse_args("'branch main' --flag key=value", "usage")
|
||||
.expect("quoted args should parse");
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
vec![
|
||||
"branch main".to_string(),
|
||||
"--flag".to_string(),
|
||||
"key=value".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use codex_protocol::protocol::Op;
|
||||
use crossterm::event::KeyCode;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
@@ -19,8 +18,6 @@ use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::exec_cell::spinner;
|
||||
use crate::key_hint;
|
||||
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
|
||||
@@ -53,7 +50,6 @@ pub(crate) struct StatusIndicatorWidget {
|
||||
elapsed_running: Duration,
|
||||
last_resume_at: Instant,
|
||||
is_paused: bool,
|
||||
app_event_tx: AppEventSender,
|
||||
frame_requester: FrameRequester,
|
||||
animations_enabled: bool,
|
||||
}
|
||||
@@ -76,11 +72,7 @@ pub fn fmt_elapsed_compact(elapsed_secs: u64) -> String {
|
||||
}
|
||||
|
||||
impl StatusIndicatorWidget {
|
||||
pub(crate) fn new(
|
||||
app_event_tx: AppEventSender,
|
||||
frame_requester: FrameRequester,
|
||||
animations_enabled: bool,
|
||||
) -> Self {
|
||||
pub(crate) fn new(frame_requester: FrameRequester, animations_enabled: bool) -> Self {
|
||||
Self {
|
||||
header: String::from("Working"),
|
||||
details: None,
|
||||
@@ -91,16 +83,11 @@ impl StatusIndicatorWidget {
|
||||
last_resume_at: Instant::now(),
|
||||
is_paused: false,
|
||||
|
||||
app_event_tx,
|
||||
frame_requester,
|
||||
animations_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn interrupt(&self) {
|
||||
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
|
||||
}
|
||||
|
||||
/// Update the animated header label (left of the brackets).
|
||||
pub(crate) fn update_header(&mut self, header: String) {
|
||||
self.header = header;
|
||||
@@ -292,13 +279,10 @@ impl Renderable for StatusIndicatorWidget {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
@@ -318,9 +302,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn renders_with_working_header() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
|
||||
let w = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), true);
|
||||
|
||||
// Render into a fixed-size test terminal and snapshot the backend.
|
||||
let mut terminal = Terminal::new(TestBackend::new(80, 2)).expect("terminal");
|
||||
@@ -332,9 +314,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn renders_truncated() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
|
||||
let w = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), true);
|
||||
|
||||
// Render into a fixed-size test terminal and snapshot the backend.
|
||||
let mut terminal = Terminal::new(TestBackend::new(20, 2)).expect("terminal");
|
||||
@@ -346,9 +326,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn renders_wrapped_details_panama_two_lines() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), false);
|
||||
let mut w = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), false);
|
||||
w.update_details(
|
||||
Some("A man a plan a canal panama".to_string()),
|
||||
StatusDetailsCapitalization::CapitalizeFirst,
|
||||
@@ -371,10 +349,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn timer_pauses_when_requested() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut widget =
|
||||
StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
|
||||
let mut widget = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), true);
|
||||
|
||||
let baseline = Instant::now();
|
||||
widget.last_resume_at = baseline;
|
||||
@@ -393,9 +368,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn details_overflow_adds_ellipsis() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
|
||||
let mut w = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), true);
|
||||
w.update_details(
|
||||
Some("abcd abcd abcd abcd".to_string()),
|
||||
StatusDetailsCapitalization::CapitalizeFirst,
|
||||
@@ -413,9 +386,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn details_args_can_disable_capitalization_and_limit_lines() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy(), true);
|
||||
let mut w = StatusIndicatorWidget::new(crate::tui::FrameRequester::test_dummy(), true);
|
||||
w.update_details(
|
||||
Some("cargo test -p codex-core and then cargo test -p codex-tui".to_string()),
|
||||
StatusDetailsCapitalization::Preserve,
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
//! the preview panel and any visible code blocks.
|
||||
//! - **Cancel-restore:** on dismiss (Esc / Ctrl+C) the `on_cancel` callback
|
||||
//! restores the theme snapshot taken when the picker opened.
|
||||
//! - **Persist on confirm:** the `AppEvent::SyntaxThemeSelected` action persists
|
||||
//! `[tui] theme = "..."` to `config.toml` via `ConfigEditsBuilder`.
|
||||
//! - **Persist on confirm:** the picker emits a canonical `/theme <name>` draft,
|
||||
//! which then persists `[tui] theme = "..."` through normal slash-command handling.
|
||||
//!
|
||||
//! Two preview renderables adapt to terminal width:
|
||||
//!
|
||||
@@ -35,6 +35,8 @@ use crate::diff_render::push_wrapped_diff_line_with_style_context;
|
||||
use crate::diff_render::push_wrapped_diff_line_with_syntax_and_style_context;
|
||||
use crate::render::highlight;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::slash_command_invocation::SlashCommandInvocation;
|
||||
use crate::status::format_directory_display;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
@@ -344,9 +346,13 @@ pub(crate) fn build_theme_picker_params(
|
||||
dismiss_on_select: true,
|
||||
search_value: Some(entry.name.clone()),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::SyntaxThemeSelected {
|
||||
name: name_for_action.clone(),
|
||||
});
|
||||
tx.send(AppEvent::HandleSlashCommandDraft(
|
||||
SlashCommandInvocation::with_args(
|
||||
SlashCommand::Theme,
|
||||
[name_for_action.clone()],
|
||||
)
|
||||
.into_user_message(),
|
||||
));
|
||||
})],
|
||||
..Default::default()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
# Slash commands
|
||||
|
||||
For an overview of Codex CLI slash commands, see [this documentation](https://developers.openai.com/codex/cli/slash-commands).
|
||||
|
||||
## TUI
|
||||
|
||||
In the TUI, type `/` to open the slash-command popup. The popup uses the same command order as the
|
||||
in-app `/help` page, with `/help` pinned at the top for discovery.
|
||||
|
||||
For commands that have both an interactive picker flow and a direct argument form, the bare
|
||||
`/command` form opens the picker and `/command ...` runs the direct argument form instead. Use
|
||||
`/help` inside the TUI for the current list of supported commands and argument syntax. Argument
|
||||
parsing uses shell-style quoting, so quote values with spaces when needed.
|
||||
|
||||
@@ -115,8 +115,8 @@ the input starts with `!` (shell command).
|
||||
5. Clears pending pastes on success and suppresses submission if the final text is empty and there
|
||||
are no attachments.
|
||||
|
||||
The same preparation path is reused for slash commands with arguments (for example `/plan` and
|
||||
`/review`) so pasted content and text elements are preserved when extracting args.
|
||||
The same preparation path is reused for slash commands with arguments (for example `/model`,
|
||||
`/plan`, and `/review`) so pasted content and text elements are preserved when extracting args.
|
||||
|
||||
The composer also treats the textarea kill buffer as separate editing state from the visible draft.
|
||||
After submit or slash-command dispatch clears the textarea, the most recent `Ctrl+K` payload is
|
||||
|
||||
Reference in New Issue
Block a user