diff --git a/src/agent/recovery.rs b/src/agent/recovery.rs index 1e528edc..5d3cabd4 100644 --- a/src/agent/recovery.rs +++ b/src/agent/recovery.rs @@ -119,6 +119,15 @@ pub fn classify_error(msg: &str) -> ErrorKind { || lower.contains("timed out") || lower.contains("request timeout") || lower.contains("server error") + // Mid-stream decode failures from reqwest/rig — the connection + // returned bytes but they didn't deserialize into the expected + // JSON envelope. Almost always transient (network blip, + // truncated chunked response, provider hiccup), so it should + // be retried like any other network error rather than surfacing + // as a hard "Other" failure. + || lower.contains("error decoding response body") + || lower.contains("invalid response body") + || lower.contains("decode error") { return ErrorKind::Network; } @@ -126,6 +135,57 @@ pub fn classify_error(msg: &str) -> ErrorKind { ErrorKind::Other } +/// Map a raw error message to a one-line user-facing explanation +/// that names *what* failed and *what to try next*. Used by the agent +/// runner when surfacing errors to the chat — beats dumping a stack +/// of `CompletionError: ProviderError: Http client error: …` at the +/// user. +/// +/// The original message is appended in parentheses as the cause so +/// the user (and any bug reports) still have the underlying details. +pub fn user_facing_error(msg: &str, attempts: usize) -> String { + let kind = classify_error(msg); + let lower = msg.to_lowercase(); + + let (headline, hint) = match kind { + ErrorKind::Auth => ( + "authentication failed talking to the LLM provider", + "check your API key env var (e.g. OPENROUTER_API_KEY) and provider config", + ), + ErrorKind::RateLimit => ( + "provider rate-limited the request", + "wait a moment and retry, or switch to a different model via /model", + ), + ErrorKind::ContextLength => ( + "conversation exceeds the model's context window", + "run /compress to summarize older turns and try again", + ), + ErrorKind::Network if lower.contains("error decoding response body") => ( + "lost the response stream from the provider (truncated or malformed body)", + "usually transient — retry. If it persists the provider may be having issues or returning non-JSON (HTML error pages, plaintext)", + ), + ErrorKind::Network => ( + "network error reaching the LLM provider", + "check connectivity / firewall / proxy; the request will retry automatically", + ), + ErrorKind::Other => ( + "the LLM provider returned an error we didn't recognize", + "see the cause below; consider /model to try a different provider", + ), + }; + + let attempts_note = if attempts > 1 { + format!(" (after {} attempt(s))", attempts) + } else { + String::new() + }; + + format!( + "{}{}\n ↳ hint: {}\n ↳ cause: {}", + headline, attempts_note, hint, msg + ) +} + #[cfg(test)] mod tests { use super::*; @@ -158,6 +218,45 @@ mod tests { classify_error("503 service unavailable"), ErrorKind::Network ); + // Reqwest decode failure mid-stream — rig surfaces it as + // `CompletionError: ProviderError: Http client error: error + // decoding response body`. Should be retried like any other + // transient network blip rather than surfacing as Other. + assert_eq!( + classify_error( + "CompletionError: ProviderError: Http client error: error decoding response body" + ), + ErrorKind::Network + ); + assert_eq!(classify_error("decode error: EOF"), ErrorKind::Network); + } + + /// `user_facing_error` produces a multi-line message with headline, + /// hint, and cause. The cause must contain the original raw + /// message so debug context isn't lost. + #[test] + fn user_facing_error_includes_cause() { + let raw = "CompletionError: ProviderError: Http client error: error decoding response body"; + let pretty = user_facing_error(raw, 1); + assert!(pretty.contains("lost the response stream")); + assert!(pretty.contains("hint:")); + assert!(pretty.contains("cause:")); + assert!(pretty.contains(raw)); + } + + /// Auth errors get a distinct headline pointing at the API key. + #[test] + fn user_facing_error_classifies_auth() { + let pretty = user_facing_error("401 unauthorized", 1); + assert!(pretty.contains("authentication failed")); + assert!(pretty.contains("API key")); + } + + /// Context-length errors point at /compress. + #[test] + fn user_facing_error_classifies_context_length() { + let pretty = user_facing_error("maximum context length exceeded", 1); + assert!(pretty.contains("/compress")); } #[test] diff --git a/src/agent/runner.rs b/src/agent/runner.rs index 7928a57b..de1ccaad 100644 --- a/src/agent/runner.rs +++ b/src/agent/runner.rs @@ -331,23 +331,23 @@ where let kind = recovery::classify_error(&msg); - // Auth and unknown errors surface immediately + // Auth and unknown errors surface immediately with a + // user-friendly headline + hint + cause breakdown. if kind == ErrorKind::Auth || kind == ErrorKind::Other { + let friendly = recovery::user_facing_error(&msg, attempts + 1); let _ = event_tx - .send(AgentEvent::Error(CompactString::new(msg))) + .send(AgentEvent::Error(CompactString::new(friendly))) .await; break; } - // Context-length errors: not retryable without compaction - // Surface a helpful error hinting at /compress + // Context-length errors aren't retryable without + // compaction — the friendly formatter already points the + // user at /compress. if kind == ErrorKind::ContextLength { - let hint = format!( - "{} — try /compress to compact the conversation, then retry", - msg - ); + let friendly = recovery::user_facing_error(&msg, attempts + 1); let _ = event_tx - .send(AgentEvent::Error(CompactString::new(hint))) + .send(AgentEvent::Error(CompactString::new(friendly))) .await; break; } @@ -357,7 +357,11 @@ where // without retrying — events already streamed live, so the user // sees what got done. if outcome.had_tool_calls { - let err = format!("{} (tool side effects already applied, not retrying)", msg); + let friendly = recovery::user_facing_error(&msg, attempts + 1); + let err = format!( + "{}\n ↳ note: tool side effects already applied; not retrying.", + friendly, + ); let _ = event_tx .send(AgentEvent::Error(CompactString::new(err))) .await; @@ -365,7 +369,8 @@ where } if !policy.should_retry(attempts, kind) { - let retry_msg = format!("{} (retries exhausted)", msg); + let friendly = recovery::user_facing_error(&msg, attempts + 1); + let retry_msg = format!("{}\n ↳ note: retries exhausted.", friendly); let _ = event_tx .send(AgentEvent::Error(CompactString::new(retry_msg))) .await; diff --git a/src/ui/avatar.rs b/src/ui/avatar.rs index f40f9549..d08b5fad 100644 --- a/src/ui/avatar.rs +++ b/src/ui/avatar.rs @@ -1,18 +1,21 @@ -//! Bottom-left ASCII avatar. +//! Inline ASCII avatar. //! -//! A tiny 3-row × 5-col face that lives in the left margin (cols -//! 0..5) of the bottom three terminal rows. It updates based on what -//! the agent is doing — thinking, speaking, running a tool, erroring, -//! resting — to give the chat a personable focal point and visible -//! activity feedback even when no tokens are streaming yet. +//! A tiny single-row face that lives on the input row, centered in the +//! left margin between the screen edge and the input prompt. Updates +//! based on what the agent is doing — thinking, speaking, running a +//! tool, erroring, resting — to give the chat a personable focal +//! point and visible activity feedback even when no tokens are +//! streaming yet. //! -//! Designed to fit inside the chat band's centering indent so it -//! never overlaps with chat content or the input prompt. +//! Single-row so it never gets caught in chat scroll: chat content +//! lives on rows 0..input_top-1, the avatar lives on input_top +//! beside the prompt, and `crossterm::ScrollUp` operations don't +//! touch the input row. use crossterm::style::Color; /// What the agent is currently doing. The renderer picks an ascii -/// face per state and draws it at the bottom-left of the screen. +/// face per state and draws it next to the input prompt. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(not(feature = "plugin"), allow(dead_code))] pub enum AvatarState { @@ -53,67 +56,53 @@ impl AvatarState { /// Width of the avatar in terminal columns. pub const AVATAR_W: usize = 5; -/// Height of the avatar in terminal rows. -pub const AVATAR_H: usize = 3; -/// Return three lines of ascii art for the given state + animation -/// tick. The `tick` boolean alternates between two slightly different -/// poses per state so the avatar visibly animates (eyes / mouth) -/// without going overboard. -pub fn art(state: AvatarState, tick: bool) -> [&'static str; AVATAR_H] { +/// Return the ASCII face for the given state + animation tick. `tick` +/// alternates between two slightly different poses (blinking eyes, +/// shifting mouth) so the avatar visibly animates while the agent +/// runs without being noisy. +pub fn art(state: AvatarState, tick: bool) -> &'static str { use AvatarState::*; match state { Idle => { if tick { - [" ,-, ", "(o o)", " \\_/ "] + "(o o)" } else { - [" ,-, ", "(- -)", " \\_/ "] + "(- -)" } } Thinking => { if tick { - [" ? ", "(o ·)", " \\_/ "] + "(o .)" } else { - [" ? ", "(· o)", " \\_/ "] + "(. o)" } } Speaking => { if tick { - [" ,-, ", "(o o)", " \\o/ "] + "(o o)" } else { - [" ,-, ", "(o o)", " \\O/ "] - } - } - Reading => { - if tick { - [" ,-, ", "[@ @]", " \\_/ "] - } else { - [" ,-, ", "[@ @]", " \\.. "] + "(o O)" } } + Reading => "[@ @]", Writing => { if tick { - [" ,-, ", "(>_<)", " \\_/ "] - } else { - [" ,-, ", "(-_-)", " \\_/ "] - } - } - Bash => { - if tick { - ["[___]", "[$_$]", "[___]"] + "(>_<)" } else { - ["[___]", "[$ $]", "[___]"] + "(-_-)" } } - Alert => [" ! ", "(O_O)", " /!\\ "], - Error => [" ,-, ", "(x_x)", " /v\\ "], - Done => [" ,-, ", "(^_^)", " \\_/ "], + Bash => "[$_$]", + Alert => "(O_O)", + Error => "(x_x)", + Done => "(^_^)", } } -/// Color the avatar should render in for the given state. Default is -/// the active theme's agent tone; alerts and errors override to the -/// loud yellow/red of the theme so the user notices. +/// Color the avatar should render in for the given state. Errors and +/// alerts override to the theme's perm / error tones; everything else +/// uses the agent tone so it visually belongs to the chat. pub fn color(state: AvatarState) -> Color { use AvatarState::*; match state { @@ -128,10 +117,10 @@ pub fn color(state: AvatarState) -> Color { mod tests { use super::*; - /// Every state must produce three lines exactly `AVATAR_W` cols - /// wide. A typo'd asymmetry would visually wobble the face. + /// Every face must be exactly `AVATAR_W` cols wide so the avatar's + /// position is stable across state transitions. #[test] - fn every_state_has_uniform_dimensions() { + fn every_state_has_uniform_width() { let states = [ AvatarState::Idle, AvatarState::Thinking, @@ -145,19 +134,15 @@ mod tests { ]; for state in states { for tick in [false, true] { - let lines = art(state, tick); - assert_eq!(lines.len(), AVATAR_H, "{:?} wrong row count", state); - for (i, line) in lines.iter().enumerate() { - assert_eq!( - line.chars().count(), - AVATAR_W, - "{:?} tick={} row {} is {:?}", - state, - tick, - i, - line, - ); - } + let face = art(state, tick); + assert_eq!( + face.chars().count(), + AVATAR_W, + "{:?} tick={} is {:?}", + state, + tick, + face, + ); } } } @@ -170,7 +155,7 @@ mod tests { assert_eq!(AvatarState::from_tool_name("edit"), AvatarState::Writing); assert_eq!(AvatarState::from_tool_name("write"), AvatarState::Writing); assert_eq!(AvatarState::from_tool_name("bash"), AvatarState::Bash); - // Unknown tools fall back to Reading (observational default). + // Unknown tools fall back to Reading. assert_eq!( AvatarState::from_tool_name("mcp_some_tool"), AvatarState::Reading diff --git a/src/ui/renderer.rs b/src/ui/renderer.rs index c90742d6..71ff0829 100644 --- a/src/ui/renderer.rs +++ b/src/ui/renderer.rs @@ -905,38 +905,38 @@ impl Renderer { /// centering indent is too narrow (`< AVATAR_W + 1`) to fit the /// avatar without overlapping chat content. fn draw_avatar(&self, stdout: &mut io::Stdout, input_top: u16) -> io::Result<()> { - use crate::ui::avatar::{AVATAR_H, AVATAR_W, art, color}; - // Need at least AVATAR_W + 1 cols of indent so the avatar - // doesn't bleed into chat content. Also need at least AVATAR_H - // rows of vertical headroom above the input. + use crate::ui::avatar::{AVATAR_W, art, color}; + // Single-row avatar painted on the *input* row, horizontally + // centered in the left margin between col 0 and the input + // prompt. Single-row + on the input row means the avatar + // never gets caught in `ensure_room`'s `ScrollUp(1)` — the + // input row sits below the scroll region, so the avatar + // doesn't smear into the scrollback when chat grows. let indent = self.content_indent(); - if indent < AVATAR_W + 1 { + if indent < AVATAR_W + 2 { + // Not enough margin to fit the avatar with breathing + // room around the input prompt's left edge. return Ok(()); } - if input_top < AVATAR_H as u16 { - return Ok(()); - } - let lines = art(self.avatar_state, self.avatar_tick); + let face = art(self.avatar_state, self.avatar_tick); let painted = self.color(color(self.avatar_state)); - let top_row = input_top - AVATAR_H as u16; - for (i, line) in lines.iter().enumerate() { - stdout.execute(MoveTo(0, top_row + i as u16))?; - // Hide cursor while painting so it doesn't drag across. - // Wipe the 5-col patch first, then write the face. - write!(stdout, "{}", " ".repeat(AVATAR_W))?; - stdout.execute(MoveTo(0, top_row + i as u16))?; - write!(stdout, "{}", SetForegroundColor(painted))?; - // Bold attribute for the phosphor bloom, matching the - // chat content rules. - if crate::ui::theme::is_bright(color(self.avatar_state)) { - write!(stdout, "{}", SetAttribute(Attribute::Bold))?; - } - write!(stdout, "{}", line)?; - if crate::ui::theme::is_bright(color(self.avatar_state)) { - write!(stdout, "{}", SetAttribute(Attribute::NormalIntensity))?; - } - write!(stdout, "{}", ResetColor)?; + // Center within [0, indent): x = (indent - AVATAR_W) / 2 + let x = ((indent - AVATAR_W) / 2) as u16; + // Wipe the full margin (cols 0..indent) first so a face from + // a previous state doesn't leave fossils when its position + // shifts (e.g. theme change, indent recomputed on resize). + stdout.execute(MoveTo(0, input_top))?; + write!(stdout, "{}", " ".repeat(indent))?; + stdout.execute(MoveTo(x, input_top))?; + write!(stdout, "{}", SetForegroundColor(painted))?; + if crate::ui::theme::is_bright(color(self.avatar_state)) { + write!(stdout, "{}", SetAttribute(Attribute::Bold))?; + } + write!(stdout, "{}", face)?; + if crate::ui::theme::is_bright(color(self.avatar_state)) { + write!(stdout, "{}", SetAttribute(Attribute::NormalIntensity))?; } + write!(stdout, "{}", ResetColor)?; Ok(()) }