From 4ffd82b0967ccac21e2c3b3bd9dad53519ba36b0 Mon Sep 17 00:00:00 2001 From: DaiChaoXiong Date: Sun, 9 Aug 2026 21:43:28 +0800 Subject: [PATCH] feat(tui): animated working indicator with phase detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- crates/focus-tui/src/app.rs | 6 ++- crates/focus-tui/src/state.rs | 28 +++++++++++++ crates/focus-tui/src/ui.rs | 57 ++++++++++++++++++++++++--- crates/focus-tui/tests/state_tests.rs | 50 +++++++++++++++++++++++ 4 files changed, 135 insertions(+), 6 deletions(-) diff --git a/crates/focus-tui/src/app.rs b/crates/focus-tui/src/app.rs index 9d6f945..12d14ce 100644 --- a/crates/focus-tui/src/app.rs +++ b/crates/focus-tui/src/app.rs @@ -208,6 +208,9 @@ pub struct App { /// 消息区视图高度(由 draw 填充)。 /// Messages-area view height (filled by draw). pub view_height: u16, + /// 帧计数(供 spinner 动画)。 + /// Frame counter (drives the spinner animation). + pub frame: u64, pub quit: bool, } @@ -238,6 +241,7 @@ impl App { last_usage: None, geometry: Vec::new(), view_height: 0, + frame: 0, quit: false, } } @@ -334,7 +338,7 @@ impl App { self.input.clear(); self.caret = 0; self.follow = true; - self.status = "▶ running…".into(); + self.status.clear(); // 动画 spinner 指示运行中 / the animated spinner shows "working" self.spawn_job(Some(text.to_string())); } diff --git a/crates/focus-tui/src/state.rs b/crates/focus-tui/src/state.rs index fd9dce7..e77f8e6 100644 --- a/crates/focus-tui/src/state.rs +++ b/crates/focus-tui/src/state.rs @@ -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. pub fn finalize(&mut self) { diff --git a/crates/focus-tui/src/ui.rs b/crates/focus-tui/src/ui.rs index 432e25f..5abae59 100644 --- a/crates/focus-tui/src/ui.rs +++ b/crates/focus-tui/src/ui.rs @@ -56,6 +56,9 @@ pub fn run() -> io::Result<()> { /// Main loop: draw → handle input → drain background events. fn app_loop(terminal: &mut Terminal>, app: &mut App) -> io::Result<()> { loop { + // 帧计数推进 spinner 动画。 + // Advance the frame counter for the spinner animation. + app.frame = app.frame.wrapping_add(1); terminal.draw(|f| draw(f, app))?; if app.quit { return Ok(()); @@ -104,6 +107,25 @@ fn input_box_height(input: &str, width: u16) -> 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。 /// Messages area: notes + block list → one Paragraph with geometry. 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 (lines, geometry) = layout_blocks(&blocks, width); + let (mut lines, geometry) = layout_blocks(&blocks, width); app.geometry = geometry; 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 max_scroll = total.saturating_sub(area.height.max(1)); if app.follow { @@ -412,10 +448,16 @@ fn draw_status(frame: &mut Frame, area: Rect, app: &App) { ratio, 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() - } else if app.is_running() { - "▶ running…".to_string() } else { "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 left = truncate(&left, width.saturating_sub(20).max(10)); 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![ Span::styled(left, Style::new().fg(Color::Blue).bold()), 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); } diff --git a/crates/focus-tui/tests/state_tests.rs b/crates/focus-tui/tests/state_tests.rs index a7a5358..af78311 100644 --- a/crates/focus-tui/tests/state_tests.rs +++ b/crates/focus-tui/tests/state_tests.rs @@ -229,3 +229,53 @@ fn build_blocks_includes_live_run() { // A later block appeared → the duration is settled. 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…"); +}