feat(tui): add focus-tui terminal UI

- ratatui + crossterm chat UI: streaming assistant text, multi-line input,
  bottom status bar (model / protocol / session id / context usage)
- thinking blocks collapse to a summary with duration + estimated tokens;
  tool calls collapse to a concrete summary (path / command) + duration;
  click or Tab expands the details
- config modal (/config): provider, OpenAI protocol, baseUrl, apiKey, model,
  contextWindow — persisted to ~/.focus/config.json; session list (/sessions)
  on the harness JSONL tree; /new, /compact, /help
- auto-compaction (80% threshold, no confirmation) + dynamic system prompt
  with usage info run in a background job (fake-provider-testable)
- Esc aborts a run (abandons the job thread; bounded by the provider timeout)
- tests: config codec, summaries/durations/wrap/caret, UI state machine with
  an injected clock, job flow with a fake provider, session persistence
This commit is contained in:
DaiChaoXiong 2026-08-09 21:35:58 +08:00
parent 792f2fae4c
commit 676dc1503e
15 changed files with 3404 additions and 0 deletions

View File

@ -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"

931
crates/focus-tui/src/app.rs Normal file
View File

@ -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<String>,
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<FormField>,
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::<u64>().ok().filter(|w| *w > 0)
}
_ => {}
}
}
}
}
}
}
/// 会话列表表单。
/// The session-list form.
#[derive(Debug, Clone)]
pub struct SessionsForm {
pub sessions: Vec<String>,
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<Message>,
/// 已写入会话的消息数。
/// Messages already written to the session.
pub persisted: usize,
pub session_id: Option<String>,
pub last_entry_id: Option<String>,
pub session_seq: u64,
pub store: SessionStore,
pub input: String,
pub caret: usize,
pub run_rx: Option<Receiver<JobEvent>>,
pub run_join: Option<JoinHandle<()>>,
pub run_state: Option<RunState>,
/// 已提交 assistant 消息的思考耗时((消息索引, 内容索引) → 毫秒)。
/// Committed assistant thinking durations ((message idx, content idx) → ms).
pub thinking_durations: HashMap<(usize, usize), u64>,
pub expanded: HashSet<BlockId>,
pub notes: Vec<String>,
pub status: String,
pub scroll: u16,
pub follow: bool,
pub modal: Option<Modal>,
pub last_usage: Option<Usage>,
/// 最近一次渲染的块几何(供点击映射),由 draw 填充。
/// Geometry of the last render (for click mapping); filled by draw.
pub geometry: Vec<BlockGeometry>,
/// 消息区视图高度(由 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<String>) {
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<String>) {
let (tx, rx) = channel::<JobEvent>();
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<Message> = 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<Modal> {
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()
}
}

View File

@ -0,0 +1,178 @@
//! 持久化的 TUI 配置provider、协议、端点、模型与上下文窗口。
//! Persisted TUI config: provider, protocol, endpoints, model and context
//! window.
//!
//! 配置存于 `<data>/config.json`(手写 JSON 编解码),在 TUI 里通过 `/config`
//! 编辑provider 构造时传入。
//! Stored at `<data>/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,
/// OpenAIResponses + 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<Self> {
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<String>,
/// 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<u64>,
}
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
}
/// 配置文件路径(`<data>/config.json`)。
/// The config file path (`<data>/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))
}
}

View File

@ -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=1CJK 等=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<String> {
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)
}

254
crates/focus-tui/src/job.rs Normal file
View File

@ -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<Message>,
to_persist: Vec<Message>,
appended_count: usize,
error: Option<String>,
},
}
/// 一次后台任务prompt / continue / 仅压缩)。
/// One background job (prompt / continue / compact-only).
pub struct RunJob {
/// 任务开始时的 transcript。
/// The transcript at job start.
pub transcript: Vec<Message>,
/// 用户消息(`None` 表示仅压缩,不运行 agent
/// The user message (`None` means compact-only, no agent run).
pub user_text: Option<String>,
/// 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<Box<dyn StreamProvider>>,
}
/// 启动一个后台任务。
/// Spawn a background job.
pub fn spawn(job: RunJob, tx: Sender<JobEvent>) -> 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<JobEvent>) {
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<String, String> {
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<dyn StreamProvider> {
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<JobEvent>);
impl EventSink for ChannelSink {
fn emit(&mut self, event: AgentEvent) {
let _ = self.0.send(JobEvent::Agent(event));
}
}

View File

@ -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;

View File

@ -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()
}

View File

@ -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<dyn Fn() -> 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<u64>,
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<u64>,
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<u64>,
}
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<u64>,
pub output: String,
pub result_text: String,
pub is_error: bool,
}
impl ToolExecUi {
/// 耗时毫秒。
/// Duration in ms.
pub fn duration_ms(&self) -> Option<u64> {
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<AssistantMessage>,
/// 思考块计时,按 (回合, 内容索引) 键控。
/// Thinking timings, keyed by (turn, content index).
pub thinking: HashMap<(usize, usize), ThinkingTiming>,
/// 工具执行列表。
/// Tool executions.
pub tool_execs: Vec<ToolExecUi>,
/// 已完成的回合数(也用于思考键控的当前回合号)。
/// 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", &"<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::<Vec<_>>()
.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<BlockId>,
) -> Vec<UiBlock> {
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<UiBlock>,
id: BlockId,
block: &ContentBlock,
thinking_duration: Option<u64>,
expanded: &HashSet<BlockId>,
) {
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<BlockId>, 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 { .. }
)
}
}

559
crates/focus-tui/src/ui.rs Normal file
View File

@ -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<Terminal<CrosstermBackend<Stdout>>> {
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<CrosstermBackend<Stdout>>) -> 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 查看命令直接输入内容发送EnterShift+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<CrosstermBackend<Stdout>>, 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<UiBlock> = 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<Line<'static>>, Vec<BlockGeometry>) {
let mut lines: Vec<Line<'static>> = 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<Line<'static>> {
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<Line> = 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
EnterShift+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<Line> = 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<Line> = 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::<Vec<_>>()
.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<Line> = 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.

View File

@ -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);
}

View File

@ -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");
}

View File

@ -0,0 +1,79 @@
//! 测试共享的假 provider把一段文本作为流式回复回放。
//! Shared test fake provider: replays a text as a streaming reply.
//!
//! 摘要调用(系统提示含 `<summary>`)与普通 agent 调用返回不同文本,
//! 从而在零网络的情况下测试自动压缩流程。
//! Summary calls (system prompt contains `<summary>`) 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("<summary>");
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<StreamEvent>,
}
impl StreamIterator for ReplayIter {
fn next_event(&mut self) -> Option<StreamEvent> {
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()
}
}

View File

@ -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));
}

View File

@ -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<u64>) -> 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<JobEvent> {
let (tx, rx): (_, Receiver<JobEvent>) = 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<Message>, &Vec<Message>, 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<Message> {
(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() {
// 超过阈值(窗口 100transcript 约 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<String>;
}
impl AsUserText for Message {
fn as_user_text(&self) -> Option<String> {
match self {
Message::User(u) => u.content[0].as_text().map(|t| t.text.clone()),
_ => None,
}
}
}

View File

@ -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<AtomicU64>);
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<ContentBlock>) -> 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));
}