diff --git a/crates/focus-tui/Cargo.toml b/crates/focus-tui/Cargo.toml new file mode 100644 index 0000000..47b276a --- /dev/null +++ b/crates/focus-tui/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "focus-tui" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Terminal UI for the focus agent (ratatui + crossterm)" + +[[bin]] +name = "focus" +path = "src/main.rs" + +[dependencies] +focus-core.workspace = true +focus-providers.workspace = true +focus-tools.workspace = true +focus-harness.workspace = true +focus-json.workspace = true +ratatui = "0.30" +crossterm = "0.28" diff --git a/crates/focus-tui/src/app.rs b/crates/focus-tui/src/app.rs new file mode 100644 index 0000000..9d6f945 --- /dev/null +++ b/crates/focus-tui/src/app.rs @@ -0,0 +1,931 @@ +//! App 状态与交互逻辑:命令、键盘/鼠标、后台任务粘合、会话持久化。 +//! App state and interaction logic: commands, keyboard/mouse, job glue, and +//! session persistence. + +use crate::config::{ProviderKind, TuiConfig}; +use crate::format::display_width; +use crate::job::{JobEvent, RunJob}; +use crate::state::*; +use focus_core::model::*; +use focus_harness::estimate_messages; +use focus_harness::prompt::{default_context, ContextUsage, SystemPromptTemplate}; +use focus_harness::session::{SessionEntry, SessionStore}; +use focus_providers::config::{known_context_window, resolve_context_window}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::mpsc::{channel, Receiver}; +use std::thread::JoinHandle; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// 一个已渲染块的几何信息(行范围,供点击映射)。 +/// Geometry of one rendered block (line range, for click mapping). +#[derive(Debug, Clone)] +pub struct BlockGeometry { + pub id: BlockId, + /// 内容坐标中的起始行(含)。 + /// Start line in content coordinates (inclusive). + pub start: u16, + /// 内容坐标中的结束行(不含)。 + /// End line in content coordinates (exclusive). + pub end: u16, +} + +/// 配置表单的字段。 +/// A config-form field. +#[derive(Debug, Clone)] +pub enum FormField { + /// 单选。 + /// A choice. + Choice { + label: String, + options: Vec, + selected: usize, + }, + /// 文本输入。 + /// Text input. + Text { label: String, value: String }, +} + +impl FormField { + pub fn label(&self) -> &str { + match self { + FormField::Choice { label, .. } => label, + FormField::Text { label, .. } => label, + } + } +} + +/// 配置编辑表单。 +/// The config-editing form. +#[derive(Debug, Clone)] +pub struct ConfigForm { + pub fields: Vec, + pub active: usize, +} + +impl ConfigForm { + /// 从当前配置构建表单。 + /// Build the form from the current config. + pub fn from_config(cfg: &TuiConfig) -> Self { + let protocol = match cfg.openai_protocol { + focus_providers::openai::OpenAiProtocol::Responses => "responses", + focus_providers::openai::OpenAiProtocol::ChatCompletions => "chatCompletions", + }; + Self { + fields: vec![ + FormField::Choice { + label: "provider".into(), + options: vec!["anthropic".into(), "openai".into()], + selected: match cfg.provider { + ProviderKind::Anthropic => 0, + ProviderKind::OpenAI => 1, + }, + }, + FormField::Choice { + label: "openaiProtocol".into(), + options: vec!["responses".into(), "chatCompletions".into()], + selected: if protocol == "chatCompletions" { 1 } else { 0 }, + }, + FormField::Text { + label: "baseUrl".into(), + value: cfg.base_url.clone().unwrap_or_default(), + }, + FormField::Text { + label: "apiKey".into(), + value: cfg.api_key.clone(), + }, + FormField::Text { + label: "model".into(), + value: cfg.model.clone(), + }, + FormField::Text { + label: "contextWindow".into(), + value: cfg + .context_window + .map(|w| w.to_string()) + .unwrap_or_default(), + }, + ], + active: 0, + } + } + + /// 把表单写回配置(空字符串 → None)。 + /// Write the form back into the config (empty string → None). + pub fn apply(self, cfg: &mut TuiConfig) { + for field in self.fields { + match field { + FormField::Choice { + label, selected, .. + } => match label.as_str() { + "provider" => { + cfg.provider = if selected == 1 { + ProviderKind::OpenAI + } else { + ProviderKind::Anthropic + } + } + "openaiProtocol" => { + cfg.openai_protocol = if selected == 1 { + focus_providers::openai::OpenAiProtocol::ChatCompletions + } else { + focus_providers::openai::OpenAiProtocol::Responses + } + } + _ => {} + }, + FormField::Text { label, value } => { + let value = value.trim().to_string(); + match label.as_str() { + "baseUrl" => { + cfg.base_url = if value.is_empty() { None } else { Some(value) } + } + "apiKey" => cfg.api_key = value, + "model" => { + if !value.is_empty() { + cfg.model = value; + } + } + "contextWindow" => { + cfg.context_window = value.parse::().ok().filter(|w| *w > 0) + } + _ => {} + } + } + } + } + } +} + +/// 会话列表表单。 +/// The session-list form. +#[derive(Debug, Clone)] +pub struct SessionsForm { + pub sessions: Vec, + pub selected: usize, +} + +/// 打开的模态框。 +/// An open modal. +#[derive(Debug, Clone)] +pub enum Modal { + Config(ConfigForm), + Sessions(SessionsForm), + Help, +} + +/// 交互式应用状态。 +/// The interactive app state. +#[derive(Debug)] +pub struct App { + pub config: TuiConfig, + pub transcript: Vec, + /// 已写入会话的消息数。 + /// Messages already written to the session. + pub persisted: usize, + pub session_id: Option, + pub last_entry_id: Option, + pub session_seq: u64, + pub store: SessionStore, + pub input: String, + pub caret: usize, + pub run_rx: Option>, + pub run_join: Option>, + pub run_state: Option, + /// 已提交 assistant 消息的思考耗时((消息索引, 内容索引) → 毫秒)。 + /// Committed assistant thinking durations ((message idx, content idx) → ms). + pub thinking_durations: HashMap<(usize, usize), u64>, + pub expanded: HashSet, + pub notes: Vec, + pub status: String, + pub scroll: u16, + pub follow: bool, + pub modal: Option, + pub last_usage: Option, + /// 最近一次渲染的块几何(供点击映射),由 draw 填充。 + /// Geometry of the last render (for click mapping); filled by draw. + pub geometry: Vec, + /// 消息区视图高度(由 draw 填充)。 + /// Messages-area view height (filled by draw). + pub view_height: u16, + pub quit: bool, +} + +impl App { + /// 创建一个新应用(加载配置与默认数据目录)。 + /// Create a new app (loading the config and default data dir). + pub fn new() -> Self { + Self { + config: TuiConfig::load(), + transcript: Vec::new(), + persisted: 0, + session_id: None, + last_entry_id: None, + session_seq: 0, + store: SessionStore::default(), + input: String::new(), + caret: 0, + run_rx: None, + run_join: None, + run_state: None, + thinking_durations: HashMap::new(), + expanded: HashSet::new(), + notes: Vec::new(), + status: String::new(), + scroll: 0, + follow: true, + modal: None, + last_usage: None, + geometry: Vec::new(), + view_height: 0, + quit: false, + } + } + + /// 推送一条系统说明(显示在消息区顶部)。 + /// Push a system note (shown at the top of the message area). + pub fn push_note(&mut self, text: impl Into) { + self.notes.push(text.into()); + } + + /// 是否正在运行任务。 + /// Whether a job is running. + pub fn is_running(&self) -> bool { + self.run_rx.is_some() + } + + /// 会话是否已存在(否则在首次发送时创建)。 + /// Whether a session exists (created on first send otherwise). + pub fn ensure_session(&mut self) { + if self.session_id.is_none() { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + self.session_id = Some(format!("s{}-{}", secs, self.session_seq)); + self.session_seq += 1; + } + } + + /// 把消息写入当前会话(增量)。 + /// Write messages into the current session (incrementally). + pub fn persist_entries(&mut self, messages: &[Message]) { + let Some(sid) = self.session_id.clone() else { + return; + }; + for m in messages { + let id = match self.store.next_entry_id(&sid) { + Ok(i) => i, + Err(_) => return, + }; + let entry = SessionEntry::new(id.clone(), self.last_entry_id.clone(), m.clone()); + if self.store.append(&sid, &entry).is_err() { + return; + } + self.last_entry_id = Some(id); + self.persisted += 1; + } + } + + /// 渲染当前系统提示(含 Usage/上下文占用)。 + /// Render the current system prompt (with usage / context occupancy). + fn render_system_prompt(&self) -> String { + let model = self.config.model.clone(); + let window = resolve_context_window(self.config.context_window, &model); + let window_known = + self.config.context_window.is_some() || known_context_window(&model).is_some(); + let mut ctx = default_context(); + ctx.context_usage = Some(ContextUsage { + estimated_tokens: estimate_messages(&self.transcript), + context_window: window, + window_known, + }); + ctx.last_usage = self.last_usage.clone(); + SystemPromptTemplate::default().render(&ctx) + } + + /// 发送一条消息(若以 `/` 开头则视为命令)。 + /// Send a message (commands start with `/`). + pub fn send(&mut self, text: &str) { + let trimmed = text.trim(); + if trimmed.is_empty() { + return; + } + if let Some(cmd) = trimmed.strip_prefix('/') { + self.handle_command(cmd); + return; + } + if !self.config.is_ready() { + self.status = "⚠ 未配置:请先运行 /config".into(); + return; + } + if self.is_running() { + self.status = "⚠ 已有任务在运行".into(); + return; + } + self.ensure_session(); + // 用户消息先入 UI 的 transcript 用于即时显示;持久化由后台任务完成 + // (job 的 to_persist 会包含它),避免与 agent 循环重复写入。 + // The user message enters the UI transcript for immediate display; + // persistence happens in the background job (its to_persist includes + // it), avoiding a duplicate write with the agent loop. + let user = Message::user_text(text.to_string()); + self.transcript.push(user); + self.input.clear(); + self.caret = 0; + self.follow = true; + self.status = "▶ running…".into(); + self.spawn_job(Some(text.to_string())); + } + + /// 继续对话(对最后一条 assistant 消息再追问)。 + /// Continue the conversation (re-prompt after an assistant turn). + pub fn r#continue(&mut self) { + self.send("continue"); + } + + /// 启动后台任务(prompt 或仅压缩)。 + /// Spawn a background job (prompt or compact-only). + fn spawn_job(&mut self, user_text: Option) { + let (tx, rx) = channel::(); + let system_prompt = self.render_system_prompt(); + // 给任务的 transcript 不含刚加入的用户消息——agent 循环会把它加入并 + // 计入 to_persist,从而避免重复持久化。 + // The job's transcript excludes the just-added user message — the + // agent loop adds it back and it lands in to_persist, avoiding + // duplicate persistence. + let job_transcript = match &user_text { + Some(_) => self.transcript[..self.transcript.len().saturating_sub(1)].to_vec(), + None => self.transcript.clone(), + }; + let job = RunJob { + transcript: job_transcript, + user_text, + config: self.config.clone(), + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + system_prompt, + provider: None, + }; + let join = crate::job::spawn(job, tx); + self.run_rx = Some(rx); + self.run_join = Some(join); + self.run_state = Some(RunState::new(real_clock())); + } + + /// 排空后台任务事件。 + /// Drain background job events. + pub fn drain_job_events(&mut self) { + let rx = match self.run_rx.take() { + Some(rx) => rx, + None => return, + }; + let mut finished = false; + while let Ok(ev) = rx.try_recv() { + if matches!(ev, JobEvent::Done { .. }) { + finished = true; + } + self.on_job_event(ev); + } + if finished { + self.run_join = None; + self.run_state = None; + } else { + self.run_rx = Some(rx); + } + } + + /// 处理一个后台事件。 + /// Handle one background event. + fn on_job_event(&mut self, ev: JobEvent) { + match ev { + JobEvent::Agent(ev) => { + if let Some(rs) = &mut self.run_state { + rs.on_agent_event(&ev); + } + } + JobEvent::Note(n) => self.status = n, + JobEvent::Done { + messages, + to_persist, + appended_count, + error, + } => { + // 收尾思考计时并映射到已提交消息。 + // Finalize thinking timings and map them onto committed + // messages. + if let Some(rs) = &mut self.run_state { + rs.finalize(); + let tail_start = messages.len().saturating_sub(appended_count); + let mut turn = 0usize; + for (off, msg) in messages.iter().enumerate().skip(tail_start) { + if let Message::Assistant(a) = msg { + for (bi, block) in a.content.iter().enumerate() { + if let ContentBlock::Thinking(_) = block { + if let Some(timing) = rs.thinking.get(&(turn, bi)) { + self.thinking_durations + .insert((off, bi), timing.duration_ms()); + } + } + } + turn += 1; + } + } + } + + self.persist_entries(&to_persist); + self.transcript = messages; + if let Some(err) = error { + self.status = format!("⚠ {}", err); + self.push_note(format!("运行出错:{}", err)); + } else { + self.status = "✓ 完成".into(); + } + if let Some(last) = self.transcript.iter().rev().find_map(|m| match m { + Message::Assistant(a) => Some(a.usage.clone()), + _ => None, + }) { + self.last_usage = Some(last); + } + self.follow = true; + } + } + } + + /// 中止当前运行(放弃该任务,线程随超时自然结束)。 + /// Abort the current run (abandon the job; the thread ends within its + /// timeout). + pub fn abort_run(&mut self) { + if self.run_rx.is_none() { + return; + } + self.run_rx = None; + self.run_join = None; + let aborted = if let Some(rs) = &mut self.run_state { + rs.finalize(); + match rs.partial.clone() { + Some(mut p) => { + p.stop_reason = StopReason::Aborted; + p.error_message = Some("aborted by user".into()); + p + } + None => AssistantMessage { + content: Vec::new(), + model: self.config.model.clone(), + usage: Usage::default(), + stop_reason: StopReason::Aborted, + error_message: Some("aborted by user".into()), + timestamp: now_ms(), + }, + } + } else { + AssistantMessage { + content: Vec::new(), + model: self.config.model.clone(), + usage: Usage::default(), + stop_reason: StopReason::Aborted, + error_message: Some("aborted by user".into()), + timestamp: now_ms(), + } + }; + self.transcript.push(Message::Assistant(aborted)); + let tail = self + .transcript + .get(self.persisted..) + .unwrap_or_default() + .to_vec(); + self.persist_entries(&tail); + self.run_state = None; + self.status = "⏹ 已中止".into(); + self.follow = true; + } + + /// 开始新会话。 + /// Start a new session. + pub fn new_session(&mut self) { + if self.is_running() { + self.status = "⚠ 任务运行中,无法切换会话".into(); + return; + } + self.transcript.clear(); + self.persisted = 0; + self.session_id = None; + self.last_entry_id = None; + self.thinking_durations.clear(); + self.expanded.clear(); + self.last_usage = None; + self.notes.clear(); + self.push_note("新会话已开始。"); + self.scroll = 0; + self.follow = true; + } + + /// 加载一个会话(替换 transcript)。 + /// Load a session (replacing the transcript). + pub fn load_session(&mut self, session_id: &str) { + let entries = match self.store.load(session_id) { + Ok(e) => e, + Err(e) => { + self.status = format!("⚠ 加载会话失败:{}", e); + return; + } + }; + let messages: Vec = entries.iter().map(|e| e.message.clone()).collect(); + self.transcript = messages; + self.persisted = entries.len(); + self.session_id = Some(session_id.to_string()); + self.last_entry_id = entries.last().map(|e| e.id.clone()); + self.thinking_durations.clear(); + self.expanded.clear(); + self.notes.clear(); + self.push_note(format!( + "已加载会话 {}({} 条消息)。", + session_id, + entries.len() + )); + if let Some(last) = self.transcript.iter().rev().find_map(|m| match m { + Message::Assistant(a) => Some(a.usage.clone()), + _ => None, + }) { + self.last_usage = Some(last); + } + self.scroll = 0; + self.follow = true; + } + + /// 执行一条命令(不含前导 `/`)。 + /// Run a command (without the leading `/`). + fn handle_command(&mut self, cmd: &str) { + match cmd.trim() { + "new" => self.new_session(), + "sessions" => { + if self.is_running() { + self.status = "⚠ 任务运行中".into(); + return; + } + match self.store.list_sessions() { + Ok(sessions) => { + if sessions.is_empty() { + self.status = "暂无历史会话".into(); + } else { + self.modal = Some(Modal::Sessions(SessionsForm { + sessions, + selected: 0, + })); + } + } + Err(e) => self.status = format!("⚠ {}", e), + } + } + "config" => { + if self.is_running() { + self.status = "⚠ 任务运行中".into(); + return; + } + self.modal = Some(Modal::Config(ConfigForm::from_config(&self.config))); + } + "compact" => { + if !self.config.is_ready() { + self.status = "⚠ 未配置".into(); + return; + } + if self.is_running() { + self.status = "⚠ 已有任务在运行".into(); + return; + } + if self.transcript.is_empty() { + self.status = "无内容可压缩".into(); + return; + } + self.ensure_session(); + self.status = "▶ compacting…".into(); + self.spawn_job(None); + } + "help" => { + self.modal = Some(Modal::Help); + } + other => { + self.status = format!("未知命令:/{}(/help 查看帮助)", other); + } + } + } + + /// 处理一个键盘事件。返回 true 表示退出。 + /// Handle a key event. Returns true to quit. + pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; + if key.kind != KeyEventKind::Press { + return false; + } + // Ctrl+C 永远退出。 + // Ctrl+C always quits. + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + self.quit = true; + return true; + } + + if let Some(modal) = self.modal.take() { + if let Some(kept) = self.handle_modal_key(modal, key) { + self.modal = Some(kept); + } + return false; + } + + match key.code { + KeyCode::Esc => { + if self.is_running() { + self.abort_run(); + } else { + self.quit = true; + } + } + KeyCode::Enter => { + if key.modifiers.contains(KeyModifiers::SHIFT) { + self.insert_char('\n'); + } else { + let text = self.input.clone(); + if !text.trim().is_empty() { + self.send(&text); + } + } + } + KeyCode::Tab => self.toggle_latest_collapsible(), + KeyCode::Backspace => self.backspace(), + KeyCode::Delete => self.delete(), + KeyCode::Left => self.move_caret(-1), + KeyCode::Right => self.move_caret(1), + KeyCode::Up => self.move_caret_line(-1), + KeyCode::Down => self.move_caret_line(1), + KeyCode::Home => { + self.caret = self.line_start(self.caret); + } + KeyCode::End => { + self.caret = self.line_end(self.caret); + } + KeyCode::PageUp => self.scroll = self.scroll.saturating_add(10), + KeyCode::PageDown => { + let max = self.max_scroll(); + self.scroll = (self.scroll + 10).min(max); + } + KeyCode::Char(c) => { + // Ctrl+D 继续。 + // Ctrl+D continues. + if c == 'd' && key.modifiers.contains(KeyModifiers::CONTROL) { + if self.is_running() { + self.status = "⚠ 任务运行中".into(); + } else if self.config.is_ready() && !self.transcript.is_empty() { + self.r#continue(); + } + return false; + } + self.insert_char(c); + } + _ => {} + } + false + } + + /// 处理模态框内的键盘事件。返回 `Some` 表示保持打开,`None` 表示关闭。 + /// Handle a key event inside a modal. Returns `Some` to keep it open, + /// `None` to close it. + fn handle_modal_key(&mut self, modal: Modal, key: crossterm::event::KeyEvent) -> Option { + use crossterm::event::{KeyCode, KeyModifiers}; + match modal { + Modal::Help => { + // 任意键关闭帮助。 + // Any key closes help. + None + } + Modal::Config(mut form) => match key.code { + KeyCode::Esc => None, + KeyCode::Enter => { + form.apply(&mut self.config); + if let Err(e) = self.config.save() { + self.status = format!("⚠ 保存配置失败:{}", e); + } else { + self.status = "✓ 配置已保存".into(); + } + None + } + KeyCode::Up | KeyCode::BackTab => { + form.active = (form.active + form.fields.len() - 1) % form.fields.len(); + Some(Modal::Config(form)) + } + KeyCode::Down | KeyCode::Tab => { + form.active = (form.active + 1) % form.fields.len(); + Some(Modal::Config(form)) + } + KeyCode::Left => { + if let FormField::Choice { selected, .. } = &mut form.fields[form.active] { + *selected = selected.saturating_sub(1); + } + Some(Modal::Config(form)) + } + KeyCode::Right => { + if let FormField::Choice { + options, selected, .. + } = &mut form.fields[form.active] + { + *selected = (*selected + 1).min(options.len().saturating_sub(1)); + } + Some(Modal::Config(form)) + } + KeyCode::Backspace => { + if let FormField::Text { value, .. } = &mut form.fields[form.active] { + value.pop(); + } + Some(Modal::Config(form)) + } + KeyCode::Char(c) => { + if c == 'c' && key.modifiers.contains(KeyModifiers::CONTROL) { + return None; + } + if let FormField::Text { value, .. } = &mut form.fields[form.active] { + value.push(c); + } + Some(Modal::Config(form)) + } + _ => Some(Modal::Config(form)), + }, + Modal::Sessions(mut form) => match key.code { + KeyCode::Esc => None, + KeyCode::Up => { + form.selected = form.selected.saturating_sub(1); + Some(Modal::Sessions(form)) + } + KeyCode::Down => { + form.selected = (form.selected + 1).min(form.sessions.len().saturating_sub(1)); + Some(Modal::Sessions(form)) + } + KeyCode::Enter => { + if let Some(id) = form.sessions.get(form.selected) { + let id = id.clone(); + self.load_session(&id); + } + None + } + _ => Some(Modal::Sessions(form)), + }, + } + } + + /// 处理鼠标事件(点击展开/折叠,滚轮滚动)。 + /// Handle a mouse event (click toggles expand, wheel scrolls). + pub fn handle_mouse(&mut self, m: crossterm::event::MouseEvent) { + use crossterm::event::MouseEventKind; + match m.kind { + MouseEventKind::Down(crossterm::event::MouseButton::Left) => { + // 只处理消息区内的点击。 + // Only clicks inside the messages area. + if m.row < self.view_height { + let content_row = m.row + self.scroll; + if let Some(block) = self + .geometry + .iter() + .find(|g| g.start <= content_row && content_row < g.end) + { + toggle_expanded(&mut self.expanded, &block.id); + } + } + } + MouseEventKind::ScrollUp => self.scroll = self.scroll.saturating_add(3), + MouseEventKind::ScrollDown => { + let max = self.max_scroll(); + self.scroll = (self.scroll + 3).min(max); + } + _ => {} + } + } + + /// 把输入框的滚动对齐到底部(跟随模式)。 + /// Align the scroll to the bottom when following. + pub fn clamp_scroll(&mut self) { + if self.follow { + self.scroll = self.max_scroll(); + } + } + + /// 最大滚动偏移(由最近渲染的几何决定)。 + /// Max scroll offset (derived from the last render's geometry). + fn max_scroll(&self) -> u16 { + let total = self.geometry.last().map(|g| g.end).unwrap_or(0); + total.saturating_sub(self.view_height.max(1)) + } + + /// 切换最近一个可折叠块。 + /// Toggle the most recent collapsible block. + fn toggle_latest_collapsible(&mut self) { + let id = self + .geometry + .iter() + .rev() + .find(|g| { + let id = &g.id; + id.0.starts_with("live-") || id.0.starts_with("exec-") || id.0.contains("-r") + }) + .map(|g| g.id.clone()); + if let Some(id) = id { + toggle_expanded(&mut self.expanded, &id); + } + } + + // ---- 输入编辑 ---- + // ---- input editing ---------------------------------------------------- + + fn insert_char(&mut self, c: char) { + self.input.insert(self.caret, c); + self.caret += c.len_utf8(); + } + + fn backspace(&mut self) { + if let Some((idx, _len)) = self.input[..self.caret].char_indices().next_back() { + self.input.replace_range(idx..self.caret, ""); + self.caret = idx; + } + } + + fn delete(&mut self) { + if self.caret < self.input.len() { + let len = self.input[self.caret..] + .chars() + .next() + .map(|c| c.len_utf8()) + .unwrap_or(0); + self.input.replace_range(self.caret..self.caret + len, ""); + } + } + + fn move_caret(&mut self, dir: i32) { + if dir < 0 { + if let Some((idx, _)) = self.input[..self.caret].char_indices().next_back() { + self.caret = idx; + } + } else if self.caret < self.input.len() { + let len = self.input[self.caret..] + .chars() + .next() + .map(|c| c.len_utf8()) + .unwrap_or(0); + self.caret += len; + } + } + + fn line_start(&self, pos: usize) -> usize { + self.input[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0) + } + + fn line_end(&self, pos: usize) -> usize { + self.input[pos..] + .find('\n') + .map(|i| pos + i) + .unwrap_or(self.input.len()) + } + + fn move_caret_line(&mut self, dir: i32) { + let line_start = self.line_start(self.caret); + let line_end = self.line_end(self.caret); + let col = display_width(&self.input[line_start..self.caret]); + if dir < 0 { + if line_start == 0 { + self.caret = 0; + return; + } + let prev_line_end = line_start - 1; // '\n' 的位置 / the '\n' position + let prev_start = self.input[..prev_line_end] + .rfind('\n') + .map(|i| i + 1) + .unwrap_or(0); + self.caret = prev_start + position_at_col(&self.input[prev_start..prev_line_end], col); + } else { + if line_end == self.input.len() { + self.caret = self.input.len(); + return; + } + let next_start = line_end + 1; + let next_end = self.input[next_start..] + .find('\n') + .map(|i| next_start + i) + .unwrap_or(self.input.len()); + self.caret = next_start + position_at_col(&self.input[next_start..next_end], col); + } + } +} + +/// 在 `s` 中定位显示宽度 `col` 对应的字节偏移(尽力而为)。 +/// Byte offset in `s` for a display column (best effort). +fn position_at_col(s: &str, col: usize) -> usize { + let mut w = 0usize; + for (i, c) in s.char_indices() { + let cw = crate::format::display_width_char(c); + if w + cw > col { + return i; + } + w += cw; + } + s.len() +} + +impl Default for App { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/focus-tui/src/config.rs b/crates/focus-tui/src/config.rs new file mode 100644 index 0000000..2d08fa1 --- /dev/null +++ b/crates/focus-tui/src/config.rs @@ -0,0 +1,178 @@ +//! 持久化的 TUI 配置:provider、协议、端点、模型与上下文窗口。 +//! Persisted TUI config: provider, protocol, endpoints, model and context +//! window. +//! +//! 配置存于 `/config.json`(手写 JSON 编解码),在 TUI 里通过 `/config` +//! 编辑,provider 构造时传入。 +//! Stored at `/config.json` (hand-written JSON), edited via `/config` in +//! the TUI, and passed into providers at construction time. + +use focus_json::JsonValue; +use std::path::PathBuf; + +/// 后端 provider 种类。 +/// The backend provider kind. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProviderKind { + /// Anthropic Messages API。 + /// The Anthropic Messages API. + Anthropic, + /// OpenAI(Responses + Chat Completions 双协议)。 + /// OpenAI (both the Responses API and Chat Completions). + OpenAI, +} + +impl ProviderKind { + /// 协议线路上使用的字符串形式。 + /// The wire-format string. + pub fn as_str(&self) -> &'static str { + match self { + ProviderKind::Anthropic => "anthropic", + ProviderKind::OpenAI => "openai", + } + } + + /// 由字符串解析。 + /// Parse from a string. + pub fn parse_str(s: &str) -> Option { + match s { + "anthropic" => Some(ProviderKind::Anthropic), + "openai" => Some(ProviderKind::OpenAI), + _ => None, + } + } +} + +/// TUI 的完整配置。 +/// The full TUI configuration. +#[derive(Debug, Clone)] +pub struct TuiConfig { + /// 后端 provider。 + /// The backend provider. + pub provider: ProviderKind, + /// OpenAI 协议(仅 OpenAI 生效)。 + /// The OpenAI protocol (OpenAI only). + pub openai_protocol: focus_providers::openai::OpenAiProtocol, + /// API 基址(`None` 使用默认端点)。 + /// API base URL (`None` uses the default endpoint). + pub base_url: Option, + /// API 密钥。 + /// The API key. + pub api_key: String, + /// 模型 id。 + /// The model id. + pub model: String, + /// 上下文窗口(`None` 走已知表/兜底)。 + /// Context window (`None` falls back to the known table / default). + pub context_window: Option, +} + +impl Default for TuiConfig { + fn default() -> Self { + Self { + provider: ProviderKind::Anthropic, + openai_protocol: focus_providers::openai::OpenAiProtocol::default(), + base_url: None, + api_key: String::new(), + model: "claude-sonnet-4".into(), + context_window: None, + } + } +} + +impl TuiConfig { + /// 是否已配置可用(有 api_key 且 model 非空)。 + /// Whether the config is usable (api_key set and model non-empty). + pub fn is_ready(&self) -> bool { + !self.api_key.is_empty() && !self.model.is_empty() + } + + /// 序列化为 JSON。 + /// Serialize to JSON. + pub fn to_json(&self) -> JsonValue { + let mut o = JsonValue::obj(); + o.insert("provider", self.provider.as_str().into()).ok(); + o.insert( + "openaiProtocol", + match self.openai_protocol { + focus_providers::openai::OpenAiProtocol::Responses => "responses", + focus_providers::openai::OpenAiProtocol::ChatCompletions => "chatCompletions", + } + .into(), + ) + .ok(); + if let Some(u) = &self.base_url { + o.insert("baseUrl", u.clone().into()).ok(); + } + o.insert("apiKey", self.api_key.clone().into()).ok(); + o.insert("model", self.model.clone().into()).ok(); + if let Some(w) = self.context_window { + o.insert("contextWindow", (w as f64).into()).ok(); + } + o + } + + /// 从 JSON 解析(未知字段忽略,非法值回落默认)。 + /// Parse from JSON (unknown fields ignored, invalid values fall back to + /// defaults). + pub fn from_json(value: &JsonValue) -> Self { + let mut cfg = Self::default(); + if let Some(p) = value.get_str("provider").and_then(ProviderKind::parse_str) { + cfg.provider = p; + } + cfg.openai_protocol = match value.get_str("openaiProtocol") { + Some("chatCompletions") => focus_providers::openai::OpenAiProtocol::ChatCompletions, + _ => focus_providers::openai::OpenAiProtocol::Responses, + }; + if let Some(u) = value.get_str("baseUrl") { + if !u.is_empty() { + cfg.base_url = Some(u.to_string()); + } + } + if let Some(k) = value.get_str("apiKey") { + cfg.api_key = k.to_string(); + } + if let Some(m) = value.get_str("model") { + if !m.is_empty() { + cfg.model = m.to_string(); + } + } + if let Some(w) = value.get_num("contextWindow") { + if w > 0.0 { + cfg.context_window = Some(w as u64); + } + } + cfg + } + + /// 配置文件路径(`/config.json`)。 + /// The config file path (`/config.json`). + pub fn config_path() -> PathBuf { + focus_harness::data_dir().join("config.json") + } + + /// 从磁盘加载;文件缺失或损坏时返回默认配置。 + /// Load from disk; missing or corrupt files yield the default config. + pub fn load() -> Self { + let path = Self::config_path(); + match std::fs::read_to_string(&path) { + Ok(text) => match focus_json::parse(&text) { + Ok(value) => Self::from_json(&value), + Err(_) => Self::default(), + }, + Err(_) => Self::default(), + } + } + + /// 保存到磁盘(自动创建父目录)。 + /// Save to disk (auto-creating parent directories). + pub fn save(&self) -> Result<(), String> { + let path = Self::config_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("create config dir {}: {}", parent.display(), e))?; + } + let text = focus_json::to_string(&self.to_json()); + std::fs::write(&path, text).map_err(|e| format!("write config {}: {}", path.display(), e)) + } +} diff --git a/crates/focus-tui/src/format.rs b/crates/focus-tui/src/format.rs new file mode 100644 index 0000000..25987af --- /dev/null +++ b/crates/focus-tui/src/format.rs @@ -0,0 +1,160 @@ +//! 摘要与格式化辅助:工具摘要、耗时、token 数与文本截断。 +//! Summary & formatting helpers: tool summaries, durations, token counts and +//! text truncation. + +use focus_json::JsonValue; + +/// 生成工具调用的摘要行(折叠态显示的具体内容,而非仅工具名)。 +/// Build a tool-call summary line (concrete content for the collapsed state, +/// not just the tool name). +/// +/// - `read`/`write`/`edit` → 显示路径; +/// - `shell` → 显示命令; +/// - 其他 → 显示 `name` + 紧凑 JSON 参数。 +/// - `read`/`write`/`edit` → the path; +/// - `shell` → the command; +/// - anything else → `name` + compact JSON args. +pub fn summarize_tool(name: &str, args: &JsonValue) -> String { + match name { + "read" => { + let p = args.get_str("path").unwrap_or("?"); + let range = match (args.get_num("startLine"), args.get_num("endLine")) { + (Some(s), Some(e)) => format!(" lines {}-{}", s as usize, e as usize), + (Some(s), None) => format!(" from line {}", s as usize), + _ => String::new(), + }; + format!("read {}{}", p, range) + } + "write" => { + let p = args.get_str("path").unwrap_or("?"); + let len = args.get_str("content").map(|c| c.len()).unwrap_or(0); + format!("write {} ({} bytes)", p, len) + } + "edit" => { + let p = args.get_str("path").unwrap_or("?"); + let n = args.get_arr("edits").map(|e| e.len()).unwrap_or(0); + format!("edit {} ({} edit{})", p, n, if n == 1 { "" } else { "s" }) + } + "shell" => { + let cmd = args.get_str("command").unwrap_or("?"); + format!("shell: {}", truncate(cmd, 80)) + } + other => { + let json = focus_json::to_string(args); + format!("{} {}", other, truncate(&json, 80)) + } + } +} + +/// 格式化耗时(毫秒 → 人类可读)。 +/// Format a duration (ms → human readable). +pub fn format_duration(ms: u64) -> String { + if ms < 1_000 { + format!("{}ms", ms) + } else if ms < 60_000 { + format!("{:.1}s", ms as f64 / 1_000.0) + } else { + let secs = ms / 1_000; + format!("{}m {:02}s", secs / 60, secs % 60) + } +} + +/// 千位分隔的 token 数。 +/// Token count with thousands separators. +pub fn format_tokens(n: u64) -> String { + let s = n.to_string(); + let bytes = s.as_bytes(); + let mut out = String::with_capacity(s.len() + s.len() / 3); + for (i, b) in bytes.iter().enumerate() { + if i > 0 && (bytes.len() - i) % 3 == 0 { + out.push(','); + } + out.push(*b as char); + } + out +} + +/// 截断文本(保留头部;`max` 按字符计)。 +/// Truncate text (keep the head; `max` counts chars). +pub fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + let head: String = s.chars().take(max).collect(); + format!("{}…", head) + } +} + +/// 截断多行输出(保留头部若干行 + 尾部若干行,中间省略)。 +/// Truncate multi-line output (head + tail lines, ellipsis in between). +pub fn truncate_lines(s: &str, max_lines: usize, max_chars: usize) -> String { + let lines: Vec<&str> = s.lines().collect(); + let lines: Vec<&str> = if lines.len() <= max_lines { + lines + } else { + let keep = max_lines.saturating_sub(2) / 2; + let mut out: Vec<&str> = lines.iter().take(keep).copied().collect(); + out.push("… (truncated)"); + out.extend(lines.iter().skip(lines.len().saturating_sub(keep)).copied()); + out + }; + let joined = lines.join("\n"); + truncate(&joined, max_chars) +} + +/// 单字符的显示宽度(ASCII=1,其余=2)。 +/// Display width of one char (ASCII=1, else 2). +pub fn display_width_char(c: char) -> usize { + if c.is_ascii() { + 1 + } else { + 2 + } +} + +/// 字符串的显示宽度(ASCII=1,CJK 等=2)。 +/// Display width of a string (ASCII=1, CJK etc. = 2). +pub fn display_width(s: &str) -> usize { + s.chars().map(display_width_char).sum() +} + +/// 按显示宽度换行(保留原有 `\n`;超宽的单词会中途截断)。 +/// Wrap text at a display width (preserving existing `\n`; over-wide words +/// are broken mid-word). +pub fn wrap_text(s: &str, width: usize) -> Vec { + if width == 0 { + return vec![s.to_string()]; + } + let mut out = Vec::new(); + for line in s.split('\n') { + if display_width(line) <= width { + out.push(line.to_string()); + continue; + } + let mut current = String::new(); + let mut w = 0usize; + for c in line.chars() { + let cw = display_width_char(c); + if w + cw > width { + out.push(std::mem::take(&mut current)); + w = 0; + } + current.push(c); + w += cw; + } + out.push(current); + } + out +} + +/// 计算输入框内光标的 (行, 列, 总行数)。 +/// Compute the caret's (row, col, total_rows) inside an input box. +pub fn caret_position(text: &str, caret: usize, width: usize) -> (u16, u16, u16) { + let caret = caret.min(text.len()); + let before = &text[..caret]; + let wrapped_before = wrap_text(before, width); + let total = wrap_text(text, width).len(); + let row = wrapped_before.len().saturating_sub(1); + let col = display_width(wrapped_before.last().map(|s| s.as_str()).unwrap_or("")); + (row as u16, col as u16, total as u16) +} diff --git a/crates/focus-tui/src/job.rs b/crates/focus-tui/src/job.rs new file mode 100644 index 0000000..3c1d1b6 --- /dev/null +++ b/crates/focus-tui/src/job.rs @@ -0,0 +1,254 @@ +//! 后台任务:自动压缩 + 运行 agent(在独立线程中执行同步的 agent 循环)。 +//! Background jobs: auto-compaction + running the agent (the synchronous agent +//! loop executes on a dedicated thread). + +use crate::config::{ProviderKind, TuiConfig}; +use focus_core::event::{AgentEvent, EventSink}; +use focus_core::model::{ContentBlock, Message}; +use focus_core::provider::{ProviderRequest, StreamEvent, StreamProvider}; +use focus_core::tool::ToolRegistry; +use focus_core::Agent; +use focus_harness::compaction::{ + apply_summary, plan_compaction, DEFAULT_KEEP_RATIO, DEFAULT_THRESHOLD_RATIO, +}; +use focus_harness::estimate_messages; +use focus_providers::anthropic::AnthropicProvider; +use focus_providers::config::{resolve_context_window, ProviderConfig}; +use focus_providers::openai::OpenAiProvider; +use focus_tools::{EditTool, ReadTool, ShellTool, WriteTool}; +use std::path::PathBuf; +use std::sync::mpsc::Sender; +use std::thread::JoinHandle; +use std::time::Duration; + +/// 压缩触发阈值(估算占用比例)。 +/// Compaction trigger threshold (estimated occupancy ratio). +pub const COMPACT_THRESHOLD: f64 = DEFAULT_THRESHOLD_RATIO; + +/// 后台任务发给 UI 的事件。 +/// Events the background job sends to the UI. +#[derive(Debug)] +pub enum JobEvent { + /// agent 事件(转发)。 + /// An agent event (forwarded). + Agent(AgentEvent), + /// 状态说明(如"正在压缩…")。 + /// A status note (e.g. "compacting…"). + Note(String), + /// 任务结束。`messages` 为**最终完整 transcript**;`to_persist` 为需要新写入 + /// 会话的消息(含压缩产生的摘要消息);`appended_count` 为本轮 agent 实际追加 + /// 的消息数(不含摘要消息,用于思考耗时映射)。 + /// Job finished. `messages` is the **final full transcript**; + /// `to_persist` are the messages that must be newly written to the session + /// (including a compaction summary); `appended_count` is how many messages + /// the agent run actually appended (excluding the summary; used to map + /// thinking durations). + Done { + messages: Vec, + to_persist: Vec, + appended_count: usize, + error: Option, + }, +} + +/// 一次后台任务(prompt / continue / 仅压缩)。 +/// One background job (prompt / continue / compact-only). +pub struct RunJob { + /// 任务开始时的 transcript。 + /// The transcript at job start. + pub transcript: Vec, + /// 用户消息(`None` 表示仅压缩,不运行 agent)。 + /// The user message (`None` means compact-only, no agent run). + pub user_text: Option, + /// TUI 配置(provider / 模型 / 端点)。 + /// TUI config (provider / model / endpoints). + pub config: TuiConfig, + /// 项目根目录(工具的相对路径基准 + shell cwd)。 + /// Project root (tools' relative-path base + shell cwd). + pub cwd: PathBuf, + /// 渲染好的系统提示(含 Usage/上下文占用)。 + /// The rendered system prompt (with usage / context occupancy). + pub system_prompt: String, + /// 注入的 provider(测试用;`None` 时按配置构造)。 + /// Injected provider (for tests; `None` builds one from the config). + pub provider: Option>, +} + +/// 启动一个后台任务。 +/// Spawn a background job. +pub fn spawn(job: RunJob, tx: Sender) -> JoinHandle<()> { + std::thread::Builder::new() + .name("focus-tui-job".into()) + .spawn(move || run_job(job, tx)) + .expect("spawn tui job thread") +} + +/// 执行任务。 +/// Execute the job. +fn run_job(job: RunJob, tx: Sender) { + let _ = tx.send(JobEvent::Note("starting…".into())); + + // 0. provider:测试注入或按配置构造;摘要与 agent 共用。 + // 0. Provider: injected (tests) or built from config; shared between the + // summarizer and the agent. + let provider = match job.provider { + Some(b) => b, + None => build_provider(&job.config), + }; + + // 1. 自动压缩(无用户确认,超过阈值即在回合间执行)。 + // 1. Auto-compaction (no user confirmation; runs between turns when over + // the threshold). + let mut transcript = job.transcript.clone(); + let mut compacted = false; + let window = resolve_context_window(job.config.context_window, &job.config.model); + let estimated = estimate_messages(&transcript); + let over = !transcript.is_empty() && (estimated as f64) >= (window as f64) * COMPACT_THRESHOLD; + if over { + let _ = tx.send(JobEvent::Note(format!( + "compacting… ({} tokens used of {})", + crate::format::format_tokens(estimated), + crate::format::format_tokens(window) + ))); + if let Some(plan) = + plan_compaction(&transcript, window, COMPACT_THRESHOLD, DEFAULT_KEEP_RATIO) + { + match summarize(provider.as_ref(), &job.config, &plan) { + Ok(summary) => { + transcript = apply_summary(&summary, &plan); + compacted = true; + } + Err(e) => { + let _ = tx.send(JobEvent::Note(format!("compaction failed: {}", e))); + } + } + } + } + + // 2. 构建 agent 并运行(compaction 之后)。 + // 2. Build the agent and run (after compaction). + let tools = build_tools(&job.cwd); + let config = focus_core::AgentConfig { + model: job.config.model.clone(), + system_prompt: job.system_prompt, + max_tokens: Some(4096), + ..Default::default() + }; + let mut agent = Agent::new(config, provider, tools); + agent.replace_messages(transcript.clone()); + + let mut sink = ChannelSink(tx.clone()); + let result = match &job.user_text { + Some(text) => agent.prompt(text.clone(), &mut sink), + None => Ok(()), // 仅压缩任务,不运行 agent / compact-only job + }; + + let final_transcript = agent.messages().to_vec(); + // 需要新写入会话的消息:压缩摘要(如有)+ 本轮追加的消息。 + // Messages to newly persist: the compaction summary (if any) + the + // messages appended by this run. + let appended = final_transcript[transcript.len().min(final_transcript.len())..].to_vec(); + let mut to_persist = Vec::new(); + if compacted { + if let Some(first) = final_transcript.first() { + to_persist.push(first.clone()); + } + } + to_persist.extend(appended.iter().cloned()); + + match result { + Ok(()) => { + let _ = tx.send(JobEvent::Done { + messages: final_transcript, + to_persist, + appended_count: appended.len(), + error: None, + }); + } + Err(e) => { + let _ = tx.send(JobEvent::Done { + messages: final_transcript, + to_persist, + appended_count: appended.len(), + error: Some(e.to_string()), + }); + } + } +} + +/// 用 provider 生成摘要(收集流事件中的 Done 文本)。 +/// Generate a summary via the provider (collect the Done text from the stream). +fn summarize( + provider: &dyn StreamProvider, + config: &TuiConfig, + plan: &focus_harness::compaction::CompactionPlan, +) -> Result { + let request = ProviderRequest { + model: config.model.clone(), + system_prompt: plan.summary_instruction.clone(), + messages: plan.summarize.clone(), + tools: focus_json::JsonValue::arr(), + max_tokens: Some(2000), + temperature: None, + }; + let mut iter = provider.stream(&request).map_err(|e| e.to_string())?; + let mut text = String::new(); + while let Some(ev) = iter.next_event() { + match ev { + StreamEvent::Done { message } => { + for block in &message.content { + if let ContentBlock::Text(t) = block { + text.push_str(&t.text); + } + } + break; + } + StreamEvent::Error { error } => { + return Err(error + .error_message + .unwrap_or_else(|| "summary stream errored".into())); + } + _ => {} + } + } + if text.trim().is_empty() { + return Err("summary stream produced no text".into()); + } + Ok(text) +} + +/// 依据配置构造 provider。 +/// Build a provider from the config. +pub fn build_provider(config: &TuiConfig) -> Box { + let pc = ProviderConfig { + api_key: config.api_key.clone(), + base_url: config.base_url.clone(), + context_window: config.context_window, + timeout: Duration::from_secs(120), + }; + match config.provider { + ProviderKind::Anthropic => Box::new(AnthropicProvider::new(pc)), + ProviderKind::OpenAI => Box::new(OpenAiProvider::new(pc, config.openai_protocol)), + } +} + +/// 依据项目根目录构造工具注册表。 +/// Build the tool registry for a project root. +pub fn build_tools(cwd: &std::path::Path) -> ToolRegistry { + let mut registry = ToolRegistry::new(); + registry.register(Box::new(ReadTool::new(cwd))); + registry.register(Box::new(WriteTool::new(cwd))); + registry.register(Box::new(EditTool::new(cwd))); + registry.register(Box::new(ShellTool::new(cwd))); + registry +} + +/// 把 agent 事件转发到 UI channel 的 sink。 +/// A sink that forwards agent events to the UI channel. +struct ChannelSink(Sender); + +impl EventSink for ChannelSink { + fn emit(&mut self, event: AgentEvent) { + let _ = self.0.send(JobEvent::Agent(event)); + } +} diff --git a/crates/focus-tui/src/lib.rs b/crates/focus-tui/src/lib.rs new file mode 100644 index 0000000..dc07409 --- /dev/null +++ b/crates/focus-tui/src/lib.rs @@ -0,0 +1,38 @@ +//! focus-tui:终端交互层。 +//! focus-tui: the terminal interaction layer. +//! +//! 组装 provider / tools / harness,提供交互式对话(流式、折叠的思考与工具块、 +//! 会话管理、自动压缩)。渲染使用 ratatui + crossterm(白名单特批,见 AGENTS.md +//! §2.1)。本 crate 只做**组装与呈现**,业务逻辑都在下层 crate。 +//! Assembles providers / tools / harness into an interactive chat (streaming, +//! collapsible thinking & tool blocks, session management, auto-compaction). +//! Rendering uses ratatui + crossterm (whitelist-exempted, see AGENTS.md +//! §2.1). This crate only **assembles and renders**; the business logic lives +//! in the lower crates. + +#![forbid(unsafe_code)] + +/// 交互应用状态与命令处理。 +/// Interactive app state and command handling. +pub mod app; + +/// 持久化的 TUI 配置(provider / 模型 / 端点等)。 +/// Persisted TUI configuration (provider / model / endpoints). +pub mod config; +/// 摘要与格式化辅助(工具摘要、耗时、token 格式)。 +/// Summary & formatting helpers (tool summaries, durations, token formats). +pub mod format; +/// 后台任务:运行 agent、自动压缩。 +/// Background jobs: running the agent, auto-compaction. +pub mod job; +/// 纯 UI 状态机:从 Agent 事件构建可渲染的块(可独立测试)。 +/// Pure UI state machine: builds renderable blocks from Agent events +/// (testable without a terminal). +pub mod state; + +pub use config::{ProviderKind, TuiConfig}; +pub use state::UiBlock; + +/// 终端渲染与主循环(内部模块)。 +/// Terminal rendering and the main loop. +pub mod ui; diff --git a/crates/focus-tui/src/main.rs b/crates/focus-tui/src/main.rs new file mode 100644 index 0000000..bceff77 --- /dev/null +++ b/crates/focus-tui/src/main.rs @@ -0,0 +1,12 @@ +//! focus 二进制入口:启动 TUI。 +//! focus binary entry point: launches the TUI. + +use std::io; + +fn main() -> io::Result<()> { + // 确保数据目录存在(config/sessions 会在使用时创建,这里先行确保)。 + // Ensure the data dir exists (config/sessions are created on use; make + // sure it exists up front). + let _ = std::fs::create_dir_all(focus_harness::data_dir()); + focus_tui::ui::run() +} diff --git a/crates/focus-tui/src/state.rs b/crates/focus-tui/src/state.rs new file mode 100644 index 0000000..fd9dce7 --- /dev/null +++ b/crates/focus-tui/src/state.rs @@ -0,0 +1,476 @@ +//! 纯 UI 状态机:从 [`AgentEvent`] 构建可渲染的块,独立于终端(可测试)。 +//! Pure UI state machine: builds renderable blocks from [`AgentEvent`]s, +//! independent of the terminal (testable). +//! +//! 关键设计:思考与工具调用默认**折叠**,只显示摘要(思考显示耗时与 token 估算, +//! 工具显示具体内容摘要 + 耗时);点击/Tab 展开详情。展开状态用稳定的 +//! [`BlockId`] 保存在 `expanded` 集合中,跨重建保持。 +//! Key design: thinking and tool calls are **collapsed** by default, showing +//! only summaries (thinking shows duration + estimated tokens; tools show a +//! concrete-content summary + duration); clicking/Tab expands the details. +//! Expansion state lives in an `expanded` set keyed by stable [`BlockId`]s so +//! it survives rebuilds. + +use focus_core::event::AgentEvent; +use focus_core::model::*; +use focus_json::JsonValue; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +/// 注入时钟(便于测试:确定性时间)。 +/// Injected clock (deterministic in tests). +pub type Clock = Arc u64 + Send + Sync>; + +/// 真实时钟。 +/// The real clock. +pub fn real_clock() -> Clock { + Arc::new(now_ms) +} + +/// 块在屏幕上的稳定标识(用于展开状态与点击映射)。 +/// A stable identifier for a block on screen (expansion state & click mapping). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct BlockId(pub String); + +/// 一个可渲染的 UI 块。 +/// One renderable UI block. +#[derive(Debug, Clone)] +pub enum UiBlock { + /// 用户消息。 + /// A user message. + UserText { id: BlockId, text: String }, + /// assistant 消息头(模型 / 用量 / 停止原因)。 + /// Assistant message header (model / usage / stop reason). + AssistantHeader { + id: BlockId, + model: String, + usage: Usage, + stop_reason: StopReason, + }, + /// 普通文本。 + /// Plain text. + Text { id: BlockId, text: String }, + /// 思考块:折叠时显示耗时与 token 估算。 + /// Thinking block: collapsed shows duration + estimated tokens. + Thinking { + id: BlockId, + text: String, + duration_ms: Option, + tokens: u64, + expanded: bool, + }, + /// assistant 发出的工具调用。 + /// A tool call issued by the assistant. + ToolCall { + id: BlockId, + name: String, + summary: String, + args: JsonValue, + expanded: bool, + }, + /// 工具执行结果消息(来自已提交的 transcript)。 + /// A tool-result message (from the committed transcript). + ToolResult { + id: BlockId, + name: String, + text: String, + is_error: bool, + expanded: bool, + }, + /// 工具执行过程(运行中 / 完成,含耗时与输出)。 + /// A tool execution (running / done, with duration and output). + ToolExec { + id: BlockId, + name: String, + summary: String, + status: ExecStatus, + duration_ms: Option, + output: String, + result_text: String, + is_error: bool, + expanded: bool, + }, + /// 系统提示(欢迎、帮助、状态说明等)。 + /// A system note (welcome, help, status). + Note { text: String }, +} + +/// 工具执行状态。 +/// Tool execution status. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExecStatus { + /// 正在运行。 + /// Running. + Running, + /// 已结束。 + /// Finished. + Done, +} + +/// 思考块的计时状态。 +/// Timing state of a thinking block. +#[derive(Debug, Clone)] +pub struct ThinkingTiming { + /// 首次出现(思考开始)的墙钟时间。 + /// Wall-clock time when first seen (thinking start). + pub started_ms: u64, + /// 结束时间(出现后续内容块或消息结束时)。 + /// End time (a later content block appeared, or the message ended). + pub ended_ms: Option, +} + +impl ThinkingTiming { + /// 耗时毫秒(未结束时按开始时间计 0)。 + /// Duration in ms (0 while not ended). + pub fn duration_ms(&self) -> u64 { + self.ended_ms + .unwrap_or(self.started_ms) + .saturating_sub(self.started_ms) + } +} + +/// 一个工具执行的 UI 状态。 +/// UI state of one tool execution. +#[derive(Debug, Clone)] +pub struct ToolExecUi { + pub tool_call_id: String, + pub name: String, + pub summary: String, + pub status: ExecStatus, + pub started_ms: u64, + pub ended_ms: Option, + pub output: String, + pub result_text: String, + pub is_error: bool, +} + +impl ToolExecUi { + /// 耗时毫秒。 + /// Duration in ms. + pub fn duration_ms(&self) -> Option { + self.ended_ms.map(|e| e.saturating_sub(self.started_ms)) + } +} + +/// 单次运行的流式状态(一次 `prompt`/`continue` 的整个 agent 循环)。 +/// Streaming state of a single run (the whole agent loop of one +/// `prompt`/`continue`). +#[derive(Clone)] +pub struct RunState { + clock: Clock, + /// 最新的 partial assistant 消息。 + /// The latest partial assistant message. + pub partial: Option, + /// 思考块计时,按 (回合, 内容索引) 键控。 + /// Thinking timings, keyed by (turn, content index). + pub thinking: HashMap<(usize, usize), ThinkingTiming>, + /// 工具执行列表。 + /// Tool executions. + pub tool_execs: Vec, + /// 已完成的回合数(也用于思考键控的当前回合号)。 + /// Completed turns (also the current turn number for thinking keys). + pub turn_count: usize, +} + +impl std::fmt::Debug for RunState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RunState") + .field("clock", &"") + .field("partial", &self.partial) + .field("thinking", &self.thinking) + .field("tool_execs", &self.tool_execs) + .field("turn_count", &self.turn_count) + .finish() + } +} + +impl RunState { + /// 创建空的运行状态(注入时钟)。 + /// Create an empty run state (with an injected clock). + pub fn new(clock: Clock) -> Self { + Self { + clock, + partial: None, + thinking: HashMap::new(), + tool_execs: Vec::new(), + turn_count: 0, + } + } + + /// 处理一个 agent 事件。 + /// Handle one agent event. + pub fn on_agent_event(&mut self, ev: &AgentEvent) { + match ev { + AgentEvent::MessageStart { message } + | AgentEvent::MessageUpdate { message, .. } + | AgentEvent::MessageEnd { message } => { + if let Message::Assistant(a) = message { + self.update_partial(a); + } + } + AgentEvent::ToolExecutionStart { + tool_call_id, + tool_name, + args, + } => { + self.tool_execs.push(ToolExecUi { + tool_call_id: tool_call_id.clone(), + name: tool_name.clone(), + summary: crate::format::summarize_tool(tool_name, args), + status: ExecStatus::Running, + started_ms: (self.clock)(), + ended_ms: None, + output: String::new(), + result_text: String::new(), + is_error: false, + }); + } + AgentEvent::ToolExecutionUpdate { + tool_call_id, + partial_result, + .. + } => { + if let Some(exec) = self + .tool_execs + .iter_mut() + .find(|e| e.tool_call_id == *tool_call_id) + { + for block in &partial_result.content { + if let Some(t) = block.as_text() { + if !exec.output.is_empty() { + exec.output.push('\n'); + } + exec.output.push_str(&t.text); + } + } + } + } + AgentEvent::ToolExecutionEnd { + tool_call_id, + tool_name, + result, + is_error, + } => { + if let Some(exec) = self + .tool_execs + .iter_mut() + .find(|e| e.tool_call_id == *tool_call_id) + { + exec.status = ExecStatus::Done; + exec.ended_ms = Some((self.clock)()); + exec.name = tool_name.clone(); + exec.result_text = join_text(&result.content); + exec.is_error = *is_error; + } + } + AgentEvent::TurnEnd { .. } => self.turn_count += 1, + _ => {} + } + } + + /// 用新的 partial 快照更新状态并推进思考计时。 + /// Update from a new partial snapshot and advance thinking timings. + fn update_partial(&mut self, partial: &AssistantMessage) { + self.partial = Some(partial.clone()); + let content = &partial.content; + let turn = self.turn_count; + for (i, block) in content.iter().enumerate() { + if !matches!(block, ContentBlock::Thinking(_)) { + continue; + } + let timing = self + .thinking + .entry((turn, i)) + .or_insert_with(|| ThinkingTiming { + started_ms: (self.clock)(), + ended_ms: None, + }); + // 后续出现了内容块 → 思考结束。 + // A later content block appeared → the thinking finished. + if timing.ended_ms.is_none() && content.len() > i + 1 { + timing.ended_ms = Some((self.clock)()); + } + } + } + + /// 消息结束时收尾未结算的思考计时。 + /// Finalize unsettled thinking timings at message end. + pub fn finalize(&mut self) { + let now = (self.clock)(); + for timing in self.thinking.values_mut() { + if timing.ended_ms.is_none() { + timing.ended_ms = Some(now); + } + } + } +} + +/// 拼接文本块(工具输出等)。 +/// Join text blocks (tool output etc.). +fn join_text(blocks: &[ContentBlock]) -> String { + blocks + .iter() + .filter_map(|c| c.as_text().map(|t| t.text.clone())) + .collect::>() + .join("\n") +} + +/// 从已提交的 transcript 与运行状态构建可渲染块列表。 +/// Build the renderable block list from the committed transcript and the run +/// state. +/// +/// `thinking_durations`:已提交 assistant 消息的思考耗时((消息索引, 内容索引) → 毫秒)。 +/// `thinking_durations`: committed assistant thinking durations +/// ((message index, content index) → ms). +pub fn build_blocks( + transcript: &[Message], + thinking_durations: &std::collections::HashMap<(usize, usize), u64>, + run_state: Option<&RunState>, + expanded: &HashSet, +) -> Vec { + let mut out = Vec::new(); + for (mi, msg) in transcript.iter().enumerate() { + match msg { + Message::User(u) => { + let text = join_text(&u.content); + out.push(UiBlock::UserText { + id: BlockId(format!("m{}-0", mi)), + text, + }); + } + Message::Assistant(a) => { + out.push(UiBlock::AssistantHeader { + id: BlockId(format!("m{}-h", mi)), + model: a.model.clone(), + usage: a.usage.clone(), + stop_reason: a.stop_reason, + }); + for (bi, block) in a.content.iter().enumerate() { + push_content_block( + &mut out, + BlockId(format!("m{}-{}", mi, bi)), + block, + thinking_durations.get(&(mi, bi)).copied(), + expanded, + ); + } + } + Message::ToolResult(t) => { + out.push(UiBlock::ToolResult { + id: BlockId(format!("m{}-r", mi)), + name: t.tool_name.clone(), + text: join_text(&t.content), + is_error: t.is_error, + expanded: expanded.contains(&BlockId(format!("m{}-r", mi))), + }); + } + } + } + + if let Some(rs) = run_state { + if let Some(partial) = &rs.partial { + out.push(UiBlock::AssistantHeader { + id: BlockId("live-h".into()), + model: partial.model.clone(), + usage: partial.usage.clone(), + stop_reason: partial.stop_reason, + }); + for (bi, block) in partial.content.iter().enumerate() { + let timing = rs.thinking.get(&(rs.turn_count, bi)); + let duration = timing.map(|t| t.duration_ms()); + push_content_block( + &mut out, + BlockId(format!("live-{}", bi)), + block, + duration, + expanded, + ); + } + } + for (ei, exec) in rs.tool_execs.iter().enumerate() { + out.push(UiBlock::ToolExec { + id: BlockId(format!("exec-{}", ei)), + name: exec.name.clone(), + summary: exec.summary.clone(), + status: exec.status, + duration_ms: exec.duration_ms(), + output: exec.output.clone(), + result_text: exec.result_text.clone(), + is_error: exec.is_error, + expanded: expanded.contains(&BlockId(format!("exec-{}", ei))), + }); + } + } + out +} + +/// 把单个内容块压入块列表(思考/工具调用默认折叠)。 +/// Push one content block into the list (thinking/tool calls collapsed by +/// default). +fn push_content_block( + out: &mut Vec, + id: BlockId, + block: &ContentBlock, + thinking_duration: Option, + expanded: &HashSet, +) { + let is_expanded = expanded.contains(&id); + match block { + ContentBlock::Text(t) => out.push(UiBlock::Text { + id, + text: t.text.clone(), + }), + ContentBlock::Thinking(t) => out.push(UiBlock::Thinking { + id, + text: t.thinking.clone(), + duration_ms: thinking_duration, + tokens: focus_harness::estimate_tokens(&t.thinking), + expanded: is_expanded, + }), + ContentBlock::ToolCall(tc) => out.push(UiBlock::ToolCall { + id, + name: tc.name.clone(), + summary: crate::format::summarize_tool(&tc.name, &tc.arguments), + args: tc.arguments.clone(), + expanded: is_expanded, + }), + ContentBlock::Image(_) => {} + } +} + +/// 切换一个块的展开状态。 +/// Toggle a block's expansion state. +pub fn toggle_expanded(expanded: &mut HashSet, id: &BlockId) { + if !expanded.remove(id) { + expanded.insert(id.clone()); + } +} + +impl UiBlock { + /// 块的稳定 id(`Note` 无 id)。 + /// The block's stable id (`Note` has none). + pub fn id(&self) -> Option<&BlockId> { + match self { + UiBlock::UserText { id, .. } + | UiBlock::AssistantHeader { id, .. } + | UiBlock::Text { id, .. } + | UiBlock::Thinking { id, .. } + | UiBlock::ToolCall { id, .. } + | UiBlock::ToolResult { id, .. } + | UiBlock::ToolExec { id, .. } => Some(id), + UiBlock::Note { .. } => None, + } + } + + /// 是否可展开/折叠(点击或 Tab)。 + /// Whether the block is expandable (click or Tab). + pub fn is_expandable(&self) -> bool { + matches!( + self, + UiBlock::Thinking { .. } + | UiBlock::ToolCall { .. } + | UiBlock::ToolResult { .. } + | UiBlock::ToolExec { .. } + ) + } +} diff --git a/crates/focus-tui/src/ui.rs b/crates/focus-tui/src/ui.rs new file mode 100644 index 0000000..432e25f --- /dev/null +++ b/crates/focus-tui/src/ui.rs @@ -0,0 +1,559 @@ +//! 终端渲染与主循环(ratatui + crossterm)。 +//! Terminal rendering and the main loop (ratatui + crossterm). + +use crate::app::{App, BlockGeometry, ConfigForm, FormField, Modal}; +use crate::format::{format_duration, format_tokens, truncate, wrap_text}; +use crate::state::{ExecStatus, UiBlock}; +use focus_harness::estimate_messages; +use focus_providers::config::resolve_context_window; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Alignment, Constraint, Layout, Margin, Position, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::{Frame, Terminal}; +use std::io::{self, Stdout}; +use std::time::Duration; + +/// 进入 TUI:原始模式 + 备用屏幕 + 鼠标捕获。 +/// Enter the TUI: raw mode + alternate screen + mouse capture. +fn init_terminal() -> io::Result>> { + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!( + io::stdout(), + crossterm::terminal::EnterAlternateScreen, + crossterm::event::EnableMouseCapture + )?; + let terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; + Ok(terminal) +} + +/// 退出 TUI:恢复终端。 +/// Leave the TUI: restore the terminal. +fn restore_terminal(terminal: &mut Terminal>) -> io::Result<()> { + let _ = terminal.show_cursor(); + crossterm::execute!( + io::stdout(), + crossterm::event::DisableMouseCapture, + crossterm::terminal::LeaveAlternateScreen + )?; + crossterm::terminal::disable_raw_mode()?; + Ok(()) +} + +/// 运行 TUI 主循环。 +/// Run the TUI main loop. +pub fn run() -> io::Result<()> { + let mut terminal = init_terminal()?; + let mut app = App::new(); + app.push_note("欢迎使用 focus!/config 配置 provider,/help 查看命令;直接输入内容发送(Enter),Shift+Enter 换行。"); + let result = app_loop(&mut terminal, &mut app); + restore_terminal(&mut terminal)?; + result +} + +/// 主循环:绘制 → 处理输入 → 排空后台事件。 +/// Main loop: draw → handle input → drain background events. +fn app_loop(terminal: &mut Terminal>, app: &mut App) -> io::Result<()> { + loop { + terminal.draw(|f| draw(f, app))?; + if app.quit { + return Ok(()); + } + if crossterm::event::poll(Duration::from_millis(30))? { + match crossterm::event::read()? { + crossterm::event::Event::Key(k) => { + app.handle_key(k); + } + crossterm::event::Event::Mouse(m) => app.handle_mouse(m), + _ => {} + } + } + app.drain_job_events(); + } +} + +/// 全量绘制。 +/// Full draw. +pub fn draw(frame: &mut Frame, app: &mut App) { + let area = frame.area(); + if area.width < 10 || area.height < 3 { + return; + } + let input_height = input_box_height(&app.input, area.width.saturating_sub(2)); + let chunks = Layout::vertical([ + Constraint::Min(3), + Constraint::Length(input_height), + Constraint::Length(1), + ]) + .split(area); + + draw_messages(frame, chunks[0], app); + draw_input(frame, chunks[1], app); + draw_status(frame, chunks[2], app); + + if let Some(modal) = &app.modal { + draw_modal(frame, area, modal); + } +} + +/// 输入框高度(随行数增长,封顶 4 行内容)。 +/// Input-box height (grows with lines, capped at 4 content rows). +fn input_box_height(input: &str, width: u16) -> u16 { + let rows = wrap_text(input, width.max(1) as usize).len(); + (rows.min(4) + 2) as u16 +} + +/// 消息区:notes + 块列表 → 带几何的单 Paragraph。 +/// Messages area: notes + block list → one Paragraph with geometry. +fn draw_messages(frame: &mut Frame, area: Rect, app: &mut App) { + let mut blocks: Vec = app + .notes + .iter() + .map(|n| UiBlock::Note { text: n.clone() }) + .collect(); + blocks.extend(crate::state::build_blocks( + &app.transcript, + &app.thinking_durations, + app.run_state.as_ref(), + &app.expanded, + )); + + let width = area.width.saturating_sub(1).max(1) as usize; + let (lines, geometry) = layout_blocks(&blocks, width); + app.geometry = geometry; + app.view_height = area.height; + + let total = lines.len() as u16; + let max_scroll = total.saturating_sub(area.height.max(1)); + if app.follow { + app.scroll = max_scroll; + } else { + app.scroll = app.scroll.min(max_scroll); + } + + let para = Paragraph::new(Text::from(lines)) + .wrap(Wrap { trim: false }) + .scroll((app.scroll, 0)); + frame.render_widget(para, area); +} + +/// 把块列表排版为文本行 + 几何信息(行范围供点击)。 +/// Lay out blocks into text lines + geometry (line ranges for clicking). +fn layout_blocks(blocks: &[UiBlock], width: usize) -> (Vec>, Vec) { + let mut lines: Vec> = Vec::new(); + let mut geometry = Vec::new(); + for block in blocks { + let start = lines.len() as u16; + let block_lines = block_lines(block, width); + lines.extend(block_lines); + let end = lines.len() as u16; + if block.is_expandable() { + if let Some(id) = block.id() { + geometry.push(BlockGeometry { + id: id.clone(), + start, + end, + }); + } + } + // 块间空行。 + // Blank line between blocks. + lines.push(Line::raw("")); + } + (lines, geometry) +} + +/// 把一个块渲染成若干文本行。 +/// Render one block into text lines. +fn block_lines(block: &UiBlock, width: usize) -> Vec> { + let dim = Style::new().fg(Color::DarkGray); + let mut out = Vec::new(); + match block { + UiBlock::Note { text } => { + for line in wrap_text(text, width) { + out.push(Line::from(Span::styled( + line, + Style::new().fg(Color::DarkGray).italic(), + ))); + } + } + UiBlock::UserText { text, .. } => { + let prefix = Span::styled("You: ", Style::new().fg(Color::Cyan).bold()); + for line in wrap_text(text, width.saturating_sub(5)) { + out.push(Line::from(vec![prefix.clone(), Span::raw(line)])); + } + } + UiBlock::AssistantHeader { + model, + usage, + stop_reason, + .. + } => { + let meta = format!( + "🤖 {} · {} · in {} out {}", + model, + stop_reason.as_str(), + usage.input_tokens, + usage.output_tokens + ); + out.push(Line::from(Span::styled(meta, dim))); + } + UiBlock::Text { text, .. } => { + for line in wrap_text(text, width) { + out.push(Line::from(Span::raw(line))); + } + } + UiBlock::Thinking { + text, + duration_ms, + tokens, + expanded, + .. + } => { + if *expanded { + out.push(Line::from(Span::styled( + "▾ 🧠 thinking", + Style::new().fg(Color::Yellow).bold(), + ))); + for line in wrap_text(text, width.saturating_sub(2)) { + out.push(Line::from(Span::styled(format!(" {}", line), dim))); + } + out.push(Line::from(Span::styled(" ──", dim))); + } else { + let dur = duration_ms + .map(format_duration) + .map(|d| format!(" · {}", d)) + .unwrap_or_default(); + out.push(Line::from(Span::styled( + format!( + "▸ 🧠 thinking{} · ~{} tokens(点击/Tab 展开)", + dur, + format_tokens(*tokens) + ), + Style::new().fg(Color::Yellow), + ))); + } + } + UiBlock::ToolCall { + name, + summary, + args, + expanded, + .. + } => { + if *expanded { + out.push(Line::from(Span::styled( + format!("▾ 🔧 {}", name), + Style::new().fg(Color::Magenta).bold(), + ))); + let json = focus_json::to_string(args); + for line in wrap_text(&json, width.saturating_sub(2)) { + out.push(Line::from(Span::styled(format!(" {}", line), dim))); + } + } else { + out.push(Line::from(Span::styled( + format!("▸ 🔧 {}(点击/Tab 展开)", summary), + Style::new().fg(Color::Magenta), + ))); + } + } + UiBlock::ToolResult { + name, + text, + is_error, + expanded, + .. + } => { + let mark = if *is_error { "✗" } else { "✓" }; + if *expanded { + out.push(Line::from(Span::styled( + format!("▾ 📦 {} {}", mark, name), + if *is_error { + Style::new().fg(Color::Red).bold() + } else { + Style::new().fg(Color::Green).bold() + }, + ))); + for line in wrap_text(text, width.saturating_sub(2)) { + out.push(Line::from(Span::styled(format!(" {}", line), dim))); + } + } else { + out.push(Line::from(Span::styled( + format!("▸ 📦 {} {}(点击/Tab 展开)", mark, name), + if *is_error { + Style::new().fg(Color::Red) + } else { + Style::new().fg(Color::Green) + }, + ))); + } + } + UiBlock::ToolExec { + summary, + status, + duration_ms, + output, + result_text, + is_error, + expanded, + .. + } => { + let running = *status == ExecStatus::Running; + if *expanded { + let state = if running { "● 运行中" } else { "✓" }; + out.push(Line::from(Span::styled( + format!("▾ 🔧 {} · {}", summary, state), + Style::new().fg(Color::Magenta).bold(), + ))); + if running && !output.is_empty() { + for line in wrap_text(output, width.saturating_sub(2)) { + out.push(Line::from(Span::styled(format!(" {}", line), dim))); + } + } + if !running { + if !result_text.is_empty() { + for line in wrap_text(result_text, width.saturating_sub(2)) { + out.push(Line::from(Span::styled(format!(" {}", line), dim))); + } + } + if *is_error { + out.push(Line::from(Span::styled( + " ✗ 工具执行失败", + Style::new().fg(Color::Red), + ))); + } + } + } else { + let dur = duration_ms + .map(format_duration) + .map(|d| format!(" · {}", d)) + .unwrap_or_default(); + let state = if running { + " ● 运行中".to_string() + } else if *is_error { + " ✗".to_string() + } else { + " ✓".to_string() + }; + out.push(Line::from(Span::styled( + format!("▸ 🔧 {}{}{}(点击/Tab 展开)", summary, dur, state), + Style::new().fg(Color::Magenta), + ))); + } + } + } + out +} + +/// 输入框:多行文本 + 光标。 +/// Input box: multi-line text + cursor. +fn draw_input(frame: &mut Frame, area: Rect, app: &App) { + let inner = area.inner(Margin { + horizontal: 1, + vertical: 1, + }); + let width = inner.width.max(1) as usize; + let (caret_row, caret_col, total) = crate::format::caret_position(&app.input, app.caret, width); + let max_rows = 4u16; + let scroll = total.saturating_sub(max_rows); + + let lines: Vec = wrap_text(&app.input, width) + .into_iter() + .map(Line::raw) + .collect(); + let para = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title(" input ") + .border_style(Style::new().fg(Color::DarkGray)), + ) + .scroll((scroll, 0)); + frame.render_widget(para, area); + + // 光标(模态框打开时不显示,避免与模态框光标冲突)。 + // Cursor (hidden while a modal is open). + if app.modal.is_none() { + let rendered_row = caret_row.saturating_sub(scroll); + let x = inner.x + caret_col; + let y = inner.y + rendered_row; + frame.set_cursor_position(Position::new(x, y)); + } +} + +/// 状态栏(最底部一行):模型、协议、会话、上下文占用、用量、状态。 +/// Status bar (bottom-most line): model, protocol, session, context usage, +/// last usage, status. +fn draw_status(frame: &mut Frame, area: Rect, app: &App) { + let model = app.config.model.clone(); + let provider = app.config.provider.as_str(); + let session = app.session_id.clone().unwrap_or_else(|| "-".into()); + let window = resolve_context_window(app.config.context_window, &model); + let estimated = estimate_messages(&app.transcript); + let ratio = if window == 0 { + 0.0 + } else { + estimated as f64 / window as f64 * 100.0 + }; + let usage = app + .last_usage + .as_ref() + .map(|u| format!("in {} out {}", u.input_tokens, u.output_tokens)) + .unwrap_or_default(); + + let left = format!( + "[{} · {}] [{}] [ctx {} / {} · {:.1}%] [{}]", + provider, + model, + session, + format_tokens(estimated), + format_tokens(window), + ratio, + usage + ); + let right = if !app.status.is_empty() { + app.status.clone() + } else if app.is_running() { + "▶ running…".to_string() + } else { + "Esc 退出 · Ctrl+D 继续 · Tab/点击 展开".to_string() + }; + + let width = area.width.saturating_sub(1) as usize; + let left = truncate(&left, width.saturating_sub(20).max(10)); + let right = truncate(&right, 20); + let line = Line::from(vec![ + Span::styled(left, Style::new().fg(Color::Blue).bold()), + Span::styled(" ", dim_style()), + Span::styled(right, Style::new().fg(Color::DarkGray)), + ]); + frame.render_widget(Paragraph::new(line).alignment(Alignment::Left), area); +} + +fn dim_style() -> Style { + Style::new().fg(Color::DarkGray) +} + +/// 居中弹窗区域。 +/// A centered popup rect. +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let v = Layout::vertical([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area)[1]; + Layout::horizontal([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(v)[1] +} + +/// 模态框。 +/// A modal. +fn draw_modal(frame: &mut Frame, area: Rect, modal: &Modal) { + match modal { + Modal::Help => draw_help_modal(frame, area), + Modal::Config(form) => draw_config_modal(frame, area, form), + Modal::Sessions(form) => draw_sessions_modal(frame, area, form), + } +} + +const HELP_TEXT: &str = "\ +focus — 终端编码 agent + +发送:Enter(Shift+Enter 换行) +中止:Esc(运行中);退出:Esc(空闲)/ Ctrl+C +继续:Ctrl+D(对最后一条 assistant 消息再追问) +展开/折叠:点击思考/工具块,或 Tab 切换最近一个 +滚动:PageUp / PageDown / 鼠标滚轮 + +命令: + /new 新会话 + /sessions 切换历史会话 + /config 配置 provider / baseUrl / apiKey / model / contextWindow + /compact 手动压缩上下文 + /help 本帮助 + +思考块与工具调用默认折叠,只显示摘要(耗时 / 内容); +点击或 Tab 可查看完整细节。"; + +fn draw_help_modal(frame: &mut Frame, area: Rect) { + let rect = centered_rect(70, 70, area); + let lines: Vec = wrap_text(HELP_TEXT, rect.width.saturating_sub(4) as usize) + .into_iter() + .map(|l| Line::from(Span::styled(l, dim_style()))) + .collect(); + let para = Paragraph::new(Text::from(lines)) + .block(Block::default().borders(Borders::ALL).title(" help ")); + frame.render_widget(para, rect); +} + +fn draw_config_modal(frame: &mut Frame, area: Rect, form: &ConfigForm) { + let rect = centered_rect(65, 70, area); + let mut lines: Vec = Vec::new(); + for (i, field) in form.fields.iter().enumerate() { + let active = i == form.active; + let base = if active { + Style::new().fg(Color::Black).bg(Color::White).bold() + } else { + dim_style() + }; + let label = field.label(); + let value = match field { + FormField::Choice { + options, selected, .. + } => options + .iter() + .enumerate() + .map(|(i, o)| { + if i == *selected { + format!("[{o}]") + } else { + o.clone() + } + }) + .collect::>() + .join(" "), + FormField::Text { value, .. } => value.clone(), + }; + lines.push(Line::from(Span::styled( + format!("{}: {}", label, value), + base, + ))); + } + lines.push(Line::raw("")); + lines.push(Line::from(Span::styled( + "↑↓ 切换字段 · ←→ 切换选项 · Enter 保存 · Esc 取消", + dim_style(), + ))); + let para = Paragraph::new(Text::from(lines)) + .block(Block::default().borders(Borders::ALL).title(" /config ")); + frame.render_widget(para, rect); +} + +fn draw_sessions_modal(frame: &mut Frame, area: Rect, form: &crate::app::SessionsForm) { + let rect = centered_rect(60, 70, area); + let mut lines: Vec = Vec::new(); + for (i, id) in form.sessions.iter().enumerate() { + let style = if i == form.selected { + Style::new().fg(Color::Black).bg(Color::White).bold() + } else { + dim_style() + }; + lines.push(Line::from(Span::styled(id.clone(), style))); + } + lines.push(Line::raw("")); + lines.push(Line::from(Span::styled( + "↑↓ 选择 · Enter 加载 · Esc 取消", + dim_style(), + ))); + let para = Paragraph::new(Text::from(lines)) + .block(Block::default().borders(Borders::ALL).title(" /sessions ")); + frame.render_widget(para, rect); +} + +// emphasis. diff --git a/crates/focus-tui/tests/app_session_tests.rs b/crates/focus-tui/tests/app_session_tests.rs new file mode 100644 index 0000000..d499121 --- /dev/null +++ b/crates/focus-tui/tests/app_session_tests.rs @@ -0,0 +1,86 @@ +//! App 级会话持久化的测试(临时数据目录,零网络)。 +//! App-level session persistence tests (temp data dir, zero network). + +use focus_core::model::Message; +use focus_harness::session::SessionStore; +use focus_tui::app::App; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; + +static DIR_SEQ: AtomicUsize = AtomicUsize::new(0); + +fn temp_data_dir(tag: &str) -> PathBuf { + let seq = DIR_SEQ.fetch_add(1, Ordering::SeqCst); + let d = std::env::temp_dir().join(format!( + "focus-tui-app-{}-{}-{}", + tag, + std::process::id(), + seq + )); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// 构造一个使用临时数据目录的 App(避免环境变量在并行测试间竞争)。 +/// Build an App on a temp data dir (avoids env-var races in parallel tests). +fn app_on(dir: &PathBuf) -> App { + let mut app = App::new(); + app.store = SessionStore::new(dir); + app +} + +#[test] +fn persist_and_reload_session() { + let dir = temp_data_dir("persist"); + + let mut app = app_on(&dir); + assert_eq!(app.session_id, None); + app.ensure_session(); + let sid = app.session_id.clone().unwrap(); + + app.persist_entries(&[Message::user_text("first")]); + app.persist_entries(&[Message::user_text("second")]); + assert_eq!(app.persisted, 2); + assert_eq!(app.last_entry_id.as_deref(), Some("e2")); + + // 另一个 App 实例加载同一会话。 + // Another App instance loads the same session. + let mut app2 = app_on(&dir); + app2.load_session(&sid); + assert_eq!(app2.transcript.len(), 2); + assert_eq!(app2.persisted, 2); + let text = match &app2.transcript[1] { + Message::User(u) => u.content[0].as_text().map(|t| t.text.clone()), + _ => None, + }; + assert_eq!(text.as_deref(), Some("second")); + + // 续写:追加消息(parentId 链延续到 e3)。 + // Continue: appending continues the parentId chain at e3. + app2.persist_entries(&[Message::user_text("third")]); + assert_eq!(app2.last_entry_id.as_deref(), Some("e3")); + let sessions = app2.store.list_sessions().unwrap(); + assert_eq!(sessions, vec![sid.clone()]); +} + +#[test] +fn new_session_resets_state() { + let dir = temp_data_dir("new"); + + let mut app = app_on(&dir); + app.ensure_session(); + let sid1 = app.session_id.clone().unwrap(); + app.persist_entries(&[Message::user_text("hi")]); + assert_eq!(app.persisted, 1); + + app.new_session(); + assert_eq!(app.session_id, None); + assert_eq!(app.transcript.len(), 0); + assert_eq!(app.persisted, 0); + assert_eq!(app.last_entry_id, None); + + app.ensure_session(); + let sid2 = app.session_id.clone().unwrap(); + assert_ne!(sid1, sid2); +} diff --git a/crates/focus-tui/tests/config_tests.rs b/crates/focus-tui/tests/config_tests.rs new file mode 100644 index 0000000..7fcf75a --- /dev/null +++ b/crates/focus-tui/tests/config_tests.rs @@ -0,0 +1,62 @@ +//! TuiConfig 的 JSON 编解码与默认值测试。 +//! Tests for TuiConfig JSON codec and defaults. + +use focus_json::JsonValue; +use focus_tui::config::{ProviderKind, TuiConfig}; + +#[test] +fn defaults() { + let cfg = TuiConfig::default(); + assert_eq!(cfg.provider, ProviderKind::Anthropic); + assert_eq!(cfg.api_key, ""); + assert_eq!(cfg.model, "claude-sonnet-4"); + assert_eq!(cfg.base_url, None); + assert_eq!(cfg.context_window, None); + assert!(!cfg.is_ready()); +} + +#[test] +fn roundtrip_json() { + let cfg = TuiConfig { + provider: ProviderKind::OpenAI, + openai_protocol: focus_providers::openai::OpenAiProtocol::ChatCompletions, + base_url: Some("https://localhost:8080/v1".into()), + api_key: "sk-abc".into(), + model: "gpt-4o".into(), + context_window: Some(128_000), + }; + + let json = cfg.to_json(); + let restored = TuiConfig::from_json(&json); + assert_eq!(restored.provider, ProviderKind::OpenAI); + assert_eq!( + restored.openai_protocol, + focus_providers::openai::OpenAiProtocol::ChatCompletions + ); + assert_eq!( + restored.base_url.as_deref(), + Some("https://localhost:8080/v1") + ); + assert_eq!(restored.api_key, "sk-abc"); + assert_eq!(restored.model, "gpt-4o"); + assert_eq!(restored.context_window, Some(128_000)); + assert!(restored.is_ready()); +} + +#[test] +fn tolerates_missing_and_invalid_fields() { + // 空对象 → 全默认。 + // Empty object → all defaults. + let cfg = TuiConfig::from_json(&JsonValue::obj()); + assert_eq!(cfg.provider, ProviderKind::Anthropic); + // 非法字段回落默认。 + // Invalid fields fall back to defaults. + let mut o = JsonValue::obj(); + o.insert("provider", "bogus".into()).ok(); + o.insert("contextWindow", JsonValue::Num(-5.0)).ok(); + o.insert("model", "".into()).ok(); + let cfg = TuiConfig::from_json(&o); + assert_eq!(cfg.provider, ProviderKind::Anthropic); + assert_eq!(cfg.context_window, None); + assert_eq!(cfg.model, "claude-sonnet-4"); +} diff --git a/crates/focus-tui/tests/fake/mod.rs b/crates/focus-tui/tests/fake/mod.rs new file mode 100644 index 0000000..b3a8c92 --- /dev/null +++ b/crates/focus-tui/tests/fake/mod.rs @@ -0,0 +1,79 @@ +//! 测试共享的假 provider:把一段文本作为流式回复回放。 +//! Shared test fake provider: replays a text as a streaming reply. +//! +//! 摘要调用(系统提示含 ``)与普通 agent 调用返回不同文本, +//! 从而在零网络的情况下测试自动压缩流程。 +//! Summary calls (system prompt contains ``) and normal agent calls +//! return different texts, exercising auto-compaction with zero network. + +use focus_core::provider::{ + ProviderEventReducer, ProviderRequest, StreamEvent, StreamIterator, StreamProvider, + StreamResult, +}; +use std::fmt; + +/// 一个回放文本回复的假 provider。 +/// A fake provider that replays a text reply. +#[derive(Debug, Clone)] +pub struct FakeProvider { + /// 普通 agent 调用的回复。 + /// Reply for normal agent calls. + pub text: String, + /// 摘要调用的回复。 + /// Reply for summary calls. + pub summary: String, +} + +impl FakeProvider { + /// 构造假 provider。 + /// Build the fake provider. + pub fn new(text: &str, summary: &str) -> Self { + Self { + text: text.to_string(), + summary: summary.to_string(), + } + } +} + +impl StreamProvider for FakeProvider { + fn stream(&self, request: &ProviderRequest) -> StreamResult { + let is_summary = request.system_prompt.contains(""); + let text = if is_summary { + self.summary.clone() + } else { + self.text.clone() + }; + let mut reducer = ProviderEventReducer::new(&request.model); + let mut events = reducer.text_delta(&text); + events.extend(reducer.finalize_events()); + let message = reducer.finish().expect("final message"); + events.push(StreamEvent::Done { message }); + Ok(Box::new(ReplayIter { events })) + } +} + +/// 回放固定事件序列的流。 +/// A stream that replays a fixed event sequence. +struct ReplayIter { + events: Vec, +} + +impl StreamIterator for ReplayIter { + fn next_event(&mut self) -> Option { + if self.events.is_empty() { + None + } else { + Some(self.events.remove(0)) + } + } +} + +/// 供测试 debug 输出使用。 +/// For test debug output. +impl fmt::Debug for ReplayIter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ReplayIter") + .field("remaining", &self.events.len()) + .finish() + } +} diff --git a/crates/focus-tui/tests/format_tests.rs b/crates/focus-tui/tests/format_tests.rs new file mode 100644 index 0000000..7d3bf6f --- /dev/null +++ b/crates/focus-tui/tests/format_tests.rs @@ -0,0 +1,110 @@ +//! 摘要与格式化辅助的测试。 +//! Tests for summary & formatting helpers. + +use focus_json::JsonValue; +use focus_tui::format::{ + caret_position, display_width, format_duration, format_tokens, summarize_tool, truncate, + truncate_lines, wrap_text, +}; + +fn obj(pairs: &[(&str, JsonValue)]) -> JsonValue { + let mut o = JsonValue::obj(); + for (k, v) in pairs { + o.insert(*k, v.clone()).ok(); + } + o +} + +#[test] +fn tool_summaries_show_concrete_content() { + // read → 路径 + 行范围。 + // read → path + line range. + let args = obj(&[ + ("path", "src/main.rs".into()), + ("startLine", 10u64.into()), + ("endLine", 20u64.into()), + ]); + assert_eq!( + summarize_tool("read", &args), + "read src/main.rs lines 10-20" + ); + + // write → 路径 + 字节数。 + // write → path + byte count. + let args = obj(&[("path", "a.txt".into()), ("content", "hello world".into())]); + assert_eq!(summarize_tool("write", &args), "write a.txt (11 bytes)"); + + // edit → 路径 + 编辑数。 + // edit → path + edit count. + let edits = JsonValue::Arr(vec![JsonValue::obj(), JsonValue::obj()]); + let args = obj(&[("path", "b.rs".into()), ("edits", edits)]); + assert_eq!(summarize_tool("edit", &args), "edit b.rs (2 edits)"); + + // shell → 命令(截断)。 + // shell → the command (truncated). + let args = obj(&[("command", "cargo test --workspace".into())]); + assert_eq!( + summarize_tool("shell", &args), + "shell: cargo test --workspace" + ); + + // 未知工具 → 紧凑 JSON。 + // Unknown tool → compact JSON. + let args = obj(&[("x", 1u64.into())]); + assert_eq!(summarize_tool("weird", &args), "weird {\"x\":1}"); +} + +#[test] +fn durations() { + assert_eq!(format_duration(0), "0ms"); + assert_eq!(format_duration(450), "450ms"); + assert_eq!(format_duration(12_300), "12.3s"); + assert_eq!(format_duration(125_000), "2m 05s"); +} + +#[test] +fn token_formatting() { + assert_eq!(format_tokens(0), "0"); + assert_eq!(format_tokens(999), "999"); + assert_eq!(format_tokens(12_345), "12,345"); + assert_eq!(format_tokens(1_000_000), "1,000,000"); +} + +#[test] +fn truncation() { + assert_eq!(truncate("hello", 10), "hello"); + assert_eq!(truncate("hello world", 5), "hello…"); + let multi = "a\nb\nc\nd\ne\nf\ng"; + let out = truncate_lines(multi, 4, 100); + assert!(out.contains("truncated"), "got: {}", out); + assert!(out.contains('a')); + assert!(out.contains('g')); +} + +#[test] +fn wrapping_and_width() { + assert_eq!(display_width("abc"), 3); + assert_eq!(display_width("中文"), 4); + let wrapped = wrap_text("aaaaaa", 3); + assert_eq!(wrapped, vec!["aaa".to_string(), "aaa".to_string()]); + // 保留显式换行。 + // Preserve explicit newlines. + let wrapped = wrap_text("ab\ncd", 10); + assert_eq!(wrapped, vec!["ab".to_string(), "cd".to_string()]); +} + +#[test] +fn caret_positioning() { + // 单行:caret 在末尾。 + // Single line: caret at the end. + let (row, col, total) = caret_position("hello", 5, 10); + assert_eq!((row, col, total), (0, 5, 1)); + // 换行后:caret 在第二行开头。 + // After a newline: caret at the start of line 2. + let (row, col, total) = caret_position("ab\ncd", 3, 10); + assert_eq!((row, col, total), (1, 0, 2)); + // 超宽行会折行。 + // Over-wide lines wrap. + let (row, col, total) = caret_position("aaaaaa", 6, 3); + assert_eq!((row, col, total), (1, 3, 2)); +} diff --git a/crates/focus-tui/tests/job_tests.rs b/crates/focus-tui/tests/job_tests.rs new file mode 100644 index 0000000..9215141 --- /dev/null +++ b/crates/focus-tui/tests/job_tests.rs @@ -0,0 +1,208 @@ +//! 后台任务的测试:运行、自动压缩、仅压缩(注入假 provider,零网络)。 +//! Background-job tests: running, auto-compaction, compact-only (injected fake +//! provider, zero network). + +mod fake; + +use focus_core::model::*; +use focus_tui::config::TuiConfig; +use focus_tui::job::{JobEvent, RunJob}; +use std::path::PathBuf; +use std::sync::mpsc::{channel, Receiver}; + +fn ready_config(window: Option) -> TuiConfig { + TuiConfig { + api_key: "sk-test".into(), + model: "gpt-4o".into(), + context_window: window, + ..TuiConfig::default() + } +} + +/// 运行任务并收集事件,直到 Done。 +/// Run the job and collect events until Done. +fn collect(job: RunJob) -> Vec { + let (tx, rx): (_, Receiver) = channel(); + let handle = focus_tui::job::spawn(job, tx); + let mut events = Vec::new(); + while let Ok(ev) = rx.recv() { + let done = matches!(ev, JobEvent::Done { .. }); + events.push(ev); + if done { + break; + } + } + let _ = handle.join(); + events +} + +fn done_of(events: &[JobEvent]) -> (&Vec, &Vec, usize, Option<&str>) { + match events.iter().rev().find_map(|e| match e { + JobEvent::Done { + messages, + to_persist, + appended_count, + error, + } => Some((messages, to_persist, *appended_count, error.as_deref())), + _ => None, + }) { + Some(x) => x, + None => panic!("no Done event"), + } +} + +fn long_transcript(n: usize) -> Vec { + (0..n) + .map(|i| Message::user_text(format!("msg {} {}", i, "x".repeat(200)))) + .collect() +} + +#[test] +fn run_appends_user_and_assistant() { + let job = RunJob { + transcript: Vec::new(), + user_text: Some("hi".into()), + config: ready_config(None), + cwd: PathBuf::from("."), + system_prompt: "sys".into(), + provider: Some(Box::new(fake::FakeProvider::new("agent reply", "SUMMARY"))), + }; + let events = collect(job); + let (messages, to_persist, appended, error) = done_of(&events); + assert_eq!(error, None); + assert_eq!(messages.len(), 2); + assert!(matches!(&messages[0], Message::User(_))); + let last = messages.last().unwrap(); + let text = match last { + Message::Assistant(a) => a.content[0].as_text().map(|t| t.text.clone()), + _ => None, + }; + assert_eq!(text.as_deref(), Some("agent reply")); + // 用户消息由任务统一追加并计入 to_persist。 + // The user message is appended by the job and counted in to_persist. + assert_eq!(to_persist.len(), 2); + assert_eq!(appended, 2); + // 转发了 agent 事件。 + // Agent events were forwarded. + assert!(events.iter().any(|e| matches!(e, JobEvent::Agent(_)))); +} + +#[test] +fn auto_compaction_then_run() { + // 超过阈值(窗口 100,transcript 约 1000+ token)。 + // Over the threshold (window 100, transcript ~1000+ tokens). + let transcript = long_transcript(10); + let job = RunJob { + transcript, + user_text: Some("next".into()), + config: ready_config(Some(100)), + cwd: PathBuf::from("."), + system_prompt: "sys".into(), + provider: Some(Box::new(fake::FakeProvider::new( + "agent reply", + "SUMMARY TEXT", + ))), + }; + let events = collect(job); + let (messages, to_persist, appended, error) = done_of(&events); + assert_eq!(error, None); + + // 最终 transcript 以摘要 user 消息开头。 + // The final transcript starts with a summary user message. + let first = &messages[0]; + let summary_text = match first { + Message::User(u) => u.content[0].as_text().map(|t| t.text.clone()), + _ => None, + }; + assert!( + summary_text + .as_deref() + .unwrap_or("") + .contains("SUMMARY TEXT"), + "expected summary in transcript head, got {:?}", + summary_text + ); + // 末尾是 user("next") + assistant("agent reply")。 + // The tail is user("next") + assistant("agent reply"). + let n = messages.len(); + assert!(matches!(&messages[n - 2], Message::User(_))); + let last_text = match &messages[n - 1] { + Message::Assistant(a) => a.content[0].as_text().map(|t| t.text.clone()), + _ => None, + }; + assert_eq!(last_text.as_deref(), Some("agent reply")); + // to_persist = 摘要 + 本轮追加(user + assistant)。 + // to_persist = summary + this run's appended (user + assistant). + assert_eq!(to_persist.len(), 3); + assert_eq!(appended, 2); + // 有压缩说明。 + // A compaction note was emitted. + assert!(events + .iter() + .any(|e| matches!(e, JobEvent::Note(n) if n.contains("compacting")))); +} + +#[test] +fn compact_only_produces_summary() { + let transcript = long_transcript(10); + let job = RunJob { + transcript, + user_text: None, + config: ready_config(Some(100)), + cwd: PathBuf::from("."), + system_prompt: "sys".into(), + provider: Some(Box::new(fake::FakeProvider::new( + "agent reply", + "SUMMARY TEXT", + ))), + }; + let events = collect(job); + let (messages, to_persist, appended, error) = done_of(&events); + assert_eq!(error, None); + // 摘要消息出现在头部且要被持久化。 + // The summary message heads the transcript and is persisted. + assert!(messages[0] + .as_user_text() + .map(|t| t.contains("SUMMARY TEXT")) + .unwrap_or(false)); + assert_eq!(to_persist.len(), 1); + assert_eq!(appended, 0); +} + +#[test] +fn below_threshold_no_compaction() { + // 窗口极大 → 不触发压缩。 + // Huge window → no compaction. + let transcript = long_transcript(3); + let job = RunJob { + transcript, + user_text: Some("hi".into()), + config: ready_config(Some(1_000_000)), + cwd: PathBuf::from("."), + system_prompt: "sys".into(), + provider: Some(Box::new(fake::FakeProvider::new("agent reply", "SUMMARY"))), + }; + let events = collect(job); + assert!(!events + .iter() + .any(|e| matches!(e, JobEvent::Note(n) if n.contains("compacting")))); + let (messages, to_persist, appended, _) = done_of(&events); + assert_eq!(messages.len(), 5); // 3 user + user("hi") + assistant + assert_eq!(to_persist.len(), 2); + assert_eq!(appended, 2); +} + +/// 为 `Message` 提供便捷的文本访问(测试辅助)。 +/// Convenience text accessor on `Message` (test helper). +trait AsUserText { + fn as_user_text(&self) -> Option; +} + +impl AsUserText for Message { + fn as_user_text(&self) -> Option { + match self { + Message::User(u) => u.content[0].as_text().map(|t| t.text.clone()), + _ => None, + } + } +} diff --git a/crates/focus-tui/tests/state_tests.rs b/crates/focus-tui/tests/state_tests.rs new file mode 100644 index 0000000..a7a5358 --- /dev/null +++ b/crates/focus-tui/tests/state_tests.rs @@ -0,0 +1,231 @@ +//! 纯 UI 状态机的测试:思考计时、工具执行、块构建与折叠。 +//! Tests for the pure UI state machine: thinking timings, tool executions, +//! block building and collapsing. + +use focus_core::event::AgentEvent; +use focus_core::model::*; +use focus_core::tool::{ToolResult, ToolUpdate}; +use focus_json::JsonValue; +use focus_tui::state::*; +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +/// 可推进的假时钟。 +/// An advanceable fake clock. +struct FakeClock(std::sync::Arc); + +impl FakeClock { + fn new() -> Self { + Self(std::sync::Arc::new(AtomicU64::new(0))) + } + fn clock(&self) -> Clock { + let inner = self.0.clone(); + Arc::new(move || inner.load(Ordering::SeqCst)) + } + fn advance(&self, ms: u64) { + self.0.fetch_add(ms, Ordering::SeqCst); + } +} + +fn thinking_block(text: &str) -> ContentBlock { + ContentBlock::Thinking(ThinkingContent { + thinking: text.into(), + signature: None, + redacted: false, + }) +} + +fn assistant(content: Vec) -> AssistantMessage { + AssistantMessage { + content, + model: "m".into(), + usage: Usage::default(), + stop_reason: StopReason::Stop, + error_message: None, + timestamp: 0, + } +} + +#[test] +fn thinking_duration_is_measured() { + let fc = FakeClock::new(); + let mut rs = RunState::new(fc.clock()); + + // 思考块出现(t=0)。 + // Thinking block appears (t=0). + let p1 = assistant(vec![thinking_block("hmm")]); + rs.on_agent_event(&AgentEvent::MessageStart { + message: Message::Assistant(p1), + }); + fc.advance(500); + // 思考 + 文本(思考结束于 t=500)。 + // Thinking + text (thinking ends at t=500). + let p2 = assistant(vec![thinking_block("hmm"), ContentBlock::text("answer")]); + rs.on_agent_event(&AgentEvent::MessageUpdate { + message: Message::Assistant(p2), + event_type: "text_start".into(), + }); + fc.advance(300); + let p3 = assistant(vec![ + thinking_block("hmm"), + ContentBlock::text("answer extended"), + ]); + rs.on_agent_event(&AgentEvent::MessageUpdate { + message: Message::Assistant(p3.clone()), + event_type: "text_delta".into(), + }); + rs.on_agent_event(&AgentEvent::MessageEnd { + message: Message::Assistant(p3.clone()), + }); + rs.finalize(); + + let timing = rs.thinking.get(&(0, 0)).expect("thinking timing"); + assert_eq!(timing.duration_ms(), 500); +} + +#[test] +fn tool_execution_tracks_summary_output_and_duration() { + let fc = FakeClock::new(); + let mut rs = RunState::new(fc.clock()); + + let mut args = JsonValue::obj(); + args.insert("command", "cargo test".into()).ok(); + rs.on_agent_event(&AgentEvent::ToolExecutionStart { + tool_call_id: "call_1".into(), + tool_name: "shell".into(), + args, + }); + assert_eq!(rs.tool_execs.len(), 1); + assert_eq!(rs.tool_execs[0].status, ExecStatus::Running); + assert_eq!(rs.tool_execs[0].summary, "shell: cargo test"); + + fc.advance(100); + rs.on_agent_event(&AgentEvent::ToolExecutionUpdate { + tool_call_id: "call_1".into(), + tool_name: "shell".into(), + partial_result: ToolUpdate { + content: vec![ContentBlock::text("compiling…")], + details: JsonValue::obj(), + }, + }); + fc.advance(100); + rs.on_agent_event(&AgentEvent::ToolExecutionUpdate { + tool_call_id: "call_1".into(), + tool_name: "shell".into(), + partial_result: ToolUpdate { + content: vec![ContentBlock::text("2 passed")], + details: JsonValue::obj(), + }, + }); + rs.on_agent_event(&AgentEvent::ToolExecutionEnd { + tool_call_id: "call_1".into(), + tool_name: "shell".into(), + result: ToolResult::text("2 passed"), + is_error: false, + }); + + let exec = &rs.tool_execs[0]; + assert_eq!(exec.status, ExecStatus::Done); + assert_eq!(exec.duration_ms(), Some(200)); + assert_eq!(exec.output, "compiling…\n2 passed"); + assert_eq!(exec.result_text, "2 passed"); + assert!(!exec.is_error); +} + +#[test] +fn blocks_are_collapsed_by_default_and_summarized() { + let mut expanded = HashSet::new(); + let transcript = vec![ + Message::user_text("hi"), + Message::Assistant(assistant(vec![ + thinking_block("secret reasoning"), + ContentBlock::ToolCall(ToolCall { + id: "c1".into(), + name: "read".into(), + arguments: { + let mut a = JsonValue::obj(); + a.insert("path", "src/main.rs".into()).ok(); + a + }, + }), + ])), + ]; + let blocks = build_blocks(&transcript, &HashMap::new(), None, &expanded); + + // 折叠态:思考块只显示摘要(无展开文本),工具调用只显示摘要。 + // Collapsed: thinking shows only its summary; tool calls show only their + // summary. + for b in &blocks { + match b { + UiBlock::Thinking { + text, + expanded, + duration_ms, + .. + } => { + assert!(!expanded); + assert_eq!(text, "secret reasoning"); + assert_eq!(*duration_ms, None); + } + UiBlock::ToolCall { + summary, expanded, .. + } => { + assert!(!expanded); + assert_eq!(summary, "read src/main.rs"); + } + _ => {} + } + } + + // 展开后:expanded 标记生效。 + // After expanding: the expanded flag takes effect. + for b in &blocks { + if let Some(id) = b.id() { + toggle_expanded(&mut expanded, id); + } + } + let blocks = build_blocks(&transcript, &HashMap::new(), None, &expanded); + let mut expanded_count = 0; + for b in &blocks { + if let UiBlock::Thinking { expanded, .. } | UiBlock::ToolCall { expanded, .. } = b { + assert!(*expanded); + expanded_count += 1; + } + } + assert_eq!(expanded_count, 2); +} + +#[test] +fn build_blocks_includes_live_run() { + let fc = FakeClock::new(); + let mut rs = RunState::new(fc.clock()); + rs.on_agent_event(&AgentEvent::MessageStart { + message: Message::Assistant(assistant(vec![thinking_block("why")])), + }); + fc.advance(250); + rs.on_agent_event(&AgentEvent::MessageUpdate { + message: Message::Assistant(assistant(vec![ + thinking_block("why"), + ContentBlock::text("because"), + ])), + event_type: "text_start".into(), + }); + + let blocks = build_blocks(&[], &HashMap::new(), Some(&rs), &HashSet::new()); + // live 头 + 思考 + 文本。 + // live header + thinking + text. + let thinking = blocks + .iter() + .find_map(|b| match b { + UiBlock::Thinking { + duration_ms, text, .. + } => Some((text.clone(), *duration_ms)), + _ => None, + }) + .expect("live thinking block"); + assert_eq!(thinking.0, "why"); + // 思考块结束后有文本 → 已有耗时。 + // A later block appeared → the duration is settled. + assert_eq!(thinking.1, Some(250)); +}