1017 lines
38 KiB
Rust
1017 lines
38 KiB
Rust
//! 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>,
|
||
/// 已提交 tool_result 消息的执行耗时((消息索引, 0) → 毫秒)。
|
||
/// Committed tool_result execution durations ((message idx, 0) → ms).
|
||
pub tool_durations: HashMap<(usize, usize), u64>,
|
||
pub expanded: HashSet<BlockId>,
|
||
/// 二级展开(显示全部内容)的块。
|
||
/// Blocks expanded to the full level (show everything).
|
||
pub full: 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,
|
||
/// 消息区总内容行数(由 draw 填充;滚动上限依据)。
|
||
/// Total content lines of the message area (filled by draw; the scroll
|
||
/// limit is based on it).
|
||
pub content_lines: u16,
|
||
/// 帧计数(供 spinner 动画)。
|
||
/// Frame counter (drives the spinner animation).
|
||
pub frame: u64,
|
||
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(),
|
||
tool_durations: HashMap::new(),
|
||
expanded: HashSet::new(),
|
||
full: HashSet::new(),
|
||
notes: Vec::new(),
|
||
status: String::new(),
|
||
scroll: 0,
|
||
follow: true,
|
||
modal: None,
|
||
last_usage: None,
|
||
geometry: Vec::new(),
|
||
view_height: 0,
|
||
content_lines: 0,
|
||
frame: 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.clear(); // 动画 spinner 指示运行中 / the animated spinner shows "working"
|
||
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) {
|
||
match msg {
|
||
Message::Assistant(a) => {
|
||
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;
|
||
}
|
||
Message::ToolResult(t) => {
|
||
// 把该轮的工具执行耗时映射到 tool_result 消息。
|
||
// Map the tool-execution duration onto the
|
||
// tool_result message.
|
||
if let Some(exec) = rs
|
||
.tool_execs
|
||
.iter()
|
||
.find(|e| e.tool_call_id == t.tool_call_id)
|
||
{
|
||
if let Some(d) = exec.duration_ms() {
|
||
self.tool_durations.insert((off, 0), d);
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
self.persist_entries(&to_persist);
|
||
self.transcript = messages;
|
||
// 错误优先取 job 报告的 error;若错误被编码成消息(如 provider 的
|
||
// HTTP 400 → StreamEvent::Error → assistant error 消息),
|
||
// 从最后一条 assistant 消息的 error_message 提取。
|
||
// Prefer the job's reported error; when the error is encoded as
|
||
// a message (provider HTTP 400 → StreamEvent::Error → an
|
||
// assistant error message), extract it from the last assistant.
|
||
let surfaced = error.or_else(|| {
|
||
self.transcript.iter().rev().find_map(|m| match m {
|
||
Message::Assistant(a) if a.stop_reason == StopReason::Error => {
|
||
a.error_message.clone()
|
||
}
|
||
_ => None,
|
||
})
|
||
});
|
||
if let Some(err) = surfaced {
|
||
self.status = format!("⚠ {}", crate::format::truncate(&err, 80));
|
||
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.tool_durations.clear();
|
||
self.expanded.clear();
|
||
self.full.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.tool_durations.clear();
|
||
self.expanded.clear();
|
||
self.full.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::Home => {
|
||
self.caret = self.line_start(self.caret);
|
||
}
|
||
KeyCode::End => {
|
||
self.caret = self.line_end(self.caret);
|
||
}
|
||
KeyCode::PageUp => {
|
||
// 向上翻页:减小内容偏移(scroll 为内容偏移量)。
|
||
// Page up: decrease the content offset.
|
||
self.follow = false;
|
||
self.scroll = self.scroll.saturating_sub(self.view_height.max(1));
|
||
}
|
||
KeyCode::PageDown => {
|
||
self.scroll = (self.scroll + self.view_height.max(1)).min(self.max_scroll());
|
||
self.follow = self.scroll >= self.max_scroll();
|
||
}
|
||
KeyCode::Up => {
|
||
if self.input.is_empty() {
|
||
// 输入为空时 ↑↓ 滚动消息区(否则用于移动光标)。
|
||
// Up/Down scroll the messages when the input is empty
|
||
// (otherwise they move the caret).
|
||
self.follow = false;
|
||
self.scroll = self.scroll.saturating_sub(1);
|
||
} else {
|
||
self.move_caret_line(-1);
|
||
}
|
||
}
|
||
KeyCode::Down => {
|
||
if self.input.is_empty() {
|
||
self.scroll = (self.scroll + 1).min(self.max_scroll());
|
||
self.follow = self.scroll >= self.max_scroll();
|
||
} else {
|
||
self.move_caret_line(1);
|
||
}
|
||
}
|
||
KeyCode::Char(c) => {
|
||
// Ctrl+U 逐行向上滚动(vim 风格)。
|
||
// Ctrl+U scrolls up line-by-line (vim style).
|
||
if c == 'u' && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||
self.follow = false;
|
||
self.scroll = self.scroll.saturating_sub(1);
|
||
return false;
|
||
}
|
||
// 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)
|
||
{
|
||
cycle_expansion(&mut self.expanded, &mut self.full, &block.id);
|
||
}
|
||
}
|
||
}
|
||
// crossterm 把 Linux(xterm 协议)与 Windows(ConPTY)的滚轮事件
|
||
// 都归一化为 ScrollUp/ScrollDown。注意方向:scroll 是内容偏移量,
|
||
// 向上滚(看更早内容)要减小偏移,向下滚才增大;每格 1 行,逐行滚动。
|
||
// 向上滚必须取消 follow,否则每次绘制会被吸回底部。
|
||
// crossterm normalizes wheel events from both Linux (xterm) and
|
||
// Windows (ConPTY) to ScrollUp/ScrollDown. Direction: `scroll` is
|
||
// the content offset, so scrolling up (older content) decreases
|
||
// it and scrolling down increases it; one line per notch.
|
||
// Scrolling up must clear follow, otherwise every draw snaps back.
|
||
MouseEventKind::ScrollUp | MouseEventKind::ScrollLeft => {
|
||
self.follow = false;
|
||
self.scroll = self.scroll.saturating_sub(1);
|
||
}
|
||
MouseEventKind::ScrollDown | MouseEventKind::ScrollRight => {
|
||
self.scroll = (self.scroll + 1).min(self.max_scroll());
|
||
self.follow = self.scroll >= self.max_scroll();
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
/// 把输入框的滚动对齐到底部(跟随模式)。
|
||
/// 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 (based on the last render's total line count; the
|
||
/// geometry only covers expandable blocks and is unusable for this).
|
||
fn max_scroll(&self) -> u16 {
|
||
self.content_lines.saturating_sub(self.view_height.max(1))
|
||
}
|
||
|
||
/// 切换最近一个可折叠块(几何只含可展开块:思考与工具)。
|
||
/// Toggle the most recent collapsible block (geometry only holds
|
||
/// expandable blocks: thinking and tools).
|
||
fn toggle_latest_collapsible(&mut self) {
|
||
if let Some(id) = self.geometry.last().map(|g| g.id.clone()) {
|
||
cycle_expansion(&mut self.expanded, &mut self.full, &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()
|
||
}
|
||
}
|