1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
//! Inline ASCII avatar.
//!
//! 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.
//!
//! 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 next to the input prompt.
#[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. Used by the avatar
/// tests to assert each face string is exactly this many cells;
/// production now reads the face width directly from the string
/// length via ratatui's set_stringn.
#[allow(dead_code)]
pub const AVATAR_W: usize = 5;
/// 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)"
} else {
"(- -)"
}
}
Thinking => {
if tick {
"(o .)"
} else {
"(. o)"
}
}
Speaking => {
if tick {
"(o o)"
} else {
"(o O)"
}
}
Reading => "[@ @]",
Writing => {
if tick {
"(>_<)"
} else {
"(-_-)"
}
}
Bash => "[$_$]",
Alert => "(O_O)",
Error => "(x_x)",
Done => "(^_^)",
}
}
/// 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 {
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 face must be exactly `AVATAR_W` cols wide so the avatar's
/// position is stable across state transitions.
#[test]
fn every_state_has_uniform_width() {
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 face = art(state, tick);
assert_eq!(
face.chars().count(),
AVATAR_W,
"{:?} tick={} is {:?}",
state,
tick,
face,
);
}
}
}
/// 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.
assert_eq!(
AvatarState::from_tool_name("mcp_some_tool"),
AvatarState::Reading
);
}
/// Regression guard for the "avatar stuck on (O_O) Alert after a
/// permission dialog resolves" bug. When the user lets a tool
/// proceed, the UI loop resets the avatar via
/// `from_tool_name(&ask_req.tool)`. That reset must always land on
/// a working face — never on `Alert` (the prompt-time face) and
/// never on `Done`/`Error`/`Idle` — for every tool that can ever
/// be gated behind a permission prompt.
#[test]
fn permission_allow_reset_never_lands_on_alert() {
let gated_tools = [
"read",
"grep",
"find_files",
"list_dir",
"lsp",
"semantic",
"write",
"edit",
"apply_patch",
"write_todo_list",
"bash",
"shell",
"memory",
"skill",
"webfetch",
"task",
"mcp_tool:server:name",
];
for tool in gated_tools {
let state = AvatarState::from_tool_name(tool);
assert!(
matches!(
state,
AvatarState::Reading | AvatarState::Writing | AvatarState::Bash
),
"tool {:?} reset to non-working avatar state {:?}",
tool,
state,
);
assert_ne!(
state,
AvatarState::Alert,
"tool {:?} must not reset to the Alert face",
tool,
);
}
}
}