TUIをinline viewportに変更

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

12
Cargo.lock generated
View File

@ -2812,18 +2812,6 @@ dependencies = [
"ratatui", "ratatui",
"serde_json", "serde_json",
"tokio", "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]] [[package]]

View File

@ -10,4 +10,3 @@ ratatui = "0.30.0"
crossterm = "0.28" crossterm = "0.28"
tokio = { version = "1.49", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] } tokio = { version = "1.49", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] }
serde_json = "1.0" serde_json = "1.0"
tui-scrollview = "0.6.4"

View File

@ -1,11 +1,8 @@
use protocol::{Event, Method}; use protocol::{Event, Method};
use tui_scrollview::ScrollViewState;
pub struct App { pub struct App {
pub pod_name: String, pub pod_name: String,
pub connected: bool, pub connected: bool,
pub messages: Vec<Message>,
pub current_text: String,
pub running: bool, pub running: bool,
pub run_requests: usize, pub run_requests: usize,
pub run_input_tokens: u64, pub run_input_tokens: u64,
@ -14,13 +11,19 @@ pub struct App {
pub current_tool: Option<String>, pub current_tool: Option<String>,
pub input: String, pub input: String,
pub cursor: usize, pub cursor: usize,
pub scroll_state: ScrollViewState,
pub quit: bool, 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 { /// A unit of output to push above the inline viewport.
pub kind: MessageKind, pub enum OutputItem {
pub content: String, TurnHeader(String),
Padded(MessageKind, String),
PaddedRight(MessageKind, String),
Blank,
} }
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@ -38,8 +41,6 @@ impl App {
Self { Self {
pod_name, pod_name,
connected: false, connected: false,
messages: Vec::new(),
current_text: String::new(),
running: false, running: false,
run_requests: 0, run_requests: 0,
run_input_tokens: 0, run_input_tokens: 0,
@ -48,8 +49,9 @@ impl App {
current_tool: None, current_tool: None,
input: String::new(), input: String::new(),
cursor: 0, cursor: 0,
scroll_state: ScrollViewState::new(),
quit: false, quit: false,
output_queue: Vec::new(),
pending_text: String::new(),
} }
} }
@ -59,17 +61,14 @@ impl App {
return None; return None;
} }
self.turn_index += 1; self.turn_index += 1;
self.messages.push(Message { self.output_queue.push(OutputItem::Blank);
kind: MessageKind::TurnHeader, self.output_queue
content: format!("#{}", self.turn_index), .push(OutputItem::TurnHeader(format!("#{}", self.turn_index)));
}); self.output_queue
self.messages.push(Message { .push(OutputItem::Padded(MessageKind::User, text.clone()));
kind: MessageKind::User, self.output_queue.push(OutputItem::Blank);
content: text.clone(),
});
self.input.clear(); self.input.clear();
self.cursor = 0; self.cursor = 0;
self.scroll_to_bottom();
Some(Method::Run { input: text }) Some(Method::Run { input: text })
} }
@ -81,45 +80,39 @@ impl App {
self.current_tool = None; self.current_tool = None;
} }
Event::TextDelta { text } => { Event::TextDelta { text } => {
self.current_text.push_str(&text); self.pending_text.push_str(&text);
self.flush_pending_lines();
} }
Event::TextDone { .. } => { Event::TextDone { .. } => {
let text = std::mem::take(&mut self.current_text); // Flush any remaining partial line
if !text.is_empty() { if !self.pending_text.is_empty() {
self.messages.push(Message { let text = std::mem::take(&mut self.pending_text);
kind: MessageKind::Assistant, self.output_queue
content: text, .push(OutputItem::Padded(MessageKind::Assistant, text));
});
self.scroll_to_bottom();
} }
} }
Event::TurnEnd { .. } => { Event::TurnEnd { .. } => {
if !self.current_text.is_empty() { // Flush streaming text if TextDone wasn't received
let text = std::mem::take(&mut self.current_text); if !self.pending_text.is_empty() {
self.messages.push(Message { let text = std::mem::take(&mut self.pending_text);
kind: MessageKind::Assistant, self.output_queue
content: text, .push(OutputItem::Padded(MessageKind::Assistant, text));
});
} }
self.current_tool = None; self.current_tool = None;
} }
Event::ToolCallStart { name, .. } => { Event::ToolCallStart { name, .. } => {
self.current_tool = Some(name.clone()); self.current_tool = Some(name.clone());
self.messages.push(Message { self.output_queue
kind: MessageKind::Tool, .push(OutputItem::Padded(MessageKind::Tool, format!("[tool] {name}")));
content: format!("[tool] {name}"),
});
self.scroll_to_bottom();
} }
Event::ToolCallDone { Event::ToolCallDone {
name, arguments, .. name, arguments, ..
} => { } => {
self.current_tool = None; self.current_tool = None;
self.messages.push(Message { self.output_queue.push(OutputItem::Padded(
kind: MessageKind::Tool, MessageKind::Tool,
content: format!("[tool] {name} done ({} bytes)", arguments.len()), format!("[tool] {name} done ({} bytes)", arguments.len()),
}); ));
self.scroll_to_bottom();
} }
Event::ToolResult { Event::ToolResult {
output, is_error, .. output, is_error, ..
@ -130,11 +123,10 @@ impl App {
} else { } else {
output output
}; };
self.messages.push(Message { self.output_queue.push(OutputItem::Padded(
kind: MessageKind::Tool, MessageKind::Tool,
content: format!("{prefix} {display}"), format!("{prefix} {display}"),
}); ));
self.scroll_to_bottom();
} }
Event::Usage { Event::Usage {
input_tokens, input_tokens,
@ -144,28 +136,27 @@ impl App {
self.run_output_tokens += output_tokens.unwrap_or(0); self.run_output_tokens += output_tokens.unwrap_or(0);
} }
Event::Error { code, message } => { Event::Error { code, message } => {
self.messages.push(Message { self.output_queue.push(OutputItem::Padded(
kind: MessageKind::Error, MessageKind::Error,
content: format!("[{code:?}] {message}"), format!("[{code:?}] {message}"),
}); ));
self.scroll_to_bottom();
} }
Event::RunEnd { .. } => { Event::RunEnd { .. } => {
self.messages.push(Message { self.output_queue.push(OutputItem::PaddedRight(
kind: MessageKind::TurnStats, MessageKind::TurnStats,
content: format!( format!(
"{} reqs ↑{}/↓{}", "{} reqs ↑{}/↓{}",
self.run_requests, self.run_requests,
fmt_tokens(self.run_input_tokens), fmt_tokens(self.run_input_tokens),
fmt_tokens(self.run_output_tokens), fmt_tokens(self.run_output_tokens),
), ),
}); ));
self.output_queue.push(OutputItem::Blank);
self.running = false; self.running = false;
self.run_requests = 0; self.run_requests = 0;
self.run_input_tokens = 0; self.run_input_tokens = 0;
self.run_output_tokens = 0; self.run_output_tokens = 0;
self.current_tool = None; self.current_tool = None;
self.scroll_to_bottom();
} }
Event::ToolCallArgsDelta { .. } => {} Event::ToolCallArgsDelta { .. } => {}
Event::History { items } => { 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) { pub fn insert_char(&mut self, c: char) {
self.input.insert(self.cursor, c); self.input.insert(self.cursor, c);
self.cursor += c.len_utf8(); self.cursor += c.len_utf8();
@ -230,20 +231,7 @@ impl App {
self.cursor = self.input.len(); 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]) { fn restore_history(&mut self, items: &[serde_json::Value]) {
self.messages.clear();
self.turn_index = 0; 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("");
@ -253,10 +241,11 @@ impl App {
let kind = match role { let kind = match role {
"user" => { "user" => {
self.turn_index += 1; self.turn_index += 1;
self.messages.push(Message { self.output_queue.push(OutputItem::Blank);
kind: MessageKind::TurnHeader, self.output_queue.push(OutputItem::TurnHeader(format!(
content: format!("#{}", self.turn_index), "#{}",
}); self.turn_index
)));
MessageKind::User MessageKind::User
} }
"assistant" => MessageKind::Assistant, "assistant" => MessageKind::Assistant,
@ -264,26 +253,20 @@ impl App {
}; };
let text = item["content"] let text = item["content"]
.as_array() .as_array()
.and_then(|parts| { .and_then(|parts| parts.iter().filter_map(|p| p["text"].as_str()).next())
parts
.iter()
.filter_map(|p| p["text"].as_str())
.next()
})
.unwrap_or(""); .unwrap_or("");
if !text.is_empty() { if !text.is_empty() {
self.messages.push(Message { self.output_queue
kind, .push(OutputItem::Padded(kind, text.to_owned()));
content: text.to_owned(), if matches!(kind, MessageKind::User) {
}); self.output_queue.push(OutputItem::Blank);
}
} }
} }
"tool_call" => { "tool_call" => {
let name = item["name"].as_str().unwrap_or("?"); let name = item["name"].as_str().unwrap_or("?");
self.messages.push(Message { self.output_queue
kind: MessageKind::Tool, .push(OutputItem::Padded(MessageKind::Tool, format!("[tool] {name}")));
content: format!("[tool] {name}"),
});
} }
"tool_result" => { "tool_result" => {
let output = item["output"].as_str().unwrap_or(""); let output = item["output"].as_str().unwrap_or("");
@ -292,19 +275,14 @@ impl App {
} else { } else {
output.to_owned() output.to_owned()
}; };
self.messages.push(Message { self.output_queue.push(OutputItem::Padded(
kind: MessageKind::Tool, MessageKind::Tool,
content: format!("[tool result] {display}"), 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 std::path::PathBuf;
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEvent, KeyModifiers};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::terminal;
use crossterm::{execute};
use protocol::Method; use protocol::Method;
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use ratatui::Terminal; use ratatui::{Terminal, TerminalOptions, Viewport};
use crate::app::App; use crate::app::App;
use crate::client::PodClient; 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 (pod_name, socket_override) = parse_args();
let socket_path = resolve_socket(&pod_name, socket_override); 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()?; terminal::enable_raw_mode()?;
let mut stdout = io::stdout(); let stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(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); let mut app = App::new(pod_name);
// Connect to pod
match PodClient::connect(&socket_path).await { match PodClient::connect(&socket_path).await {
Ok(mut client) => { Ok(mut client) => {
app.connected = true; app.connected = true;
@ -79,18 +72,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
run_loop(&mut terminal, &mut app, client).await?; run_loop(&mut terminal, &mut app, client).await?;
} }
Err(e) => { Err(e) => {
app.messages.push(app::Message { app.output_queue.push(app::OutputItem::Padded(
kind: app::MessageKind::Error, app::MessageKind::Error,
content: format!("Failed to connect to {}: {e}", socket_path.display()), format!("Failed to connect to {}: {e}", socket_path.display()),
}); ));
// Show error and wait for quit ui::flush_output(&mut terminal, &mut app)?;
run_disconnected(&mut terminal, &mut app)?; terminal.draw(|f| ui::draw(f, &app))?;
run_disconnected(&mut app)?;
} }
} }
// Restore terminal
terminal::disable_raw_mode()?; terminal::disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
Ok(()) Ok(())
} }
@ -100,9 +92,10 @@ async fn run_loop(
app: &mut App, app: &mut App,
mut client: PodClient, mut client: PodClient,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
loop { // Initial draw of the viewport
terminal.draw(|f| ui::draw(f, app))?; terminal.draw(|f| ui::draw(f, app))?;
loop {
if app.quit { if app.quit {
break; break;
} }
@ -127,26 +120,26 @@ async fn run_loop(
Some(ev) => app.handle_pod_event(ev), Some(ev) => app.handle_pod_event(ev),
None => { None => {
app.connected = false; app.connected = false;
app.messages.push(app::Message { app.output_queue.push(app::OutputItem::Padded(
kind: app::MessageKind::Error, app::MessageKind::Error,
content: "Connection lost".into(), "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(()) Ok(())
} }
fn run_disconnected( fn run_disconnected(app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
) -> Result<(), Box<dyn std::error::Error>> {
loop { loop {
terminal.draw(|f| ui::draw(f, app))?;
if event::poll(std::time::Duration::from_millis(100))? { if event::poll(std::time::Duration::from_millis(100))? {
if let TermEvent::Key(key) = event::read()? { if let TermEvent::Key(key) = event::read()? {
match key.code { match key.code {
@ -201,14 +194,6 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
app.move_cursor_end(); app.move_cursor_end();
None None
} }
KeyCode::PageUp => {
app.scroll_up();
None
}
KeyCode::PageDown => {
app.scroll_down();
None
}
KeyCode::Char(c) => { KeyCode::Char(c) => {
app.insert_char(c); app.insert_char(c);
None 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::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Padding, Paragraph, Wrap}; use ratatui::widgets::{Block, Padding, Paragraph, Wrap};
use ratatui::Frame; 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([ let chunks = Layout::vertical([
Constraint::Min(1), // messages (scroll area)
Constraint::Length(1), // separator Constraint::Length(1), // separator
Constraint::Length(1), // status line Constraint::Length(1), // status
Constraint::Length(1), // input Constraint::Length(1), // input
]) ])
.split(frame.area()); .split(area);
draw_messages(frame, app, chunks[0]); draw_separator(frame, chunks[0]);
draw_separator(frame, chunks[1]); draw_status(frame, app, chunks[1]);
draw_status(frame, app, chunks[2]); draw_input(frame, app, chunks[2]);
draw_input(frame, app, chunks[3]);
} }
fn draw_messages(frame: &mut Frame, app: &mut App, area: Rect) { /// Flush queued output items above the inline viewport via insert_before.
let width = area.width; pub fn flush_output(
let padded_inner = width.saturating_sub(1); // content width inside Block::padding(left=1) terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>,
app: &mut App,
// Build segments: (is_padded, lines, wrapped_height) ) -> std::io::Result<()> {
struct Seg<'a> { let items: Vec<OutputItem> = app.output_queue.drain(..).collect();
lines: Vec<Line<'a>>, if items.is_empty() {
padded: bool, return Ok(());
height: u16,
} }
let mut segs: Vec<Seg> = Vec::new(); let width = terminal.size()?.width;
let mut content: Vec<Line> = Vec::new();
macro_rules! flush_content { for item in items {
() => { match item {
if !content.is_empty() { OutputItem::Blank => {
let h = wrapped_height(&content, padded_inner); terminal.insert_before(1, |buf| {
segs.push(Seg { lines: std::mem::take(&mut content), padded: true, height: h }); // empty line
let _ = buf;
})?;
} }
}; OutputItem::TurnHeader(text) => {
} terminal.insert_before(1, |buf| {
let style = kind_style(&MessageKind::TurnHeader);
for msg in &app.messages { Paragraph::new(Line::from(Span::styled(text, style)))
let style = kind_style(&msg.kind); .render(buf.area, buf);
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 });
} }
MessageKind::TurnStats => { OutputItem::Padded(kind, text) => {
flush_content!(); let style = kind_style(&kind);
let lines: Vec<Line> = msg.content.lines() let lines: Vec<Line> = text
.map(|l| Line::from(Span::styled(l.to_owned(), style)).alignment(Alignment::Right)) .lines()
.map(|l| Line::from(Span::styled(l.to_owned(), style)))
.collect(); .collect();
let h = wrapped_height(&lines, padded_inner); let height = wrapped_height(&lines, width.saturating_sub(1));
segs.push(Seg { lines, padded: true, height: h }); terminal.insert_before(height, |buf| {
segs.push(Seg { lines: vec![Line::raw("")], padded: false, height: 1 }); Paragraph::new(lines)
.block(Block::default().padding(Padding::left(1)))
.wrap(Wrap { trim: false })
.render(buf.area, buf);
})?;
} }
MessageKind::User => { OutputItem::PaddedRight(kind, text) => {
for l in msg.content.lines() { let style = kind_style(&kind);
content.push(Line::from(Span::styled(l.to_owned(), style))); let lines: Vec<Line> = text
} .lines()
content.push(Line::raw("")); .map(|l| {
} Line::from(Span::styled(l.to_owned(), style)).alignment(Alignment::Right)
_ => { })
for l in msg.content.lines() { .collect();
content.push(Line::from(Span::styled(l.to_owned(), style))); 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 Ok(())
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);
} }
/// Estimate the number of visual rows after wrapping.
fn wrapped_height(lines: &[Line], avail_width: u16) -> u16 { fn wrapped_height(lines: &[Line], avail_width: u16) -> u16 {
if avail_width == 0 { if avail_width == 0 {
return lines.len() as u16; return lines.len().max(1) as u16;
} }
lines lines
.iter() .iter()
@ -126,19 +94,22 @@ fn wrapped_height(lines: &[Line], avail_width: u16) -> u16 {
let w = line.width() as u16; let w = line.width() as u16;
if w == 0 { 1 } else { w.div_ceil(avail_width) } 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 line = "".repeat(area.width as usize);
let paragraph = Paragraph::new(Line::from(Span::styled( frame.render_widget(
line, Paragraph::new(Line::from(Span::styled(
Style::default().fg(Color::DarkGray), line,
))); Style::default().fg(Color::DarkGray),
frame.render_widget(paragraph, area); ))),
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 { let conn = if app.connected {
Span::styled("", Style::default().fg(Color::Green)) Span::styled("", Style::default().fg(Color::Green))
} else { } 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); frame.render_widget(Paragraph::new(Line::from(spans)), area);
} }
fn draw_input(frame: &mut Frame, app: &App, area: 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)),
Span::raw(&app.input), 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)); 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 { match kind {
MessageKind::TurnHeader => Style::default().fg(Color::DarkGray), MessageKind::TurnHeader => Style::default().fg(Color::DarkGray),
MessageKind::User => Style::default().fg(Color::Green), MessageKind::User => Style::default().fg(Color::Green),
@ -202,3 +172,5 @@ fn kind_style(kind: &MessageKind) -> Style {
MessageKind::TurnStats => Style::default().fg(Color::DarkGray), MessageKind::TurnStats => Style::default().fg(Color::DarkGray),
} }
} }
use ratatui::widgets::Widget;