TUIをinline viewportに変更

This commit is contained in:
Keisuke Hirata 2026-04-12 07:32:06 +09:00
parent 48e62f65df
commit 9747bd6d34
5 changed files with 181 additions and 259 deletions

12
Cargo.lock generated
View File

@ -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]]

View File

@ -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"

View File

@ -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<Message>,
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<String>,
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<OutputItem>,
/// 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();
}
}

View File

@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<CrosstermBackend<io::Stdout>>,
app: &mut App,
) -> Result<(), Box<dyn std::error::Error>> {
fn run_disconnected(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
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<Method> {
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

View File

@ -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<Line<'a>>,
padded: bool,
height: u16,
/// Flush queued output items above the inline viewport via insert_before.
pub fn flush_output(
terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>,
app: &mut App,
) -> std::io::Result<()> {
let items: Vec<OutputItem> = app.output_queue.drain(..).collect();
if items.is_empty() {
return Ok(());
}
let mut segs: Vec<Seg> = Vec::new();
let mut content: Vec<Line> = 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<Line> = 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<Line> = 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<Line> = 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::<u16>()
.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;