TUI上のターンカウンタ・ターン統計の実装

This commit is contained in:
Keisuke Hirata 2026-04-12 05:41:22 +09:00
parent 0c9551eef0
commit afabd3d7fd
3 changed files with 96 additions and 22 deletions

View File

@ -17,3 +17,5 @@
- [ ] コンテキスト圧縮 (Prune + Compact) → [tickets/context-compaction.md](tickets/context-compaction.md) - [ ] コンテキスト圧縮 (Prune + Compact) → [tickets/context-compaction.md](tickets/context-compaction.md)
- [x] Protocol: request-response パターン (GetHistory等) → [tickets/request-response-protocol.md](tickets/request-response-protocol.md) - [x] Protocol: request-response パターン (GetHistory等) → [tickets/request-response-protocol.md](tickets/request-response-protocol.md)
- [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md) - [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md)
- [ ] session-store: persistence クレートの再構成wrap廃止、リネーム → [tickets/session-store-extraction.md](tickets/session-store-extraction.md)
- [ ] UI用トークン情報の記録run stats の永続化、session-store 後)

View File

@ -5,7 +5,12 @@ pub struct App {
pub connected: bool, pub connected: bool,
pub messages: Vec<Message>, pub messages: Vec<Message>,
pub current_text: String, pub current_text: String,
pub status_line: String, pub running: bool,
pub run_requests: usize,
pub run_input_tokens: u64,
pub run_output_tokens: u64,
pub turn_index: usize,
pub current_tool: Option<String>,
pub input: String, pub input: String,
pub cursor: usize, pub cursor: usize,
pub scroll: u16, pub scroll: u16,
@ -19,10 +24,12 @@ pub struct Message {
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub enum MessageKind { pub enum MessageKind {
TurnHeader,
User, User,
Assistant, Assistant,
Tool, Tool,
Error, Error,
TurnStats,
} }
impl App { impl App {
@ -32,7 +39,12 @@ impl App {
connected: false, connected: false,
messages: Vec::new(), messages: Vec::new(),
current_text: String::new(), current_text: String::new(),
status_line: String::new(), running: false,
run_requests: 0,
run_input_tokens: 0,
run_output_tokens: 0,
turn_index: 0,
current_tool: None,
input: String::new(), input: String::new(),
cursor: 0, cursor: 0,
scroll: 0, scroll: 0,
@ -45,6 +57,11 @@ impl App {
if text.is_empty() { if text.is_empty() {
return None; return None;
} }
self.turn_index += 1;
self.messages.push(Message {
kind: MessageKind::TurnHeader,
content: format!("#{}", self.turn_index),
});
self.messages.push(Message { self.messages.push(Message {
kind: MessageKind::User, kind: MessageKind::User,
content: text.clone(), content: text.clone(),
@ -57,8 +74,10 @@ impl App {
pub fn handle_pod_event(&mut self, event: Event) { pub fn handle_pod_event(&mut self, event: Event) {
match event { match event {
Event::TurnStart { turn } => { Event::TurnStart { .. } => {
self.status_line = format!("turn {turn}"); self.running = true;
self.run_requests += 1;
self.current_tool = None;
} }
Event::TextDelta { text } => { Event::TextDelta { text } => {
self.current_text.push_str(&text); self.current_text.push_str(&text);
@ -73,7 +92,7 @@ impl App {
self.scroll_to_bottom(); self.scroll_to_bottom();
} }
} }
Event::TurnEnd { turn, result } => { Event::TurnEnd { .. } => {
if !self.current_text.is_empty() { if !self.current_text.is_empty() {
let text = std::mem::take(&mut self.current_text); let text = std::mem::take(&mut self.current_text);
self.messages.push(Message { self.messages.push(Message {
@ -81,10 +100,10 @@ impl App {
content: text, content: text,
}); });
} }
self.status_line = format!("turn {turn} {result:?}"); self.current_tool = None;
} }
Event::ToolCallStart { name, .. } => { Event::ToolCallStart { name, .. } => {
self.status_line = format!("tool: {name}"); self.current_tool = Some(name.clone());
self.messages.push(Message { self.messages.push(Message {
kind: MessageKind::Tool, kind: MessageKind::Tool,
content: format!("[tool] {name}"), content: format!("[tool] {name}"),
@ -94,6 +113,7 @@ impl App {
Event::ToolCallDone { Event::ToolCallDone {
name, arguments, .. name, arguments, ..
} => { } => {
self.current_tool = None;
self.messages.push(Message { self.messages.push(Message {
kind: MessageKind::Tool, kind: MessageKind::Tool,
content: format!("[tool] {name} done ({} bytes)", arguments.len()), content: format!("[tool] {name} done ({} bytes)", arguments.len()),
@ -119,10 +139,8 @@ impl App {
input_tokens, input_tokens,
output_tokens, output_tokens,
} => { } => {
let in_t = input_tokens.unwrap_or(0); self.run_input_tokens += input_tokens.unwrap_or(0);
let out_t = output_tokens.unwrap_or(0); self.run_output_tokens += output_tokens.unwrap_or(0);
self.status_line
.push_str(&format!(" | in:{in_t} out:{out_t}"));
} }
Event::Error { code, message } => { Event::Error { code, message } => {
self.messages.push(Message { self.messages.push(Message {
@ -131,8 +149,22 @@ impl App {
}); });
self.scroll_to_bottom(); self.scroll_to_bottom();
} }
Event::RunEnd { result } => { Event::RunEnd { .. } => {
self.status_line = format!("{result:?}"); self.messages.push(Message {
kind: MessageKind::TurnStats,
content: format!(
"{} reqs ↑{}/↓{}",
self.run_requests,
fmt_tokens(self.run_input_tokens),
fmt_tokens(self.run_output_tokens),
),
});
self.running = false;
self.run_requests = 0;
self.run_input_tokens = 0;
self.run_output_tokens = 0;
self.current_tool = None;
self.scroll_to_bottom();
} }
Event::ToolCallArgsDelta { .. } => {} Event::ToolCallArgsDelta { .. } => {}
Event::History { items } => { Event::History { items } => {
@ -220,13 +252,21 @@ impl App {
fn restore_history(&mut self, items: &[serde_json::Value]) { fn restore_history(&mut self, items: &[serde_json::Value]) {
self.messages.clear(); self.messages.clear();
self.turn_index = 0;
for item in items { for item in items {
let item_type = item["type"].as_str().unwrap_or(""); let item_type = item["type"].as_str().unwrap_or("");
match item_type { match item_type {
"message" => { "message" => {
let role = item["role"].as_str().unwrap_or(""); let role = item["role"].as_str().unwrap_or("");
let kind = match role { let kind = match role {
"user" => MessageKind::User, "user" => {
self.turn_index += 1;
self.messages.push(Message {
kind: MessageKind::TurnHeader,
content: format!("#{}", self.turn_index),
});
MessageKind::User
}
"assistant" => MessageKind::Assistant, "assistant" => MessageKind::Assistant,
_ => continue, _ => continue,
}; };
@ -276,3 +316,13 @@ impl App {
self.scroll = u16::MAX; self.scroll = u16::MAX;
} }
} }
pub fn fmt_tokens(n: u64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}k", n as f64 / 1_000.0)
} else {
n.to_string()
}
}

View File

@ -1,10 +1,10 @@
use ratatui::layout::{Constraint, Layout, Position}; use ratatui::layout::{Alignment, Constraint, Layout, Position};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Wrap}; use ratatui::widgets::{Paragraph, Wrap};
use ratatui::Frame; use ratatui::Frame;
use crate::app::{App, MessageKind}; use crate::app::{fmt_tokens, App, MessageKind};
pub fn draw(frame: &mut Frame, app: &mut App) { pub fn draw(frame: &mut Frame, app: &mut App) {
let chunks = Layout::vertical([ let chunks = Layout::vertical([
@ -28,9 +28,14 @@ fn draw_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect)
.iter() .iter()
.flat_map(|(kind, content)| { .flat_map(|(kind, content)| {
let style = kind_style(kind); let style = kind_style(kind);
let align = if matches!(kind, MessageKind::TurnStats) {
Alignment::Right
} else {
Alignment::Left
};
content content
.lines() .lines()
.map(move |l| Line::from(Span::styled(l.to_owned(), style))) .map(move |l| Line::from(Span::styled(l.to_owned(), style)).alignment(align))
}) })
.collect(); .collect();
@ -72,17 +77,32 @@ fn draw_status(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
), ),
]; ];
if !app.status_line.is_empty() { if app.running {
let status = if let Some(tool) = &app.current_tool {
format!(
"request: {} | ↑{}/↓{} | tool: {tool}",
app.run_requests,
fmt_tokens(app.run_input_tokens),
fmt_tokens(app.run_output_tokens),
)
} else {
format!(
"request: {} | ↑{}/↓{}",
app.run_requests,
fmt_tokens(app.run_input_tokens),
fmt_tokens(app.run_output_tokens),
)
};
spans.push(Span::raw(" | ")); spans.push(Span::raw(" | "));
spans.push(Span::styled( spans.push(Span::styled(status, Style::default().fg(Color::Yellow)));
&app.status_line, } else {
Style::default().fg(Color::Yellow), spans.push(Span::styled(" idle", Style::default().fg(Color::DarkGray)));
));
} }
frame.render_widget(Paragraph::new(Line::from(spans)), area); frame.render_widget(Paragraph::new(Line::from(spans)), area);
} }
fn draw_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { fn draw_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
let line = Line::from(vec![ let line = Line::from(vec![
Span::styled("> ", Style::default().fg(Color::DarkGray)), Span::styled("> ", Style::default().fg(Color::DarkGray)),
@ -97,9 +117,11 @@ fn draw_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
fn kind_style(kind: &MessageKind) -> Style { fn kind_style(kind: &MessageKind) -> Style {
match kind { match kind {
MessageKind::TurnHeader => Style::default().fg(Color::DarkGray),
MessageKind::User => Style::default().fg(Color::Green), MessageKind::User => Style::default().fg(Color::Green),
MessageKind::Assistant => Style::default().fg(Color::White), MessageKind::Assistant => Style::default().fg(Color::White),
MessageKind::Tool => Style::default().fg(Color::Cyan), MessageKind::Tool => Style::default().fg(Color::Cyan),
MessageKind::Error => Style::default().fg(Color::Red), MessageKind::Error => Style::default().fg(Color::Red),
MessageKind::TurnStats => Style::default().fg(Color::DarkGray),
} }
} }