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:
parent
676dc1503e
commit
4ffd82b096
|
|
@ -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()));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@ pub fn run() -> io::Result<()> {
|
|||
/// Main loop: draw → handle input → drain background events.
|
||||
fn app_loop(terminal: &mut Terminal<CrosstermBackend<Stdout>>, 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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…");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue