mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Encapsulate Agents MD processing a bit and drop user_instructions_path from config.
523 lines
21 KiB
Rust
523 lines
21 KiB
Rust
//! Slash-command dispatch and local-recall handoff for `ChatWidget`.
|
|
//!
|
|
//! `ChatComposer` parses slash input and stages recognized command text for local
|
|
//! Up-arrow recall before returning an input result. This module owns the app-level
|
|
//! dispatch step and records the staged entry once the command has been handled, so
|
|
//! slash-command recall follows the same submitted-input rule as ordinary text.
|
|
|
|
use super::*;
|
|
|
|
impl ChatWidget {
|
|
/// Dispatch a bare slash command and record its staged local-history entry.
|
|
///
|
|
/// The composer stages history before returning `InputResult::Command`; this wrapper commits
|
|
/// that staged entry after dispatch so slash-command recall follows the same "submitted input"
|
|
/// rule as normal text.
|
|
pub(super) fn handle_slash_command_dispatch(&mut self, cmd: SlashCommand) {
|
|
self.dispatch_command(cmd);
|
|
self.bottom_pane.record_pending_slash_command_history();
|
|
}
|
|
|
|
/// Dispatch an inline slash command and record its staged local-history entry.
|
|
///
|
|
/// Inline command arguments may later be prepared through the normal submission pipeline, but
|
|
/// local command recall still tracks the original command invocation. Treating this wrapper as
|
|
/// the only input-result entry point avoids double-recording commands with inline args.
|
|
pub(super) fn handle_slash_command_with_args_dispatch(
|
|
&mut self,
|
|
cmd: SlashCommand,
|
|
args: String,
|
|
text_elements: Vec<TextElement>,
|
|
) {
|
|
self.dispatch_command_with_args(cmd, args, text_elements);
|
|
self.bottom_pane.record_pending_slash_command_history();
|
|
}
|
|
|
|
fn apply_plan_slash_command(&mut self) -> bool {
|
|
if !self.collaboration_modes_enabled() {
|
|
self.add_info_message(
|
|
"Collaboration modes are disabled.".to_string(),
|
|
Some("Enable collaboration modes to use /plan.".to_string()),
|
|
);
|
|
return false;
|
|
}
|
|
if let Some(mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) {
|
|
self.set_collaboration_mask(mask);
|
|
true
|
|
} else {
|
|
self.add_info_message(
|
|
"Plan mode unavailable right now.".to_string(),
|
|
/*hint*/ None,
|
|
);
|
|
false
|
|
}
|
|
}
|
|
|
|
pub(super) fn dispatch_command(&mut self, cmd: SlashCommand) {
|
|
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
|
|
let message = format!(
|
|
"'/{}' is disabled while a task is in progress.",
|
|
cmd.command()
|
|
);
|
|
self.add_to_history(history_cell::new_error_event(message));
|
|
self.bottom_pane.drain_pending_submission_state();
|
|
self.request_redraw();
|
|
return;
|
|
}
|
|
|
|
match cmd {
|
|
SlashCommand::Feedback => {
|
|
if !self.config.feedback_enabled {
|
|
let params = crate::bottom_pane::feedback_disabled_params();
|
|
self.bottom_pane.show_selection_view(params);
|
|
self.request_redraw();
|
|
return;
|
|
}
|
|
// Step 1: pick a category (UI built in feedback_view)
|
|
let params =
|
|
crate::bottom_pane::feedback_selection_params(self.app_event_tx.clone());
|
|
self.bottom_pane.show_selection_view(params);
|
|
self.request_redraw();
|
|
}
|
|
SlashCommand::New => {
|
|
self.app_event_tx.send(AppEvent::NewSession);
|
|
}
|
|
SlashCommand::Clear => {
|
|
self.app_event_tx.send(AppEvent::ClearUi);
|
|
}
|
|
SlashCommand::Resume => {
|
|
self.app_event_tx.send(AppEvent::OpenResumePicker);
|
|
}
|
|
SlashCommand::Fork => {
|
|
self.app_event_tx.send(AppEvent::ForkCurrentSession);
|
|
}
|
|
SlashCommand::Init => {
|
|
let init_target = self.config.cwd.join(DEFAULT_AGENTS_MD_FILENAME);
|
|
if init_target.exists() {
|
|
let message = format!(
|
|
"{DEFAULT_AGENTS_MD_FILENAME} already exists here. Skipping /init to avoid overwriting it."
|
|
);
|
|
self.add_info_message(message, /*hint*/ None);
|
|
return;
|
|
}
|
|
const INIT_PROMPT: &str = include_str!("../../prompt_for_init_command.md");
|
|
self.submit_user_message(INIT_PROMPT.to_string().into());
|
|
}
|
|
SlashCommand::Compact => {
|
|
self.clear_token_usage();
|
|
if !self.bottom_pane.is_task_running() {
|
|
self.bottom_pane.set_task_running(/*running*/ true);
|
|
}
|
|
self.app_event_tx.compact();
|
|
}
|
|
SlashCommand::Review => {
|
|
self.open_review_popup();
|
|
}
|
|
SlashCommand::Rename => {
|
|
self.session_telemetry
|
|
.counter("codex.thread.rename", /*inc*/ 1, &[]);
|
|
self.show_rename_prompt();
|
|
}
|
|
SlashCommand::Model => {
|
|
self.open_model_popup();
|
|
}
|
|
SlashCommand::Fast => {
|
|
let next_tier = if matches!(self.config.service_tier, Some(ServiceTier::Fast)) {
|
|
None
|
|
} else {
|
|
Some(ServiceTier::Fast)
|
|
};
|
|
self.set_service_tier_selection(next_tier);
|
|
}
|
|
SlashCommand::Realtime => {
|
|
if !self.realtime_conversation_enabled() {
|
|
return;
|
|
}
|
|
if self.realtime_conversation.is_live() {
|
|
self.stop_realtime_conversation_from_ui();
|
|
} else {
|
|
self.start_realtime_conversation();
|
|
}
|
|
}
|
|
SlashCommand::Settings => {
|
|
if !self.realtime_audio_device_selection_enabled() {
|
|
return;
|
|
}
|
|
self.open_realtime_audio_popup();
|
|
}
|
|
SlashCommand::Personality => {
|
|
self.open_personality_popup();
|
|
}
|
|
SlashCommand::Plan => {
|
|
self.apply_plan_slash_command();
|
|
}
|
|
SlashCommand::Collab => {
|
|
if !self.collaboration_modes_enabled() {
|
|
self.add_info_message(
|
|
"Collaboration modes are disabled.".to_string(),
|
|
Some("Enable collaboration modes to use /collab.".to_string()),
|
|
);
|
|
return;
|
|
}
|
|
self.open_collaboration_modes_popup();
|
|
}
|
|
SlashCommand::Agent | SlashCommand::MultiAgents => {
|
|
self.app_event_tx.send(AppEvent::OpenAgentPicker);
|
|
}
|
|
SlashCommand::Approvals => {
|
|
self.open_permissions_popup();
|
|
}
|
|
SlashCommand::Permissions => {
|
|
self.open_permissions_popup();
|
|
}
|
|
SlashCommand::ElevateSandbox => {
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
|
|
let windows_degraded_sandbox_enabled =
|
|
matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken);
|
|
if !windows_degraded_sandbox_enabled
|
|
|| !crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
|
|
{
|
|
// This command should not be visible/recognized outside degraded mode,
|
|
// but guard anyway in case something dispatches it directly.
|
|
return;
|
|
}
|
|
|
|
let Some(preset) = builtin_approval_presets()
|
|
.into_iter()
|
|
.find(|preset| preset.id == "auto")
|
|
else {
|
|
// Avoid panicking in interactive UI; treat this as a recoverable
|
|
// internal error.
|
|
self.add_error_message(
|
|
"Internal error: missing the 'auto' approval preset.".to_string(),
|
|
);
|
|
return;
|
|
};
|
|
|
|
if let Err(err) = self
|
|
.config
|
|
.permissions
|
|
.approval_policy
|
|
.can_set(&preset.approval)
|
|
{
|
|
self.add_error_message(err.to_string());
|
|
return;
|
|
}
|
|
|
|
self.session_telemetry.counter(
|
|
"codex.windows_sandbox.setup_elevated_sandbox_command",
|
|
/*inc*/ 1,
|
|
&[],
|
|
);
|
|
self.app_event_tx
|
|
.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset });
|
|
}
|
|
#[cfg(not(target_os = "windows"))]
|
|
{
|
|
let _ = &self.session_telemetry;
|
|
// Not supported; on non-Windows this command should never be reachable.
|
|
}
|
|
}
|
|
SlashCommand::SandboxReadRoot => {
|
|
self.add_error_message(
|
|
"Usage: /sandbox-add-read-dir <absolute-directory-path>".to_string(),
|
|
);
|
|
}
|
|
SlashCommand::Experimental => {
|
|
self.open_experimental_popup();
|
|
}
|
|
SlashCommand::Memories => {
|
|
self.open_memories_popup();
|
|
}
|
|
SlashCommand::Quit | SlashCommand::Exit => {
|
|
self.request_quit_without_confirmation();
|
|
}
|
|
SlashCommand::Logout => {
|
|
if let Err(e) = codex_login::logout(
|
|
&self.config.codex_home,
|
|
self.config.cli_auth_credentials_store_mode,
|
|
) {
|
|
tracing::error!("failed to logout: {e}");
|
|
}
|
|
self.request_quit_without_confirmation();
|
|
}
|
|
// SlashCommand::Undo => {
|
|
// self.app_event_tx.send(AppEvent::CodexOp(Op::Undo));
|
|
// }
|
|
SlashCommand::Copy => {
|
|
self.copy_last_agent_markdown();
|
|
}
|
|
SlashCommand::Diff => {
|
|
self.add_diff_in_progress();
|
|
let tx = self.app_event_tx.clone();
|
|
tokio::spawn(async move {
|
|
let text = match get_git_diff().await {
|
|
Ok((is_git_repo, diff_text)) => {
|
|
if is_git_repo {
|
|
diff_text
|
|
} else {
|
|
"`/diff` — _not inside a git repository_".to_string()
|
|
}
|
|
}
|
|
Err(e) => format!("Failed to compute diff: {e}"),
|
|
};
|
|
tx.send(AppEvent::DiffResult(text));
|
|
});
|
|
}
|
|
SlashCommand::Mention => {
|
|
self.insert_str("@");
|
|
}
|
|
SlashCommand::Skills => {
|
|
self.open_skills_menu();
|
|
}
|
|
SlashCommand::Status => {
|
|
if self.should_prefetch_rate_limits() {
|
|
let request_id = self.next_status_refresh_request_id;
|
|
self.next_status_refresh_request_id =
|
|
self.next_status_refresh_request_id.wrapping_add(1);
|
|
self.add_status_output(/*refreshing_rate_limits*/ true, Some(request_id));
|
|
self.app_event_tx.send(AppEvent::RefreshRateLimits {
|
|
origin: RateLimitRefreshOrigin::StatusCommand { request_id },
|
|
});
|
|
} else {
|
|
self.add_status_output(
|
|
/*refreshing_rate_limits*/ false, /*request_id*/ None,
|
|
);
|
|
}
|
|
}
|
|
SlashCommand::DebugConfig => {
|
|
self.add_debug_config_output();
|
|
}
|
|
SlashCommand::Title => {
|
|
self.open_terminal_title_setup();
|
|
}
|
|
SlashCommand::Statusline => {
|
|
self.open_status_line_setup();
|
|
}
|
|
SlashCommand::Theme => {
|
|
self.open_theme_picker();
|
|
}
|
|
SlashCommand::Ps => {
|
|
self.add_ps_output();
|
|
}
|
|
SlashCommand::Stop => {
|
|
self.clean_background_terminals();
|
|
}
|
|
SlashCommand::MemoryDrop => {
|
|
self.add_app_server_stub_message("Memory maintenance");
|
|
}
|
|
SlashCommand::MemoryUpdate => {
|
|
self.add_app_server_stub_message("Memory maintenance");
|
|
}
|
|
SlashCommand::Mcp => {
|
|
self.add_mcp_output();
|
|
}
|
|
SlashCommand::Apps => {
|
|
self.add_connectors_output();
|
|
}
|
|
SlashCommand::Plugins => {
|
|
self.add_plugins_output();
|
|
}
|
|
SlashCommand::Rollout => {
|
|
if let Some(path) = self.rollout_path() {
|
|
self.add_info_message(
|
|
format!("Current rollout path: {}", path.display()),
|
|
/*hint*/ None,
|
|
);
|
|
} else {
|
|
self.add_info_message(
|
|
"Rollout path is not available yet.".to_string(),
|
|
/*hint*/ None,
|
|
);
|
|
}
|
|
}
|
|
SlashCommand::TestApproval => {
|
|
use std::collections::HashMap;
|
|
|
|
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
|
|
use codex_protocol::protocol::FileChange;
|
|
|
|
self.on_apply_patch_approval_request(
|
|
"1".to_string(),
|
|
ApplyPatchApprovalRequestEvent {
|
|
call_id: "1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
changes: HashMap::from([
|
|
(
|
|
PathBuf::from("/tmp/test.txt"),
|
|
FileChange::Add {
|
|
content: "test".to_string(),
|
|
},
|
|
),
|
|
(
|
|
PathBuf::from("/tmp/test2.txt"),
|
|
FileChange::Update {
|
|
unified_diff: "+test\n-test2".to_string(),
|
|
move_path: None,
|
|
},
|
|
),
|
|
]),
|
|
reason: None,
|
|
grant_root: Some(PathBuf::from("/tmp")),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run an inline slash command.
|
|
///
|
|
/// Branches that prepare arguments should pass `record_history: false` to the composer because
|
|
/// the staged slash-command entry is the recall record; using the normal submission-history
|
|
/// path as well would make a single command appear twice during Up-arrow navigation.
|
|
pub(super) fn dispatch_command_with_args(
|
|
&mut self,
|
|
cmd: SlashCommand,
|
|
args: String,
|
|
_text_elements: Vec<TextElement>,
|
|
) {
|
|
if !cmd.supports_inline_args() {
|
|
self.dispatch_command(cmd);
|
|
return;
|
|
}
|
|
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
|
|
let message = format!(
|
|
"'/{}' is disabled while a task is in progress.",
|
|
cmd.command()
|
|
);
|
|
self.add_to_history(history_cell::new_error_event(message));
|
|
self.request_redraw();
|
|
return;
|
|
}
|
|
|
|
let trimmed = args.trim();
|
|
match cmd {
|
|
SlashCommand::Fast => {
|
|
if trimmed.is_empty() {
|
|
self.dispatch_command(cmd);
|
|
return;
|
|
}
|
|
let prepared_args = if self.bottom_pane.composer_text().is_empty() {
|
|
args
|
|
} else {
|
|
let Some((prepared_args, _prepared_elements)) = self
|
|
.bottom_pane
|
|
.prepare_inline_args_submission(/*record_history*/ false)
|
|
else {
|
|
return;
|
|
};
|
|
prepared_args
|
|
};
|
|
match prepared_args.trim().to_ascii_lowercase().as_str() {
|
|
"on" => self.set_service_tier_selection(Some(ServiceTier::Fast)),
|
|
"off" => self.set_service_tier_selection(/*service_tier*/ None),
|
|
"status" => {
|
|
let status = if matches!(self.config.service_tier, Some(ServiceTier::Fast))
|
|
{
|
|
"on"
|
|
} else {
|
|
"off"
|
|
};
|
|
self.add_info_message(
|
|
format!("Fast mode is {status}."),
|
|
/*hint*/ None,
|
|
);
|
|
}
|
|
_ => {
|
|
self.add_error_message("Usage: /fast [on|off|status]".to_string());
|
|
}
|
|
}
|
|
}
|
|
SlashCommand::Rename if !trimmed.is_empty() => {
|
|
self.session_telemetry
|
|
.counter("codex.thread.rename", /*inc*/ 1, &[]);
|
|
let Some((prepared_args, _prepared_elements)) = self
|
|
.bottom_pane
|
|
.prepare_inline_args_submission(/*record_history*/ false)
|
|
else {
|
|
return;
|
|
};
|
|
let Some(name) = crate::legacy_core::util::normalize_thread_name(&prepared_args)
|
|
else {
|
|
self.add_error_message("Thread name cannot be empty.".to_string());
|
|
return;
|
|
};
|
|
self.app_event_tx.set_thread_name(name);
|
|
self.bottom_pane.drain_pending_submission_state();
|
|
}
|
|
SlashCommand::Plan if !trimmed.is_empty() => {
|
|
if !self.apply_plan_slash_command() {
|
|
return;
|
|
}
|
|
let Some((prepared_args, prepared_elements)) = self
|
|
.bottom_pane
|
|
.prepare_inline_args_submission(/*record_history*/ false)
|
|
else {
|
|
return;
|
|
};
|
|
let local_images = self
|
|
.bottom_pane
|
|
.take_recent_submission_images_with_placeholders();
|
|
let remote_image_urls = self.take_remote_image_urls();
|
|
let user_message = UserMessage {
|
|
text: prepared_args,
|
|
local_images,
|
|
remote_image_urls,
|
|
text_elements: prepared_elements,
|
|
mention_bindings: self.bottom_pane.take_recent_submission_mention_bindings(),
|
|
};
|
|
if self.is_session_configured() {
|
|
self.reasoning_buffer.clear();
|
|
self.full_reasoning_buffer.clear();
|
|
self.set_status_header(String::from("Working"));
|
|
self.submit_user_message(user_message);
|
|
} else {
|
|
self.queue_user_message(user_message);
|
|
}
|
|
}
|
|
SlashCommand::Review if !trimmed.is_empty() => {
|
|
let Some((prepared_args, _prepared_elements)) = self
|
|
.bottom_pane
|
|
.prepare_inline_args_submission(/*record_history*/ false)
|
|
else {
|
|
return;
|
|
};
|
|
self.submit_op(AppCommand::review(ReviewRequest {
|
|
target: ReviewTarget::Custom {
|
|
instructions: prepared_args,
|
|
},
|
|
user_facing_hint: None,
|
|
}));
|
|
self.bottom_pane.drain_pending_submission_state();
|
|
}
|
|
SlashCommand::Resume if !trimmed.is_empty() => {
|
|
let Some((prepared_args, _prepared_elements)) = self
|
|
.bottom_pane
|
|
.prepare_inline_args_submission(/*record_history*/ false)
|
|
else {
|
|
return;
|
|
};
|
|
self.app_event_tx
|
|
.send(AppEvent::ResumeSessionByIdOrName(prepared_args));
|
|
self.bottom_pane.drain_pending_submission_state();
|
|
}
|
|
SlashCommand::SandboxReadRoot if !trimmed.is_empty() => {
|
|
let Some((prepared_args, _prepared_elements)) = self
|
|
.bottom_pane
|
|
.prepare_inline_args_submission(/*record_history*/ false)
|
|
else {
|
|
return;
|
|
};
|
|
self.app_event_tx
|
|
.send(AppEvent::BeginWindowsSandboxGrantReadRoot {
|
|
path: prepared_args,
|
|
});
|
|
self.bottom_pane.drain_pending_submission_state();
|
|
}
|
|
_ => self.dispatch_command(cmd),
|
|
}
|
|
}
|
|
}
|