feat(tui): animated working indicator with phase detection

- Braille spinner driven by a per-frame counter, shown in both the status
  bar (bottom) and at the bottom of the message area while a job runs
- phase detection (RunState::working_phase): running tool > thinking >
  streaming > generic, so the hint reads e.g. "⠹ shell: cargo test…"
- testable via the injected clock (tests/state_tests.rs)
This commit is contained in:
DaiChaoXiong 2026-08-09 21:43:28 +08:00
parent 676dc1503e
commit 4ffd82b096
4 changed files with 135 additions and 6 deletions

View File

@ -208,6 +208,9 @@ pub struct App {
/// 消息区视图高度(由 draw 填充)。 /// 消息区视图高度(由 draw 填充)。
/// Messages-area view height (filled by draw). /// Messages-area view height (filled by draw).
pub view_height: u16, pub view_height: u16,
/// 帧计数(供 spinner 动画)。
/// Frame counter (drives the spinner animation).
pub frame: u64,
pub quit: bool, pub quit: bool,
} }
@ -238,6 +241,7 @@ impl App {
last_usage: None, last_usage: None,
geometry: Vec::new(), geometry: Vec::new(),
view_height: 0, view_height: 0,
frame: 0,
quit: false, quit: false,
} }
} }
@ -334,7 +338,7 @@ impl App {
self.input.clear(); self.input.clear();
self.caret = 0; self.caret = 0;
self.follow = true; self.follow = true;
self.status = "▶ running…".into(); self.status.clear(); // 动画 spinner 指示运行中 / the animated spinner shows "working"
self.spawn_job(Some(text.to_string())); self.spawn_job(Some(text.to_string()));
} }

View File

@ -293,6 +293,34 @@ impl RunState {
} }
} }
/// 当前工作阶段(供 working 指示):运行中的工具 / 思考中 / 流式输出。
/// The current working phase (for the working indicator): a running tool /
/// thinking / streaming.
pub fn working_phase(&self) -> String {
if let Some(exec) = self
.tool_execs
.iter()
.rev()
.find(|e| e.status == ExecStatus::Running)
{
return format!("{}", exec.summary);
}
if let Some(p) = &self.partial {
// 以最后一个内容块为准:思考未结束 → thinking否则视为流式输出。
// Look at the last content block: still thinking → "thinking…",
// otherwise treat it as streaming.
if p.content
.last()
.map(|c| matches!(c, ContentBlock::Thinking(_)))
.unwrap_or(false)
{
return "thinking…".to_string();
}
return "streaming…".to_string();
}
"working…".to_string()
}
/// 消息结束时收尾未结算的思考计时。 /// 消息结束时收尾未结算的思考计时。
/// Finalize unsettled thinking timings at message end. /// Finalize unsettled thinking timings at message end.
pub fn finalize(&mut self) { pub fn finalize(&mut self) {

View File

@ -56,6 +56,9 @@ pub fn run() -> io::Result<()> {
/// Main loop: draw → handle input → drain background events. /// Main loop: draw → handle input → drain background events.
fn app_loop(terminal: &mut Terminal<CrosstermBackend<Stdout>>, app: &mut App) -> io::Result<()> { fn app_loop(terminal: &mut Terminal<CrosstermBackend<Stdout>>, app: &mut App) -> io::Result<()> {
loop { loop {
// 帧计数推进 spinner 动画。
// Advance the frame counter for the spinner animation.
app.frame = app.frame.wrapping_add(1);
terminal.draw(|f| draw(f, app))?; terminal.draw(|f| draw(f, app))?;
if app.quit { if app.quit {
return Ok(()); return Ok(());
@ -104,6 +107,25 @@ fn input_box_height(input: &str, width: u16) -> u16 {
(rows.min(4) + 2) as u16 (rows.min(4) + 2) as u16
} }
/// 动画 spinner 字符集Braille
/// The animated spinner character set (Braille).
const SPINNER: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
/// 按帧取 spinner 字符。
/// Pick the spinner char for a frame.
fn spinner(frame: u64) -> char {
SPINNER[(frame as usize) % SPINNER.len()]
}
/// 当前工作阶段:委托给运行状态的阶段检测。
/// The current working phase: delegated to the run state.
fn working_phase(app: &App) -> String {
match &app.run_state {
Some(rs) => rs.working_phase(),
None => "working…".to_string(),
}
}
/// 消息区notes + 块列表 → 带几何的单 Paragraph。 /// 消息区notes + 块列表 → 带几何的单 Paragraph。
/// Messages area: notes + block list → one Paragraph with geometry. /// Messages area: notes + block list → one Paragraph with geometry.
fn draw_messages(frame: &mut Frame, area: Rect, app: &mut App) { fn draw_messages(frame: &mut Frame, area: Rect, app: &mut App) {
@ -120,10 +142,24 @@ fn draw_messages(frame: &mut Frame, area: Rect, app: &mut App) {
)); ));
let width = area.width.saturating_sub(1).max(1) as usize; let width = area.width.saturating_sub(1).max(1) as usize;
let (lines, geometry) = layout_blocks(&blocks, width); let (mut lines, geometry) = layout_blocks(&blocks, width);
app.geometry = geometry; app.geometry = geometry;
app.view_height = area.height; app.view_height = area.height;
// 运行中:在消息区底部追加一行醒目的 working 指示。
// Running: append a prominent working line at the bottom of the messages.
if app.is_running() {
let phase = if !app.status.is_empty() {
app.status.clone()
} else {
working_phase(app)
};
lines.push(Line::from(Span::styled(
format!("{} {}", spinner(app.frame), phase),
Style::new().fg(Color::Yellow).bold(),
)));
}
let total = lines.len() as u16; let total = lines.len() as u16;
let max_scroll = total.saturating_sub(area.height.max(1)); let max_scroll = total.saturating_sub(area.height.max(1));
if app.follow { if app.follow {
@ -412,10 +448,16 @@ fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
ratio, ratio,
usage usage
); );
let right = if !app.status.is_empty() { let right = if app.is_running() {
// 运行中spinner + 阶段(或状态说明,如 "compacting…")。
// Running: spinner + phase (or a status note like "compacting…").
if !app.status.is_empty() {
format!("{} {}", spinner(app.frame), app.status)
} else {
format!("{} {}", spinner(app.frame), working_phase(app))
}
} else if !app.status.is_empty() {
app.status.clone() app.status.clone()
} else if app.is_running() {
"▶ running…".to_string()
} else { } else {
"Esc 退出 · Ctrl+D 继续 · Tab/点击 展开".to_string() "Esc 退出 · Ctrl+D 继续 · Tab/点击 展开".to_string()
}; };
@ -423,10 +465,15 @@ fn draw_status(frame: &mut Frame, area: Rect, app: &App) {
let width = area.width.saturating_sub(1) as usize; let width = area.width.saturating_sub(1) as usize;
let left = truncate(&left, width.saturating_sub(20).max(10)); let left = truncate(&left, width.saturating_sub(20).max(10));
let right = truncate(&right, 20); let right = truncate(&right, 20);
let right_style = if app.is_running() {
Style::new().fg(Color::Yellow).bold()
} else {
Style::new().fg(Color::DarkGray)
};
let line = Line::from(vec![ let line = Line::from(vec![
Span::styled(left, Style::new().fg(Color::Blue).bold()), Span::styled(left, Style::new().fg(Color::Blue).bold()),
Span::styled(" ", dim_style()), Span::styled(" ", dim_style()),
Span::styled(right, Style::new().fg(Color::DarkGray)), Span::styled(right, right_style),
]); ]);
frame.render_widget(Paragraph::new(line).alignment(Alignment::Left), area); frame.render_widget(Paragraph::new(line).alignment(Alignment::Left), area);
} }

View File

@ -229,3 +229,53 @@ fn build_blocks_includes_live_run() {
// A later block appeared → the duration is settled. // A later block appeared → the duration is settled.
assert_eq!(thinking.1, Some(250)); assert_eq!(thinking.1, Some(250));
} }
/// working 阶段检测:运行中的工具 > 思考中 > 流式输出。
/// Working-phase detection: running tool > thinking > streaming.
#[test]
fn working_phase_detection() {
let fc = FakeClock::new();
let mut rs = RunState::new(fc.clock());
// 无状态 → 一般工作。
// No state → generic work.
assert_eq!(rs.working_phase(), "working…");
// 思考中。
// Thinking.
rs.on_agent_event(&AgentEvent::MessageStart {
message: Message::Assistant(assistant(vec![thinking_block("hmm")])),
});
assert_eq!(rs.working_phase(), "thinking…");
// 流式输出(思考后出现文本)。
// Streaming (text after thinking).
rs.on_agent_event(&AgentEvent::MessageUpdate {
message: Message::Assistant(assistant(vec![
thinking_block("hmm"),
ContentBlock::text("answer"),
])),
event_type: "text_start".into(),
});
assert_eq!(rs.working_phase(), "streaming…");
// 运行中的工具优先。
// A running tool takes precedence.
let mut args = JsonValue::obj();
args.insert("command", "cargo test".into()).ok();
rs.on_agent_event(&AgentEvent::ToolExecutionStart {
tool_call_id: "c1".into(),
tool_name: "shell".into(),
args,
});
assert_eq!(rs.working_phase(), "shell: cargo test…");
// 工具结束后回到流式。
// Back to streaming after the tool finishes.
rs.on_agent_event(&AgentEvent::ToolExecutionEnd {
tool_call_id: "c1".into(),
tool_name: "shell".into(),
result: ToolResult::text("ok"),
is_error: false,
});
assert_eq!(rs.working_phase(), "streaming…");
}