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 crate::app::{fmt_tokens, App, MessageKind, OutputItem}; /// 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::Length(1), // separator Constraint::Length(1), // status Constraint::Length(1), // input ]) .split(area); draw_separator(frame, chunks[0]); draw_status(frame, app, chunks[1]); draw_input(frame, app, chunks[2]); } /// 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 width = terminal.size()?.width; for item in items { match item { OutputItem::Blank => { terminal.insert_before(1, |buf| { // empty line let _ = buf; })?; } 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); })?; } 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 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); })?; } 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); })?; } } } Ok(()) } fn wrapped_height(lines: &[Line], avail_width: u16) -> u16 { if avail_width == 0 { return lines.len().max(1) as u16; } lines .iter() .map(|line| { let w = line.width() as u16; if w == 0 { 1 } else { w.div_ceil(avail_width) } }) .sum::() .max(1) } fn draw_separator(frame: &mut Frame, area: Rect) { let line = "─".repeat(area.width as usize); 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: Rect) { let conn = if app.connected { Span::styled("●", Style::default().fg(Color::Green)) } else { Span::styled("○", Style::default().fg(Color::Red)) }; let mut spans = vec![ conn, Span::raw(" "), Span::styled( &app.pod_name, Style::default().add_modifier(Modifier::BOLD), ), ]; 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(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: Rect) { let line = Line::from(vec![ Span::styled("> ", Style::default().fg(Color::DarkGray)), Span::raw(&app.input), ]); frame.render_widget(Paragraph::new(line), area); let cursor_x = area.x + 2 + app.input[..app.cursor].chars().count() as u16; let cursor_y = area.y; frame.set_cursor_position(Position::new(cursor_x, cursor_y)); } pub 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), } } use ratatui::widgets::Widget;