diff --git a/CHANGELOG.md b/CHANGELOG.md index d87cc4c..a32cac1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ All notable changes to Subconscious Code are documented here. This project uses [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added + +- Interactive follow-up queue: press `Tab` during a turn to queue the current + draft, or `Esc` to hand it off after the active tool call. + +### Changed + +- Turn dividers show only elapsed time unless files changed, then add compact + `+N -N` counts without redundant prose. + +### Fixed + +- In-app copy now uses native system clipboards locally and tmux's clipboard + bridge when available, with OSC 52 retained for remote sessions. + ## [0.1.0] - 2026-09-01 Initial public release. @@ -25,4 +42,5 @@ Initial public release. - Benchmark completion review, no-progress handling, and endpoint diagnostics. - Linux sandboxing and fail-closed headless permissions. +[Unreleased]: https://github.com/subconscious-systems/subconscious-code/compare/v0.1.0...HEAD [0.1.0]: https://github.com/subconscious-systems/subconscious-code/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 7354e3c..0d1059c 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,9 @@ sc Inside the TUI, type a request normally. Use `@path` to include a file, `/menu` to edit settings or resume a session, `Shift+Tab` to change permission mode, -`Esc` to interrupt a turn, and `Ctrl+C` to quit. +`Tab` to queue a draft while a turn runs, `Esc` to stop, and `Ctrl+C` to quit. +If a message is queued, `Esc` waits for the current tool call to finish and +then sends it; press `Esc` again to stop immediately. For a non-interactive read-only task: @@ -308,11 +310,12 @@ fields without prompt or tool-result content. The trajectory is an explicit transcript artifact and may contain sensitive task data; review it before sharing. -In the TUI: `Shift+Tab` cycles permission mode, `Esc` cancels a turn, `Ctrl+C` -quits, `@` completes file paths, `/` completes commands (`/menu`, `/clear`, -`/help`, `/mode`, `/rewind`). The status bar shows the model, mode, and current -context tokens/cache-hit rate; a preflight estimate is shown until the provider -returns the authoritative prompt-token count. +In the TUI: `Shift+Tab` cycles permission mode, `Tab` queues a draft during a +turn, `Esc` stops (or sends a queued message after the active tool call), and +`Ctrl+C` quits. `@` completes file paths and `/` completes commands (`/menu`, +`/clear`, `/help`, `/mode`, `/rewind`). The status bar shows the model, mode, +and current context tokens/cache-hit rate; a preflight estimate is shown until +the provider returns the authoritative prompt-token count. ### `/menu` diff --git a/crates/rc-rt/src/action.rs b/crates/rc-rt/src/action.rs index 45cf731..cb5d298 100644 --- a/crates/rc-rt/src/action.rs +++ b/crates/rc-rt/src/action.rs @@ -8,6 +8,9 @@ use rc_core::{AgentMode, AskResponse}; pub enum UserAction { /// Submit a user prompt; the driver runs one turn. Submit(String), + /// Queue a user prompt behind the in-flight turn. If the turn finishes or + /// is cancelled, the queued prompt starts automatically. + Queue(String), /// Cancel the in-flight turn, including its model/tool cancellation budget, /// and deny any pending permission prompt so the turn can terminate. Cancel, diff --git a/crates/rc-rt/src/pump.rs b/crates/rc-rt/src/pump.rs index 9b3e6fe..260e726 100644 --- a/crates/rc-rt/src/pump.rs +++ b/crates/rc-rt/src/pump.rs @@ -1,7 +1,8 @@ //! The action pump: drains `UserAction`s from the host and dispatches them. //! //! - `Submit` starts a turn with a fresh cancel token the pump owns as a local -//! (one task → no shared-slot race with a new turn). +//! (one task → no shared-slot race with a new turn); `Queue` retains a +//! follow-up until that turn reaches its terminal boundary. //! - `Cancel` fires the token and denies any pending ask so the prompter //! unblocks and the turn winds down. //! - `SetMode` swaps the engine mode atomically (immediate), tells the host via @@ -12,6 +13,7 @@ //! driver exit once its current turn finishes). use rc_core::PermissionChecker; +use std::collections::VecDeque; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; @@ -30,6 +32,7 @@ pub(crate) async fn pump_task( ) { let mut active: Option<(u64, CancellationToken)> = None; let mut next_turn_id = 0u64; + let mut queued = VecDeque::new(); loop { let action = tokio::select! { biased; @@ -37,6 +40,13 @@ pub(crate) async fn pump_task( if let Some(DriverFeedback::TurnFinished { turn_id }) = feedback { if active.as_ref().is_some_and(|(active_id, _)| *active_id == turn_id) { active = None; + if let Some(prompt) = queued.pop_front() { + let Some(next) = start_turn(&mut next_turn_id, prompt, &driver_tx).await + else { + break; + }; + active = Some(next); + } } } continue; @@ -52,19 +62,19 @@ pub(crate) async fn pump_task( )); continue; } - next_turn_id = next_turn_id.wrapping_add(1); - let token = CancellationToken::new(); - active = Some((next_turn_id, token.clone())); - if driver_tx - .send(DriverCmd::Run { - turn_id: next_turn_id, - prompt, - cancel: token, - }) - .await - .is_err() - { + let Some(next) = start_turn(&mut next_turn_id, prompt, &driver_tx).await else { break; + }; + active = Some(next); + } + UserAction::Queue(prompt) => { + if active.is_some() { + queued.push_back(prompt); + } else { + let Some(next) = start_turn(&mut next_turn_id, prompt, &driver_tx).await else { + break; + }; + active = Some(next); } } UserAction::Cancel => { @@ -131,3 +141,22 @@ pub(crate) async fn pump_task( } } } + +async fn start_turn( + next_turn_id: &mut u64, + prompt: String, + driver_tx: &mpsc::Sender, +) -> Option<(u64, CancellationToken)> { + *next_turn_id = next_turn_id.wrapping_add(1); + let turn_id = *next_turn_id; + let token = CancellationToken::new(); + driver_tx + .send(DriverCmd::Run { + turn_id, + prompt, + cancel: token.clone(), + }) + .await + .ok()?; + Some((turn_id, token)) +} diff --git a/crates/rc-rt/src/runtime.rs b/crates/rc-rt/src/runtime.rs index 9700246..38dff73 100644 --- a/crates/rc-rt/src/runtime.rs +++ b/crates/rc-rt/src/runtime.rs @@ -123,10 +123,20 @@ impl Runtime { /// Push a user action (sync — safe from any thread/task). pub fn action(&self, action: UserAction) { + self.try_action(action); + } + + /// Try to push a user action, returning whether the runtime accepted it. + /// Interactive hosts use this when they must only mutate local UI state + /// after the matching action is safely in the runtime queue. + pub fn try_action(&self, action: UserAction) -> bool { if self.actions_tx.try_send(action).is_err() { self.events_tx.send(AgentEvent::Notice( "runtime action queue is full; input was not accepted".into(), )); + false + } else { + true } } diff --git a/crates/rc-rt/tests/runtime.rs b/crates/rc-rt/tests/runtime.rs index 080b553..09390e2 100644 --- a/crates/rc-rt/tests/runtime.rs +++ b/crates/rc-rt/tests/runtime.rs @@ -4,6 +4,7 @@ //! `rc-cli/tests/*.rs`. use std::collections::VecDeque; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -322,6 +323,149 @@ async fn duplicate_submit_is_rejected_until_the_active_turn_finishes() { rt.shutdown().await; } +#[tokio::test] +async fn queued_prompt_starts_after_the_active_turn_finishes() { + struct QueuedModel { + calls: AtomicUsize, + entered: Arc, + release: Arc, + } + + #[async_trait] + impl Model for QueuedModel { + async fn complete( + &self, + _req: ModelRequest, + sink: &dyn EventSink, + ) -> Result { + let call = self.calls.fetch_add(1, Ordering::SeqCst); + if call == 0 { + self.entered.notify_one(); + self.release.notified().await; + } + let text = if call == 0 { + "first finished" + } else { + "queued finished" + }; + sink.on_text(text); + Ok(resp_stop(text)) + } + } + + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let rt = Runtime::new( + agent( + Arc::new(QueuedModel { + calls: AtomicUsize::new(0), + entered: entered.clone(), + release: release.clone(), + }), + Arc::new(ToolRegistry::new(vec![])), + Arc::new(AllowAllChecker), + ), + session(), + None, + ); + let mut rx = rt.subscribe(); + rt.action(UserAction::Submit("first".into())); + entered.notified().await; + rt.action(UserAction::Queue("follow up".into())); + release.notify_one(); + + let got = tokio::time::timeout(Duration::from_secs(2), async { + let mut events = Vec::new(); + let mut idles = 0; + while idles < 2 { + match rx.recv().await { + Some(Ok(event)) => { + if matches!(event, AgentEvent::Idle) { + idles += 1; + } + events.push(event); + } + Some(Err(_)) => {} + None => panic!("event stream closed before queued turn"), + } + } + events + }) + .await + .expect("queued turn timed out"); + + assert_eq!( + got.iter() + .filter(|event| matches!(event, AgentEvent::Ready)) + .count(), + 2 + ); + assert!(got + .iter() + .any(|event| matches!(event, AgentEvent::Text(text) if text == "first finished"))); + assert!(got + .iter() + .any(|event| matches!(event, AgentEvent::Text(text) if text == "queued finished"))); + rt.shutdown().await; +} + +#[tokio::test] +async fn cancelling_an_active_turn_preserves_and_starts_its_queue() { + struct CancelThenRunModel { + calls: AtomicUsize, + entered: Arc, + } + + #[async_trait] + impl Model for CancelThenRunModel { + async fn complete( + &self, + _req: ModelRequest, + sink: &dyn EventSink, + ) -> Result { + if self.calls.fetch_add(1, Ordering::SeqCst) == 0 { + self.entered.notify_one(); + std::future::pending::<()>().await; + unreachable!(); + } + sink.on_text("queue survived cancellation"); + Ok(resp_stop("queue survived cancellation")) + } + } + + let entered = Arc::new(tokio::sync::Notify::new()); + let rt = Runtime::new( + agent( + Arc::new(CancelThenRunModel { + calls: AtomicUsize::new(0), + entered: entered.clone(), + }), + Arc::new(ToolRegistry::new(vec![])), + Arc::new(AllowAllChecker), + ), + session(), + None, + ); + let mut rx = rt.subscribe(); + rt.action(UserAction::Submit("first".into())); + entered.notified().await; + rt.action(UserAction::Queue("follow up".into())); + rt.action(UserAction::Cancel); + + let got = tokio::time::timeout(Duration::from_secs(2), async { + drain_until(&mut rx, |event| { + matches!(event, AgentEvent::Text(text) if text == "queue survived cancellation") + }) + .await + }) + .await + .expect("queued turn did not start after cancellation"); + assert!(got + .iter() + .any(|event| matches!(event, AgentEvent::Outcome(LoopOutcome::Cancelled)))); + rt.shutdown().await; +} + #[tokio::test] async fn file_change_artifact_arrives_before_its_tool_end() { let tools = Arc::new(ToolRegistry::new(vec![ diff --git a/crates/rc-tui/src/app.rs b/crates/rc-tui/src/app.rs index a10cacd..ffca22c 100644 --- a/crates/rc-tui/src/app.rs +++ b/crates/rc-tui/src/app.rs @@ -6,7 +6,7 @@ //! `Runtime`/`EventStream` are kept separate so a `ratatui::backend::TestBackend` //! render test needs no tokio and no model. -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; @@ -80,6 +80,12 @@ pub(crate) struct App { /// the fallback for terminals/multiplexers that turn a multiline paste /// into a burst of Char/Enter events instead of one bracketed Paste event. pending_submit: Option, + /// Prompts accepted by the runtime queue. They render only when `Ready` + /// follows the previous turn's terminal boundary. + queued_prompts: VecDeque, + /// Esc with a queued prompt waits for the current parallel tool batch to + /// finish, then cancels the turn so the runtime starts that prompt. + send_queued_after_tool: bool, } impl App { @@ -109,6 +115,8 @@ impl App { live_tools: HashMap::new(), outcome: None, pending_submit: None, + queued_prompts: VecDeque::new(), + send_queued_after_tool: false, } } } @@ -188,6 +196,19 @@ impl App { } fn apply(&mut self, ev: AgentEvent) { + // A queued prompt belongs in transcript/history at its real turn + // boundary, not when Tab is pressed in the middle of the prior answer. + if matches!(&ev, AgentEvent::Ready) && !self.view.busy { + if let Some(prompt) = self.queued_prompts.pop_front() { + self.record_prompt(&prompt); + self.begin_turn_display(); + self.view.queued_messages = self.queued_prompts.len(); + self.send_queued_after_tool = false; + self.view.queued_after_tool = false; + } + } + + let mut cancel_after_tool = false; let v = &mut self.view; match ev { AgentEvent::Text(t) => { @@ -274,6 +295,11 @@ impl App { if v.running == 0 { v.running_tool = None; v.begin_reasoning_phase(Instant::now()); + if self.send_queued_after_tool { + self.send_queued_after_tool = false; + v.queued_after_tool = false; + cancel_after_tool = true; + } } } AgentEvent::Artifact { @@ -360,6 +386,9 @@ impl App { finish_turn(v); } } + if cancel_after_tool { + self.runtime.action(UserAction::Cancel); + } } /// Keys while the `/menu` modal is open. @@ -498,6 +527,22 @@ impl App { // While an ask is open, only the answer keys are live; Enter is a no-op. if let Some(ask) = self.view.pending_ask.take() { + if key.code == KeyCode::Esc { + match esc_action(&self.view) { + EscAction::QueueAfterTool => { + self.arm_queued_after_tool(); + // Denial completes this tool call, whose ToolEnd is the + // handoff boundary that starts the queued prompt. + self.runtime.action(UserAction::PermissionAnswer { + id: ask.id, + response: AskResponse::Deny("declined".into()), + }); + } + EscAction::Cancel => self.cancel_active_turn(), + _ => self.view.pending_ask = Some(ask), + } + return; + } let response = match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => Some(AskResponse::Once), KeyCode::Char('s') | KeyCode::Char('S') => { @@ -506,7 +551,7 @@ impl App { KeyCode::Char('a') | KeyCode::Char('A') => { Some(AskResponse::Always(suggested_rule(&ask.tool, &ask.input))) } - KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + KeyCode::Char('n') | KeyCode::Char('N') => { Some(AskResponse::Deny("declined".into())) } _ => None, @@ -631,7 +676,8 @@ impl App { // Esc is overloaded by state, and the ordering matters: a // drafted prompt must never be lost to a stray Esc. See // [`esc_action`] for the decision table. - EscAction::Cancel => self.runtime.action(UserAction::Cancel), + EscAction::QueueAfterTool => self.arm_queued_after_tool(), + EscAction::Cancel => self.cancel_active_turn(), EscAction::RestoreDraft => { // Browsing history → return to the live draft, not clear it. self.view.history_pos = None; @@ -651,6 +697,7 @@ impl App { self.quit = true; } }, + KeyCode::Tab if self.view.busy => self.queue_composer(), KeyCode::BackTab => { // Shift+Tab: cycle the permission mode // (Default -> AcceptEdits -> Plan -> Ask -> Auto -> Default). @@ -1050,23 +1097,25 @@ impl App { self.refresh_menu(); } - /// Submit `text` to the model as a normal user turn: echo the prompt line, - /// mark the turn in flight (so the spinner shows the instant Enter is - /// pressed, before the driver is Ready), and dispatch `UserAction::Submit`. - /// Shared by the plain-prompt path and the prompt-expansion slash commands. - fn submit_prompt(&mut self, text: String) { - self.view.transcript.push(user_prompt_line(&text)); + /// Echo a prompt at its actual turn boundary and retain it for history. + fn record_prompt(&mut self, text: &str) { + self.view.transcript.push(user_prompt_line(text)); // Record the prompt for Alt+↑/↓ recall (deduped, bash-style), and leave // history-browsing mode — a fresh submit always returns to the live // draft. - push_history(&mut self.view.prompt_history, text.clone()); + push_history(&mut self.view.prompt_history, text.to_owned()); self.view.history_pos = None; self.view.history_draft.clear(); // Persist the prompt for cross-session recall. Best-effort — a failed // append never blocks the turn. if let Some(p) = sc_history_path() { - append_history(&p, &text); + append_history(&p, text); } + } + + /// Optimistically mark a turn in flight so the spinner appears without + /// waiting for the driver's `Ready` event. + fn begin_turn_display(&mut self) { // Optimistically mark the turn in flight so the "thinking" indicator // appears the instant Enter is pressed — there's a real gap between // Submit and the driver's Ready during which the screen would @@ -1085,7 +1134,59 @@ impl App { self.view.context_tokens = None; self.view.context_tokens_estimated = false; self.view.cache_hit_rate = None; - self.runtime.action(UserAction::Submit(text)); + } + + /// Submit `text` to the model as a normal user turn: echo the prompt line, + /// mark the turn in flight, and dispatch `UserAction::Submit`. + fn submit_prompt(&mut self, text: String) { + if !self.runtime.try_action(UserAction::Submit(text.clone())) { + return; + } + self.record_prompt(&text); + self.begin_turn_display(); + } + + /// Queue the current draft behind the running turn. The runtime owns the + /// execution queue; this mirror exists only to render each prompt when its + /// turn starts rather than interleaving it with an active answer. + fn queue_composer(&mut self) { + if self.view.composer.is_empty() { + return; + } + let text = self.view.composer.clone(); + if !self.runtime.try_action(UserAction::Queue(text.clone())) { + return; + } + self.view.composer.clear(); + self.view.clear_paste_markers(); + self.view.history_pos = None; + self.view.history_draft.clear(); + self.queued_prompts.push_back(text); + self.view.queued_messages = self.queued_prompts.len(); + let count = self.view.queued_messages; + let noun = if count == 1 { "message" } else { "messages" }; + self.view.transcript.push(Line::styled( + format!("· queued {count} {noun} — Esc sends after the next tool call"), + dim_style(), + )); + self.refresh_menu(); + self.jump_to_bottom(); + } + + fn arm_queued_after_tool(&mut self) { + self.send_queued_after_tool = true; + self.view.queued_after_tool = true; + self.view.transcript.push(Line::styled( + "· queued message will send after the current tool call".to_string(), + dim_style(), + )); + self.jump_to_bottom(); + } + + fn cancel_active_turn(&mut self) { + self.send_queued_after_tool = false; + self.view.queued_after_tool = false; + self.runtime.action(UserAction::Cancel); } /// Push a styled info block: one accent heading line, then chrome body @@ -1139,6 +1240,9 @@ impl App { self.view .transcript .push(mk(" Shift+Tab cycle permission mode")); + self.view + .transcript + .push(mk(" Tab queue a draft during an active turn")); self.view .transcript .push(mk(" PgUp/PgDn scroll the transcript")); @@ -1155,7 +1259,7 @@ impl App { .transcript .push(mk(" Ctrl+W / U delete word / clear the line")); self.view.transcript.push(mk( - " Esc interrupt a turn · clear a draft · quit when idle", + " Esc stop · send queued after tool · clear · quit", )); self.view.transcript.push(mk(" Ctrl+C quit")); self.view.transcript.push(mk( @@ -1585,6 +1689,7 @@ fn resolve_staged_submit( /// (never quit and lose a drafted prompt to a stray Esc) is testable with no /// `Runtime`. Priority: /// +/// busy + queue → QueueAfterTool (a second Esc cancels immediately) /// busy → Cancel the in-flight turn /// browsing hist → RestoreDraft (return to the live draft, not clear it) /// draft present → Clear the composer (a second Esc, now empty, quits) @@ -1593,7 +1698,9 @@ fn resolve_staged_submit( /// The menu and ask handlers `return` before the keymap reaches `Esc`, so this /// only fires on a bare Esc with neither overlay open. fn esc_action(state: &crate::view::ViewState) -> EscAction { - if state.busy { + if state.busy && state.queued_messages > 0 && !state.queued_after_tool { + EscAction::QueueAfterTool + } else if state.busy { EscAction::Cancel } else if state.history_pos.is_some() { EscAction::RestoreDraft @@ -1607,6 +1714,7 @@ fn esc_action(state: &crate::view::ViewState) -> EscAction { /// The resolved effect of an `Esc` press. See [`esc_action`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum EscAction { + QueueAfterTool, Cancel, RestoreDraft, Clear, @@ -2198,30 +2306,108 @@ fn git_branch(dir: &Path) -> Option { None } -/// Put `text` on the *user's* clipboard with OSC 52. +/// Put `text` on the user's clipboard. /// -/// Not a clipboard crate on purpose: `sc` is routinely run over SSH, where the -/// process has no access to the clipboard the user is actually pasting into — -/// a local clipboard API would copy into the void on the remote host. OSC 52 -/// travels back down the same terminal connection and the terminal emulator -/// does the copying, so it works identically local and remote. +/// Local sessions prefer the OS clipboard because terminals may silently block +/// OSC 52. tmux gets its purpose-built clipboard bridge. SSH and unsupported +/// local environments fall back to OSC 52, which travels through the terminal +/// connection to the clipboard on the user's machine. /// /// Inside tmux the sequence has to be wrapped in a DCS passthrough (and its /// ESCs doubled) or tmux swallows it. Terminals that refuse OSC 52 for /// security drop it silently — there is no reply to wait for — which is why /// select mode (Ctrl+O) stays as the fallback. fn copy_to_clipboard(text: &str) -> std::io::Result<()> { + if std::env::var_os("TMUX").is_some() + && copy_with_command("tmux", &["load-buffer", "-w", "-"], text).is_ok() + { + return Ok(()); + } + let remote = + std::env::var_os("SSH_CONNECTION").is_some() || std::env::var_os("SSH_TTY").is_some(); + if !remote && copy_to_system_clipboard(text).is_ok() { + return Ok(()); + } + copy_with_osc52(text) +} + +fn copy_with_osc52(text: &str) -> std::io::Result<()> { use std::io::Write; - let osc = format!("\x1b]52;c;{}\x07", base64(text.as_bytes())); - let seq = match std::env::var_os("TMUX") { - Some(_) => format!("\x1bPtmux;{}\x1b\\", osc.replace('\x1b', "\x1b\x1b")), - None => osc, - }; + let seq = osc52_sequence(text, std::env::var_os("TMUX").is_some()); let mut out = std::io::stdout(); out.write_all(seq.as_bytes())?; out.flush() } +fn osc52_sequence(text: &str, tmux: bool) -> String { + let osc = format!("\x1b]52;c;{}\x07", base64(text.as_bytes())); + if tmux { + format!("\x1bPtmux;{}\x1b\\", osc.replace('\x1b', "\x1b\x1b")) + } else { + osc + } +} + +fn copy_with_command(program: &str, args: &[&str], text: &str) -> std::io::Result<()> { + use std::io::Write; + use std::process::{Command, Stdio}; + + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + child + .stdin + .take() + .ok_or_else(|| std::io::Error::other("clipboard command has no stdin"))? + .write_all(text.as_bytes())?; + let status = child.wait()?; + if status.success() { + Ok(()) + } else { + Err(std::io::Error::other(format!( + "clipboard command exited with {status}" + ))) + } +} + +#[cfg(target_os = "macos")] +fn copy_to_system_clipboard(text: &str) -> std::io::Result<()> { + copy_with_command("pbcopy", &[], text) +} + +#[cfg(target_os = "windows")] +fn copy_to_system_clipboard(text: &str) -> std::io::Result<()> { + copy_with_command("clip.exe", &[], text) +} + +#[cfg(target_os = "linux")] +fn copy_to_system_clipboard(text: &str) -> std::io::Result<()> { + for (program, args) in [ + ("wl-copy", &[][..]), + ("xclip", &["-selection", "clipboard"][..]), + ("xsel", &["--clipboard", "--input"][..]), + ] { + if copy_with_command(program, args, text).is_ok() { + return Ok(()); + } + } + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "no supported system clipboard command", + )) +} + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +fn copy_to_system_clipboard(_text: &str) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "no system clipboard integration for this platform", + )) +} + /// Standard-alphabet base64 with padding (RFC 4648). /// /// Hand-rolled because OSC 52 is the only thing in the workspace that needs @@ -2367,7 +2553,8 @@ mod tests { .map(|span| span.content.as_ref()) .collect(); assert!(divider.contains("worked for 1m 15s"), "{divider}"); - assert!(divider.contains("3 lines changed (+2 -1)"), "{divider}"); + assert!(divider.contains("· +2 -1"), "{divider}"); + assert!(!divider.contains("changed"), "{divider}"); assert!(view.turn_file_changes.is_empty()); assert!(!view.busy); @@ -2490,6 +2677,15 @@ mod tests { assert_eq!(base64("é".as_bytes()), "w6k="); } + #[test] + fn osc52_sequence_supports_direct_and_tmux_terminals() { + let direct = osc52_sequence("copy me", false); + assert_eq!(direct, "\x1b]52;c;Y29weSBtZQ==\x07"); + + let tmux = osc52_sequence("copy me", true); + assert_eq!(tmux, "\x1bPtmux;\x1b\x1b]52;c;Y29weSBtZQ==\x07\x1b\\"); + } + /// A selection reads the same whichever way it was dragged. #[test] fn selection_orders_row_major_in_both_directions() { @@ -3195,9 +3391,18 @@ mod tests { s.composer = "half-typed prompt".into(); assert_eq!(esc_action(&s), EscAction::Clear); - // Busy always cancels the turn, even with a draft present. + // Busy with no queue cancels the turn, even with a draft present. s.busy = true; assert_eq!(esc_action(&s), EscAction::Cancel); + + // A queued follow-up makes the first Esc wait for the current tool + // boundary; once armed, a second Esc still cancels immediately. + s.queued_messages = 1; + assert_eq!(esc_action(&s), EscAction::QueueAfterTool); + s.queued_after_tool = true; + assert_eq!(esc_action(&s), EscAction::Cancel); + s.queued_messages = 0; + s.queued_after_tool = false; s.busy = false; // Browsing history → restore the stashed draft, not clear. diff --git a/crates/rc-tui/src/view.rs b/crates/rc-tui/src/view.rs index 852906b..7193c9d 100644 --- a/crates/rc-tui/src/view.rs +++ b/crates/rc-tui/src/view.rs @@ -175,6 +175,10 @@ pub(crate) struct ViewState { /// Cleared when the next preflight estimate arrives to avoid stale rates. pub cache_hit_rate: Option, pub busy: bool, + /// Follow-up prompts accepted by the runtime but not started yet. + pub queued_messages: usize, + /// Whether Esc has armed cancellation at the end of the current tool batch. + pub queued_after_tool: bool, pub pending_ask: Option, pub composer: String, /// Multiline regions displayed as `[pasted N lines]` instead of expanding @@ -318,6 +322,8 @@ impl ViewState { context_tokens_estimated: false, cache_hit_rate: None, busy: false, + queued_messages: 0, + queued_after_tool: false, pending_ask: None, composer: String::new(), paste_markers: Vec::new(), @@ -1664,7 +1670,7 @@ fn draw_status(frame: &mut Frame, state: &ViewState, area: Rect, now: Instant) { } // Right side: a context-sensitive hint. Scrolled up shows the held-view - // indicator (where the new content is); busy shows the interrupt key; + // indicator (where the new content is); busy shows the stop/queue keys; // idle shows the discoverability hint. Right-aligned so it parks at the // screen edge instead of trailing the left content. let right = right_hint(state); @@ -1705,23 +1711,17 @@ pub(crate) fn turn_divider_line( lines_removed: usize, ) -> Line<'static> { let p = theme::palette(); - let changed = lines_added.saturating_add(lines_removed); - let noun = if changed == 1 { "line" } else { "lines" }; - let label = if changed == 0 { + let label = if lines_added == 0 && lines_removed == 0 { vec![Span::styled( - format!("worked for {duration} · 0 lines changed"), + format!("worked for {duration}"), p.accent_dim(), )] } else { vec![ - Span::styled( - format!("worked for {duration} · {changed} {noun} changed ("), - p.accent_dim(), - ), + Span::styled(format!("worked for {duration} · "), p.accent_dim()), Span::styled(format!("+{lines_added}"), p.semantic(Color::Green)), Span::styled(" ", p.accent_dim()), Span::styled(format!("-{lines_removed}"), p.semantic(Color::Red)), - Span::styled(")", p.accent_dim()), ] }; let mut spans = Vec::with_capacity(label.len() + 2); @@ -1807,9 +1807,30 @@ fn right_hint(state: &ViewState) -> Vec> { return vec![Span::styled(indicator, p.body())]; } if state.busy { + if state.queued_after_tool { + return vec![ + Span::styled("queued after tool", p.body()), + Span::styled(" · Esc ", p.code()), + Span::styled("stop", p.body()), + ]; + } + if state.queued_messages > 0 { + return vec![ + Span::styled("Esc ", p.code()), + Span::styled("send after tool", p.body()), + ]; + } + if !state.composer.is_empty() { + return vec![ + Span::styled("Tab ", p.code()), + Span::styled("queue", p.body()), + Span::styled(" · Esc ", p.code()), + Span::styled("stop", p.body()), + ]; + } return vec![ Span::styled("Esc ", p.code()), - Span::styled("interrupt", p.body()), + Span::styled("stop", p.body()), ]; } // A drafted prompt is one Esc from being cleared (not lost — the second @@ -3669,10 +3690,12 @@ mod tests { assert_eq!(divider.chars().count(), 120, "divider width: {divider:?}"); assert!( - divider.starts_with("─ worked for 12.4s · 0 lines changed ─"), + divider.starts_with("─ worked for 12.4s ─"), "left-aligned duration: {divider:?}" ); - assert!(divider.contains("0 lines changed"), "{divider:?}"); + assert!(!divider.contains("changed"), "{divider:?}"); + assert!(!divider.contains("+0"), "{divider:?}"); + assert!(!divider.contains("-0"), "{divider:?}"); assert!(divider.ends_with('─'), "right terminal edge: {divider:?}"); } @@ -3778,17 +3801,28 @@ mod tests { ); } - /// Busy → the right hint switches to the interrupt affordance. + /// Busy → the right hint switches to the stop affordance. #[test] - fn status_right_hint_shows_interrupt_when_busy() { + fn status_right_hint_shows_stop_when_busy() { let mut state = ViewState::new("m".into()); state.busy = true; let screen = rendered(&mut state); assert!(screen.contains("Esc"), "busy shows Esc: {screen}"); - assert!( - screen.contains("interrupt"), - "busy shows interrupt: {screen}" - ); + assert!(screen.contains("stop"), "busy shows stop: {screen}"); + } + + #[test] + fn status_right_hint_explains_queue_handoff() { + let mut state = ViewState::new("m".into()); + state.busy = true; + state.queued_messages = 1; + let screen = rendered(&mut state); + assert!(screen.contains("send after tool"), "queued hint: {screen}"); + + state.queued_after_tool = true; + let screen = rendered(&mut state); + assert!(screen.contains("queued after tool"), "armed hint: {screen}"); + assert!(screen.contains("Esc stop"), "second Esc hint: {screen}"); } /// Idle with a drafted prompt → the right hint surfaces "Esc clear" so the diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index f8c1ace..99c9a1e 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -83,7 +83,8 @@ Useful controls: | `/` | Show available slash commands | | `/menu` | Open projects, sessions, API key, and settings | | `Shift+Tab` | Cycle permission mode | -| `Esc` | Interrupt the active turn | +| `Tab` | Queue the current draft during an active turn | +| `Esc` | Stop, or send a queued message after the active tool call | | `Ctrl+O` | Release/capture the mouse for native terminal selection | | `Ctrl+C` | Quit |