pub use crate::protocol::{WsInbound, WsOutbound, serialize_inbound, serialize_outbound}; mod tui; use crate::client::tui::app::{App, MessageRole}; use crate::client::tui::event::{ handle_key_event, handle_paste, request_history, request_session_list, send, }; use crate::client::tui::ui::render_ui; use crossterm::{ event::{self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyEventKind}, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; use futures_util::StreamExt; use ratatui::{Terminal, prelude::CrosstermBackend}; use std::io; use std::{fs, path::PathBuf}; use tokio_tungstenite::{connect_async, tungstenite::Message}; pub async fn run(gateway_url: &str) -> Result<(), Box> { let client_id = load_or_create_client_id(); let separator = if gateway_url.contains('?') { '&' } else { '?' }; let connect_url = format!("{gateway_url}{separator}client_id={client_id}"); let (ws_stream, _) = connect_async(&connect_url).await?; tracing::info!("Connected to gateway"); let (ws_sender, ws_receiver) = ws_stream.split(); let mut app = App::new(); app.ws_sender = Some(ws_sender); app.ws_receiver = Some(ws_receiver); enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; terminal.clear()?; let result = run_app(&mut terminal, app).await; // Cleanup terminal, ignore errors let _ = execute!( terminal.backend_mut(), DisableBracketedPaste, LeaveAlternateScreen ); let _ = disable_raw_mode(); let _ = terminal.show_cursor(); result } fn load_or_create_client_id() -> String { let generated = uuid::Uuid::new_v4().simple().to_string(); let Some(home) = dirs::home_dir() else { return generated; }; let dir = home.join(".picobot"); let path: PathBuf = dir.join("tui_client_id"); if let Ok(value) = fs::read_to_string(&path) { let value = value.trim(); if !value.is_empty() && value.len() <= 64 && value .bytes() .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') { return value.to_string(); } } if fs::create_dir_all(dir).is_ok() { let _ = fs::write(path, &generated); } generated } async fn run_app( terminal: &mut Terminal>, mut app: App, ) -> Result<(), Box> { let mut ws_receiver = app.ws_receiver.take().unwrap(); let mut event_reader = event::EventStream::new(); let mut ws_open = true; send(&mut app, WsInbound::GetSlashCommands).await; request_session_list(&mut app).await; loop { terminal.draw(|f| render_ui(f, &app))?; tokio::select! { msg = ws_receiver.next(), if ws_open => { match msg { Some(Ok(Message::Text(text))) => { if let Ok(outbound) = serde_json::from_str::(&text) { handle_ws_message(&mut app, outbound).await; } } Some(Ok(Message::Close(_))) | None => { tracing::info!("Gateway disconnected"); app.connected = false; app.ws_sender = None; ws_open = false; app.status_message = Some("Gateway 连接已关闭;按两次 Ctrl+C 退出".to_string()); } Some(Err(error)) => { app.connected = false; app.ws_sender = None; ws_open = false; app.status_message = Some(format!("Gateway 连接错误:{error}")); } _ => {} } } event_result = event_reader.next() => { match event_result { Some(Ok(Event::Key(key))) if key.kind != KeyEventKind::Release => { handle_key_event(&mut app, key).await; } Some(Ok(Event::Paste(text))) => handle_paste(&mut app, &text), Some(Err(error)) => { app.status_message = Some(format!("终端输入错误:{error}")); } _ => {} } } } if app.should_quit { break; } } Ok(()) } async fn handle_ws_message(app: &mut App, outbound: WsOutbound) { match outbound { WsOutbound::AssistantResponse { content, session_id, .. } => { app.pending_responses = app.pending_responses.saturating_sub(1); app.status_message = None; if session_id .as_ref() .is_none_or(|session_id| app.current_session_id.as_ref() == Some(session_id)) { app.add_message(MessageRole::Assistant, content); } else { app.status_message = Some("另一个会话已完成响应".to_string()); } request_session_list(app).await; } WsOutbound::Error { message, .. } => { app.pending_responses = app.pending_responses.saturating_sub(1); app.status_message = Some(message.clone()); app.add_message(MessageRole::System, format!("Error: {}", message)); } WsOutbound::SessionEstablished { session_id } => { app.connected = true; app.set_current_session(Some(session_id.clone())); request_history(app, session_id).await; request_session_list(app).await; } WsOutbound::SessionCreated { session_id, .. } => { app.set_current_session(Some(session_id.clone())); app.status_message = None; request_history(app, session_id).await; request_session_list(app).await; } WsOutbound::SessionList { sessions, current_session_id, } => { app.set_sessions(sessions); if let Some(id) = current_session_id { let changed = app.current_session_id.as_deref() != Some(&id); app.set_current_session(Some(id.clone())); if changed { request_history(app, id).await; } } } WsOutbound::SessionLoaded { session_id, .. } => { app.set_current_session(Some(session_id.clone())); request_history(app, session_id).await; request_session_list(app).await; } WsOutbound::SessionHistory { session_id, messages, } => app.set_history(&session_id, messages), // The first Todo UI is WebUI-only. CLI keeps receiving ordinary task // notifications and may inspect plans through /todo. WsOutbound::SessionPlan { .. } | WsOutbound::PlanUpdated { .. } => {} WsOutbound::SessionRenamed { session_id, title } => { if let Some(session) = app .sessions .iter_mut() .find(|session| session.session_id == session_id) { session.title = title; } request_session_list(app).await; } WsOutbound::SessionArchived { session_id } => { app.sessions .retain(|session| session.session_id != session_id); request_session_list(app).await; } WsOutbound::SessionDeleted { session_id } => { if app.current_session_id.as_ref() == Some(&session_id) { app.set_current_session(None); } app.sessions .retain(|session| session.session_id != session_id); request_session_list(app).await; } WsOutbound::HistoryCleared { session_id } => { if app.current_session_id.as_deref() == Some(&session_id) { app.messages.clear(); } app.status_message = None; request_session_list(app).await; } WsOutbound::SlashCommandsList { commands } => { app.set_commands(commands); } WsOutbound::Pong => {} WsOutbound::CommandExecuted { message } => { app.pending_responses = app.pending_responses.saturating_sub(1); app.status_message = None; app.add_message(MessageRole::System, message); request_session_list(app).await; } WsOutbound::SystemNotification { content, session_id, } => { if session_id .as_ref() .is_none_or(|session_id| app.current_session_id.as_ref() == Some(session_id)) { app.add_message(MessageRole::System, content); } } } }