From 8b120504a7fd8d738d785e74eca48bf29d29eae1 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 12 Apr 2026 07:32:06 +0900 Subject: [PATCH] =?UTF-8?q?TUI=E3=82=92inline=20viewport=E3=81=AB=E5=A4=89?= =?UTF-8?q?=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 12 --- crates/tui/Cargo.toml | 1 - crates/tui/src/app.rs | 174 +++++++++++++++++---------------------- crates/tui/src/main.rs | 73 +++++++---------- crates/tui/src/ui.rs | 180 +++++++++++++++++------------------------ 5 files changed, 181 insertions(+), 259 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e6ef435d..b7203f36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2812,18 +2812,6 @@ dependencies = [ "ratatui", "serde_json", "tokio", - "tui-scrollview", -] - -[[package]] -name = "tui-scrollview" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94a94f467c7ac7c291039b0733e3b2d379c77884e34fc27d167921fc1ab4842f" -dependencies = [ - "indoc", - "ratatui-core", - "ratatui-widgets", ] [[package]] diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 34ef587a..b733fff9 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -10,4 +10,3 @@ ratatui = "0.30.0" crossterm = "0.28" tokio = { version = "1.49", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] } serde_json = "1.0" -tui-scrollview = "0.6.4" diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 340cc3ff..3d9739ac 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -1,11 +1,8 @@ use protocol::{Event, Method}; -use tui_scrollview::ScrollViewState; pub struct App { pub pod_name: String, pub connected: bool, - pub messages: Vec, - pub current_text: String, pub running: bool, pub run_requests: usize, pub run_input_tokens: u64, @@ -14,13 +11,19 @@ pub struct App { pub current_tool: Option, pub input: String, pub cursor: usize, - pub scroll_state: ScrollViewState, pub quit: bool, + /// Lines waiting to be flushed to terminal via insert_before. + pub output_queue: Vec, + /// Partial streaming text not yet terminated by newline. + pending_text: String, } -pub struct Message { - pub kind: MessageKind, - pub content: String, +/// A unit of output to push above the inline viewport. +pub enum OutputItem { + TurnHeader(String), + Padded(MessageKind, String), + PaddedRight(MessageKind, String), + Blank, } #[derive(Clone, Copy)] @@ -38,8 +41,6 @@ impl App { Self { pod_name, connected: false, - messages: Vec::new(), - current_text: String::new(), running: false, run_requests: 0, run_input_tokens: 0, @@ -48,8 +49,9 @@ impl App { current_tool: None, input: String::new(), cursor: 0, - scroll_state: ScrollViewState::new(), quit: false, + output_queue: Vec::new(), + pending_text: String::new(), } } @@ -59,17 +61,14 @@ impl App { 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(), - }); + self.output_queue.push(OutputItem::Blank); + self.output_queue + .push(OutputItem::TurnHeader(format!("#{}", self.turn_index))); + self.output_queue + .push(OutputItem::Padded(MessageKind::User, text.clone())); + self.output_queue.push(OutputItem::Blank); self.input.clear(); self.cursor = 0; - self.scroll_to_bottom(); Some(Method::Run { input: text }) } @@ -81,45 +80,39 @@ impl App { self.current_tool = None; } Event::TextDelta { text } => { - self.current_text.push_str(&text); + self.pending_text.push_str(&text); + self.flush_pending_lines(); } Event::TextDone { .. } => { - let text = std::mem::take(&mut self.current_text); - if !text.is_empty() { - self.messages.push(Message { - kind: MessageKind::Assistant, - content: text, - }); - self.scroll_to_bottom(); + // Flush any remaining partial line + if !self.pending_text.is_empty() { + let text = std::mem::take(&mut self.pending_text); + self.output_queue + .push(OutputItem::Padded(MessageKind::Assistant, text)); } } Event::TurnEnd { .. } => { - if !self.current_text.is_empty() { - let text = std::mem::take(&mut self.current_text); - self.messages.push(Message { - kind: MessageKind::Assistant, - content: text, - }); + // Flush streaming text if TextDone wasn't received + if !self.pending_text.is_empty() { + let text = std::mem::take(&mut self.pending_text); + self.output_queue + .push(OutputItem::Padded(MessageKind::Assistant, text)); } self.current_tool = None; } Event::ToolCallStart { name, .. } => { self.current_tool = Some(name.clone()); - self.messages.push(Message { - kind: MessageKind::Tool, - content: format!("[tool] {name}"), - }); - self.scroll_to_bottom(); + self.output_queue + .push(OutputItem::Padded(MessageKind::Tool, format!("[tool] {name}"))); } Event::ToolCallDone { name, arguments, .. } => { self.current_tool = None; - self.messages.push(Message { - kind: MessageKind::Tool, - content: format!("[tool] {name} done ({} bytes)", arguments.len()), - }); - self.scroll_to_bottom(); + self.output_queue.push(OutputItem::Padded( + MessageKind::Tool, + format!("[tool] {name} done ({} bytes)", arguments.len()), + )); } Event::ToolResult { output, is_error, .. @@ -130,11 +123,10 @@ impl App { } else { output }; - self.messages.push(Message { - kind: MessageKind::Tool, - content: format!("{prefix} {display}"), - }); - self.scroll_to_bottom(); + self.output_queue.push(OutputItem::Padded( + MessageKind::Tool, + format!("{prefix} {display}"), + )); } Event::Usage { input_tokens, @@ -144,28 +136,27 @@ impl App { self.run_output_tokens += output_tokens.unwrap_or(0); } Event::Error { code, message } => { - self.messages.push(Message { - kind: MessageKind::Error, - content: format!("[{code:?}] {message}"), - }); - self.scroll_to_bottom(); + self.output_queue.push(OutputItem::Padded( + MessageKind::Error, + format!("[{code:?}] {message}"), + )); } Event::RunEnd { .. } => { - self.messages.push(Message { - kind: MessageKind::TurnStats, - content: format!( + self.output_queue.push(OutputItem::PaddedRight( + MessageKind::TurnStats, + format!( "{} reqs ↑{}/↓{}", self.run_requests, fmt_tokens(self.run_input_tokens), fmt_tokens(self.run_output_tokens), ), - }); + )); + self.output_queue.push(OutputItem::Blank); 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 } => { @@ -174,6 +165,16 @@ impl App { } } + /// Extract complete lines (ending with \n) from pending_text and queue them. + fn flush_pending_lines(&mut self) { + while let Some(pos) = self.pending_text.find('\n') { + let line = self.pending_text[..pos].to_owned(); + self.pending_text = self.pending_text[pos + 1..].to_owned(); + self.output_queue + .push(OutputItem::Padded(MessageKind::Assistant, line)); + } + } + pub fn insert_char(&mut self, c: char) { self.input.insert(self.cursor, c); self.cursor += c.len_utf8(); @@ -230,20 +231,7 @@ impl App { self.cursor = self.input.len(); } - pub fn scroll_up(&mut self) { - self.scroll_state.scroll_up(); - self.scroll_state.scroll_up(); - self.scroll_state.scroll_up(); - } - - pub fn scroll_down(&mut self) { - self.scroll_state.scroll_down(); - self.scroll_state.scroll_down(); - self.scroll_state.scroll_down(); - } - 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(""); @@ -253,10 +241,11 @@ impl App { let kind = match role { "user" => { self.turn_index += 1; - self.messages.push(Message { - kind: MessageKind::TurnHeader, - content: format!("#{}", self.turn_index), - }); + self.output_queue.push(OutputItem::Blank); + self.output_queue.push(OutputItem::TurnHeader(format!( + "#{}", + self.turn_index + ))); MessageKind::User } "assistant" => MessageKind::Assistant, @@ -264,26 +253,20 @@ impl App { }; let text = item["content"] .as_array() - .and_then(|parts| { - parts - .iter() - .filter_map(|p| p["text"].as_str()) - .next() - }) + .and_then(|parts| parts.iter().filter_map(|p| p["text"].as_str()).next()) .unwrap_or(""); if !text.is_empty() { - self.messages.push(Message { - kind, - content: text.to_owned(), - }); + self.output_queue + .push(OutputItem::Padded(kind, text.to_owned())); + if matches!(kind, MessageKind::User) { + self.output_queue.push(OutputItem::Blank); + } } } "tool_call" => { let name = item["name"].as_str().unwrap_or("?"); - self.messages.push(Message { - kind: MessageKind::Tool, - content: format!("[tool] {name}"), - }); + self.output_queue + .push(OutputItem::Padded(MessageKind::Tool, format!("[tool] {name}"))); } "tool_result" => { let output = item["output"].as_str().unwrap_or(""); @@ -292,19 +275,14 @@ impl App { } else { output.to_owned() }; - self.messages.push(Message { - kind: MessageKind::Tool, - content: format!("[tool result] {display}"), - }); + self.output_queue.push(OutputItem::Padded( + MessageKind::Tool, + format!("[tool result] {display}"), + )); } _ => {} } } - self.scroll_to_bottom(); - } - - fn scroll_to_bottom(&mut self) { - self.scroll_state.scroll_to_bottom(); } } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index ab27a4e0..0f9789ca 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -6,11 +6,10 @@ use std::io; use std::path::PathBuf; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEvent, KeyModifiers}; -use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; -use crossterm::{execute}; +use crossterm::terminal; use protocol::Method; use ratatui::backend::CrosstermBackend; -use ratatui::Terminal; +use ratatui::{Terminal, TerminalOptions, Viewport}; use crate::app::App; use crate::client::PodClient; @@ -54,24 +53,18 @@ async fn main() -> Result<(), Box> { let (pod_name, socket_override) = parse_args(); let socket_path = resolve_socket(&pod_name, socket_override); - // Install panic hook to restore terminal - let original_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |info| { - let _ = terminal::disable_raw_mode(); - let _ = execute!(io::stdout(), LeaveAlternateScreen); - original_hook(info); - })); - - // Setup terminal terminal::enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; + let stdout = io::stdout(); let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; + let mut terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Inline(3), + }, + )?; let mut app = App::new(pod_name); - // Connect to pod match PodClient::connect(&socket_path).await { Ok(mut client) => { app.connected = true; @@ -79,18 +72,17 @@ async fn main() -> Result<(), Box> { run_loop(&mut terminal, &mut app, client).await?; } Err(e) => { - app.messages.push(app::Message { - kind: app::MessageKind::Error, - content: format!("Failed to connect to {}: {e}", socket_path.display()), - }); - // Show error and wait for quit - run_disconnected(&mut terminal, &mut app)?; + app.output_queue.push(app::OutputItem::Padded( + app::MessageKind::Error, + format!("Failed to connect to {}: {e}", socket_path.display()), + )); + ui::flush_output(&mut terminal, &mut app)?; + terminal.draw(|f| ui::draw(f, &app))?; + run_disconnected(&mut app)?; } } - // Restore terminal terminal::disable_raw_mode()?; - execute!(terminal.backend_mut(), LeaveAlternateScreen)?; Ok(()) } @@ -100,9 +92,10 @@ async fn run_loop( app: &mut App, mut client: PodClient, ) -> Result<(), Box> { - loop { - terminal.draw(|f| ui::draw(f, app))?; + // Initial draw of the viewport + terminal.draw(|f| ui::draw(f, app))?; + loop { if app.quit { break; } @@ -127,26 +120,26 @@ async fn run_loop( Some(ev) => app.handle_pod_event(ev), None => { app.connected = false; - app.messages.push(app::Message { - kind: app::MessageKind::Error, - content: "Connection lost".into(), - }); + app.output_queue.push(app::OutputItem::Padded( + app::MessageKind::Error, + "Connection lost".into(), + )); } } } } + + // Flush any queued output above the viewport + ui::flush_output(terminal, app)?; + // Redraw the fixed viewport (status + input) + terminal.draw(|f| ui::draw(f, app))?; } Ok(()) } -fn run_disconnected( - terminal: &mut Terminal>, - app: &mut App, -) -> Result<(), Box> { +fn run_disconnected(app: &mut App) -> Result<(), Box> { loop { - terminal.draw(|f| ui::draw(f, app))?; - if event::poll(std::time::Duration::from_millis(100))? { if let TermEvent::Key(key) = event::read()? { match key.code { @@ -201,14 +194,6 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { app.move_cursor_end(); None } - KeyCode::PageUp => { - app.scroll_up(); - None - } - KeyCode::PageDown => { - app.scroll_down(); - None - } KeyCode::Char(c) => { app.insert_char(c); None diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 71970965..4e41fbeb 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -1,124 +1,92 @@ -use ratatui::layout::{Alignment, Constraint, Layout, Position, Rect, Size}; +use ratatui::layout::{Alignment, Constraint, Layout, Position, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Padding, Paragraph, Wrap}; use ratatui::Frame; -use tui_scrollview::{ScrollView, ScrollbarVisibility}; -use crate::app::{fmt_tokens, App, MessageKind}; +use crate::app::{fmt_tokens, App, MessageKind, OutputItem}; -pub fn draw(frame: &mut Frame, app: &mut App) { +/// Draw the fixed viewport (3 lines: separator, status, input). +pub fn draw(frame: &mut Frame, app: &App) { + let area = frame.area(); let chunks = Layout::vertical([ - Constraint::Min(1), // messages (scroll area) Constraint::Length(1), // separator - Constraint::Length(1), // status line + Constraint::Length(1), // status Constraint::Length(1), // input ]) - .split(frame.area()); + .split(area); - draw_messages(frame, app, chunks[0]); - draw_separator(frame, chunks[1]); - draw_status(frame, app, chunks[2]); - draw_input(frame, app, chunks[3]); + draw_separator(frame, chunks[0]); + draw_status(frame, app, chunks[1]); + draw_input(frame, app, chunks[2]); } -fn draw_messages(frame: &mut Frame, app: &mut App, area: Rect) { - let width = area.width; - let padded_inner = width.saturating_sub(1); // content width inside Block::padding(left=1) - - // Build segments: (is_padded, lines, wrapped_height) - struct Seg<'a> { - lines: Vec>, - padded: bool, - height: u16, +/// Flush queued output items above the inline viewport via insert_before. +pub fn flush_output( + terminal: &mut ratatui::Terminal>, + app: &mut App, +) -> std::io::Result<()> { + let items: Vec = app.output_queue.drain(..).collect(); + if items.is_empty() { + return Ok(()); } - let mut segs: Vec = Vec::new(); - let mut content: Vec = Vec::new(); + let width = terminal.size()?.width; - macro_rules! flush_content { - () => { - if !content.is_empty() { - let h = wrapped_height(&content, padded_inner); - segs.push(Seg { lines: std::mem::take(&mut content), padded: true, height: h }); + for item in items { + match item { + OutputItem::Blank => { + terminal.insert_before(1, |buf| { + // empty line + let _ = buf; + })?; } - }; - } - - for msg in &app.messages { - let style = kind_style(&msg.kind); - match msg.kind { - MessageKind::TurnHeader => { - flush_content!(); - if !segs.is_empty() { - segs.push(Seg { lines: vec![Line::raw("")], padded: false, height: 1 }); - } - let lines = vec![Line::from(Span::styled(msg.content.clone(), style))]; - segs.push(Seg { lines, padded: false, height: 1 }); + OutputItem::TurnHeader(text) => { + terminal.insert_before(1, |buf| { + let style = kind_style(&MessageKind::TurnHeader); + Paragraph::new(Line::from(Span::styled(text, style))) + .render(buf.area, buf); + })?; } - MessageKind::TurnStats => { - flush_content!(); - let lines: Vec = msg.content.lines() - .map(|l| Line::from(Span::styled(l.to_owned(), style)).alignment(Alignment::Right)) + OutputItem::Padded(kind, text) => { + let style = kind_style(&kind); + let lines: Vec = text + .lines() + .map(|l| Line::from(Span::styled(l.to_owned(), style))) .collect(); - let h = wrapped_height(&lines, padded_inner); - segs.push(Seg { lines, padded: true, height: h }); - segs.push(Seg { lines: vec![Line::raw("")], padded: false, height: 1 }); + let height = wrapped_height(&lines, width.saturating_sub(1)); + terminal.insert_before(height, |buf| { + Paragraph::new(lines) + .block(Block::default().padding(Padding::left(1))) + .wrap(Wrap { trim: false }) + .render(buf.area, buf); + })?; } - MessageKind::User => { - for l in msg.content.lines() { - content.push(Line::from(Span::styled(l.to_owned(), style))); - } - content.push(Line::raw("")); - } - _ => { - for l in msg.content.lines() { - content.push(Line::from(Span::styled(l.to_owned(), style))); - } + OutputItem::PaddedRight(kind, text) => { + let style = kind_style(&kind); + let lines: Vec = text + .lines() + .map(|l| { + Line::from(Span::styled(l.to_owned(), style)).alignment(Alignment::Right) + }) + .collect(); + let height = wrapped_height(&lines, width.saturating_sub(1)); + terminal.insert_before(height, |buf| { + Paragraph::new(lines) + .block(Block::default().padding(Padding::left(1))) + .wrap(Wrap { trim: false }) + .render(buf.area, buf); + })?; } } } - // In-progress streaming text - if !app.current_text.is_empty() { - let style = kind_style(&MessageKind::Assistant); - for l in app.current_text.lines() { - content.push(Line::from(Span::styled(l.to_owned(), style))); - } - } - - flush_content!(); - - // Total content height - let total_height: u16 = segs.iter().map(|s| s.height).sum(); - - // Build ScrollView - let mut sv = ScrollView::new(Size::new(width, total_height.max(1))) - .horizontal_scrollbar_visibility(ScrollbarVisibility::Never); - - let mut y: u16 = 0; - for seg in segs { - let rect = Rect::new(0, y, width, seg.height); - if seg.padded { - sv.render_widget( - Paragraph::new(seg.lines) - .block(Block::default().padding(Padding::left(1))) - .wrap(Wrap { trim: false }), - rect, - ); - } else { - sv.render_widget(Paragraph::new(seg.lines), rect); - } - y += seg.height; - } - - frame.render_stateful_widget(sv, area, &mut app.scroll_state); + Ok(()) } -/// Estimate the number of visual rows after wrapping. fn wrapped_height(lines: &[Line], avail_width: u16) -> u16 { if avail_width == 0 { - return lines.len() as u16; + return lines.len().max(1) as u16; } lines .iter() @@ -126,19 +94,22 @@ fn wrapped_height(lines: &[Line], avail_width: u16) -> u16 { let w = line.width() as u16; if w == 0 { 1 } else { w.div_ceil(avail_width) } }) - .sum() + .sum::() + .max(1) } -fn draw_separator(frame: &mut Frame, area: ratatui::layout::Rect) { +fn draw_separator(frame: &mut Frame, area: Rect) { let line = "─".repeat(area.width as usize); - let paragraph = Paragraph::new(Line::from(Span::styled( - line, - Style::default().fg(Color::DarkGray), - ))); - frame.render_widget(paragraph, area); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + line, + Style::default().fg(Color::DarkGray), + ))), + area, + ); } -fn draw_status(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { +fn draw_status(frame: &mut Frame, app: &App, area: Rect) { let conn = if app.connected { Span::styled("●", Style::default().fg(Color::Green)) } else { @@ -179,8 +150,7 @@ fn draw_status(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { 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: Rect) { let line = Line::from(vec![ Span::styled("> ", Style::default().fg(Color::DarkGray)), Span::raw(&app.input), @@ -192,7 +162,7 @@ fn draw_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { frame.set_cursor_position(Position::new(cursor_x, cursor_y)); } -fn kind_style(kind: &MessageKind) -> Style { +pub fn kind_style(kind: &MessageKind) -> Style { match kind { MessageKind::TurnHeader => Style::default().fg(Color::DarkGray), MessageKind::User => Style::default().fg(Color::Green), @@ -202,3 +172,5 @@ fn kind_style(kind: &MessageKind) -> Style { MessageKind::TurnStats => Style::default().fg(Color::DarkGray), } } + +use ratatui::widgets::Widget;