mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
## Why The Codex App has animated pets, but the TUI had no equivalent ambient companion surface. This brings that experience into terminal Codex while keeping the main chat flow usable: the pet should feel present, but it cannot cover transcript text, composer input, approvals, or picker content. The feature also needs to be terminal-aware. Different terminals support different image protocols, tmux can interfere with image rendering, and some users will want pets disabled entirely or anchored differently depending on their layout. <table> <tr><td> <img width="4110" height="2584" alt="CleanShot 2026-05-05 at 12 41 45@2x" src="https://github.com/user-attachments/assets/68a1fcbc-2104-48d6-b834-69c6aaa95cdf" /> <p align="center">macOS - Ghostty, iTerm2 and WezTerm with Custom Pet</p> </td></tr> <tr><td> ![Uploading CleanShot 2026-05-10 at 20.28.30.png…]() <p align="center">Windows Terminal</p> </td></tr> <tr><td> <img width="3902" height="2752" alt="CleanShot 2026-05-05 at 12 39 02@2x" src="https://github.com/user-attachments/assets/300e2931-6b00-467e-91cb-ab8e28470500" /> <p align="center">Linux - WezTerm and Ghostty</p> </td></tr> </table> ## What Changed - Add a TUI ambient pet renderer in `codex-rs/tui/src/pets/`. - Port the app-style pet animation states so the sprite changes with task status, waiting-for-input states, review/ready states, and failures. - Add `/pets` selection UI with a preview pane, loading state, built-in pet choices, and a first-row `Disable terminal pets` option. - Download built-in pet spritesheets on demand from the same public CDN path already used by Android, under `https://persistent.oaistatic.com/codex/pets/v1/...`, and cache them locally under `~/.codex/cache/tui-pets/`. - Keep custom pets local. - Add config support for pet selection, disabling pets, and choosing whether the pet follows the composer bottom or anchors to the terminal bottom. - Reserve layout space around the pet so transcript wrapping, live responses, and composer input do not render underneath the sprite. - Gate image rendering by terminal capability, disable image pets under tmux, and support both Kitty Graphics and SIXEL terminals. - Add redraw cleanup for terminal image artifacts, including sixel cell clearing. ## Current Scope - This is an initial TUI version of ambient pets, not full App parity. - It focuses on ambient sprite rendering, `/pets` selection, custom pets, terminal capability gating, and on-demand CDN-backed built-in assets. - The ambient text overlay is currently disabled, so the TUI renders the pet sprite without extra status text beside it. ## How to Test 1. Start Codex TUI in a terminal with image support. 2. Run `/pets`. 3. Confirm the picker shows built-in pets plus custom pets, and the first item is `Disable terminal pets`. 4. On a fresh `~/.codex/cache/tui-pets/`, move onto a built-in pet and confirm the first preview downloads the spritesheet from the shared Codex pets CDN and renders successfully. 5. Move through the pet list and confirm subsequent built-in previews use the local cache. 6. Select a pet, then send and receive messages. Confirm transcript and composer text wrap before the pet instead of rendering underneath the sprite. 7. Change the pet anchor setting and confirm the pet can either follow the composer bottom or sit at the terminal bottom. 8. Return to `/pets`, choose `Disable terminal pets`, and confirm the sprite disappears cleanly. Targeted tests: - `cargo test -p codex-tui ambient_pet_` - `cargo test -p codex-tui resize_reflow_wraps_transcript_early_when_pet_is_enabled` - `cargo insta pending-snapshots`
1039 lines
41 KiB
Rust
1039 lines
41 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::goal_validation::GoalObjectiveValidationSource;
|
|
use super::*;
|
|
use crate::app_event::ThreadGoalSetMode;
|
|
use crate::bottom_pane::prompt_args::parse_slash_name;
|
|
use crate::bottom_pane::slash_commands::BuiltinCommandFlags;
|
|
use crate::bottom_pane::slash_commands::ServiceTierCommand;
|
|
use crate::bottom_pane::slash_commands::SlashCommandItem;
|
|
use crate::bottom_pane::slash_commands::find_slash_command;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum SlashCommandDispatchSource {
|
|
Live,
|
|
Queued,
|
|
}
|
|
|
|
struct PreparedSlashCommandArgs {
|
|
args: String,
|
|
text_elements: Vec<TextElement>,
|
|
local_images: Vec<LocalImageAttachment>,
|
|
remote_image_urls: Vec<String>,
|
|
mention_bindings: Vec<MentionBinding>,
|
|
source: SlashCommandDispatchSource,
|
|
}
|
|
|
|
const SIDE_STARTING_CONTEXT_LABEL: &str = "Side starting...";
|
|
const SIDE_REVIEW_UNAVAILABLE_MESSAGE: &str =
|
|
"'/side' is unavailable while code review is running.";
|
|
const SIDE_SLASH_COMMAND_UNAVAILABLE_HINT: &str = "Press Esc to return to the main thread first.";
|
|
const GOAL_USAGE: &str = "Usage: /goal <objective>";
|
|
const GOAL_USAGE_HINT: &str = "Example: /goal improve benchmark coverage";
|
|
const RAW_USAGE: &str = "Usage: /raw [on|off]";
|
|
|
|
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);
|
|
if cmd == SlashCommand::Goal {
|
|
self.bottom_pane.drain_pending_submission_state();
|
|
}
|
|
self.bottom_pane.record_pending_slash_command_history();
|
|
}
|
|
|
|
pub(super) fn handle_service_tier_command_dispatch(&mut self, command: ServiceTierCommand) {
|
|
if self.active_side_conversation {
|
|
self.add_error_message(format!(
|
|
"'/{}' is unavailable in side conversations. {SIDE_SLASH_COMMAND_UNAVAILABLE_HINT}",
|
|
command.name
|
|
));
|
|
self.bottom_pane.drain_pending_submission_state();
|
|
self.bottom_pane.record_pending_slash_command_history();
|
|
return;
|
|
}
|
|
self.toggle_service_tier_from_ui(command);
|
|
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
|
|
}
|
|
}
|
|
|
|
fn request_side_conversation(
|
|
&mut self,
|
|
parent_thread_id: ThreadId,
|
|
user_message: Option<UserMessage>,
|
|
) {
|
|
self.set_side_conversation_context_label(Some(SIDE_STARTING_CONTEXT_LABEL.to_string()));
|
|
self.request_redraw();
|
|
self.app_event_tx.send(AppEvent::StartSide {
|
|
parent_thread_id,
|
|
user_message,
|
|
});
|
|
}
|
|
|
|
fn request_empty_side_conversation(&mut self) {
|
|
let Some(parent_thread_id) = self.thread_id else {
|
|
self.add_error_message("'/side' is unavailable before the session starts.".to_string());
|
|
return;
|
|
};
|
|
|
|
self.request_side_conversation(parent_thread_id, /*user_message*/ None);
|
|
}
|
|
|
|
fn emit_raw_output_mode_changed(&self, enabled: bool) {
|
|
self.app_event_tx
|
|
.send(AppEvent::RawOutputModeChanged { enabled });
|
|
}
|
|
|
|
pub(super) fn dispatch_command(&mut self, cmd: SlashCommand) {
|
|
if !self.ensure_slash_command_allowed_in_side_conversation(cmd) {
|
|
return;
|
|
}
|
|
if !self.ensure_side_command_allowed_outside_review(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.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::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::Goal => {
|
|
if !self.config.features.enabled(Feature::Goals) {
|
|
return;
|
|
}
|
|
if let Some(thread_id) = self.thread_id {
|
|
self.app_event_tx
|
|
.send(AppEvent::OpenThreadGoalMenu { thread_id });
|
|
self.append_message_history_entry("/goal".to_string());
|
|
} else {
|
|
self.add_info_message(
|
|
GOAL_USAGE.to_string(),
|
|
Some(GOAL_USAGE_HINT.to_string()),
|
|
);
|
|
}
|
|
}
|
|
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::Side => {
|
|
self.request_empty_side_conversation();
|
|
}
|
|
SlashCommand::Agent | SlashCommand::MultiAgents => {
|
|
self.app_event_tx.send(AppEvent::OpenAgentPicker);
|
|
}
|
|
SlashCommand::Permissions => {
|
|
self.open_permissions_popup();
|
|
}
|
|
SlashCommand::Vim => {
|
|
self.toggle_vim_mode_and_notify();
|
|
}
|
|
SlashCommand::Keymap => {
|
|
self.open_keymap_picker();
|
|
}
|
|
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::AutoReview => {
|
|
self.open_auto_review_denials_popup();
|
|
}
|
|
SlashCommand::Memories => {
|
|
self.open_memories_popup();
|
|
}
|
|
SlashCommand::Quit | SlashCommand::Exit => {
|
|
self.request_quit_without_confirmation();
|
|
}
|
|
SlashCommand::Logout => {
|
|
self.app_event_tx.send(AppEvent::Logout);
|
|
}
|
|
SlashCommand::Copy => {
|
|
self.copy_last_agent_markdown();
|
|
}
|
|
SlashCommand::Raw => {
|
|
let enabled = self.toggle_raw_output_mode_and_notify();
|
|
self.emit_raw_output_mode_changed(enabled);
|
|
}
|
|
SlashCommand::Diff => {
|
|
self.add_diff_in_progress();
|
|
let tx = self.app_event_tx.clone();
|
|
let runner = self.workspace_command_runner.clone();
|
|
let cwd = self
|
|
.current_cwd
|
|
.clone()
|
|
.unwrap_or_else(|| self.config.cwd.to_path_buf());
|
|
tokio::spawn(async move {
|
|
let text = match runner {
|
|
Some(runner) => match get_git_diff(runner.as_ref(), &cwd).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}"),
|
|
},
|
|
None => "Failed to compute diff: workspace command runner unavailable"
|
|
.to_string(),
|
|
};
|
|
tx.send(AppEvent::DiffResult(text));
|
|
});
|
|
}
|
|
SlashCommand::Mention => {
|
|
self.insert_str("@");
|
|
}
|
|
SlashCommand::Skills => {
|
|
self.open_skills_menu();
|
|
}
|
|
SlashCommand::Hooks => {
|
|
self.add_hooks_output();
|
|
}
|
|
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::Ide => {
|
|
self.handle_ide_command();
|
|
}
|
|
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::Pets => {
|
|
self.open_pets_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(McpServerStatusDetail::ToolsAndAuthOnly);
|
|
}
|
|
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 crate::approval_events::ApplyPatchApprovalRequestEvent;
|
|
use crate::diff_model::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 !self.ensure_slash_command_allowed_in_side_conversation(cmd) {
|
|
return;
|
|
}
|
|
if !self.ensure_side_command_allowed_outside_review(cmd) {
|
|
return;
|
|
}
|
|
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();
|
|
if trimmed.is_empty() {
|
|
self.dispatch_command(cmd);
|
|
return;
|
|
}
|
|
|
|
if cmd == SlashCommand::Goal
|
|
&& !self.goal_objective_with_pending_pastes_is_allowed(&args, &text_elements)
|
|
{
|
|
return;
|
|
}
|
|
|
|
let Some((prepared_args, prepared_elements)) =
|
|
self.prepare_live_inline_args(args, text_elements)
|
|
else {
|
|
return;
|
|
};
|
|
self.dispatch_prepared_command_with_args(
|
|
cmd,
|
|
PreparedSlashCommandArgs {
|
|
args: prepared_args,
|
|
text_elements: prepared_elements,
|
|
local_images: Vec::new(),
|
|
remote_image_urls: Vec::new(),
|
|
mention_bindings: Vec::new(),
|
|
source: SlashCommandDispatchSource::Live,
|
|
},
|
|
);
|
|
}
|
|
|
|
fn prepare_live_inline_args(
|
|
&mut self,
|
|
args: String,
|
|
text_elements: Vec<TextElement>,
|
|
) -> Option<(String, Vec<TextElement>)> {
|
|
if self.bottom_pane.composer_text().is_empty() {
|
|
Some((args, text_elements))
|
|
} else {
|
|
self.bottom_pane
|
|
.prepare_inline_args_submission(/*record_history*/ false)
|
|
}
|
|
}
|
|
|
|
fn prepared_inline_user_message(
|
|
&mut self,
|
|
args: String,
|
|
text_elements: Vec<TextElement>,
|
|
mut local_images: Vec<LocalImageAttachment>,
|
|
mut remote_image_urls: Vec<String>,
|
|
mut mention_bindings: Vec<MentionBinding>,
|
|
source: SlashCommandDispatchSource,
|
|
) -> UserMessage {
|
|
if source == SlashCommandDispatchSource::Live {
|
|
local_images = self
|
|
.bottom_pane
|
|
.take_recent_submission_images_with_placeholders();
|
|
remote_image_urls = self.take_remote_image_urls();
|
|
mention_bindings = self.bottom_pane.take_recent_submission_mention_bindings();
|
|
}
|
|
UserMessage {
|
|
text: args,
|
|
local_images,
|
|
remote_image_urls,
|
|
text_elements,
|
|
mention_bindings,
|
|
}
|
|
}
|
|
|
|
fn dispatch_prepared_command_with_args(
|
|
&mut self,
|
|
cmd: SlashCommand,
|
|
prepared: PreparedSlashCommandArgs,
|
|
) {
|
|
let PreparedSlashCommandArgs {
|
|
args,
|
|
text_elements,
|
|
local_images,
|
|
remote_image_urls,
|
|
mention_bindings,
|
|
source,
|
|
} = prepared;
|
|
let trimmed = args.trim();
|
|
match cmd {
|
|
SlashCommand::Ide => {
|
|
self.handle_ide_command_args(trimmed);
|
|
}
|
|
SlashCommand::Mcp => match trimmed.to_ascii_lowercase().as_str() {
|
|
"verbose" => self.add_mcp_output(McpServerStatusDetail::Full),
|
|
_ => self.add_error_message("Usage: /mcp [verbose]".to_string()),
|
|
},
|
|
SlashCommand::Keymap => match trimmed.to_ascii_lowercase().as_str() {
|
|
"" => self.open_keymap_picker(),
|
|
"debug" => {
|
|
match crate::keymap::RuntimeKeymap::from_config(&self.config.tui_keymap) {
|
|
Ok(runtime_keymap) => self.open_keymap_debug(&runtime_keymap),
|
|
Err(err) => {
|
|
self.add_error_message(format!(
|
|
"Invalid `tui.keymap` configuration: {err}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
_ => self.add_error_message("Usage: /keymap [debug]".to_string()),
|
|
},
|
|
SlashCommand::Raw => match trimmed.to_ascii_lowercase().as_str() {
|
|
"on" => {
|
|
self.set_raw_output_mode_and_notify(/*enabled*/ true);
|
|
self.emit_raw_output_mode_changed(/*enabled*/ true);
|
|
}
|
|
"off" => {
|
|
self.set_raw_output_mode_and_notify(/*enabled*/ false);
|
|
self.emit_raw_output_mode_changed(/*enabled*/ false);
|
|
}
|
|
_ => self.add_error_message(RAW_USAGE.to_string()),
|
|
},
|
|
SlashCommand::Rename if !trimmed.is_empty() => {
|
|
if !self.ensure_thread_rename_allowed() {
|
|
return;
|
|
}
|
|
self.session_telemetry
|
|
.counter("codex.thread.rename", /*inc*/ 1, &[]);
|
|
let Some(name) = crate::legacy_core::util::normalize_thread_name(&args) else {
|
|
self.add_error_message("Thread name cannot be empty.".to_string());
|
|
return;
|
|
};
|
|
self.app_event_tx.set_thread_name(name);
|
|
}
|
|
SlashCommand::Plan if !trimmed.is_empty() => {
|
|
if !self.apply_plan_slash_command() {
|
|
return;
|
|
}
|
|
let user_message = self.prepared_inline_user_message(
|
|
args,
|
|
text_elements,
|
|
local_images,
|
|
remote_image_urls,
|
|
mention_bindings,
|
|
source,
|
|
);
|
|
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::Goal if !trimmed.is_empty() => {
|
|
if !self.config.features.enabled(Feature::Goals) {
|
|
return;
|
|
}
|
|
enum GoalControlCommand {
|
|
Clear,
|
|
SetStatus(AppThreadGoalStatus),
|
|
}
|
|
let control_command = match trimmed.to_ascii_lowercase().as_str() {
|
|
"clear" => Some(GoalControlCommand::Clear),
|
|
"edit" => {
|
|
self.app_event_tx.send(AppEvent::OpenThreadGoalEditor {
|
|
thread_id: self.thread_id,
|
|
});
|
|
if source == SlashCommandDispatchSource::Live {
|
|
self.bottom_pane.drain_pending_submission_state();
|
|
}
|
|
return;
|
|
}
|
|
"pause" => Some(GoalControlCommand::SetStatus(AppThreadGoalStatus::Paused)),
|
|
"resume" => Some(GoalControlCommand::SetStatus(AppThreadGoalStatus::Active)),
|
|
_ => None,
|
|
};
|
|
if let Some(command) = control_command {
|
|
let Some(thread_id) = self.thread_id else {
|
|
self.add_info_message(
|
|
GOAL_USAGE.to_string(),
|
|
Some(
|
|
"The session must start before you can change a goal.".to_string(),
|
|
),
|
|
);
|
|
return;
|
|
};
|
|
match command {
|
|
GoalControlCommand::Clear => {
|
|
self.app_event_tx
|
|
.send(AppEvent::ClearThreadGoal { thread_id });
|
|
}
|
|
GoalControlCommand::SetStatus(status) => {
|
|
self.app_event_tx
|
|
.send(AppEvent::SetThreadGoalStatus { thread_id, status });
|
|
}
|
|
}
|
|
self.append_message_history_entry(format!("/goal {trimmed}"));
|
|
if source == SlashCommandDispatchSource::Live {
|
|
self.bottom_pane.drain_pending_submission_state();
|
|
}
|
|
return;
|
|
}
|
|
let objective = args.trim();
|
|
if objective.is_empty() {
|
|
self.add_error_message("Goal objective must not be empty.".to_string());
|
|
self.add_info_message(
|
|
GOAL_USAGE.to_string(),
|
|
Some(GOAL_USAGE_HINT.to_string()),
|
|
);
|
|
if source == SlashCommandDispatchSource::Live {
|
|
self.bottom_pane.drain_pending_submission_state();
|
|
}
|
|
return;
|
|
}
|
|
let validation_source = match source {
|
|
SlashCommandDispatchSource::Live => GoalObjectiveValidationSource::Live,
|
|
SlashCommandDispatchSource::Queued => GoalObjectiveValidationSource::Queued,
|
|
};
|
|
if !self.goal_objective_is_allowed(objective, validation_source) {
|
|
return;
|
|
}
|
|
let Some(thread_id) = self.thread_id else {
|
|
if source == SlashCommandDispatchSource::Live {
|
|
self.queue_user_message_with_options(
|
|
UserMessage {
|
|
text: format!("/goal {args}"),
|
|
local_images: Vec::new(),
|
|
remote_image_urls: Vec::new(),
|
|
text_elements: Vec::new(),
|
|
mention_bindings: Vec::new(),
|
|
},
|
|
QueuedInputAction::ParseSlash,
|
|
);
|
|
self.bottom_pane.drain_pending_submission_state();
|
|
} else {
|
|
self.add_info_message(
|
|
GOAL_USAGE.to_string(),
|
|
Some("The session must start before you can set a goal.".to_string()),
|
|
);
|
|
}
|
|
return;
|
|
};
|
|
self.app_event_tx.send(AppEvent::SetThreadGoalObjective {
|
|
thread_id,
|
|
objective: objective.to_string(),
|
|
mode: ThreadGoalSetMode::ConfirmIfExists,
|
|
});
|
|
self.append_message_history_entry(format!("/goal {trimmed}"));
|
|
if source == SlashCommandDispatchSource::Live {
|
|
self.bottom_pane.drain_pending_submission_state();
|
|
}
|
|
}
|
|
SlashCommand::Side if !trimmed.is_empty() => {
|
|
let Some(parent_thread_id) = self.thread_id else {
|
|
self.add_error_message(
|
|
"'/side' is unavailable before the session starts.".to_string(),
|
|
);
|
|
return;
|
|
};
|
|
let user_message = self.prepared_inline_user_message(
|
|
args,
|
|
text_elements,
|
|
local_images,
|
|
remote_image_urls,
|
|
mention_bindings,
|
|
source,
|
|
);
|
|
self.request_side_conversation(parent_thread_id, Some(user_message));
|
|
}
|
|
SlashCommand::Review if !trimmed.is_empty() => {
|
|
self.submit_op(AppCommand::review(ReviewTarget::Custom {
|
|
instructions: args,
|
|
}));
|
|
}
|
|
SlashCommand::Resume if !trimmed.is_empty() => {
|
|
self.app_event_tx
|
|
.send(AppEvent::ResumeSessionByIdOrName(args));
|
|
}
|
|
SlashCommand::SandboxReadRoot if !trimmed.is_empty() => {
|
|
self.app_event_tx
|
|
.send(AppEvent::BeginWindowsSandboxGrantReadRoot { path: args });
|
|
}
|
|
SlashCommand::Pets
|
|
if matches!(
|
|
args.trim().to_ascii_lowercase().as_str(),
|
|
"disable" | "disabled" | "hide" | "hidden" | "off" | "none"
|
|
) =>
|
|
{
|
|
self.app_event_tx.send(AppEvent::PetDisabled);
|
|
}
|
|
SlashCommand::Pets if !trimmed.is_empty() => {
|
|
self.select_pet_by_id(args);
|
|
}
|
|
_ => self.dispatch_command(cmd),
|
|
}
|
|
if source == SlashCommandDispatchSource::Live && cmd != SlashCommand::Goal {
|
|
self.bottom_pane.drain_pending_submission_state();
|
|
}
|
|
}
|
|
|
|
pub(super) fn submit_queued_slash_prompt(&mut self, user_message: UserMessage) -> QueueDrain {
|
|
let UserMessage {
|
|
text,
|
|
local_images,
|
|
remote_image_urls,
|
|
text_elements,
|
|
mention_bindings,
|
|
} = user_message;
|
|
let Some((name, rest, rest_offset)) = parse_slash_name(&text) else {
|
|
self.submit_user_message(UserMessage {
|
|
text,
|
|
local_images,
|
|
remote_image_urls,
|
|
text_elements,
|
|
mention_bindings,
|
|
});
|
|
return QueueDrain::Stop;
|
|
};
|
|
|
|
if name.contains('/') {
|
|
self.submit_user_message(UserMessage {
|
|
text,
|
|
local_images,
|
|
remote_image_urls,
|
|
text_elements,
|
|
mention_bindings,
|
|
});
|
|
return QueueDrain::Stop;
|
|
}
|
|
|
|
let service_tier_commands = self.current_model_service_tier_commands();
|
|
let Some(command) =
|
|
find_slash_command(name, self.builtin_command_flags(), &service_tier_commands)
|
|
else {
|
|
self.add_info_message(
|
|
format!(
|
|
r#"Unrecognized command '/{name}'. Type "/" for a list of supported commands."#
|
|
),
|
|
/*hint*/ None,
|
|
);
|
|
return QueueDrain::Continue;
|
|
};
|
|
|
|
if rest.is_empty() {
|
|
return match command {
|
|
SlashCommandItem::Builtin(cmd) => {
|
|
self.dispatch_command(cmd);
|
|
self.queued_command_drain_result(cmd)
|
|
}
|
|
SlashCommandItem::ServiceTier(command) => {
|
|
self.handle_service_tier_command_dispatch(command);
|
|
QueueDrain::Continue
|
|
}
|
|
};
|
|
}
|
|
|
|
if !command.supports_inline_args() {
|
|
self.submit_user_message(UserMessage {
|
|
text,
|
|
local_images,
|
|
remote_image_urls,
|
|
text_elements,
|
|
mention_bindings,
|
|
});
|
|
return QueueDrain::Stop;
|
|
}
|
|
let SlashCommandItem::Builtin(cmd) = command else {
|
|
self.submit_user_message(UserMessage {
|
|
text,
|
|
local_images,
|
|
remote_image_urls,
|
|
text_elements,
|
|
mention_bindings,
|
|
});
|
|
return QueueDrain::Stop;
|
|
};
|
|
|
|
let trimmed_start = rest.trim_start();
|
|
let leading_trimmed = rest.len().saturating_sub(trimmed_start.len());
|
|
let trimmed_rest = trimmed_start.trim_end();
|
|
let args_elements = Self::slash_command_args_elements(
|
|
trimmed_rest,
|
|
rest_offset + leading_trimmed,
|
|
&text_elements,
|
|
);
|
|
if cmd == SlashCommand::Goal
|
|
&& !self.goal_objective_is_allowed(trimmed_rest, GoalObjectiveValidationSource::Queued)
|
|
{
|
|
return QueueDrain::Continue;
|
|
}
|
|
self.dispatch_prepared_command_with_args(
|
|
cmd,
|
|
PreparedSlashCommandArgs {
|
|
args: trimmed_rest.to_string(),
|
|
text_elements: args_elements,
|
|
local_images,
|
|
remote_image_urls,
|
|
mention_bindings,
|
|
source: SlashCommandDispatchSource::Queued,
|
|
},
|
|
);
|
|
self.queued_command_drain_result(cmd)
|
|
}
|
|
|
|
fn builtin_command_flags(&self) -> BuiltinCommandFlags {
|
|
#[cfg(target_os = "windows")]
|
|
let allow_elevate_sandbox = {
|
|
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
|
|
matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken)
|
|
};
|
|
#[cfg(not(target_os = "windows"))]
|
|
let allow_elevate_sandbox = false;
|
|
|
|
BuiltinCommandFlags {
|
|
collaboration_modes_enabled: self.collaboration_modes_enabled(),
|
|
connectors_enabled: self.connectors_enabled(),
|
|
plugins_command_enabled: self.config.features.enabled(Feature::Plugins),
|
|
goal_command_enabled: self.config.features.enabled(Feature::Goals),
|
|
service_tier_commands_enabled: self.fast_mode_enabled(),
|
|
personality_command_enabled: self.config.features.enabled(Feature::Personality),
|
|
realtime_conversation_enabled: self.realtime_conversation_enabled(),
|
|
audio_device_selection_enabled: self.realtime_audio_device_selection_enabled(),
|
|
allow_elevate_sandbox,
|
|
side_conversation_active: self.active_side_conversation,
|
|
}
|
|
}
|
|
|
|
fn queued_command_drain_result(&self, cmd: SlashCommand) -> QueueDrain {
|
|
if self.is_user_turn_pending_or_running() || !self.bottom_pane.no_modal_or_popup_active() {
|
|
return QueueDrain::Stop;
|
|
}
|
|
match cmd {
|
|
SlashCommand::Ide
|
|
| SlashCommand::Status
|
|
| SlashCommand::DebugConfig
|
|
| SlashCommand::Ps
|
|
| SlashCommand::Stop
|
|
| SlashCommand::MemoryDrop
|
|
| SlashCommand::MemoryUpdate
|
|
| SlashCommand::Mcp
|
|
| SlashCommand::Apps
|
|
| SlashCommand::Plugins
|
|
| SlashCommand::Rollout
|
|
| SlashCommand::Copy
|
|
| SlashCommand::Raw
|
|
| SlashCommand::Vim
|
|
| SlashCommand::Diff
|
|
| SlashCommand::Rename
|
|
| SlashCommand::TestApproval => QueueDrain::Continue,
|
|
SlashCommand::Feedback
|
|
| SlashCommand::New
|
|
| SlashCommand::Clear
|
|
| SlashCommand::Resume
|
|
| SlashCommand::Fork
|
|
| SlashCommand::Init
|
|
| SlashCommand::Compact
|
|
| SlashCommand::Review
|
|
| SlashCommand::Model
|
|
| SlashCommand::Realtime
|
|
| SlashCommand::Settings
|
|
| SlashCommand::Personality
|
|
| SlashCommand::Plan
|
|
| SlashCommand::Goal
|
|
| SlashCommand::Collab
|
|
| SlashCommand::Side
|
|
| SlashCommand::Keymap
|
|
| SlashCommand::Agent
|
|
| SlashCommand::MultiAgents
|
|
| SlashCommand::Permissions
|
|
| SlashCommand::ElevateSandbox
|
|
| SlashCommand::SandboxReadRoot
|
|
| SlashCommand::Experimental
|
|
| SlashCommand::AutoReview
|
|
| SlashCommand::Memories
|
|
| SlashCommand::Quit
|
|
| SlashCommand::Exit
|
|
| SlashCommand::Logout
|
|
| SlashCommand::Mention
|
|
| SlashCommand::Skills
|
|
| SlashCommand::Hooks
|
|
| SlashCommand::Title
|
|
| SlashCommand::Statusline
|
|
| SlashCommand::Theme
|
|
| SlashCommand::Pets => QueueDrain::Stop,
|
|
}
|
|
}
|
|
|
|
fn slash_command_args_elements(
|
|
rest: &str,
|
|
rest_offset: usize,
|
|
text_elements: &[TextElement],
|
|
) -> Vec<TextElement> {
|
|
if rest.is_empty() || text_elements.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
text_elements
|
|
.iter()
|
|
.filter_map(|elem| {
|
|
if elem.byte_range.end <= rest_offset {
|
|
return None;
|
|
}
|
|
let start = elem.byte_range.start.saturating_sub(rest_offset);
|
|
let mut end = elem.byte_range.end.saturating_sub(rest_offset);
|
|
if start >= rest.len() {
|
|
return None;
|
|
}
|
|
end = end.min(rest.len());
|
|
(start < end).then_some(elem.map_range(|_| ByteRange { start, end }))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn ensure_slash_command_allowed_in_side_conversation(&mut self, cmd: SlashCommand) -> bool {
|
|
if !self.active_side_conversation || cmd.available_in_side_conversation() {
|
|
return true;
|
|
}
|
|
self.add_error_message(format!(
|
|
"'/{}' is unavailable in side conversations. {SIDE_SLASH_COMMAND_UNAVAILABLE_HINT}",
|
|
cmd.command()
|
|
));
|
|
self.bottom_pane.drain_pending_submission_state();
|
|
false
|
|
}
|
|
|
|
fn ensure_side_command_allowed_outside_review(&mut self, cmd: SlashCommand) -> bool {
|
|
if cmd != SlashCommand::Side || !self.review.is_review_mode {
|
|
return true;
|
|
}
|
|
|
|
self.add_error_message(SIDE_REVIEW_UNAVAILABLE_MESSAGE.to_string());
|
|
self.bottom_pane.drain_pending_submission_state();
|
|
false
|
|
}
|
|
}
|