CLI UX: Input prompt (»), G> output prefix, answering spinner
Three UX improvements for interactive mode: 1. Input prompt: Shows '»' (bold cyan) on stdin before reading user input 2. Output prefix: Agent responses prefixed with 'G>' (bold green) 3. Thinking indicator: Animated spinner on stderr showing 'answering (N.Ns)' with braille frame characters, auto-clears when response arrives - Start/stop via tokio::sync::watch channel + tokio::spawn - Only shows when interactive (atty detection) - Does not affect gateway/sub-agent modes
This commit is contained in:
@@ -22,4 +22,5 @@ uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
async-trait.workspace = true
|
||||
rand.workspace = true
|
||||
dirs.workspace = true
|
||||
dirs.workspace = true
|
||||
atty.workspace = true
|
||||
@@ -24,6 +24,18 @@ use tracing::{debug, error, info, span, warn, Level};
|
||||
use crate::context::ContextCompressor;
|
||||
use crate::tools::ToolExecutor;
|
||||
|
||||
// ─── CLI color constants ──────────────────────────────────────────────
|
||||
mod cli_color {
|
||||
pub const RESET: &str = "\x1b[0m";
|
||||
pub const BOLD: &str = "\x1b[1m";
|
||||
pub const DIM: &str = "\x1b[2m";
|
||||
pub const CYAN: &str = "\x1b[36m";
|
||||
pub const BRIGHT_CYAN: &str = "\x1b[96m";
|
||||
pub const BRIGHT_MAGENTA: &str = "\x1b[95m";
|
||||
pub const GREEN: &str = "\x1b[32m";
|
||||
pub const YELLOW: &str = "\x1b[33m";
|
||||
}
|
||||
|
||||
// ─── Error classification ───────────────────────────────────────────────
|
||||
|
||||
/// Categories of errors the agent loop can encounter during LLM calls.
|
||||
@@ -369,7 +381,12 @@ impl AgentLoop {
|
||||
.build_context(&messages, &self.config.system_prompt, &self.tool_executor.lock().await.definitions(), self.config.max_tokens);
|
||||
|
||||
*self.status.lock().await = AgentStatus::Thinking;
|
||||
let response = self.call_with_retry(context).await?;
|
||||
|
||||
// Show thinking animation while waiting for LLM
|
||||
let spinner_handle = self.start_thinking_indicator();
|
||||
let response = self.call_with_retry(context).await;
|
||||
self.stop_thinking_indicator(spinner_handle).await;
|
||||
let response = response?;
|
||||
|
||||
messages.push(Message::new(MessageRole::Assistant, &response.content)
|
||||
.with_tool_calls(response.tool_calls.clone().unwrap_or_default()));
|
||||
@@ -525,9 +542,73 @@ impl AgentLoop {
|
||||
)))
|
||||
}
|
||||
|
||||
/// Display output to stdout with GyanDoot prefix.
|
||||
fn display_output(&self, output: &str) {
|
||||
println!(
|
||||
"\n{bold}{green}G{reset}> {output}",
|
||||
bold = cli_color::BOLD,
|
||||
green = cli_color::GREEN,
|
||||
reset = cli_color::RESET,
|
||||
output = output,
|
||||
);
|
||||
}
|
||||
|
||||
/// Start the thinking animation (spinner on stderr).
|
||||
/// Returns a handle to stop the animation.
|
||||
fn start_thinking_indicator(&self) -> tokio::sync::watch::Sender<bool> {
|
||||
use std::io::Write;
|
||||
let (tx, mut rx) = tokio::sync::watch::channel(true);
|
||||
let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut frame_idx = 0;
|
||||
let start = std::time::Instant::now();
|
||||
while *rx.borrow_and_update() {
|
||||
let spinner = frames[frame_idx % frames.len()];
|
||||
frame_idx += 1;
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
eprint!(
|
||||
"\r{magenta}{spinner}{reset} {bold}answering{reset} ({elapsed:.1}s) ",
|
||||
magenta = cli_color::BRIGHT_MAGENTA,
|
||||
spinner = spinner,
|
||||
reset = cli_color::RESET,
|
||||
bold = cli_color::BOLD,
|
||||
elapsed = elapsed,
|
||||
);
|
||||
std::io::stderr().flush().ok();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
// Clear the spinner line
|
||||
eprint!("\r\x1b[K");
|
||||
std::io::stderr().flush().ok();
|
||||
});
|
||||
|
||||
tx
|
||||
}
|
||||
|
||||
/// Stop the thinking animation.
|
||||
async fn stop_thinking_indicator(&self, handle: tokio::sync::watch::Sender<bool>) {
|
||||
let _ = handle.send(false);
|
||||
// Small delay for the spinner task to finish and clear the line
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
/// Read a line of input from stdin.
|
||||
async fn read_input(&self) -> anyhow::Result<String> {
|
||||
use std::io::Write;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
|
||||
// Show input prompt
|
||||
if atty::is(atty::Stream::Stdin) {
|
||||
print!(
|
||||
"{bold}{cyan}»{reset} ",
|
||||
bold = cli_color::BOLD,
|
||||
cyan = cli_color::BRIGHT_CYAN,
|
||||
reset = cli_color::RESET,
|
||||
);
|
||||
std::io::stdout().flush().ok();
|
||||
}
|
||||
|
||||
let mut line = String::new();
|
||||
let mut reader = tokio::io::BufReader::new(tokio::io::stdin());
|
||||
reader.read_line(&mut line).await?;
|
||||
@@ -538,11 +619,6 @@ impl AgentLoop {
|
||||
fn should_exit(&self, input: &str) -> bool {
|
||||
matches!(input.to_lowercase().as_str(), "exit" | "quit" | "bye")
|
||||
}
|
||||
|
||||
/// Display output to stdout.
|
||||
fn display_output(&self, output: &str) {
|
||||
println!("\n{}", output);
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate exponential backoff with jitter.
|
||||
|
||||
Reference in New Issue
Block a user