From cf6d57c5685c94de37e9ac129a33c1856195b4bd Mon Sep 17 00:00:00 2001 From: xiaoxixi Date: Sun, 26 Apr 2026 21:00:17 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20=E6=B7=BB=E5=8A=A0=20TUI?= =?UTF-8?q?=20=E7=BB=84=E4=BB=B6=E4=BB=A5=E6=94=AF=E6=8C=81=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E7=95=8C=E9=9D=A2=E5=92=8C=E8=BE=93=E5=85=A5=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.toml | 4 + src/client/mod.rs | 272 +++++++--------------- src/client/tui/app.rs | 155 ++++++++++++ src/client/tui/components/chat_history.rs | 37 +++ src/client/tui/components/help_popup.rs | 42 ++++ src/client/tui/components/input_area.rs | 21 ++ src/client/tui/components/mod.rs | 5 + src/client/tui/components/session_list.rs | 45 ++++ src/client/tui/components/title_bar.rs | 25 ++ src/client/tui/event.rs | 196 ++++++++++++++++ src/client/tui/markdown.rs | 3 + src/client/tui/mod.rs | 5 + src/client/tui/ui.rs | 57 +++++ 13 files changed, 683 insertions(+), 184 deletions(-) create mode 100644 src/client/tui/app.rs create mode 100644 src/client/tui/components/chat_history.rs create mode 100644 src/client/tui/components/help_popup.rs create mode 100644 src/client/tui/components/input_area.rs create mode 100644 src/client/tui/components/mod.rs create mode 100644 src/client/tui/components/session_list.rs create mode 100644 src/client/tui/components/title_bar.rs create mode 100644 src/client/tui/event.rs create mode 100644 src/client/tui/markdown.rs create mode 100644 src/client/tui/mod.rs create mode 100644 src/client/tui/ui.rs diff --git a/Cargo.toml b/Cargo.toml index a77d084..9f104ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,3 +28,7 @@ base64 = "0.22" tempfile = "3" meval = "0.2" rusqlite = { version = "0.32", features = ["bundled"] } +ratatui = "0.27" +crossterm = { version = "0.28", features = ["event-stream"] } +termimad = "0.34" +textwrap = "0.16" diff --git a/src/client/mod.rs b/src/client/mod.rs index cc91313..16d2402 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,214 +1,118 @@ pub use crate::protocol::{WsInbound, WsOutbound, serialize_inbound, serialize_outbound}; -mod channel; -mod input; +mod tui; -use futures_util::{SinkExt, StreamExt}; +use crate::client::tui::app::{App, MessageRole}; +use crate::client::tui::event::handle_key_event; +use crate::client::tui::ui::render_ui; +use crossterm::{ + event::{self, Event}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use futures_util::StreamExt; +use ratatui::{prelude::CrosstermBackend, Terminal}; +use std::io; use tokio_tungstenite::{connect_async, tungstenite::Message}; -use input::{InputCommand, InputEvent, InputHandler}; - -fn format_session_list(sessions: &[crate::protocol::SessionSummary], current_session_id: Option<&str>) -> String { - if sessions.is_empty() { - return "No sessions found.".to_string(); - } - - let mut lines = Vec::with_capacity(sessions.len() + 1); - lines.push("Sessions:".to_string()); - for session in sessions { - let marker = if current_session_id == Some(session.session_id.as_str()) { - "*" - } else { - "-" - }; - let archived = if session.archived_at.is_some() { - " [archived]" - } else { - "" - }; - lines.push(format!( - "{} {} | {} | {} messages{}", - marker, - session.session_id, - session.title, - session.message_count, - archived, - )); - } - - lines.join("\n") -} - -fn parse_message(raw: &str) -> Result { - serde_json::from_str(raw) -} - pub async fn run(gateway_url: &str) -> Result<(), Box> { let (ws_stream, _) = connect_async(gateway_url).await?; tracing::info!(url = %gateway_url, "Connected to gateway"); - let (mut sender, mut receiver) = ws_stream.split(); + let (ws_sender, ws_receiver) = ws_stream.split(); - let mut input = InputHandler::new(); - let mut current_session_id: Option = None; - input.write_output("picobot CLI - Commands: /new [title], /reset, /sessions, /use , /rename , /archive, /delete, /clear, /quit\n").await?; + let mut app = App::new(gateway_url.to_string()); + app.ws_sender = Some(ws_sender); + app.ws_receiver = Some(ws_receiver); + + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let result = run_app(&mut terminal, app).await; + + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + result +} + +async fn run_app( + terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, + mut app: App, +) -> Result<(), Box<dyn std::error::Error>> { + let mut ws_receiver = app.ws_receiver.take().unwrap(); + let mut event_reader = event::EventStream::new(); - // Main loop: poll both stdin and WebSocket loop { + terminal.draw(|f| render_ui(f, &app))?; + tokio::select! { - // Handle WebSocket messages - msg = receiver.next() => { + msg = ws_receiver.next() => { match msg { Some(Ok(Message::Text(text))) => { - let text = text.to_string(); - if let Ok(outbound) = parse_message(&text) { - match outbound { - WsOutbound::AssistantResponse { content, .. } => { - input.write_response(&content).await?; - } - WsOutbound::Error { message, .. } => { - input.write_output(&format!("Error: {}", message)).await?; - } - WsOutbound::SessionEstablished { session_id } => { - current_session_id = Some(session_id.clone()); - #[cfg(debug_assertions)] - tracing::debug!(session_id = %session_id, "Session established"); - input.write_output(&format!("Session: {}\n", session_id)).await?; - } - WsOutbound::SessionCreated { session_id, title } => { - current_session_id = Some(session_id.clone()); - input.write_output(&format!("Created session: {} ({})\n", session_id, title)).await?; - } - WsOutbound::SessionList { sessions, current_session_id: listed_current } => { - let display = format_session_list(&sessions, listed_current.as_deref()); - input.write_output(&format!("{}\n", display)).await?; - } - WsOutbound::SessionLoaded { session_id, title, message_count } => { - current_session_id = Some(session_id.clone()); - input.write_output(&format!("Loaded session: {} ({}, {} messages)\n", session_id, title, message_count)).await?; - } - WsOutbound::SessionRenamed { session_id, title } => { - input.write_output(&format!("Renamed session: {} -> {}\n", session_id, title)).await?; - } - WsOutbound::SessionArchived { session_id } => { - input.write_output(&format!("Archived session: {}\n", session_id)).await?; - } - WsOutbound::SessionDeleted { session_id } => { - if current_session_id.as_deref() == Some(session_id.as_str()) { - current_session_id = None; - } - input.write_output(&format!("Deleted session: {}\n", session_id)).await?; - } - WsOutbound::HistoryCleared { session_id } => { - input.write_output(&format!("Cleared history for session: {}\n", session_id)).await?; - } - _ => {} - } + if let Ok(outbound) = serde_json::from_str::<WsOutbound>(&text) { + handle_ws_message(&mut app, outbound); } } Some(Ok(Message::Close(_))) | None => { tracing::info!("Gateway disconnected"); - input.write_output("Gateway disconnected").await?; - break; + app.quit(); } _ => {} } } - // Handle stdin input - result = input.read_input("> ") => { - match result { - Ok(Some(event)) => { - match event { - InputEvent::Command(InputCommand::Exit) => { - input.write_output("Goodbye!").await?; - break; - } - InputEvent::Command(InputCommand::Clear) => { - let inbound = WsInbound::ClearHistory { - chat_id: None, - session_id: current_session_id.clone(), - }; - if let Ok(text) = serialize_inbound(&inbound) { - let _ = sender.send(Message::Text(text.into())).await; - } - continue; - } - InputEvent::Command(InputCommand::New(title)) => { - let inbound = WsInbound::CreateSession { title }; - if let Ok(text) = serialize_inbound(&inbound) { - let _ = sender.send(Message::Text(text.into())).await; - } - continue; - } - InputEvent::Command(InputCommand::Sessions) => { - let inbound = WsInbound::ListSessions { - include_archived: true, - }; - if let Ok(text) = serialize_inbound(&inbound) { - let _ = sender.send(Message::Text(text.into())).await; - } - continue; - } - InputEvent::Command(InputCommand::Use(session_id)) => { - let inbound = WsInbound::LoadSession { session_id }; - if let Ok(text) = serialize_inbound(&inbound) { - let _ = sender.send(Message::Text(text.into())).await; - } - continue; - } - InputEvent::Command(InputCommand::Rename(title)) => { - let inbound = WsInbound::RenameSession { - session_id: current_session_id.clone(), - title, - }; - if let Ok(text) = serialize_inbound(&inbound) { - let _ = sender.send(Message::Text(text.into())).await; - } - continue; - } - InputEvent::Command(InputCommand::Archive) => { - let inbound = WsInbound::ArchiveSession { - session_id: current_session_id.clone(), - }; - if let Ok(text) = serialize_inbound(&inbound) { - let _ = sender.send(Message::Text(text.into())).await; - } - continue; - } - InputEvent::Command(InputCommand::Delete) => { - let inbound = WsInbound::DeleteSession { - session_id: current_session_id.clone(), - }; - if let Ok(text) = serialize_inbound(&inbound) { - let _ = sender.send(Message::Text(text.into())).await; - } - continue; - } - InputEvent::Message(content) => { - let inbound = WsInbound::UserInput { - content, - channel: None, - chat_id: current_session_id.clone(), - sender_id: None, - }; - if let Ok(text) = serialize_inbound(&inbound) { - if sender.send(Message::Text(text.into())).await.is_err() { - tracing::error!("Failed to send message to gateway"); - break; - } - } - } - } - } - Ok(None) => break, - Err(e) => { - tracing::error!(error = %e, "Input error"); - break; - } + event_result = event_reader.next() => { + if let Some(Ok(Event::Key(key))) = event_result { + handle_key_event(&mut app, key).await; } } } + + if app.should_quit { + break; + } } Ok(()) } + +fn handle_ws_message(app: &mut App, outbound: WsOutbound) { + match outbound { + WsOutbound::AssistantResponse { content, .. } => { + app.add_message(MessageRole::Assistant, content); + } + WsOutbound::Error { message, .. } => { + app.add_message(MessageRole::System, format!("Error: {}", message)); + } + WsOutbound::SessionEstablished { session_id } => { + app.set_current_session(Some(session_id)); + } + WsOutbound::SessionCreated { session_id, .. } => { + app.set_current_session(Some(session_id)); + } + WsOutbound::SessionList { sessions, current_session_id } => { + app.set_sessions(sessions); + if let Some(id) = current_session_id { + app.set_current_session(Some(id)); + } + } + WsOutbound::SessionLoaded { session_id, .. } => { + app.set_current_session(Some(session_id)); + } + WsOutbound::SessionRenamed { .. } => {} + WsOutbound::SessionArchived { .. } => {} + WsOutbound::SessionDeleted { session_id } => { + if app.current_session_id.as_ref() == Some(&session_id) { + app.set_current_session(None); + } + } + WsOutbound::HistoryCleared { .. } => { + app.messages.clear(); + } + _ => {} + } +} diff --git a/src/client/tui/app.rs b/src/client/tui/app.rs new file mode 100644 index 0000000..3aae7ef --- /dev/null +++ b/src/client/tui/app.rs @@ -0,0 +1,155 @@ +use crate::protocol::SessionSummary; +use std::collections::VecDeque; +use tokio_tungstenite::tungstenite::Message; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FocusArea { + TitleBar, + SessionList, + ChatHistory, + InputArea, +} + +#[derive(Debug, Clone)] +pub enum MessageRole { + User, + Assistant, + System, +} + +#[derive(Debug, Clone)] +pub struct ChatMessage { + pub role: MessageRole, + pub content: String, +} + +pub struct App { + pub gateway_url: String, + pub ws_sender: Option< + futures_util::stream::SplitSink< + tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>, + >, + Message, + >, + >, + pub ws_receiver: Option< + futures_util::stream::SplitStream< + tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>, + >, + >, + >, + + pub current_session_id: Option<String>, + pub sessions: Vec<SessionSummary>, + + pub messages: VecDeque<ChatMessage>, + + pub focus: FocusArea, + pub input: String, + pub input_cursor_pos: usize, + pub show_help: bool, + pub chat_scroll_offset: u16, + pub session_scroll_offset: u16, + pub should_quit: bool, +} + +impl App { + pub fn new(gateway_url: String) -> Self { + Self { + gateway_url, + ws_sender: None, + ws_receiver: None, + current_session_id: None, + sessions: Vec::new(), + messages: VecDeque::new(), + focus: FocusArea::InputArea, + input: String::new(), + input_cursor_pos: 0, + show_help: false, + chat_scroll_offset: 0, + session_scroll_offset: 0, + should_quit: false, + } + } + + pub fn add_message(&mut self, role: MessageRole, content: String) { + self.messages.push_back(ChatMessage { role, content }); + self.chat_scroll_offset = 0; + } + + pub fn set_sessions(&mut self, sessions: Vec<SessionSummary>) { + self.sessions = sessions; + } + + pub fn set_current_session(&mut self, session_id: Option<String>) { + self.current_session_id = session_id; + self.messages.clear(); + } + + pub fn scroll_chat_up(&mut self) { + self.chat_scroll_offset = self.chat_scroll_offset.saturating_add(1); + } + + pub fn scroll_chat_down(&mut self) { + self.chat_scroll_offset = self.chat_scroll_offset.saturating_sub(1); + } + + pub fn scroll_session_up(&mut self) { + self.session_scroll_offset = self.session_scroll_offset.saturating_add(1); + } + + pub fn scroll_session_down(&mut self) { + self.session_scroll_offset = self.session_scroll_offset.saturating_sub(1); + } + + pub fn input_insert_char(&mut self, c: char) { + self.input.insert(self.input_cursor_pos, c); + self.input_cursor_pos += 1; + } + + pub fn input_delete_char(&mut self) { + if self.input_cursor_pos > 0 { + self.input.remove(self.input_cursor_pos - 1); + self.input_cursor_pos -= 1; + } + } + + pub fn input_move_cursor_left(&mut self) { + self.input_cursor_pos = self.input_cursor_pos.saturating_sub(1); + } + + pub fn input_move_cursor_right(&mut self) { + if self.input_cursor_pos < self.input.len() { + self.input_cursor_pos += 1; + } + } + + pub fn input_move_cursor_to_start(&mut self) { + self.input_cursor_pos = 0; + } + + pub fn input_move_cursor_to_end(&mut self) { + self.input_cursor_pos = self.input.len(); + } + + pub fn input_clear(&mut self) { + self.input.clear(); + self.input_cursor_pos = 0; + } + + pub fn take_input(&mut self) -> String { + let input = std::mem::take(&mut self.input); + self.input_cursor_pos = 0; + input + } + + pub fn toggle_help(&mut self) { + self.show_help = !self.show_help; + } + + pub fn quit(&mut self) { + self.should_quit = true; + } +} diff --git a/src/client/tui/components/chat_history.rs b/src/client/tui/components/chat_history.rs new file mode 100644 index 0000000..70186e3 --- /dev/null +++ b/src/client/tui/components/chat_history.rs @@ -0,0 +1,37 @@ +use crate::client::tui::app::{App, MessageRole}; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::Line, + widgets::{Block, Borders, List, ListItem}, + Frame, +}; + +pub fn render(f: &mut Frame, area: Rect, app: &App) { + let items: Vec<ListItem> = app + .messages + .iter() + .map(|msg| { + let (prefix, color) = match msg.role { + MessageRole::User => ("[User] ", Color::Blue), + MessageRole::Assistant => ("[Assistant] ", Color::Green), + MessageRole::System => ("[System] ", Color::Red), + }; + + let content = vec![ + Line::from(vec![ratatui::text::Span::styled( + prefix, + Style::default().fg(color).add_modifier(Modifier::BOLD), + )]), + Line::from(msg.content.as_str()), + Line::from(""), + ]; + + ListItem::new(content) + }) + .collect(); + + let list = List::new(items).block(Block::default().title("Conversation").borders(Borders::ALL)); + + f.render_widget(list, area); +} diff --git a/src/client/tui/components/help_popup.rs b/src/client/tui/components/help_popup.rs new file mode 100644 index 0000000..c70fef8 --- /dev/null +++ b/src/client/tui/components/help_popup.rs @@ -0,0 +1,42 @@ +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Clear, List, ListItem}, + Frame, +}; + +pub fn render(f: &mut Frame, area: Rect) { + f.render_widget(Clear, area); + + let help_text = vec![ + ListItem::new("Commands:"), + ListItem::new(" /new [title] - Create new session"), + ListItem::new(" /sessions - List all sessions"), + ListItem::new(" /use <id> - Load session"), + ListItem::new(" /rename <title> - Rename session"), + ListItem::new(" /archive - Archive session"), + ListItem::new(" /delete - Delete session"), + ListItem::new(" /clear - Clear history"), + ListItem::new(" /help, /? - Show this help"), + ListItem::new(" /quit, /q - Quit"), + ListItem::new(""), + ListItem::new("Keyboard:"), + ListItem::new(" Enter - Send message"), + ListItem::new(" Esc, q - Quit"), + ListItem::new(" ? - Show help"), + ListItem::new(" Arrow keys - Navigate"), + ]; + + let list = List::new(help_text).block( + Block::default() + .title("Help") + .title_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .borders(Borders::ALL), + ); + + f.render_widget(list, area); +} diff --git a/src/client/tui/components/input_area.rs b/src/client/tui/components/input_area.rs new file mode 100644 index 0000000..e396ae0 --- /dev/null +++ b/src/client/tui/components/input_area.rs @@ -0,0 +1,21 @@ +use crate::client::tui::app::App; +use ratatui::{ + layout::Rect, + style::{Color, Style}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +pub fn render(f: &mut Frame, area: Rect, app: &App) { + let input = Paragraph::new(app.input.as_str()) + .style(Style::default().fg(Color::White)) + .block(Block::default().title("Input").borders(Borders::ALL)); + + f.render_widget(input, area); + + let cursor_x = area.x + 1 + app.input_cursor_pos as u16; + let cursor_y = area.y + 1; + if cursor_x < area.right() && cursor_y < area.bottom() { + f.set_cursor(cursor_x, cursor_y); + } +} diff --git a/src/client/tui/components/mod.rs b/src/client/tui/components/mod.rs new file mode 100644 index 0000000..5fc9a27 --- /dev/null +++ b/src/client/tui/components/mod.rs @@ -0,0 +1,5 @@ +pub mod chat_history; +pub mod help_popup; +pub mod input_area; +pub mod session_list; +pub mod title_bar; diff --git a/src/client/tui/components/session_list.rs b/src/client/tui/components/session_list.rs new file mode 100644 index 0000000..f02ccf2 --- /dev/null +++ b/src/client/tui/components/session_list.rs @@ -0,0 +1,45 @@ +use crate::client::tui::app::App; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, List, ListItem}, + Frame, +}; + +pub fn render(f: &mut Frame, area: Rect, app: &App) { + let items: Vec<ListItem> = app + .sessions + .iter() + .map(|session| { + let is_current = app + .current_session_id + .as_ref() + .map_or(false, |id| id == &session.session_id); + let archived = session.archived_at.is_some(); + + let mut content = if is_current { + format!("• {}", session.title) + } else { + format!(" {}", session.title) + }; + + if archived { + content.push_str(" [archived]"); + } + + let style = if is_current { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + + ListItem::new(content).style(style) + }) + .collect(); + + let list = List::new(items).block(Block::default().title("Sessions").borders(Borders::ALL)); + + f.render_widget(list, area); +} diff --git a/src/client/tui/components/title_bar.rs b/src/client/tui/components/title_bar.rs new file mode 100644 index 0000000..a0e56e0 --- /dev/null +++ b/src/client/tui/components/title_bar.rs @@ -0,0 +1,25 @@ +use crate::client::tui::app::App; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +pub fn render(f: &mut Frame, area: Rect, app: &App) { + let title = if let Some(session_id) = &app.current_session_id { + format!("PicoBot | Session: {}", session_id) + } else { + "PicoBot".to_string() + }; + + let paragraph = Paragraph::new(title) + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .block(Block::default().borders(Borders::ALL)); + + f.render_widget(paragraph, area); +} diff --git a/src/client/tui/event.rs b/src/client/tui/event.rs new file mode 100644 index 0000000..e5148f7 --- /dev/null +++ b/src/client/tui/event.rs @@ -0,0 +1,196 @@ +use crate::client::tui::app::{App, MessageRole}; +use crate::protocol::serialize_inbound; +use crate::protocol::WsInbound; +use crossterm::event::{KeyCode, KeyEvent}; +use futures_util::SinkExt; + +pub async fn handle_key_event(app: &mut App, key: KeyEvent) { + if app.show_help { + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + app.toggle_help(); + } + _ => {} + } + return; + } + + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + app.quit(); + } + KeyCode::Char('?') => { + app.toggle_help(); + } + KeyCode::Char(c) => { + app.input_insert_char(c); + } + KeyCode::Backspace => { + app.input_delete_char(); + } + KeyCode::Left => { + app.input_move_cursor_left(); + } + KeyCode::Right => { + app.input_move_cursor_right(); + } + KeyCode::Home => { + app.input_move_cursor_to_start(); + } + KeyCode::End => { + app.input_move_cursor_to_end(); + } + KeyCode::Up => { + app.scroll_chat_up(); + } + KeyCode::Down => { + app.scroll_chat_down(); + } + KeyCode::Enter => { + let input = app.take_input(); + if !input.is_empty() { + process_input(app, input).await; + } + } + _ => {} + } +} + +async fn process_input(app: &mut App, input: String) { + let trimmed = input.trim(); + + if let Some(cmd) = parse_command(trimmed) { + match cmd { + InputCommand::Quit => { + app.quit(); + } + InputCommand::Help => { + app.toggle_help(); + } + InputCommand::Clear => { + if let Some(session_id) = &app.current_session_id { + if let Some(sender) = &mut app.ws_sender { + let inbound = WsInbound::ClearHistory { + chat_id: None, + session_id: Some(session_id.clone()), + }; + if let Ok(text) = serialize_inbound(&inbound) { + let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await; + } + } + } + } + InputCommand::New(title) => { + if let Some(sender) = &mut app.ws_sender { + let inbound = WsInbound::CreateSession { title }; + if let Ok(text) = serialize_inbound(&inbound) { + let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await; + } + } + } + InputCommand::Sessions => { + if let Some(sender) = &mut app.ws_sender { + let inbound = WsInbound::ListSessions { + include_archived: true, + }; + if let Ok(text) = serialize_inbound(&inbound) { + let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await; + } + } + } + InputCommand::Use(session_id) => { + if let Some(sender) = &mut app.ws_sender { + let inbound = WsInbound::LoadSession { session_id }; + if let Ok(text) = serialize_inbound(&inbound) { + let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await; + } + } + } + InputCommand::Rename(title) => { + if let Some(session_id) = &app.current_session_id { + if let Some(sender) = &mut app.ws_sender { + let inbound = WsInbound::RenameSession { + session_id: Some(session_id.clone()), + title, + }; + if let Ok(text) = serialize_inbound(&inbound) { + let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await; + } + } + } + } + InputCommand::Archive => { + if let Some(session_id) = &app.current_session_id { + if let Some(sender) = &mut app.ws_sender { + let inbound = WsInbound::ArchiveSession { + session_id: Some(session_id.clone()), + }; + if let Ok(text) = serialize_inbound(&inbound) { + let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await; + } + } + } + } + InputCommand::Delete => { + if let Some(session_id) = &app.current_session_id { + if let Some(sender) = &mut app.ws_sender { + let inbound = WsInbound::DeleteSession { + session_id: Some(session_id.clone()), + }; + if let Ok(text) = serialize_inbound(&inbound) { + let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await; + } + } + } + } + } + } else { + app.add_message(MessageRole::User, input.clone()); + if let Some(sender) = &mut app.ws_sender { + let inbound = WsInbound::UserInput { + content: input, + chat_id: app.current_session_id.clone(), + channel: None, + sender_id: None, + }; + if let Ok(text) = serialize_inbound(&inbound) { + let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await; + } + } + } +} + +enum InputCommand { + Quit, + Help, + Clear, + New(Option<String>), + Sessions, + Use(String), + Rename(String), + Archive, + Delete, +} + +fn parse_command(input: &str) -> Option<InputCommand> { + if !input.starts_with('/') { + return None; + } + + let parts: Vec<&str> = input.splitn(2, char::is_whitespace).collect(); + let cmd = parts[0]; + let arg = parts.get(1).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + + match cmd { + "/quit" | "/exit" | "/q" => Some(InputCommand::Quit), + "/help" | "/?" => Some(InputCommand::Help), + "/clear" | "/reset" => Some(InputCommand::Clear), + "/new" => Some(InputCommand::New(arg)), + "/sessions" => Some(InputCommand::Sessions), + "/use" => arg.map(InputCommand::Use), + "/rename" => arg.map(InputCommand::Rename), + "/archive" => Some(InputCommand::Archive), + "/delete" => Some(InputCommand::Delete), + _ => None, + } +} diff --git a/src/client/tui/markdown.rs b/src/client/tui/markdown.rs new file mode 100644 index 0000000..839d18a --- /dev/null +++ b/src/client/tui/markdown.rs @@ -0,0 +1,3 @@ +pub fn render_markdown(content: &str) -> String { + content.to_string() +} diff --git a/src/client/tui/mod.rs b/src/client/tui/mod.rs new file mode 100644 index 0000000..1eda6d2 --- /dev/null +++ b/src/client/tui/mod.rs @@ -0,0 +1,5 @@ +pub mod app; +pub mod components; +pub mod event; +pub mod markdown; +pub mod ui; diff --git a/src/client/tui/ui.rs b/src/client/tui/ui.rs new file mode 100644 index 0000000..4e89c2a --- /dev/null +++ b/src/client/tui/ui.rs @@ -0,0 +1,57 @@ +use crate::client::tui::app::App; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}, + Frame, +}; + +use super::components::*; + +pub fn render_ui(f: &mut Frame, app: &App) { + let size = f.size(); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(0), + Constraint::Length(5), + ]) + .split(size); + + title_bar::render(f, chunks[0], app); + + let middle_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(25), Constraint::Percentage(75)]) + .split(chunks[1]); + + session_list::render(f, middle_chunks[0], app); + chat_history::render(f, middle_chunks[1], app); + + input_area::render(f, chunks[2], app); + + if app.show_help { + let area = centered_rect(60, 60, size); + help_popup::render(f, area); + } +} + +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +}