重构: 添加 TUI 组件以支持聊天界面和输入处理
This commit is contained in:
parent
86dea0f874
commit
cf6d57c568
@ -28,3 +28,7 @@ base64 = "0.22"
|
|||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
meval = "0.2"
|
meval = "0.2"
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
ratatui = "0.27"
|
||||||
|
crossterm = { version = "0.28", features = ["event-stream"] }
|
||||||
|
termimad = "0.34"
|
||||||
|
textwrap = "0.16"
|
||||||
|
|||||||
@ -1,214 +1,118 @@
|
|||||||
pub use crate::protocol::{WsInbound, WsOutbound, serialize_inbound, serialize_outbound};
|
pub use crate::protocol::{WsInbound, WsOutbound, serialize_inbound, serialize_outbound};
|
||||||
|
|
||||||
mod channel;
|
mod tui;
|
||||||
mod input;
|
|
||||||
|
|
||||||
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 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<WsOutbound, serde_json::Error> {
|
|
||||||
serde_json::from_str(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
|
pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let (ws_stream, _) = connect_async(gateway_url).await?;
|
let (ws_stream, _) = connect_async(gateway_url).await?;
|
||||||
tracing::info!(url = %gateway_url, "Connected to gateway");
|
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 app = App::new(gateway_url.to_string());
|
||||||
let mut current_session_id: Option<String> = None;
|
app.ws_sender = Some(ws_sender);
|
||||||
input.write_output("picobot CLI - Commands: /new [title], /reset, /sessions, /use <session>, /rename <title>, /archive, /delete, /clear, /quit\n").await?;
|
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 {
|
loop {
|
||||||
|
terminal.draw(|f| render_ui(f, &app))?;
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
// Handle WebSocket messages
|
msg = ws_receiver.next() => {
|
||||||
msg = receiver.next() => {
|
|
||||||
match msg {
|
match msg {
|
||||||
Some(Ok(Message::Text(text))) => {
|
Some(Ok(Message::Text(text))) => {
|
||||||
let text = text.to_string();
|
if let Ok(outbound) = serde_json::from_str::<WsOutbound>(&text) {
|
||||||
if let Ok(outbound) = parse_message(&text) {
|
handle_ws_message(&mut app, outbound);
|
||||||
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?;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(Ok(Message::Close(_))) | None => {
|
Some(Ok(Message::Close(_))) | None => {
|
||||||
tracing::info!("Gateway disconnected");
|
tracing::info!("Gateway disconnected");
|
||||||
input.write_output("Gateway disconnected").await?;
|
app.quit();
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Handle stdin input
|
event_result = event_reader.next() => {
|
||||||
result = input.read_input("> ") => {
|
if let Some(Ok(Event::Key(key))) = event_result {
|
||||||
match result {
|
handle_key_event(&mut app, key).await;
|
||||||
Ok(Some(event)) => {
|
}
|
||||||
match event {
|
}
|
||||||
InputEvent::Command(InputCommand::Exit) => {
|
}
|
||||||
input.write_output("Goodbye!").await?;
|
|
||||||
|
if app.should_quit {
|
||||||
break;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
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();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
155
src/client/tui/app.rs
Normal file
155
src/client/tui/app.rs
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/client/tui/components/chat_history.rs
Normal file
37
src/client/tui/components/chat_history.rs
Normal file
@ -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);
|
||||||
|
}
|
||||||
42
src/client/tui/components/help_popup.rs
Normal file
42
src/client/tui/components/help_popup.rs
Normal file
@ -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);
|
||||||
|
}
|
||||||
21
src/client/tui/components/input_area.rs
Normal file
21
src/client/tui/components/input_area.rs
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/client/tui/components/mod.rs
Normal file
5
src/client/tui/components/mod.rs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
pub mod chat_history;
|
||||||
|
pub mod help_popup;
|
||||||
|
pub mod input_area;
|
||||||
|
pub mod session_list;
|
||||||
|
pub mod title_bar;
|
||||||
45
src/client/tui/components/session_list.rs
Normal file
45
src/client/tui/components/session_list.rs
Normal file
@ -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);
|
||||||
|
}
|
||||||
25
src/client/tui/components/title_bar.rs
Normal file
25
src/client/tui/components/title_bar.rs
Normal file
@ -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);
|
||||||
|
}
|
||||||
196
src/client/tui/event.rs
Normal file
196
src/client/tui/event.rs
Normal file
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/client/tui/markdown.rs
Normal file
3
src/client/tui/markdown.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
pub fn render_markdown(content: &str) -> String {
|
||||||
|
content.to_string()
|
||||||
|
}
|
||||||
5
src/client/tui/mod.rs
Normal file
5
src/client/tui/mod.rs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
pub mod app;
|
||||||
|
pub mod components;
|
||||||
|
pub mod event;
|
||||||
|
pub mod markdown;
|
||||||
|
pub mod ui;
|
||||||
57
src/client/tui/ui.rs
Normal file
57
src/client/tui/ui.rs
Normal file
@ -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]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user