628 lines
20 KiB
Rust
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)
|
|
);
|
|
}
|
|
}
|