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:
2026-06-12 11:56:10 +05:30
parent e06094c0a2
commit 1d8e26e683
3 changed files with 85 additions and 7 deletions

1
Cargo.lock generated
View File

@@ -674,6 +674,7 @@ version = "2.0.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"atty",
"chrono", "chrono",
"dirs", "dirs",
"gyandoot-browser", "gyandoot-browser",

View File

@@ -23,3 +23,4 @@ chrono.workspace = true
async-trait.workspace = true async-trait.workspace = true
rand.workspace = true rand.workspace = true
dirs.workspace = true dirs.workspace = true
atty.workspace = true

View File

@@ -24,6 +24,18 @@ use tracing::{debug, error, info, span, warn, Level};
use crate::context::ContextCompressor; use crate::context::ContextCompressor;
use crate::tools::ToolExecutor; 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 ─────────────────────────────────────────────── // ─── Error classification ───────────────────────────────────────────────
/// Categories of errors the agent loop can encounter during LLM calls. /// 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); .build_context(&messages, &self.config.system_prompt, &self.tool_executor.lock().await.definitions(), self.config.max_tokens);
*self.status.lock().await = AgentStatus::Thinking; *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) messages.push(Message::new(MessageRole::Assistant, &response.content)
.with_tool_calls(response.tool_calls.clone().unwrap_or_default())); .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. /// Read a line of input from stdin.
async fn read_input(&self) -> anyhow::Result<String> { async fn read_input(&self) -> anyhow::Result<String> {
use std::io::Write;
use tokio::io::AsyncBufReadExt; 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 line = String::new();
let mut reader = tokio::io::BufReader::new(tokio::io::stdin()); let mut reader = tokio::io::BufReader::new(tokio::io::stdin());
reader.read_line(&mut line).await?; reader.read_line(&mut line).await?;
@@ -538,11 +619,6 @@ impl AgentLoop {
fn should_exit(&self, input: &str) -> bool { fn should_exit(&self, input: &str) -> bool {
matches!(input.to_lowercase().as_str(), "exit" | "quit" | "bye") 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. /// Calculate exponential backoff with jitter.