focus/crates/focus-tui/src/app.rs

1017 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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 把 Linuxxterm 协议)与 WindowsConPTY的滚轮事件
// 都归一化为 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()
}
}