From c0d283b47d601463fd22789421dce2fba39d6c52 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 12 Apr 2026 05:41:22 +0900 Subject: [PATCH] =?UTF-8?q?TUI=E4=B8=8A=E3=81=AE=E3=82=BF=E3=83=BC?= =?UTF-8?q?=E3=83=B3=E3=82=AB=E3=82=A6=E3=83=B3=E3=82=BF=E3=83=BB=E3=82=BF?= =?UTF-8?q?=E3=83=BC=E3=83=B3=E7=B5=B1=E8=A8=88=E3=81=AE=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 2 ++ crates/tui/src/app.rs | 78 +++++++++++++++++++++++++++++++++++-------- crates/tui/src/ui.rs | 38 ++++++++++++++++----- 3 files changed, 96 insertions(+), 22 deletions(-) diff --git a/TODO.md b/TODO.md index 0054c847..e8d14a5a 100644 --- a/TODO.md +++ b/TODO.md @@ -17,3 +17,5 @@ - [ ] コンテキスト圧縮 (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) - [ ] パーミッション: パターンベースのツール実行制御 → [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 後) diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 56a60d9c..65fa8ba1 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -5,7 +5,12 @@ pub struct App { pub connected: bool, pub messages: Vec, 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, pub input: String, pub cursor: usize, pub scroll: u16, @@ -19,10 +24,12 @@ pub struct Message { #[derive(Clone, Copy)] pub enum MessageKind { + TurnHeader, User, Assistant, Tool, Error, + TurnStats, } impl App { @@ -32,7 +39,12 @@ impl App { connected: false, messages: Vec::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(), cursor: 0, scroll: 0, @@ -45,6 +57,11 @@ impl App { if text.is_empty() { return None; } + self.turn_index += 1; + self.messages.push(Message { + kind: MessageKind::TurnHeader, + content: format!("#{}", self.turn_index), + }); self.messages.push(Message { kind: MessageKind::User, content: text.clone(), @@ -57,8 +74,10 @@ impl App { pub fn handle_pod_event(&mut self, event: Event) { match event { - Event::TurnStart { turn } => { - self.status_line = format!("turn {turn}"); + Event::TurnStart { .. } => { + self.running = true; + self.run_requests += 1; + self.current_tool = None; } Event::TextDelta { text } => { self.current_text.push_str(&text); @@ -73,7 +92,7 @@ impl App { self.scroll_to_bottom(); } } - Event::TurnEnd { turn, result } => { + Event::TurnEnd { .. } => { if !self.current_text.is_empty() { let text = std::mem::take(&mut self.current_text); self.messages.push(Message { @@ -81,10 +100,10 @@ impl App { content: text, }); } - self.status_line = format!("turn {turn} {result:?}"); + self.current_tool = None; } Event::ToolCallStart { name, .. } => { - self.status_line = format!("tool: {name}"); + self.current_tool = Some(name.clone()); self.messages.push(Message { kind: MessageKind::Tool, content: format!("[tool] {name}"), @@ -94,6 +113,7 @@ impl App { Event::ToolCallDone { name, arguments, .. } => { + self.current_tool = None; self.messages.push(Message { kind: MessageKind::Tool, content: format!("[tool] {name} done ({} bytes)", arguments.len()), @@ -119,10 +139,8 @@ impl App { input_tokens, output_tokens, } => { - let in_t = input_tokens.unwrap_or(0); - let out_t = output_tokens.unwrap_or(0); - self.status_line - .push_str(&format!(" | in:{in_t} out:{out_t}")); + self.run_input_tokens += input_tokens.unwrap_or(0); + self.run_output_tokens += output_tokens.unwrap_or(0); } Event::Error { code, message } => { self.messages.push(Message { @@ -131,8 +149,22 @@ impl App { }); self.scroll_to_bottom(); } - Event::RunEnd { result } => { - self.status_line = format!("{result:?}"); + Event::RunEnd { .. } => { + 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::History { items } => { @@ -220,13 +252,21 @@ impl App { fn restore_history(&mut self, items: &[serde_json::Value]) { self.messages.clear(); + self.turn_index = 0; for item in items { let item_type = item["type"].as_str().unwrap_or(""); match item_type { "message" => { let role = item["role"].as_str().unwrap_or(""); 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, _ => continue, }; @@ -276,3 +316,13 @@ impl App { 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() + } +} diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index db2831a3..0f0c94e4 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -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::text::{Line, Span}; use ratatui::widgets::{Paragraph, Wrap}; use ratatui::Frame; -use crate::app::{App, MessageKind}; +use crate::app::{fmt_tokens, App, MessageKind}; pub fn draw(frame: &mut Frame, app: &mut App) { let chunks = Layout::vertical([ @@ -28,9 +28,14 @@ fn draw_messages(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect) .iter() .flat_map(|(kind, content)| { let style = kind_style(kind); + let align = if matches!(kind, MessageKind::TurnStats) { + Alignment::Right + } else { + Alignment::Left + }; content .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(); @@ -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::styled( - &app.status_line, - Style::default().fg(Color::Yellow), - )); + spans.push(Span::styled(status, Style::default().fg(Color::Yellow))); + } else { + spans.push(Span::styled(" idle", Style::default().fg(Color::DarkGray))); } frame.render_widget(Paragraph::new(Line::from(spans)), area); } + fn draw_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { let line = Line::from(vec![ 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 { match kind { + MessageKind::TurnHeader => Style::default().fg(Color::DarkGray), MessageKind::User => Style::default().fg(Color::Green), MessageKind::Assistant => Style::default().fg(Color::White), MessageKind::Tool => Style::default().fg(Color::Cyan), MessageKind::Error => Style::default().fg(Color::Red), + MessageKind::TurnStats => Style::default().fg(Color::DarkGray), } }