添加steer 命令
This commit is contained in:
parent
b64a30ef48
commit
10d25a88f9
|
|
@ -698,6 +698,13 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
||||||
argument_hint: Some("[count]"),
|
argument_hint: Some("[count]"),
|
||||||
resume_supported: false,
|
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 {
|
SlashCommandSpec {
|
||||||
name: "tokens",
|
name: "tokens",
|
||||||
aliases: &[],
|
aliases: &[],
|
||||||
|
|
@ -1179,6 +1186,9 @@ pub enum SlashCommand {
|
||||||
History {
|
History {
|
||||||
count: Option<String>,
|
count: Option<String>,
|
||||||
},
|
},
|
||||||
|
Steer {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1222,6 +1232,7 @@ impl SlashCommand {
|
||||||
Self::Config { .. } => "/config",
|
Self::Config { .. } => "/config",
|
||||||
Self::Memory { .. } => "/memory",
|
Self::Memory { .. } => "/memory",
|
||||||
Self::History { .. } => "/history",
|
Self::History { .. } => "/history",
|
||||||
|
Self::Steer { .. } => "/steer",
|
||||||
Self::Diff => "/diff",
|
Self::Diff => "/diff",
|
||||||
Self::Status => "/status",
|
Self::Status => "/status",
|
||||||
Self::Stats => "/stats",
|
Self::Stats => "/stats",
|
||||||
|
|
@ -1488,10 +1499,13 @@ pub fn validate_slash_command_input(
|
||||||
"tag" => SlashCommand::Tag { label: remainder },
|
"tag" => SlashCommand::Tag { label: remainder },
|
||||||
"output-style" => SlashCommand::OutputStyle { style: remainder },
|
"output-style" => SlashCommand::OutputStyle { style: remainder },
|
||||||
"add-dir" => SlashCommand::AddDir { path: remainder },
|
"add-dir" => SlashCommand::AddDir { path: remainder },
|
||||||
"history" => SlashCommand::History {
|
"history" => SlashCommand::History {
|
||||||
count: optional_single_arg(command, &args, "[count]")?,
|
count: optional_single_arg(command, &args, "[count]")?,
|
||||||
},
|
},
|
||||||
other => SlashCommand::Unknown(other.to_string()),
|
"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> {
|
fn validate_no_args(command: &str, args: &[&str]) -> Result<(), SlashCommandParseError> {
|
||||||
|
|
@ -4153,8 +4167,9 @@ pub fn handle_slash_command(
|
||||||
| SlashCommand::Tag { .. }
|
| SlashCommand::Tag { .. }
|
||||||
| SlashCommand::OutputStyle { .. }
|
| SlashCommand::OutputStyle { .. }
|
||||||
| SlashCommand::AddDir { .. }
|
| SlashCommand::AddDir { .. }
|
||||||
| SlashCommand::History { .. }
|
| SlashCommand::History { .. }
|
||||||
| SlashCommand::Unknown(_) => None,
|
| SlashCommand::Steer { .. }
|
||||||
|
| SlashCommand::Unknown(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4501,6 +4516,40 @@ mod tests {
|
||||||
assert_eq!(parsed, Ok(Some(SlashCommand::History { count: None })));
|
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]
|
#[test]
|
||||||
fn parses_history_command_with_numeric_count() {
|
fn parses_history_command_with_numeric_count() {
|
||||||
// given
|
// given
|
||||||
|
|
@ -4676,6 +4725,7 @@ mod tests {
|
||||||
assert!(help.contains("/config [env|hooks|model|plugins]"));
|
assert!(help.contains("/config [env|hooks|model|plugins]"));
|
||||||
assert!(help.contains("/mcp [list|show <server>|help]"));
|
assert!(help.contains("/mcp [list|show <server>|help]"));
|
||||||
assert!(help.contains("/memory"));
|
assert!(help.contains("/memory"));
|
||||||
|
assert!(help.contains("/steer <text>"));
|
||||||
assert!(help.contains("/init"));
|
assert!(help.contains("/init"));
|
||||||
assert!(help.contains("/diff"));
|
assert!(help.contains("/diff"));
|
||||||
assert!(help.contains("/version"));
|
assert!(help.contains("/version"));
|
||||||
|
|
@ -4691,7 +4741,7 @@ mod tests {
|
||||||
assert!(help.contains("aliases: /skill"));
|
assert!(help.contains("aliases: /skill"));
|
||||||
assert!(!help.contains("/login"));
|
assert!(!help.contains("/login"));
|
||||||
assert!(!help.contains("/logout"));
|
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);
|
assert!(resume_supported_slash_commands().len() >= 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1349,6 +1349,7 @@ pub(crate) fn slash_command_completion_candidates_with_sessions(
|
||||||
"/plugin update ",
|
"/plugin update ",
|
||||||
"/plugins list",
|
"/plugins list",
|
||||||
"/pr ",
|
"/pr ",
|
||||||
|
"/steer ",
|
||||||
"/resume ",
|
"/resume ",
|
||||||
"/session list",
|
"/session list",
|
||||||
"/session switch ",
|
"/session switch ",
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ pub(crate) struct LiveCli {
|
||||||
pub(crate) runtime: BuiltRuntime,
|
pub(crate) runtime: BuiltRuntime,
|
||||||
pub(crate) session: SessionHandle,
|
pub(crate) session: SessionHandle,
|
||||||
pub(crate) prompt_history: Vec<PromptHistoryEntry>,
|
pub(crate) prompt_history: Vec<PromptHistoryEntry>,
|
||||||
|
pub(crate) steer_queue: SteerQueue,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LiveCli {
|
impl LiveCli {
|
||||||
|
|
@ -52,6 +53,7 @@ impl LiveCli {
|
||||||
runtime,
|
runtime,
|
||||||
session,
|
session,
|
||||||
prompt_history: Vec::new(),
|
prompt_history: Vec::new(),
|
||||||
|
steer_queue: new_steer_queue(),
|
||||||
};
|
};
|
||||||
cli.persist_session()?;
|
cli.persist_session()?;
|
||||||
Ok(cli)
|
Ok(cli)
|
||||||
|
|
@ -152,16 +154,18 @@ impl LiveCli {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
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 runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?;
|
||||||
let mut spinner = Spinner::new();
|
let mut spinner = Spinner::new();
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
spinner.tick(
|
spinner.tick(
|
||||||
"🦀 Thinking...",
|
"🦀 Thinking... (type /steer <text> to guide)",
|
||||||
TerminalRenderer::new().color_theme(),
|
TerminalRenderer::new().color_theme(),
|
||||||
&mut stdout,
|
&mut stdout,
|
||||||
)?;
|
)?;
|
||||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
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();
|
hook_abort_monitor.stop();
|
||||||
match result {
|
match result {
|
||||||
Ok(summary) => {
|
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>> {
|
pub(crate) fn reload_runtime_features(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let runtime = build_runtime(
|
let runtime = build_runtime(
|
||||||
self.runtime.session().clone(),
|
self.runtime.session().clone(),
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ mod tool_executor;
|
||||||
mod runtime_builder;
|
mod runtime_builder;
|
||||||
mod repl_commands;
|
mod repl_commands;
|
||||||
mod setup_wizard;
|
mod setup_wizard;
|
||||||
|
mod steer;
|
||||||
|
|
||||||
pub(crate) use api_client::*;
|
pub(crate) use api_client::*;
|
||||||
pub(crate) use args::*;
|
pub(crate) use args::*;
|
||||||
|
|
@ -57,6 +58,8 @@ pub(crate) use render::*;
|
||||||
pub(crate) use commands::*;
|
pub(crate) use commands::*;
|
||||||
pub(crate) use runtime::*;
|
pub(crate) use runtime::*;
|
||||||
|
|
||||||
|
pub(crate) use steer::*;
|
||||||
|
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{self, IsTerminal, Read, Write};
|
use std::io::{self, IsTerminal, Read, Write};
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,15 @@ impl LiveCli {
|
||||||
self.print_prompt_history(count.as_deref());
|
self.print_prompt_history(count.as_deref());
|
||||||
false
|
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 => {
|
SlashCommand::Stats => {
|
||||||
let usage = UsageTracker::from_session(self.runtime.session()).cumulative_usage();
|
let usage = UsageTracker::from_session(self.runtime.session()).cumulative_usage();
|
||||||
println!("{}", format_cost_report(usage));
|
println!("{}", format_cost_report(usage));
|
||||||
|
|
@ -1154,7 +1163,8 @@ pub(crate) fn run_resume_command(
|
||||||
| SlashCommand::Ide { .. }
|
| SlashCommand::Ide { .. }
|
||||||
| SlashCommand::Tag { .. }
|
| SlashCommand::Tag { .. }
|
||||||
| SlashCommand::OutputStyle { .. }
|
| SlashCommand::OutputStyle { .. }
|
||||||
| SlashCommand::AddDir { .. } => Err("unsupported resumed slash command".into()),
|
| SlashCommand::AddDir { .. }
|
||||||
|
| SlashCommand::Steer { .. } => Err("unsupported resumed slash command".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue