yoi/crates/tui/src/app.rs

287 lines
8.8 KiB
Rust

use protocol::{Event, Method};
pub struct App {
pub pod_name: String,
pub connected: bool,
pub messages: Vec<Message>,
pub current_text: String,
pub input: String,
pub cursor: usize,
pub scroll: u16,
pub quit: bool,
}
pub struct Message {
pub kind: MessageKind,
pub content: String,
}
#[derive(Clone, Copy)]
pub enum MessageKind {
User,
Assistant,
Tool,
Error,
Status,
}
impl App {
pub fn new(pod_name: String) -> Self {
Self {
pod_name,
connected: false,
messages: Vec::new(),
current_text: String::new(),
input: String::new(),
cursor: 0,
scroll: 0,
quit: false,
}
}
pub fn submit_input(&mut self) -> Option<Method> {
let text = self.input.trim().to_owned();
if text.is_empty() {
return None;
}
self.messages.push(Message {
kind: MessageKind::User,
content: text.clone(),
});
self.input.clear();
self.cursor = 0;
self.scroll_to_bottom();
Some(Method::Run { input: text })
}
pub fn handle_pod_event(&mut self, event: Event) {
match event {
Event::TurnStart { turn } => {
self.push_status(format!("[turn {turn}] start"));
}
Event::TextDelta { text } => {
self.current_text.push_str(&text);
}
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();
}
}
Event::TurnEnd { turn, result } => {
// Flush any remaining text delta
if !self.current_text.is_empty() {
let text = std::mem::take(&mut self.current_text);
self.messages.push(Message {
kind: MessageKind::Assistant,
content: text,
});
}
self.push_status(format!("[turn {turn}] end ({result:?})"));
}
Event::ToolCallStart { name, .. } => {
self.messages.push(Message {
kind: MessageKind::Tool,
content: format!("[tool] {name}"),
});
self.scroll_to_bottom();
}
Event::ToolCallDone {
name, arguments, ..
} => {
self.messages.push(Message {
kind: MessageKind::Tool,
content: format!("[tool] {name} done ({} bytes)", arguments.len()),
});
self.scroll_to_bottom();
}
Event::ToolResult {
output, is_error, ..
} => {
let prefix = if is_error { "[tool error]" } else { "[tool result]" };
let display = if output.len() > 200 {
format!("{}...", &output[..200])
} else {
output
};
self.messages.push(Message {
kind: MessageKind::Tool,
content: format!("{prefix} {display}"),
});
self.scroll_to_bottom();
}
Event::Usage {
input_tokens,
output_tokens,
} => {
self.push_status(format!(
"[usage] in={} out={}",
input_tokens.unwrap_or(0),
output_tokens.unwrap_or(0),
));
}
Event::Error { code, message } => {
self.messages.push(Message {
kind: MessageKind::Error,
content: format!("[{code:?}] {message}"),
});
self.scroll_to_bottom();
}
Event::RunEnd { result } => {
self.push_status(format!("[run end] {result:?}"));
}
Event::ToolCallArgsDelta { .. } => {}
Event::History { items } => {
self.restore_history(&items);
}
}
}
pub fn insert_char(&mut self, c: char) {
self.input.insert(self.cursor, c);
self.cursor += c.len_utf8();
}
pub fn delete_char_before(&mut self) {
if self.cursor > 0 {
let prev = self.input[..self.cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
self.input.drain(prev..self.cursor);
self.cursor = prev;
}
}
pub fn delete_char_after(&mut self) {
if self.cursor < self.input.len() {
let next = self.input[self.cursor..]
.char_indices()
.nth(1)
.map(|(i, _)| self.cursor + i)
.unwrap_or(self.input.len());
self.input.drain(self.cursor..next);
}
}
pub fn move_cursor_left(&mut self) {
if self.cursor > 0 {
self.cursor = self.input[..self.cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
}
}
pub fn move_cursor_right(&mut self) {
if self.cursor < self.input.len() {
self.cursor = self.input[self.cursor..]
.char_indices()
.nth(1)
.map(|(i, _)| self.cursor + i)
.unwrap_or(self.input.len());
}
}
pub fn move_cursor_home(&mut self) {
self.cursor = 0;
}
pub fn move_cursor_end(&mut self) {
self.cursor = self.input.len();
}
pub fn scroll_up(&mut self) {
self.scroll = self.scroll.saturating_sub(3);
}
pub fn scroll_down(&mut self) {
self.scroll = self.scroll.saturating_add(3);
}
/// Total visible lines (for rendering the in-progress text as part of output).
pub fn display_lines(&self) -> Vec<(&MessageKind, &str)> {
let mut lines: Vec<(&MessageKind, &str)> = self
.messages
.iter()
.map(|m| (&m.kind, m.content.as_str()))
.collect();
if !self.current_text.is_empty() {
lines.push((&MessageKind::Assistant, &self.current_text));
}
lines
}
fn restore_history(&mut self, items: &[serde_json::Value]) {
self.messages.clear();
for item in items {
let item_type = item["type"].as_str().unwrap_or("");
match item_type {
"message" => {
let role = item["role"].as_str().unwrap_or("");
let kind = match role {
"user" => MessageKind::User,
"assistant" => MessageKind::Assistant,
_ => continue,
};
let text = item["content"]
.as_array()
.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(),
});
}
}
"tool_call" => {
let name = item["name"].as_str().unwrap_or("?");
self.messages.push(Message {
kind: MessageKind::Tool,
content: format!("[tool] {name}"),
});
}
"tool_result" => {
let output = item["output"].as_str().unwrap_or("");
let display = if output.len() > 200 {
format!("{}...", &output[..200])
} else {
output.to_owned()
};
self.messages.push(Message {
kind: MessageKind::Tool,
content: format!("[tool result] {display}"),
});
}
_ => {}
}
}
self.scroll_to_bottom();
}
fn push_status(&mut self, content: String) {
self.messages.push(Message {
kind: MessageKind::Status,
content,
});
self.scroll_to_bottom();
}
fn scroll_to_bottom(&mut self) {
// Will be clamped during rendering
self.scroll = u16::MAX;
}
}