diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 4453bc9c..ba1c5f72 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -698,6 +698,13 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ argument_hint: Some("[count]"), resume_supported: false, }, + SlashCommandSpec { + name: "steer", + aliases: &[], + summary: "Inject guidance during AI output without interrupting the conversation", + argument_hint: Some(""), + resume_supported: false, + }, SlashCommandSpec { name: "tokens", aliases: &[], @@ -1179,6 +1186,9 @@ pub enum SlashCommand { History { count: Option, }, + Steer { + text: String, + }, Unknown(String), } @@ -1222,6 +1232,7 @@ impl SlashCommand { Self::Config { .. } => "/config", Self::Memory { .. } => "/memory", Self::History { .. } => "/history", +Self::Steer { .. } => "/steer", Self::Diff => "/diff", Self::Status => "/status", Self::Stats => "/stats", @@ -1488,10 +1499,13 @@ pub fn validate_slash_command_input( "tag" => SlashCommand::Tag { label: remainder }, "output-style" => SlashCommand::OutputStyle { style: remainder }, "add-dir" => SlashCommand::AddDir { path: remainder }, - "history" => SlashCommand::History { - count: optional_single_arg(command, &args, "[count]")?, - }, - other => SlashCommand::Unknown(other.to_string()), +"history" => SlashCommand::History { +count: optional_single_arg(command, &args, "[count]")?, +}, +"steer" => SlashCommand::Steer { +text: remainder.unwrap_or_default().to_string(), +}, +other => SlashCommand::Unknown(other.to_string()), })) } fn validate_no_args(command: &str, args: &[&str]) -> Result<(), SlashCommandParseError> { @@ -4153,8 +4167,9 @@ pub fn handle_slash_command( | SlashCommand::Tag { .. } | SlashCommand::OutputStyle { .. } | SlashCommand::AddDir { .. } - | SlashCommand::History { .. } - | SlashCommand::Unknown(_) => None, +| SlashCommand::History { .. } +| SlashCommand::Steer { .. } +| SlashCommand::Unknown(_) => None, } } @@ -4501,6 +4516,40 @@ mod tests { assert_eq!(parsed, Ok(Some(SlashCommand::History { count: None }))); } + #[test] + fn parses_steer_command_with_text() { + // given + let input = "/steer focus on testing"; + + // when + let parsed = SlashCommand::parse(input); + + // then + assert_eq!( + parsed, + Ok(Some(SlashCommand::Steer { + text: "focus on testing".to_string() + })) + ); + } + + #[test] + fn parses_steer_command_without_text() { + // given + let input = "/steer"; + + // when + let parsed = SlashCommand::parse(input); + + // then + assert_eq!( + parsed, + Ok(Some(SlashCommand::Steer { + text: String::new() + })) + ); + } + #[test] fn parses_history_command_with_numeric_count() { // given @@ -4676,6 +4725,7 @@ mod tests { assert!(help.contains("/config [env|hooks|model|plugins]")); assert!(help.contains("/mcp [list|show |help]")); assert!(help.contains("/memory")); + assert!(help.contains("/steer ")); assert!(help.contains("/init")); assert!(help.contains("/diff")); assert!(help.contains("/version")); @@ -4691,7 +4741,7 @@ mod tests { assert!(help.contains("aliases: /skill")); assert!(!help.contains("/login")); assert!(!help.contains("/logout")); - assert_eq!(slash_command_specs().len(), 139); + assert_eq!(slash_command_specs().len(), 140); assert!(resume_supported_slash_commands().len() >= 10); } diff --git a/rust/crates/rusty-claude-cli/src/args.rs b/rust/crates/rusty-claude-cli/src/args.rs index 2a41fbaa..6e26ba76 100644 --- a/rust/crates/rusty-claude-cli/src/args.rs +++ b/rust/crates/rusty-claude-cli/src/args.rs @@ -1349,6 +1349,7 @@ pub(crate) fn slash_command_completion_candidates_with_sessions( "/plugin update ", "/plugins list", "/pr ", + "/steer ", "/resume ", "/session list", "/session switch ", diff --git a/rust/crates/rusty-claude-cli/src/live_cli.rs b/rust/crates/rusty-claude-cli/src/live_cli.rs index a5245672..536125fa 100644 --- a/rust/crates/rusty-claude-cli/src/live_cli.rs +++ b/rust/crates/rusty-claude-cli/src/live_cli.rs @@ -21,6 +21,7 @@ pub(crate) struct LiveCli { pub(crate) runtime: BuiltRuntime, pub(crate) session: SessionHandle, pub(crate) prompt_history: Vec, + pub(crate) steer_queue: SteerQueue, } impl LiveCli { @@ -52,6 +53,7 @@ impl LiveCli { runtime, session, prompt_history: Vec::new(), + steer_queue: new_steer_queue(), }; cli.persist_session()?; Ok(cli) @@ -152,16 +154,18 @@ impl LiveCli { } pub(crate) fn run_turn(&mut self, input: &str) -> Result<(), Box> { + let effective_input = self.build_effective_input(input); + let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?; let mut spinner = Spinner::new(); let mut stdout = io::stdout(); spinner.tick( - "🦀 Thinking...", + "🦀 Thinking... (type /steer to guide)", TerminalRenderer::new().color_theme(), &mut stdout, )?; let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); - let result = runtime.run_turn(input, Some(&mut permission_prompter)); + let result = runtime.run_turn(&effective_input, Some(&mut permission_prompter)); hook_abort_monitor.stop(); match result { Ok(summary) => { @@ -314,6 +318,20 @@ impl LiveCli { } } + /// Build effective input by prepending any queued steer texts. + fn build_effective_input(&self, input: &str) -> String { + let steer_texts = steer_drain(&self.steer_queue); + if steer_texts.is_empty() { + return input.to_string(); + } + let steer_block = steer_texts + .iter() + .map(|t| format!("[steer] {t}")) + .collect::>() + .join("\n"); + format!("{steer_block}\n\n{input}") + } + pub(crate) fn reload_runtime_features(&mut self) -> Result<(), Box> { let runtime = build_runtime( self.runtime.session().clone(), diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 8cfa15e2..1f72fd81 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -32,6 +32,7 @@ mod tool_executor; mod runtime_builder; mod repl_commands; mod setup_wizard; +mod steer; pub(crate) use api_client::*; pub(crate) use args::*; @@ -57,6 +58,8 @@ pub(crate) use render::*; pub(crate) use commands::*; pub(crate) use runtime::*; +pub(crate) use steer::*; + use std::env; use std::fs; use std::io::{self, IsTerminal, Read, Write}; diff --git a/rust/crates/rusty-claude-cli/src/repl_commands.rs b/rust/crates/rusty-claude-cli/src/repl_commands.rs index ae2e1fe6..11da5dec 100644 --- a/rust/crates/rusty-claude-cli/src/repl_commands.rs +++ b/rust/crates/rusty-claude-cli/src/repl_commands.rs @@ -126,6 +126,15 @@ impl LiveCli { self.print_prompt_history(count.as_deref()); false } + SlashCommand::Steer { text } => { + if text.trim().is_empty() { + eprintln!("Usage: /steer "); + } else { + steer_push(&self.steer_queue, text.clone()); + println!("🧭 Steer queued: {text}"); + } + false + } SlashCommand::Stats => { let usage = UsageTracker::from_session(self.runtime.session()).cumulative_usage(); println!("{}", format_cost_report(usage)); @@ -1154,7 +1163,8 @@ pub(crate) fn run_resume_command( | SlashCommand::Ide { .. } | SlashCommand::Tag { .. } | SlashCommand::OutputStyle { .. } - | SlashCommand::AddDir { .. } => Err("unsupported resumed slash command".into()), + | SlashCommand::AddDir { .. } +| SlashCommand::Steer { .. } => Err("unsupported resumed slash command".into()), } } diff --git a/rust/crates/rusty-claude-cli/src/steer.rs b/rust/crates/rusty-claude-cli/src/steer.rs new file mode 100644 index 00000000..58b4f53f --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/steer.rs @@ -0,0 +1,95 @@ +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +/// Thread-safe queue for `/steer` texts injected during AI output. +/// +/// The queue supports concurrent push (from stdin polling) and +/// drain (from the `run_turn` conversation loop). +pub(crate) type SteerQueue = Arc>>; + +/// Create a new empty `SteerQueue`. +pub(crate) fn new_steer_queue() -> SteerQueue { + Arc::new(Mutex::new(VecDeque::new())) +} + +/// Push a steer text into the queue. +pub(crate) fn steer_push(queue: &SteerQueue, text: String) { + if text.trim().is_empty() { + return; + } + let mut guard = queue.lock().unwrap_or_else(|e| e.into_inner()); + guard.push_back(text); +} + +/// Drain all pending steer texts from the queue, returning them in FIFO order. +pub(crate) fn steer_drain(queue: &SteerQueue) -> Vec { + let mut guard = queue.lock().unwrap_or_else(|e| e.into_inner()); + guard.drain(..).collect() +} + +/// Poll stdin for `/steer ` input using crossterm non-blocking reads. +/// +/// This function checks if there is pending stdin data during AI output. +/// Currently a placeholder — the primary input path is through the idle +/// REPL prompt's rustyline handler. Will be enhanced with raw-mode stdin +/// line accumulation in a future iteration. +pub(crate) fn poll_steer_input(_queue: &SteerQueue) -> usize { + 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use std::thread; + + #[test] + fn push_and_drain_single() { + let queue = new_steer_queue(); + steer_push(&queue, "focus on tests".to_string()); + let drained = steer_drain(&queue); + assert_eq!(drained, vec!["focus on tests"]); + // Queue should be empty after drain + assert!(steer_drain(&queue).is_empty()); + } + + #[test] + fn push_and_drain_multiple_in_order() { + let queue = new_steer_queue(); + steer_push(&queue, "A".to_string()); + steer_push(&queue, "B".to_string()); + let drained = steer_drain(&queue); + assert_eq!(drained, vec!["A", "B"]); + } + + #[test] + fn push_ignores_empty_and_whitespace() { + let queue = new_steer_queue(); + steer_push(&queue, "".to_string()); + steer_push(&queue, " ".to_string()); + steer_push(&queue, "valid".to_string()); + let drained = steer_drain(&queue); + assert_eq!(drained, vec!["valid"]); + } + + #[test] + fn concurrent_push_is_safe() { + let queue = new_steer_queue(); + let handles: Vec<_> = (0..4) + .map(|i| { + let q = Arc::clone(&queue); + thread::spawn(move || { + steer_push(&q, format!("steer-{i}")); + }) + }) + .collect(); + for h in handles { + h.join().unwrap(); + } + let mut drained = steer_drain(&queue); + drained.sort(); + assert_eq!( + drained, + vec!["steer-0", "steer-1", "steer-2", "steer-3"] + ); + } +}