628 lines
20 KiB
Rust

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<String>,
pub completion_status: crate::bus::CompletionStatus,
pub attachments: Vec<MessageAttachment>,
}
#[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<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 selected_session: usize,
pub show_archived: bool,
pub messages: VecDeque<ChatMessage>,
pub history_revision: i64,
pub active_turn: Option<TurnSnapshot>,
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<Modal>,
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<String>,
pub commands: Vec<SlashCommandInfo>,
pub show_command_menu: bool,
pub selected_command_idx: usize,
pub http_base_url: String,
pub auth_token: Option<String>,
pub client_id: String,
pub file_transfer_supported: bool,
pub pending_uploads: Vec<UploadDescriptor>,
}
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<MessageAttachment>,
) {
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<HistoryMessage>) {
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<HistoryMessage>,
) -> 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<SessionSummary>) {
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<String>) {
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<String> {
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<SlashCommandInfo>) {
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<usize> {
value[..offset]
.char_indices()
.next_back()
.map(|(index, _)| index)
}
fn next_boundary(value: &str, offset: usize) -> Option<usize> {
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)
);
}
}