TUIにThinkingを表示する実装

This commit is contained in:
2026-04-28 16:10:48 +09:00
parent 513653ce55
commit cf4c454a03
8 changed files with 366 additions and 7 deletions
+89 -1
View File
@@ -1,6 +1,10 @@
use std::time::Instant;
use protocol::{AlertLevel, AlertSource, Event, Method, RunResult, Segment};
use crate::block::{Block, CompactEvent, ToolCallBlock, ToolCallState};
use crate::block::{
Block, CompactEvent, ThinkingBlock, ThinkingState, ToolCallBlock, ToolCallState,
};
use crate::cache::FileCache;
use crate::input::InputBuffer;
use crate::scroll::Scroll;
@@ -111,9 +115,40 @@ impl App {
Event::TextDone { .. } => {
self.assistant_streaming = false;
}
Event::ThinkingStart => {
self.assistant_streaming = false;
self.blocks.push(Block::Thinking(ThinkingBlock {
text: String::new(),
state: ThinkingState::Streaming {
started_at: Instant::now(),
},
}));
}
Event::ThinkingDelta { text } => {
if let Some(b) = self.last_streaming_thinking_mut() {
b.text.push_str(&text);
}
}
Event::ThinkingDone { text } => {
if let Some(b) = self.last_streaming_thinking_mut() {
if b.text.is_empty() {
b.text = text;
}
let elapsed = match &b.state {
ThinkingState::Streaming { started_at } => {
Some(started_at.elapsed().as_secs())
}
_ => None,
};
b.state = ThinkingState::Finished {
elapsed_secs: elapsed,
};
}
}
Event::TurnEnd { .. } => {
self.assistant_streaming = false;
self.mark_orphan_tool_calls_incomplete();
self.mark_orphan_thinking_incomplete();
self.current_tool = None;
}
Event::ToolCallStart { id, name } => {
@@ -272,6 +307,39 @@ impl App {
self.assistant_streaming = true;
}
/// Walk the most recently pushed blocks looking for a thinking
/// block that's still in `Streaming`. Stops at the current
/// `TurnHeader` to avoid latching onto a thinking block from a
/// previous turn after it was somehow left dangling.
fn last_streaming_thinking_mut(&mut self) -> Option<&mut ThinkingBlock> {
for b in self.blocks.iter_mut().rev() {
match b {
Block::Thinking(t) if matches!(t.state, ThinkingState::Streaming { .. }) => {
return Some(t);
}
Block::TurnHeader { .. } => return None,
_ => continue,
}
}
None
}
fn mark_orphan_thinking_incomplete(&mut self) {
for b in self.blocks.iter_mut().rev() {
match b {
Block::Thinking(t) => {
if let ThinkingState::Streaming { started_at } = t.state {
t.state = ThinkingState::Incomplete {
elapsed_secs: Some(started_at.elapsed().as_secs()),
};
}
}
Block::TurnHeader { .. } => break,
_ => {}
}
}
}
fn find_tool_call_mut(&mut self, id: &str) -> Option<&mut ToolCallBlock> {
for b in self.blocks.iter_mut().rev() {
if let Block::ToolCall(tc) = b
@@ -396,6 +464,26 @@ impl App {
edit_snapshot: None,
}));
}
"reasoning" => {
let text = item["text"].as_str().unwrap_or("").to_owned();
let body = if text.is_empty() {
item["summary"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default()
} else {
text
};
self.blocks.push(Block::Thinking(ThinkingBlock {
text: body,
state: ThinkingState::Finished { elapsed_secs: None },
}));
}
"tool_result" => {
let id = item["call_id"].as_str().unwrap_or("").to_owned();
let summary = item["summary"].as_str().unwrap_or("").to_owned();
+22
View File
@@ -7,6 +7,8 @@
#![allow(dead_code)] // Phase 5 will consume `output` in detail mode.
use std::time::Instant;
use protocol::{AlertLevel, AlertSource, Greeting, Segment};
pub enum Block {
@@ -20,6 +22,7 @@ pub enum Block {
AssistantText {
text: String,
},
Thinking(ThinkingBlock),
ToolCall(ToolCallBlock),
Alert {
level: AlertLevel,
@@ -34,6 +37,25 @@ pub enum Block {
},
}
pub struct ThinkingBlock {
/// Accumulated reasoning body. Empty for providers that emit only
/// metadata (no plaintext deltas).
pub text: String,
pub state: ThinkingState,
}
pub enum ThinkingState {
/// Live block: actively streaming. `started_at` is `None` only for
/// blocks materialised from `Event::History`, which never enter the
/// streaming state.
Streaming { started_at: Instant },
/// Block ended cleanly with `ThinkingDone`.
Finished { elapsed_secs: Option<u64> },
/// `TurnEnd` arrived before `ThinkingDone`. Elapsed time is frozen
/// at the last observed instant.
Incomplete { elapsed_secs: Option<u64> },
}
pub enum CompactEvent {
Start,
Done { new_session_id: uuid::Uuid },
+97 -1
View File
@@ -23,7 +23,7 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use protocol::{AlertLevel, Greeting, Segment};
use crate::app::{App, alert_source_label, fmt_tokens};
use crate::block::{Block, CompactEvent};
use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState};
/// Display density for the history view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -298,6 +298,7 @@ fn render_block_into(lines: &mut Vec<Line<'static>>, block: &Block, width: u16,
Mode::Overview => push_overview_line(lines, text, width, MessageKind::Assistant, ""),
_ => push_padded_lines(lines, text, MessageKind::Assistant),
},
Block::Thinking(t) => render_thinking(lines, t, width, mode),
// ToolCall is dispatched in `compute_history` via `tool::render_tool`
// so it can consume multiple adjacent blocks (Read aggregation).
Block::ToolCall(_) => unreachable!("ToolCall handled by compute_history"),
@@ -541,6 +542,97 @@ fn count_visual_rows(text: &str, width: u16) -> usize {
total.max(1)
}
fn render_thinking(lines: &mut Vec<Line<'static>>, t: &ThinkingBlock, width: u16, mode: Mode) {
let header_style = kind_style(MessageKind::Thinking);
let body_style = Style::default().fg(Color::DarkGray);
let header = match &t.state {
ThinkingState::Streaming { started_at } => {
let secs = started_at.elapsed().as_secs();
format!("Thinking... ({})", fmt_elapsed(secs))
}
ThinkingState::Finished { elapsed_secs } => match elapsed_secs {
Some(s) => format!("Thought for {}", fmt_elapsed(*s)),
None => "Thought".to_owned(),
},
ThinkingState::Incomplete { elapsed_secs } => match elapsed_secs {
Some(s) => format!("Thinking interrupted ({})", fmt_elapsed(*s)),
None => "Thinking interrupted".to_owned(),
},
};
if matches!(mode, Mode::Overview) {
push_overview_line(lines, &header, width, MessageKind::Thinking, "");
return;
}
lines.push(Line::from(Span::styled(header, header_style)));
if t.text.is_empty() {
return;
}
match mode {
Mode::Detail => {
for raw in t.text.lines() {
lines.push(Line::from(vec![
Span::styled(" ", body_style),
Span::styled(raw.to_owned(), body_style),
]));
}
}
Mode::Normal => {
// Streaming: show the *latest* tail to keep the cursor of
// attention near where new tokens are appearing. Finished:
// show the first line as a static preview — collapsing it
// entirely would lose the only context most users want
// ("what was it thinking about").
let preview = match &t.state {
ThinkingState::Streaming { .. } => trailing_line_preview(&t.text),
_ => first_line_preview(&t.text),
};
if !preview.is_empty() {
let budget = width.saturating_sub(2) as usize;
let truncated = truncate_with_ellipsis(&preview, budget);
lines.push(Line::from(vec![
Span::styled(" ", body_style),
Span::styled(truncated, body_style),
]));
}
}
Mode::Overview => unreachable!("handled above"),
}
}
/// Last segment of `text` after the final newline (or the whole string
/// if it has no newline). Used as the live "what is it thinking now"
/// 1-liner.
fn trailing_line_preview(text: &str) -> String {
text.rsplit_once('\n')
.map(|(_, tail)| tail)
.unwrap_or(text)
.trim_end()
.to_owned()
}
/// First non-empty line of `text`. Used as the static preview after a
/// thinking block finishes, mirroring the "first line + (+N lines)"
/// idiom of the overview mode.
fn first_line_preview(text: &str) -> String {
text.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.to_owned()
}
fn fmt_elapsed(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else {
format!("{}m{:02}s", secs / 60, secs % 60)
}
}
fn render_compact(lines: &mut Vec<Line<'static>>, evt: &CompactEvent, width: u16, mode: Mode) {
let (text, kind) = match evt {
CompactEvent::Start => ("[compact] starting".to_owned(), MessageKind::NoticeWarn),
@@ -745,6 +837,7 @@ pub enum MessageKind {
TurnHeader,
User,
Assistant,
Thinking,
TurnStats,
NoticeWarn,
NoticeError,
@@ -755,6 +848,9 @@ pub fn kind_style(kind: MessageKind) -> Style {
MessageKind::TurnHeader => Style::default().fg(Color::DarkGray),
MessageKind::User => Style::default().fg(Color::Green),
MessageKind::Assistant => Style::default().fg(Color::White),
MessageKind::Thinking => Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::ITALIC),
MessageKind::TurnStats => Style::default().fg(Color::DarkGray),
MessageKind::NoticeWarn => Style::default()
.fg(Color::Black)