Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions src/ui/avatar.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
//! Bottom-left 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.
//!
//! Designed to fit inside the chat band's centering indent so it
//! never overlaps with chat content or the input prompt.

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.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(feature = "plugin"), allow(dead_code))]
pub enum AvatarState {
/// Nothing happening — neutral idle face.
Idle,
/// Model is thinking (reasoning tokens streaming).
Thinking,
/// Model is producing visible output (regular tokens streaming).
Speaking,
/// A read-family tool is active (read, grep, list_dir, find_files).
Reading,
/// A write-family tool is active (write, edit, apply_patch).
Writing,
/// A bash / shell tool is active.
Bash,
/// Permission alert or other thing demanding attention.
Alert,
/// Agent encountered an error.
Error,
/// Turn completed successfully.
Done,
}

impl AvatarState {
/// Choose an avatar state for a tool name. Maps well-known tool
/// names to read/write/bash families; unknown tools default to
/// the generic `Reading` face since most plugin / MCP tools are
/// observational.
pub fn from_tool_name(name: &str) -> Self {
match name {
"read" | "grep" | "find_files" | "list_dir" | "lsp" | "semantic" => Self::Reading,
"write" | "edit" | "apply_patch" | "write_todo_list" => Self::Writing,
"bash" | "shell" => Self::Bash,
_ => Self::Reading,
}
}
}

/// 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] {
use AvatarState::*;
match state {
Idle => {
if tick {
[" ,-, ", "(o o)", " \\_/ "]
} else {
[" ,-, ", "(- -)", " \\_/ "]
}
}
Thinking => {
if tick {
[" ? ", "(o ·)", " \\_/ "]
} else {
[" ? ", "(· o)", " \\_/ "]
}
}
Speaking => {
if tick {
[" ,-, ", "(o o)", " \\o/ "]
} else {
[" ,-, ", "(o o)", " \\O/ "]
}
}
Reading => {
if tick {
[" ,-, ", "[@ @]", " \\_/ "]
} else {
[" ,-, ", "[@ @]", " \\.. "]
}
}
Writing => {
if tick {
[" ,-, ", "(>_<)", " \\_/ "]
} else {
[" ,-, ", "(-_-)", " \\_/ "]
}
}
Bash => {
if tick {
["[___]", "[$_$]", "[___]"]
} else {
["[___]", "[$ $]", "[___]"]
}
}
Alert => [" ! ", "(O_O)", " /!\\ "],
Error => [" ,-, ", "(x_x)", " /v\\ "],
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.
pub fn color(state: AvatarState) -> Color {
use AvatarState::*;
match state {
Alert => crate::ui::theme::perm(),
Error => crate::ui::theme::error(),
Done => crate::ui::theme::accent(),
_ => crate::ui::theme::agent(),
}
}

#[cfg(test)]
mod tests {
use super::*;

/// Every state must produce three lines exactly `AVATAR_W` cols
/// wide. A typo'd asymmetry would visually wobble the face.
#[test]
fn every_state_has_uniform_dimensions() {
let states = [
AvatarState::Idle,
AvatarState::Thinking,
AvatarState::Speaking,
AvatarState::Reading,
AvatarState::Writing,
AvatarState::Bash,
AvatarState::Alert,
AvatarState::Error,
AvatarState::Done,
];
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,
);
}
}
}
}

/// Tool-name → state mapping covers the common families.
#[test]
fn tool_name_maps_to_state() {
assert_eq!(AvatarState::from_tool_name("read"), AvatarState::Reading);
assert_eq!(AvatarState::from_tool_name("grep"), AvatarState::Reading);
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).
assert_eq!(
AvatarState::from_tool_name("mcp_some_tool"),
AvatarState::Reading
);
}
}
7 changes: 7 additions & 0 deletions src/ui/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub(crate) mod avatar;
mod events;
pub(crate) mod input;
mod markdown;
Expand Down Expand Up @@ -1363,6 +1364,7 @@ pub async fn run_interactive(
} => {
match event {
AgentEvent::Reasoning(text) => {
renderer.set_avatar_state(avatar::AvatarState::Thinking);
if !show_reasoning {
continue;
}
Expand All @@ -1375,6 +1377,7 @@ pub async fn run_interactive(
was_reasoning = true;
}
AgentEvent::Token(text) => {
renderer.set_avatar_state(avatar::AvatarState::Speaking);
if was_reasoning {
renderer.write_line("", Color::White)?;
agent_line_started = false;
Expand Down Expand Up @@ -1433,6 +1436,7 @@ pub async fn run_interactive(
}
AgentEvent::ToolCall { name, args } => {
was_reasoning = false;
renderer.set_avatar_state(avatar::AvatarState::from_tool_name(&name));
// If a previous tool's chamber never closed
// (errored without a ToolResult, etc.), close
// it before opening the new one. Without this
Expand Down Expand Up @@ -1565,6 +1569,7 @@ pub async fn run_interactive(
AgentEvent::Done { response, tokens, cost } => {
was_reasoning = false;
last_tool_name = None;
renderer.set_avatar_state(avatar::AvatarState::Done);

#[allow(unused_mut, unused_variables)]
let mut plugin_followup: Option<String> = None;
Expand Down Expand Up @@ -1888,6 +1893,7 @@ pub async fn run_interactive(
}
AgentEvent::Error(e) => {
was_reasoning = false;
renderer.set_avatar_state(avatar::AvatarState::Error);
close_tool_chamber_if_open(&mut renderer, &mut last_tool_name)?;
let safe = sanitize_output(&e);
renderer.write_line(&format!("error: {}", safe), c_error())?;
Expand Down Expand Up @@ -2022,6 +2028,7 @@ pub async fn run_interactive(
// the alert renders outside the chamber rather than
// nested inside it.
close_tool_chamber_if_open(&mut renderer, &mut last_tool_name)?;
renderer.set_avatar_state(avatar::AvatarState::Alert);

// Framed permission prompt. The double-bar border +
// ALERT wordmark visually arrests the eye — this is
Expand Down
67 changes: 67 additions & 0 deletions src/ui/renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ pub struct Renderer {
/// before each redraw so render_viewport/draw_bottom can repaint the
/// panel along with the rest of the screen.
panel_data: PanelData,
/// What the agent is doing — drives the bottom-left ASCII avatar.
avatar_state: crate::ui::avatar::AvatarState,
/// Animation flip; toggled by `tick_avatar()` so the avatar's
/// eyes / mouth alternate between two poses per state.
avatar_tick: bool,
}

impl Renderer {
Expand All @@ -98,9 +103,20 @@ impl Renderer {
selection_end: None,
panel_mode: PanelMode::Auto,
panel_data: PanelData::default(),
avatar_state: crate::ui::avatar::AvatarState::Idle,
avatar_tick: false,
})
}

/// Update the avatar state and trigger a repaint of the bottom-left
/// pixels. Cheap when the state hasn't changed — only the existing
/// 3-row × 5-col patch is re-drawn.
pub fn set_avatar_state(&mut self, state: crate::ui::avatar::AvatarState) {
if self.avatar_state != state {
self.avatar_state = state;
}
}

pub fn set_panel_mode(&mut self, mode: PanelMode) {
self.panel_mode = mode;
}
Expand Down Expand Up @@ -754,6 +770,7 @@ impl Renderer {
// pulse — readable without becoming distracting.
let prompt_main = if is_running {
self.spinner_tick = !self.spinner_tick;
self.avatar_tick = !self.avatar_tick;
// Two-state spinner using ░/▒ blocks so the eye registers
// motion at slow refresh rates.
if self.spinner_tick {
Expand Down Expand Up @@ -864,6 +881,16 @@ impl Renderer {
stdout.execute(MoveTo(cursor_x, cursor_row))?;
}

// Paint the avatar in the bottom-left margin (cols 0..AVATAR_W,
// rows just above the input row). Only painted when the
// centering indent leaves room — on narrow terminals where
// the chat band already starts at col 0, there's no margin to
// put a face into.
self.draw_avatar(&mut stdout, input_top)?;
// The avatar paint moves the cursor — return it to the input
// position for the visible Show below.
stdout.execute(MoveTo(cursor_x, cursor_row))?;

// draw_bottom is the only place the visible cursor belongs (at the
// user's input position). Other renderer paths (render_viewport,
// write, write_line) keep the cursor hidden so streaming output
Expand All @@ -873,6 +900,46 @@ impl Renderer {
Ok(())
}

/// Paint the bottom-left ASCII avatar at cols `0..AVATAR_W`, in the
/// three rows just above `input_top`. Skipped when the chat band's
/// 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.
let indent = self.content_indent();
if indent < AVATAR_W + 1 {
return Ok(());
}
if input_top < AVATAR_H as u16 {
return Ok(());
}
let lines = 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)?;
}
Ok(())
}

/// Paint the right-hand info panel in the rightmost `PANEL_WIDTH` cols,
/// preceded by a vertical divider. Uses cached `self.panel_data`. Caller
/// is responsible for moving the cursor back if needed.
Expand Down
Loading