TUIをinline viewportに変更
This commit is contained in:
parent
bcc7faa0ba
commit
8b120504a7
12
Cargo.lock
generated
12
Cargo.lock
generated
|
|
@ -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]]
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
Paragraph::new(Line::from(Span::styled(text, style)))
|
||||||
|
.render(buf.area, buf);
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
OutputItem::Padded(kind, text) => {
|
||||||
for msg in &app.messages {
|
let style = kind_style(&kind);
|
||||||
let style = kind_style(&msg.kind);
|
let lines: Vec<Line> = text
|
||||||
match msg.kind {
|
.lines()
|
||||||
MessageKind::TurnHeader => {
|
.map(|l| Line::from(Span::styled(l.to_owned(), style)))
|
||||||
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 => {
|
|
||||||
flush_content!();
|
|
||||||
let lines: Vec<Line> = msg.content.lines()
|
|
||||||
.map(|l| Line::from(Span::styled(l.to_owned(), style)).alignment(Alignment::Right))
|
|
||||||
.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)
|
||||||
}
|
|
||||||
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)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)))
|
.block(Block::default().padding(Padding::left(1)))
|
||||||
.wrap(Wrap { trim: false }),
|
.wrap(Wrap { trim: false })
|
||||||
rect,
|
.render(buf.area, buf);
|
||||||
);
|
})?;
|
||||||
} else {
|
}
|
||||||
sv.render_widget(Paragraph::new(seg.lines), rect);
|
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);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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 {
|
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(
|
||||||
|
Paragraph::new(Line::from(Span::styled(
|
||||||
line,
|
line,
|
||||||
Style::default().fg(Color::DarkGray),
|
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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user