TUIをinline viewportに変更
This commit is contained in:
parent
48e62f65df
commit
9747bd6d34
12
Cargo.lock
generated
12
Cargo.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user