feat(tui): add upstream-compatible slash commands

This commit is contained in:
James Peter
2026-05-08 21:55:10 +10:00
parent c081eedfb1
commit 4b4698543a
8 changed files with 446 additions and 3 deletions
+39 -1
View File
@@ -615,6 +615,27 @@ impl App<'_> {
self.input_running.store(false, Ordering::Release);
break 'main;
}
AppEvent::ClearUi => {
terminal.clear()?;
if let AppState::Chat { widget } = &mut self.app_state {
widget.abort_active_turn_for_new_chat();
}
let mut new_widget = ChatWidget::new(
self.config.clone(),
self.app_event_tx.clone(),
None,
Vec::new(),
self.enhanced_keys_supported,
self.terminal_info.clone(),
self.show_order_overlay,
self.latest_upgrade_version.clone(),
);
new_widget.enable_perf(self.timing_enabled);
new_widget.set_standard_terminal_mode(!self.alt_screen_active);
self.app_state = AppState::Chat { widget: Box::new(new_widget) };
self.terminal_runs.clear();
self.app_event_tx.send(AppEvent::RequestRedraw);
}
AppEvent::CancelRunningTask => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.cancel_running_task_from_approval();
@@ -1179,7 +1200,24 @@ impl App<'_> {
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
}
}
SlashCommand::Quit => { break 'main; }
SlashCommand::Quit | SlashCommand::Exit => { break 'main; }
SlashCommand::Clear => {
if let AppState::Chat { widget } = &mut self.app_state {
if widget.is_task_running() {
widget.history_push_plain_state(history_cell::new_error_event(
"'/clear' is disabled while a task is in progress.".to_string(),
));
self.app_event_tx.send(AppEvent::RequestRedraw);
} else {
self.app_event_tx.send(AppEvent::ClearUi);
}
}
}
SlashCommand::Copy => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.copy_last_agent_markdown();
}
}
SlashCommand::Login => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_login_command();
+3
View File
@@ -181,6 +181,9 @@ pub(crate) enum AppEvent {
/// Request to exit the application gracefully.
ExitRequest,
/// Clear the terminal UI and start a fresh chat.
ClearUi,
/// Forward an `Op` to the Agent. Using an `AppEvent` for this avoids
/// bubbling channels through layers of widgets.
CodexOp(code_core::protocol::Op),
+1
View File
@@ -48,6 +48,7 @@ impl AppEventSender {
| AppEvent::RequestRedraw
| AppEvent::Redraw
| AppEvent::ExitRequest
| AppEvent::ClearUi
| AppEvent::SetTerminalTitle { .. }
| AppEvent::EmitTuiNotification { .. }
| AppEvent::AutoCoordinatorCountdown { .. }
+40
View File
@@ -1856,6 +1856,7 @@ pub(crate) struct ChatWidget<'a> {
login_add_view_state: Option<Weak<RefCell<LoginAddAccountState>>>,
active_exec_cell: Option<ExecCell>,
history_cells: Vec<Box<dyn HistoryCell>>, // Store all history in memory
clipboard_lease: Option<crate::clipboard_copy::ClipboardLease>,
history_cell_ids: Vec<Option<HistoryId>>,
history_live_window: Option<(usize, usize)>,
history_frozen_width: u16,
@@ -6979,6 +6980,7 @@ impl ChatWidget<'_> {
login_add_view_state: None,
active_exec_cell: None,
history_cells,
clipboard_lease: None,
config: config.clone(),
mcp_tools_by_server: HashMap::new(),
mcp_server_failures: HashMap::new(),
@@ -7354,6 +7356,7 @@ impl ChatWidget<'_> {
login_add_view_state: None,
active_exec_cell: None,
history_cells,
clipboard_lease: None,
config: config.clone(),
mcp_tools_by_server: HashMap::new(),
mcp_server_failures: HashMap::new(),
@@ -17125,6 +17128,43 @@ impl ChatWidget<'_> {
self.show_settings_overlay(Some(SettingsSection::Skills));
}
/// Copy the last assistant response (raw markdown) to the system clipboard.
pub(crate) fn copy_last_agent_markdown(&mut self) {
self.copy_last_agent_markdown_with(crate::clipboard_copy::copy_to_clipboard);
}
fn copy_last_agent_markdown_with(
&mut self,
copy_fn: impl FnOnce(&str) -> Result<Option<crate::clipboard_copy::ClipboardLease>, String>,
) {
let markdown = self.history_cells.iter().rev().find_map(|cell| {
cell.as_any()
.downcast_ref::<history_cell::AssistantMarkdownCell>()
.map(|assistant| assistant.markdown().to_string())
.filter(|text| !text.is_empty())
});
match markdown {
Some(markdown) => match copy_fn(&markdown) {
Ok(lease) => {
self.clipboard_lease = lease;
self.history_push_plain_state(history_cell::plain_message_state_from_lines(
vec![Line::from("Copied last message to clipboard")],
crate::history_cell::HistoryCellType::Notice,
));
}
Err(error) => self.history_push_plain_state(history_cell::new_error_event(
format!("Copy failed: {error}"),
)),
},
None => self.history_push_plain_state(history_cell::new_error_event(
"No agent response to copy".to_string(),
)),
}
self.request_redraw();
}
#[allow(dead_code)]
pub(crate) fn add_agents_output(&mut self) {
use ratatui::text::Line;
+335
View File
@@ -0,0 +1,335 @@
//! Clipboard copy backend for the TUI's `/copy` command.
//!
//! The selection order mirrors upstream Codex: SSH uses OSC 52, local sessions
//! try the native clipboard first, WSL falls back to PowerShell, then OSC 52.
use base64::Engine;
use std::io::Write;
const OSC52_MAX_RAW_BYTES: usize = 100_000;
#[cfg(target_os = "macos")]
static STDERR_SUPPRESSION_MUTEX: std::sync::OnceLock<std::sync::Mutex<()>> =
std::sync::OnceLock::new();
pub(crate) fn copy_to_clipboard(text: &str) -> Result<Option<ClipboardLease>, String> {
copy_to_clipboard_with(
text,
is_ssh_session(),
is_wsl_session(),
osc52_copy,
arboard_copy,
wsl_clipboard_copy,
)
}
pub(crate) struct ClipboardLease {
#[cfg(target_os = "linux")]
_clipboard: Option<arboard::Clipboard>,
}
impl ClipboardLease {
#[cfg(target_os = "linux")]
fn native_linux(clipboard: arboard::Clipboard) -> Self {
Self {
_clipboard: Some(clipboard),
}
}
#[cfg(test)]
pub(crate) fn test() -> Self {
Self {
#[cfg(target_os = "linux")]
_clipboard: None,
}
}
}
fn copy_to_clipboard_with(
text: &str,
ssh_session: bool,
wsl_session: bool,
osc52_copy_fn: impl Fn(&str) -> Result<(), String>,
arboard_copy_fn: impl Fn(&str) -> Result<Option<ClipboardLease>, String>,
wsl_copy_fn: impl Fn(&str) -> Result<(), String>,
) -> Result<Option<ClipboardLease>, String> {
if ssh_session {
return osc52_copy_fn(text).map(|()| None).map_err(|osc_err| {
tracing::warn!("OSC 52 clipboard copy failed over SSH: {osc_err}");
format!("OSC 52 clipboard copy failed over SSH: {osc_err}")
});
}
match arboard_copy_fn(text) {
Ok(lease) => Ok(lease),
Err(native_err) => {
if wsl_session {
tracing::warn!(
"native clipboard copy failed: {native_err}, falling back to WSL PowerShell"
);
match wsl_copy_fn(text) {
Ok(()) => return Ok(None),
Err(wsl_err) => {
tracing::warn!(
"WSL PowerShell clipboard copy failed: {wsl_err}, falling back to OSC 52"
);
return osc52_copy_fn(text).map(|()| None).map_err(|osc_err| {
format!(
"native clipboard: {native_err}; WSL fallback: {wsl_err}; OSC 52 fallback: {osc_err}"
)
});
}
}
}
tracing::warn!("native clipboard copy failed: {native_err}, falling back to OSC 52");
osc52_copy_fn(text).map(|()| None).map_err(|osc_err| {
format!("native clipboard: {native_err}; OSC 52 fallback: {osc_err}")
})
}
}
}
fn is_ssh_session() -> bool {
std::env::var_os("SSH_TTY").is_some() || std::env::var_os("SSH_CONNECTION").is_some()
}
#[cfg(target_os = "linux")]
fn is_wsl_session() -> bool {
if std::env::var_os("WSL_DISTRO_NAME").is_some()
|| std::env::var_os("WSL_INTEROP").is_some()
{
return true;
}
std::fs::read_to_string("/proc/sys/kernel/osrelease")
.map(|release| release.to_ascii_lowercase().contains("microsoft"))
.unwrap_or(false)
}
#[cfg(not(target_os = "linux"))]
fn is_wsl_session() -> bool {
false
}
#[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
fn arboard_copy(text: &str) -> Result<Option<ClipboardLease>, String> {
#[cfg(target_os = "macos")]
let _stderr_lock = STDERR_SUPPRESSION_MUTEX
.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.map_err(|_| "stderr suppression lock poisoned".to_string())?;
let _guard = SuppressStderr::new();
let mut clipboard =
arboard::Clipboard::new().map_err(|e| format!("clipboard unavailable: {e}"))?;
clipboard
.set_text(text)
.map_err(|e| format!("failed to set clipboard text: {e}"))?;
Ok(None)
}
#[cfg(target_os = "linux")]
fn arboard_copy(text: &str) -> Result<Option<ClipboardLease>, String> {
let _guard = SuppressStderr::new();
let mut clipboard =
arboard::Clipboard::new().map_err(|e| format!("clipboard unavailable: {e}"))?;
clipboard
.set_text(text)
.map_err(|e| format!("failed to set clipboard text: {e}"))?;
Ok(Some(ClipboardLease::native_linux(clipboard)))
}
#[cfg(target_os = "android")]
fn arboard_copy(_text: &str) -> Result<Option<ClipboardLease>, String> {
Err("native clipboard unavailable on Android".to_string())
}
#[cfg(target_os = "linux")]
fn wsl_clipboard_copy(text: &str) -> Result<(), String> {
let mut child = std::process::Command::new("powershell.exe")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.args([
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; $ErrorActionPreference = 'Stop'; $text = [Console]::In.ReadToEnd(); Set-Clipboard -Value $text",
])
.spawn()
.map_err(|e| format!("failed to spawn powershell.exe: {e}"))?;
let Some(mut stdin) = child.stdin.take() else {
let _ = child.kill();
let _ = child.wait();
return Err("failed to open powershell.exe stdin".to_string());
};
if let Err(err) = stdin.write_all(text.as_bytes()) {
let _ = child.kill();
let _ = child.wait();
return Err(format!("failed to write to powershell.exe: {err}"));
}
drop(stdin);
let output = child
.wait_with_output()
.map_err(|e| format!("failed to wait for powershell.exe: {e}"))?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
let status = output.status;
Err(format!("powershell.exe exited with status {status}"))
} else {
Err(format!("powershell.exe failed: {stderr}"))
}
}
}
#[cfg(not(target_os = "linux"))]
fn wsl_clipboard_copy(_text: &str) -> Result<(), String> {
Err("WSL clipboard fallback unavailable on this platform".to_string())
}
#[cfg(target_os = "macos")]
struct SuppressStderr {
saved_fd: Option<libc::c_int>,
}
#[cfg(target_os = "macos")]
impl SuppressStderr {
fn new() -> Self {
unsafe {
let saved = libc::dup(2);
if saved < 0 {
return Self { saved_fd: None };
}
let devnull = libc::open(c"/dev/null".as_ptr(), libc::O_WRONLY);
if devnull < 0 {
libc::close(saved);
return Self { saved_fd: None };
}
if libc::dup2(devnull, 2) < 0 {
libc::close(saved);
libc::close(devnull);
return Self { saved_fd: None };
}
libc::close(devnull);
Self {
saved_fd: Some(saved),
}
}
}
}
#[cfg(target_os = "macos")]
impl Drop for SuppressStderr {
fn drop(&mut self) {
if let Some(saved) = self.saved_fd {
unsafe {
libc::dup2(saved, 2);
libc::close(saved);
}
}
}
}
#[cfg(not(target_os = "macos"))]
struct SuppressStderr;
#[cfg(not(target_os = "macos"))]
impl SuppressStderr {
fn new() -> Self {
Self
}
}
fn osc52_copy(text: &str) -> Result<(), String> {
let sequence = osc52_sequence(text, std::env::var_os("TMUX").is_some())?;
#[cfg(unix)]
{
match std::fs::OpenOptions::new().write(true).open("/dev/tty") {
Ok(tty) => match write_osc52_to_writer(tty, &sequence) {
Ok(()) => return Ok(()),
Err(err) => tracing::debug!(
"failed to write OSC 52 to /dev/tty: {err}; falling back to stdout"
),
},
Err(err) => {
tracing::debug!("failed to open /dev/tty for OSC 52: {err}; falling back to stdout")
}
}
}
write_osc52_to_writer(std::io::stdout().lock(), &sequence)
}
fn write_osc52_to_writer(mut writer: impl Write, sequence: &str) -> Result<(), String> {
writer
.write_all(sequence.as_bytes())
.map_err(|e| format!("failed to write OSC 52: {e}"))?;
writer
.flush()
.map_err(|e| format!("failed to flush OSC 52: {e}"))
}
fn osc52_sequence(text: &str, tmux: bool) -> Result<String, String> {
let raw_bytes = text.len();
if raw_bytes > OSC52_MAX_RAW_BYTES {
return Err(format!(
"OSC 52 payload too large ({raw_bytes} bytes; max {OSC52_MAX_RAW_BYTES})"
));
}
let encoded = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
if tmux {
Ok(format!("\x1bPtmux;\x1b\x1b]52;c;{encoded}\x07\x1b\\"))
} else {
Ok(format!("\x1b]52;c;{encoded}\x07"))
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::OSC52_MAX_RAW_BYTES;
use super::osc52_sequence;
use super::write_osc52_to_writer;
#[test]
fn osc52_encoding_roundtrips() {
use base64::Engine;
let text = "# Hello\n\n```rust\nfn main() {}\n```\n";
let sequence = osc52_sequence(text, false).expect("OSC 52 sequence");
let encoded = sequence
.trim_start_matches("\u{1b}]52;c;")
.trim_end_matches('\u{7}');
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.unwrap();
assert_eq!(decoded, text.as_bytes());
}
#[test]
fn osc52_rejects_payload_larger_than_limit() {
let text = "x".repeat(OSC52_MAX_RAW_BYTES + 1);
assert_eq!(
osc52_sequence(&text, false),
Err(format!(
"OSC 52 payload too large ({} bytes; max {OSC52_MAX_RAW_BYTES})",
OSC52_MAX_RAW_BYTES + 1
))
);
}
#[test]
fn write_osc52_to_writer_emits_sequence_verbatim() {
let sequence = "\u{1b}]52;c;aGVsbG8=\u{7}";
let mut output = Vec::new();
assert_eq!(write_osc52_to_writer(&mut output, sequence), Ok(()));
assert_eq!(output, sequence.as_bytes());
}
}
+1
View File
@@ -119,6 +119,7 @@ mod foundation;
mod ui_consts;
mod user_approval_widget;
mod height_manager;
mod clipboard_copy;
mod clipboard_paste;
mod greeting;
// Upstream introduced a standalone status indicator widget. Our fork renders
+24 -2
View File
@@ -53,11 +53,13 @@ pub enum SlashCommand {
Browser,
Chrome,
New,
Clear,
Init,
Compact,
Undo,
Review,
Cloud,
Copy,
Diff,
Mention,
Cmd,
@@ -92,6 +94,7 @@ pub enum SlashCommand {
Code,
Logout,
Quit,
Exit,
#[cfg(debug_assertions)]
TestApproval,
}
@@ -115,7 +118,9 @@ impl SlashCommand {
SlashCommand::Undo => "restore the workspace to the last Code snapshot",
SlashCommand::Review => "review your changes for potential issues",
SlashCommand::Cloud => "browse, apply, and create cloud tasks",
SlashCommand::Quit => "exit Code",
SlashCommand::Quit | SlashCommand::Exit => "exit Code",
SlashCommand::Clear => "clear the terminal and start a new chat",
SlashCommand::Copy => "copy last response as markdown",
SlashCommand::Diff => "show git diff (including untracked files)",
SlashCommand::Mention => "mention a file",
SlashCommand::Cmd => "run a project command",
@@ -237,7 +242,7 @@ pub fn process_slash_command_message(message: &str) -> ProcessedCommand {
let args_raw = parts.next().map(|s| s.trim()).unwrap_or("");
let canonical_command = command_str.to_ascii_lowercase();
if matches!(canonical_command.as_str(), "quit" | "exit") {
if !has_slash && matches!(canonical_command.as_str(), "quit" | "exit") {
if !has_slash && !args_raw.is_empty() {
return ProcessedCommand::NotCommand(message.to_string());
}
@@ -347,4 +352,21 @@ mod tests {
other => panic!("expected RegularCommand, got {:?}", other),
}
}
#[test]
fn upstream_compat_commands_are_regular_commands() {
for (input, expected) in [
("/exit", SlashCommand::Exit),
("/clear", SlashCommand::Clear),
("/copy", SlashCommand::Copy),
] {
match process_slash_command_message(input) {
ProcessedCommand::RegularCommand(command, command_text) => {
assert_eq!(command, expected);
assert_eq!(command_text, input);
}
other => panic!("expected RegularCommand for {input}, got {:?}", other),
}
}
}
}
+3
View File
@@ -16,9 +16,11 @@ Notes
- `/browser`: open internal browser.
- `/chrome`: connect to your Chrome browser.
- `/new`: start a new chat during a conversation.
- `/clear`: clear the terminal and start a new chat.
- `/resume`: resume a past session for this folder.
- `/rename <name>`: rename the current session (shown in the resume list).
- `/quit`: exit Code.
- `/exit`: exit Code.
- `/logout`: log out of Code.
- `/login`: manage Code sign-ins (select, add, or disconnect accounts).
- `/settings [section]`: open the settings panel. Optional section argument
@@ -29,6 +31,7 @@ Notes
- `/init`: create an `AGENTS.md` file with instructions for Code.
- `/diff`: show `git diff` (including untracked files).
- `/copy`: copy the last assistant response as markdown.
- `/undo`: open a snapshot picker so you can restore workspace files to a
previous Code snapshot and optionally rewind the conversation to that point.
- `/branch [task]`: create a worktree branch and switch to it. If a