cratesの整理
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "tui"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
protocol = { path = "../protocol" }
|
||||
ratatui = "0.29"
|
||||
crossterm = "0.28"
|
||||
tokio = { version = "1.49", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] }
|
||||
serde_json = "1.0"
|
||||
@@ -0,0 +1,227 @@
|
||||
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::ToolCallArgsDelta { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use protocol::{Event, Method};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub struct PodClient {
|
||||
writer: tokio::io::WriteHalf<UnixStream>,
|
||||
event_rx: mpsc::Receiver<Event>,
|
||||
}
|
||||
|
||||
impl PodClient {
|
||||
pub async fn connect(path: &Path) -> Result<Self, io::Error> {
|
||||
let stream = UnixStream::connect(path).await?;
|
||||
let (reader, writer) = tokio::io::split(stream);
|
||||
|
||||
let (event_tx, event_rx) = mpsc::channel::<Event>(256);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(reader).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(event) = serde_json::from_str::<Event>(&line) {
|
||||
if event_tx.send(event).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self { writer, event_rx })
|
||||
}
|
||||
|
||||
pub async fn send(&mut self, method: &Method) -> Result<(), io::Error> {
|
||||
let json = serde_json::to_string(method)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
self.writer.write_all(json.as_bytes()).await?;
|
||||
self.writer.write_all(b"\n").await?;
|
||||
self.writer.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn next_event(&mut self) -> Option<Event> {
|
||||
self.event_rx.recv().await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
mod app;
|
||||
mod client;
|
||||
mod ui;
|
||||
|
||||
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 protocol::Method;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::client::PodClient;
|
||||
|
||||
fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
|
||||
if let Some(p) = override_path {
|
||||
return p;
|
||||
}
|
||||
if let Ok(rd) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
PathBuf::from(rd).join("insomnia").join(pod_name).join("sock")
|
||||
} else if let Ok(home) = std::env::var("HOME") {
|
||||
PathBuf::from(home)
|
||||
.join(".insomnia")
|
||||
.join("run")
|
||||
.join(pod_name)
|
||||
.join("sock")
|
||||
} else {
|
||||
PathBuf::from("/tmp")
|
||||
.join("insomnia")
|
||||
.join(pod_name)
|
||||
.join("sock")
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_args() -> (String, Option<PathBuf>) {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() < 2 {
|
||||
eprintln!("usage: tui <pod_name> [--socket <path>]");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let pod_name = args[1].clone();
|
||||
let socket = args
|
||||
.windows(2)
|
||||
.find(|w| w[0] == "--socket")
|
||||
.map(|w| PathBuf::from(&w[1]));
|
||||
(pod_name, socket)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
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 backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let mut app = App::new(pod_name);
|
||||
|
||||
// Connect to pod
|
||||
match PodClient::connect(&socket_path).await {
|
||||
Ok(client) => {
|
||||
app.connected = true;
|
||||
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)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore terminal
|
||||
terminal::disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_loop(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
app: &mut App,
|
||||
mut client: PodClient,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
loop {
|
||||
terminal.draw(|f| ui::draw(f, app))?;
|
||||
|
||||
if app.quit {
|
||||
break;
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
// Terminal input
|
||||
_ = tokio::task::spawn_blocking(|| event::poll(std::time::Duration::from_millis(50))) => {
|
||||
while event::poll(std::time::Duration::ZERO)? {
|
||||
if let TermEvent::Key(key) = event::read()? {
|
||||
if let Some(method) = handle_key(app, key) {
|
||||
client.send(&method).await?;
|
||||
}
|
||||
if app.quit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pod events
|
||||
event = client.next_event() => {
|
||||
match event {
|
||||
Some(ev) => app.handle_pod_event(ev),
|
||||
None => {
|
||||
app.connected = false;
|
||||
app.messages.push(app::Message {
|
||||
kind: app::MessageKind::Error,
|
||||
content: "Connection lost".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_disconnected(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
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 {
|
||||
KeyCode::Esc => break,
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
app.quit = true;
|
||||
None
|
||||
}
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
app.quit = true;
|
||||
None
|
||||
}
|
||||
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
Some(Method::Resume)
|
||||
}
|
||||
KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
Some(Method::Cancel)
|
||||
}
|
||||
KeyCode::Enter => app.submit_input(),
|
||||
KeyCode::Backspace => {
|
||||
app.delete_char_before();
|
||||
None
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
app.delete_char_after();
|
||||
None
|
||||
}
|
||||
KeyCode::Left => {
|
||||
app.move_cursor_left();
|
||||
None
|
||||
}
|
||||
KeyCode::Right => {
|
||||
app.move_cursor_right();
|
||||
None
|
||||
}
|
||||
KeyCode::Home => {
|
||||
app.move_cursor_home();
|
||||
None
|
||||
}
|
||||
KeyCode::End => {
|
||||
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
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use ratatui::layout::{Constraint, Layout, Position};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::{App, MessageKind};
|
||||
|
||||
pub fn draw(frame: &mut Frame, app: &mut App) {
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(3),
|
||||
Constraint::Length(3),
|
||||
])
|
||||
.split(frame.area());
|
||||
|
||||
draw_status_bar(frame, app, chunks[0]);
|
||||
draw_output(frame, app, chunks[1]);
|
||||
draw_input(frame, app, chunks[2]);
|
||||
}
|
||||
|
||||
fn draw_status_bar(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
|
||||
let conn_style = if app.connected {
|
||||
Style::default().fg(Color::Green)
|
||||
} else {
|
||||
Style::default().fg(Color::Red)
|
||||
};
|
||||
let conn_text = if app.connected {
|
||||
"connected"
|
||||
} else {
|
||||
"disconnected"
|
||||
};
|
||||
|
||||
let line = Line::from(vec![
|
||||
Span::styled(&app.pod_name, Style::default().add_modifier(Modifier::BOLD)),
|
||||
Span::raw(" | "),
|
||||
Span::styled(conn_text, conn_style),
|
||||
]);
|
||||
|
||||
frame.render_widget(Paragraph::new(line), area);
|
||||
}
|
||||
|
||||
fn draw_output(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
|
||||
let display = app.display_lines();
|
||||
|
||||
let lines: Vec<Line> = display
|
||||
.iter()
|
||||
.flat_map(|(kind, content)| {
|
||||
let style = kind_style(kind);
|
||||
content.lines().map(move |l| Line::from(Span::styled(l.to_owned(), style)))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total = lines.len() as u16;
|
||||
let visible = area.height.saturating_sub(2); // block borders
|
||||
let max_scroll = total.saturating_sub(visible);
|
||||
if app.scroll > max_scroll {
|
||||
app.scroll = max_scroll;
|
||||
}
|
||||
|
||||
let block = Block::default().borders(Borders::ALL);
|
||||
let paragraph = Paragraph::new(lines)
|
||||
.block(block)
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((app.scroll, 0));
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
fn draw_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
|
||||
let display = format!("> {}", app.input);
|
||||
let block = Block::default().borders(Borders::ALL).title("Input");
|
||||
let paragraph = Paragraph::new(display).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
|
||||
// Cursor position: "> " is 2 chars, plus cursor offset in the input
|
||||
let cursor_x = area.x + 1 + 2 + app.input[..app.cursor].chars().count() as u16;
|
||||
let cursor_y = area.y + 1;
|
||||
frame.set_cursor_position(Position::new(cursor_x, cursor_y));
|
||||
}
|
||||
|
||||
fn kind_style(kind: &MessageKind) -> Style {
|
||||
match kind {
|
||||
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::Status => Style::default().fg(Color::DarkGray),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user