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:
parent
792f2fae4c
commit
676dc1503e
|
|
@ -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"
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
/// 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<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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<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)
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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 { .. }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 查看命令;直接输入内容发送(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<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
|
||||||
|
|
||||||
|
发送: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<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.
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -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() {
|
||||||
|
// 超过阈值(窗口 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<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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue