添加steer 命令

This commit is contained in:
zhaoyanchao 2026-06-23 21:48:11 +08:00
parent b64a30ef48
commit 10d25a88f9
6 changed files with 187 additions and 10 deletions

View File

@ -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("<text>"),
resume_supported: false,
},
SlashCommandSpec {
name: "tokens",
aliases: &[],
@ -1179,6 +1186,9 @@ pub enum SlashCommand {
History {
count: Option<String>,
},
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 <server>|help]"));
assert!(help.contains("/memory"));
assert!(help.contains("/steer <text>"));
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);
}

View File

@ -1349,6 +1349,7 @@ pub(crate) fn slash_command_completion_candidates_with_sessions(
"/plugin update ",
"/plugins list",
"/pr ",
"/steer ",
"/resume ",
"/session list",
"/session switch ",

View File

@ -21,6 +21,7 @@ pub(crate) struct LiveCli {
pub(crate) runtime: BuiltRuntime,
pub(crate) session: SessionHandle,
pub(crate) prompt_history: Vec<PromptHistoryEntry>,
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<dyn std::error::Error>> {
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 <text> 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::<Vec<_>>()
.join("\n");
format!("{steer_block}\n\n{input}")
}
pub(crate) fn reload_runtime_features(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let runtime = build_runtime(
self.runtime.session().clone(),

View File

@ -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};

View File

@ -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 <text>");
} 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()),
}
}

View File

@ -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<Mutex<VecDeque<String>>>;
/// 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<String> {
let mut guard = queue.lock().unwrap_or_else(|e| e.into_inner());
guard.drain(..).collect()
}
/// Poll stdin for `/steer <text>` 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"]
);
}
}