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
+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)