use crate::protocol::{ HistoryMessage, MessageAttachment, SessionSummary, SlashCommandInfo, UploadDescriptor, }; use crate::session::{TurnSnapshot, TurnStatus}; use std::collections::VecDeque; use tokio_tungstenite::tungstenite::Message; const MAX_MESSAGES: usize = 2_000; const MAX_INPUT_BYTES: usize = 16 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MessageRole { User, Assistant, System, } #[derive(Debug, Clone)] pub struct ChatMessage { pub id: String, pub role: MessageRole, pub content: String, pub reasoning_content: Option, pub completion_status: crate::bus::CompletionStatus, pub attachments: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Focus { Input, Sessions, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConfirmAction { Archive, Delete, ClearHistory, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum Modal { Rename { input: String, cursor: usize }, AttachPath { input: String, cursor: usize }, Confirm(ConfirmAction), } pub struct App { pub ws_sender: Option< futures_util::stream::SplitSink< tokio_tungstenite::WebSocketStream< tokio_tungstenite::MaybeTlsStream, >, Message, >, >, pub ws_receiver: Option< futures_util::stream::SplitStream< tokio_tungstenite::WebSocketStream< tokio_tungstenite::MaybeTlsStream, >, >, >, pub current_session_id: Option, pub sessions: Vec, pub selected_session: usize, pub show_archived: bool, pub messages: VecDeque, pub history_revision: i64, pub active_turn: Option, pub input: String, /// UTF-8 byte offset. It is always maintained at a character boundary. pub input_cursor_pos: usize, pub focus: Focus, pub modal: Option, pub show_help: bool, pub chat_scroll_from_bottom: u16, pub should_quit: bool, pub pending_quit: bool, pub connected: bool, pub pending_responses: usize, pub status_message: Option, pub commands: Vec, pub show_command_menu: bool, pub selected_command_idx: usize, pub http_base_url: String, pub auth_token: Option, pub client_id: String, pub file_transfer_supported: bool, pub pending_uploads: Vec, } impl App { pub fn new() -> Self { Self { ws_sender: None, ws_receiver: None, current_session_id: None, sessions: Vec::new(), selected_session: 0, show_archived: false, messages: VecDeque::new(), history_revision: 0, active_turn: None, input: String::new(), input_cursor_pos: 0, focus: Focus::Input, modal: None, show_help: false, chat_scroll_from_bottom: 0, should_quit: false, pending_quit: false, connected: true, pending_responses: 0, status_message: Some("正在加载会话…".to_string()), commands: Vec::new(), show_command_menu: false, selected_command_idx: 0, http_base_url: String::new(), auth_token: None, client_id: String::new(), file_transfer_supported: false, pending_uploads: Vec::new(), } } pub fn add_message(&mut self, role: MessageRole, content: String) { self.add_message_with_attachments(crate::util::short_id(), role, content, Vec::new()); } pub fn add_message_with_attachments( &mut self, id: String, role: MessageRole, content: String, attachments: Vec, ) { self.messages.push_back(ChatMessage { id, role, content, reasoning_content: None, completion_status: crate::bus::CompletionStatus::Completed, attachments, }); while self.messages.len() > MAX_MESSAGES { self.messages.pop_front(); } self.chat_scroll_from_bottom = 0; } pub fn set_history(&mut self, session_id: &str, messages: Vec) { if self.current_session_id.as_deref() != Some(session_id) { return; } let history_revision = messages .iter() .map(|message| message.seq) .max() .unwrap_or(0); let calibrates_terminal = self.active_turn.as_ref().is_some_and(|turn| { turn.status != TurnStatus::Running && messages.iter().any(|message| message.id == turn.message_id) }); self.messages = messages .into_iter() .filter_map(|message| { let role = match message.role.as_str() { "user" => MessageRole::User, "assistant" => MessageRole::Assistant, "system" | "tool" => MessageRole::System, _ => return None, }; Some(ChatMessage { id: message.id, role, content: message.content, reasoning_content: message.reasoning_content, completion_status: message.completion_status, attachments: message.attachments, }) }) .collect(); while self.messages.len() > MAX_MESSAGES { self.messages.pop_front(); } self.chat_scroll_from_bottom = 0; self.history_revision = history_revision; self.status_message = None; if calibrates_terminal { self.active_turn = None; } } pub fn apply_turn_commit( &mut self, session_id: &str, history_revision: i64, messages: Vec, ) -> bool { if self.current_session_id.as_deref() != Some(session_id) || history_revision <= self.history_revision { return false; } let calibrates_terminal = self.active_turn.as_ref().is_some_and(|turn| { turn.status != TurnStatus::Running && messages.iter().any(|message| message.id == turn.message_id) }); for message in messages { let role = match message.role.as_str() { "user" => MessageRole::User, "assistant" => MessageRole::Assistant, "system" | "tool" => MessageRole::System, _ => continue, }; let projected = ChatMessage { id: message.id.clone(), role, content: message.content, reasoning_content: message.reasoning_content, completion_status: message.completion_status, attachments: message.attachments, }; if let Some(existing) = self .messages .iter_mut() .find(|existing| existing.id == message.id) { *existing = projected; } else { self.messages.push_back(projected); } } while self.messages.len() > MAX_MESSAGES { self.messages.pop_front(); } self.history_revision = history_revision; self.chat_scroll_from_bottom = 0; self.status_message = None; if calibrates_terminal { self.active_turn = None; } true } pub fn set_sessions(&mut self, sessions: Vec) { self.sessions = sessions; if let Some(current) = &self.current_session_id && let Some(index) = self .sessions .iter() .position(|session| &session.session_id == current) { self.selected_session = index; } self.clamp_session_selection(); } pub fn set_current_session(&mut self, session_id: Option) { if self.current_session_id != session_id { self.current_session_id = session_id; self.messages.clear(); self.active_turn = None; self.history_revision = 0; self.pending_uploads.clear(); self.chat_scroll_from_bottom = 0; } if let Some(current) = &self.current_session_id && let Some(index) = self .sessions .iter() .position(|session| &session.session_id == current) { self.selected_session = index; } } pub fn apply_turn_snapshot(&mut self, snapshot: TurnSnapshot) -> bool { if self.current_session_id.as_deref() != Some(&snapshot.session_id) { return false; } if let Some(current) = &self.active_turn && current.id == snapshot.id && current.revision >= snapshot.revision { return false; } let already_committed = snapshot.status != TurnStatus::Running && self .messages .iter() .any(|message| message.id == snapshot.message_id); self.active_turn = (!already_committed).then_some(snapshot); self.chat_scroll_from_bottom = 0; true } pub fn current_title(&self) -> &str { self.current_session_id .as_ref() .and_then(|id| { self.sessions .iter() .find(|session| &session.session_id == id) }) .map(|session| session.title.as_str()) .unwrap_or("新对话") } pub fn selected_session_id(&self) -> Option { self.sessions .get(self.selected_session) .map(|session| session.session_id.clone()) } pub fn select_next_session(&mut self) { if !self.sessions.is_empty() { self.selected_session = (self.selected_session + 1).min(self.sessions.len() - 1); } } pub fn select_previous_session(&mut self) { self.selected_session = self.selected_session.saturating_sub(1); } fn clamp_session_selection(&mut self) { self.selected_session = self .selected_session .min(self.sessions.len().saturating_sub(1)); } pub fn scroll_chat_up(&mut self, lines: u16) { self.chat_scroll_from_bottom = self.chat_scroll_from_bottom.saturating_add(lines); } pub fn scroll_chat_down(&mut self, lines: u16) { self.chat_scroll_from_bottom = self.chat_scroll_from_bottom.saturating_sub(lines); } pub fn input_insert_char(&mut self, c: char) { if self.input.len() + c.len_utf8() <= MAX_INPUT_BYTES { self.input.insert(self.input_cursor_pos, c); self.input_cursor_pos += c.len_utf8(); } } pub fn input_insert_str(&mut self, text: &str) { let remaining = MAX_INPUT_BYTES.saturating_sub(self.input.len()); let mut end = text.len().min(remaining); while !text.is_char_boundary(end) { end -= 1; } self.input.insert_str(self.input_cursor_pos, &text[..end]); self.input_cursor_pos += end; } pub fn input_delete_char(&mut self) { if let Some(previous) = previous_boundary(&self.input, self.input_cursor_pos) { self.input.drain(previous..self.input_cursor_pos); self.input_cursor_pos = previous; } } pub fn input_delete_forward(&mut self) { if let Some(next) = next_boundary(&self.input, self.input_cursor_pos) { self.input.drain(self.input_cursor_pos..next); } } pub fn input_move_cursor_left(&mut self) { if let Some(previous) = previous_boundary(&self.input, self.input_cursor_pos) { self.input_cursor_pos = previous; } } pub fn input_move_cursor_right(&mut self) { if let Some(next) = next_boundary(&self.input, self.input_cursor_pos) { self.input_cursor_pos = next; } } pub fn input_move_cursor_to_start(&mut self) { let line_start = self.input[..self.input_cursor_pos] .rfind('\n') .map_or(0, |index| index + 1); self.input_cursor_pos = line_start; } pub fn input_move_cursor_to_end(&mut self) { let tail = &self.input[self.input_cursor_pos..]; self.input_cursor_pos += tail.find('\n').unwrap_or(tail.len()); } pub fn take_input(&mut self) -> String { let input = std::mem::take(&mut self.input); self.input_cursor_pos = 0; input } pub fn handle_ctrl_c_for_quit(&mut self) { if self.pending_quit { self.should_quit = true; } else { self.pending_quit = true; self.status_message = Some("再次按 Ctrl+C 退出".to_string()); } } pub fn cancel_pending_quit(&mut self) { if self.pending_quit { self.pending_quit = false; self.status_message = None; } } pub fn set_commands(&mut self, commands: Vec) { self.commands = commands; } pub fn get_filtered_commands(&self) -> Vec<&SlashCommandInfo> { let query = self .input .split_whitespace() .next() .unwrap_or("") .to_lowercase(); self.commands .iter() .filter(|command| { command.name.to_lowercase().contains(&query) || command.description.to_lowercase().contains(&query) || command .aliases .iter() .any(|alias| alias.to_lowercase().starts_with(&query)) }) .collect() } pub fn select_next_command(&mut self) { let len = self.get_filtered_commands().len(); if len > 0 { self.selected_command_idx = (self.selected_command_idx + 1) % len; } } pub fn select_previous_command(&mut self) { let len = self.get_filtered_commands().len(); if len > 0 { self.selected_command_idx = (self.selected_command_idx + len - 1) % len; } } pub fn insert_selected_command(&mut self) { let command = self .get_filtered_commands() .get(self.selected_command_idx) .and_then(|command| command.aliases.first().cloned()); if let Some(command) = command { self.input = format!("{command} "); self.input_cursor_pos = self.input.len(); } } } fn previous_boundary(value: &str, offset: usize) -> Option { value[..offset] .char_indices() .next_back() .map(|(index, _)| index) } fn next_boundary(value: &str, offset: usize) -> Option { value[offset..] .chars() .next() .map(|character| offset + character.len_utf8()) } #[cfg(test)] mod tests { use super::*; use crate::session::{TurnId, TurnPhase, TurnState}; fn turn(revision: u64, status: TurnStatus) -> TurnSnapshot { TurnState { id: TurnId("turn".into()), session_id: "current".into(), message_id: "message".into(), revision, status, phase: TurnPhase::Responding, blocks: Vec::new(), usage: None, error: None, } } #[test] fn unicode_cursor_edits_only_at_character_boundaries() { let mut app = App::new(); app.input_insert_str("你a🙂"); app.input_move_cursor_left(); app.input_delete_char(); assert_eq!(app.input, "你🙂"); assert!(app.input.is_char_boundary(app.input_cursor_pos)); app.input_delete_forward(); assert_eq!(app.input, "你"); } #[test] fn stale_history_does_not_replace_the_active_dialog() { let mut app = App::new(); app.set_current_session(Some("new".to_string())); app.add_message(MessageRole::User, "keep".to_string()); app.set_history("old", Vec::new()); assert_eq!(app.messages.len(), 1); } #[test] fn active_turn_ignores_stale_revisions_and_other_sessions() { let mut app = App::new(); app.set_current_session(Some("current".into())); assert!(app.apply_turn_snapshot(turn(2, TurnStatus::Running))); assert!(!app.apply_turn_snapshot(turn(1, TurnStatus::Running))); let mut other = turn(3, TurnStatus::Running); other.session_id = "other".into(); assert!(!app.apply_turn_snapshot(other)); assert_eq!(app.active_turn.as_ref().unwrap().revision, 2); } #[test] fn terminal_turn_remains_visible_until_history_calibrates_it() { let mut app = App::new(); app.set_current_session(Some("current".into())); app.apply_turn_snapshot(turn(3, TurnStatus::Completed)); assert!(app.active_turn.is_some()); app.set_history( "current", vec![HistoryMessage { id: "message".into(), seq: 1, role: "assistant".into(), content: "done".into(), reasoning_content: None, completion_status: crate::bus::CompletionStatus::Completed, created_at: 1, tool_call_id: None, tool_name: None, tool_calls: None, attachments: Vec::new(), }], ); assert!(app.active_turn.is_none()); } #[test] fn committed_delta_calibrates_terminal_without_reloading_history() { let mut app = App::new(); app.set_current_session(Some("current".into())); app.apply_turn_snapshot(turn(3, TurnStatus::Completed)); assert!(app.apply_turn_commit( "current", 2, vec![HistoryMessage { id: "message".into(), seq: 2, role: "assistant".into(), content: "done".into(), reasoning_content: None, completion_status: crate::bus::CompletionStatus::Completed, created_at: 1, tool_call_id: None, tool_name: None, tool_calls: None, attachments: Vec::new(), }], )); assert!(app.active_turn.is_none()); assert_eq!(app.messages.back().unwrap().content, "done"); assert!(!app.apply_turn_commit("current", 2, Vec::new())); } #[test] fn terminal_snapshot_calibrates_when_commit_arrived_first() { let mut app = App::new(); app.set_current_session(Some("current".into())); assert!(app.apply_turn_commit( "current", 2, vec![HistoryMessage { id: "message".into(), seq: 2, role: "assistant".into(), content: "done".into(), reasoning_content: None, completion_status: crate::bus::CompletionStatus::Completed, created_at: 1, tool_call_id: None, tool_name: None, tool_calls: None, attachments: Vec::new(), }], )); assert!(app.apply_turn_snapshot(turn(3, TurnStatus::Completed))); assert!(app.active_turn.is_none()); } #[test] fn failed_turn_without_a_durable_message_remains_visible_after_history_refresh() { let mut app = App::new(); app.set_current_session(Some("current".into())); app.apply_turn_snapshot(turn(3, TurnStatus::Failed)); app.set_history("current", Vec::new()); assert_eq!( app.active_turn.as_ref().map(|turn| turn.status), Some(TurnStatus::Failed) ); } }