From faca75dfbbae5b525197d574476fddea12970aed Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:28:01 +0800 Subject: [PATCH] refactor: carry a session's source on AgentEvent and tidy the review fixes The Codex router reads source off the parsed event instead of parsing the hook body again. The command scan reads each real directory once rather than capping depth, labels capitalise through one helper (alias_label no longer slices bytes), the live input rows are set where Covering is decided, and the transcript is opened once per poll. --- crates/tty7-core/src/core/agent_hooks.rs | 22 ++++----- crates/tty7-core/src/core/cli_agent.rs | 14 ++++++ crates/tty7-core/src/daemon/pane.rs | 1 + crates/tty7-core/src/daemon/server.rs | 5 +- src/terminal/composer.rs | 59 +++++++++++++----------- 5 files changed, 59 insertions(+), 42 deletions(-) diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 8a569e78..92dd93ce 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -153,8 +153,7 @@ fn build_hook_body(agent: &str, event: &str, stdin_json: &str) -> String { ("cwd", "cwd"), // Goose spells the working directory its own way. ("cwd", "working_dir"), - // What started a session (startup, resume, clear…): routes a Codex - // report — see `daemon::pane::codex_report_target`. + // What started a session — see `cli_agent::AgentEvent::source`. ("source", "source"), ] { if let Some(v) = payload @@ -345,14 +344,10 @@ fn turn_reply_model(path: &Path, reply: &str, prompt_id: Option<&str>) -> Option let deadline = std::time::Instant::now() + TRANSCRIPT_CATCH_UP; let mut read = None; loop { - let len = std::fs::metadata(path).ok().map(|m| m.len()); - if len != read { - read = len; - if let Some(model) = - transcript_tail(path).and_then(|t| reply_model_in(&t, reply, prompt_id)) - { - return Some(model); - } + if let Some(model) = + transcript_tail(path, &mut read).and_then(|t| reply_model_in(&t, reply, prompt_id)) + { + return Some(model); } if std::time::Instant::now() >= deadline { return None; @@ -361,10 +356,15 @@ fn turn_reply_model(path: &Path, reply: &str, prompt_id: Option<&str>) -> Option } } -fn transcript_tail(path: &Path) -> Option { +/// The transcript's last [`TRANSCRIPT_TAIL`] bytes, unless it is still the +/// length `read` last saw — then nothing new is in it. +fn transcript_tail(path: &Path, read: &mut Option) -> Option { use std::io::{Seek as _, SeekFrom}; let mut file = std::fs::File::open(path).ok()?; let len = file.metadata().ok()?.len(); + if read.replace(len) == Some(len) { + return None; + } file.seek(SeekFrom::Start(len.saturating_sub(TRANSCRIPT_TAIL))) .ok()?; let mut tail = Vec::new(); diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index 39b37313..888e823f 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -1201,6 +1201,9 @@ pub struct AgentEvent { pub session_id: Option, pub message: Option, pub cwd: Option, + /// What started the session, on a session start that says: `startup`, + /// `resume`, `clear` (Codex's `/new`), `compact`. + pub source: Option, pub readout: AgentReadout, } @@ -1227,6 +1230,8 @@ pub fn parse_agent_event_body(json: &[u8]) -> Option { message: Option, #[serde(default)] cwd: Option, + #[serde(default)] + source: Option, #[serde(flatten)] readout: AgentReadout, } @@ -1240,6 +1245,7 @@ pub fn parse_agent_event_body(json: &[u8]) -> Option { session_id: nonempty(w.session_id), message: nonempty(w.message), cwd: nonempty(w.cwd).map(std::path::PathBuf::from), + source: nonempty(w.source), readout: w.readout, }) } @@ -1258,6 +1264,7 @@ mod tests { session_id: None, message: None, cwd: None, + source: None, readout, }; state.apply_event(&event(AgentReadout { @@ -1769,6 +1776,7 @@ mod tests { session_id: id.map(String::from), message: msg.map(String::from), cwd: None, + source: None, readout: Default::default(), }; @@ -1832,6 +1840,7 @@ mod tests { session_id: Some("sid".into()), message: None, cwd: None, + source: None, readout: Default::default(), }); assert_eq!(s.status, AgentStatus::Idle); @@ -1848,6 +1857,7 @@ mod tests { session_id: None, message: None, cwd: None, + source: None, readout: Default::default(), }; @@ -1882,6 +1892,7 @@ mod tests { session_id: None, message: None, cwd: None, + source: None, readout: Default::default(), }; @@ -1934,6 +1945,7 @@ mod tests { session_id: None, message: None, cwd: None, + source: None, readout: Default::default(), }; @@ -1971,6 +1983,7 @@ mod tests { session_id: None, message: None, cwd: None, + source: None, readout: Default::default(), }; @@ -2003,6 +2016,7 @@ mod tests { session_id: None, message: None, cwd: cwd.map(PathBuf::from), + source: None, readout: Default::default(), }; diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index bc811040..041c1127 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -6042,6 +6042,7 @@ mod tests { session_id: None, message: None, cwd: None, + source: None, readout: Default::default(), }], None, diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index 2c260be9..7a196115 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -146,10 +146,7 @@ fn codex_report_pane( named, event.session_id.as_deref(), event.cwd.as_deref(), - // Codex's SessionStart says what started the session; `clear` is /new. - serde_json::from_str::(body) - .ok() - .is_some_and(|body| body["source"] == "clear"), + event.source.as_deref() == Some("clear"), )?; let pane = panes.into_iter().find(|p| p.id == target)?; Some((pane, event)) diff --git a/src/terminal/composer.rs b/src/terminal/composer.rs index 60990a48..5704bcb1 100644 --- a/src/terminal/composer.rs +++ b/src/terminal/composer.rs @@ -1855,21 +1855,23 @@ fn custom_commands(cwd: Option<&Path>, home: Option<&Path>) -> Vec<(String, Stri ), ]; // Directories are followed through symlinks, as Claude Code follows - // them; the bound keeps a link back up the tree from walking forever. - const MAX_DEPTH: usize = 8; + // them; each is read once, so a link back up the tree ends the walk + // instead of looping it. + let mut seen = std::collections::HashSet::new(); for (dir, scope) in roots { let Some(dir) = dir else { continue }; - let mut stack = vec![(dir, 0)]; - while let Some((dir, depth)) = stack.pop() { + let mut stack = vec![dir]; + while let Some(dir) = stack.pop() { + if !std::fs::canonicalize(&dir).is_ok_and(|real| seen.insert(real)) { + continue; + } let Ok(entries) = std::fs::read_dir(&dir) else { continue; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { - if depth < MAX_DEPTH { - stack.push((path, depth + 1)); - } + stack.push(path); } else if path.extension().is_some_and(|e| e == "md") && let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { @@ -2170,17 +2172,20 @@ fn model_alias(model: &str) -> Option<&'static str> { fn effort_label(level: &str) -> String { match level { "xhigh" => "Extra high".into(), - // A level is whatever the environment or the settings file said, so - // its first character need not be one byte. - _ => { - let mut chars = level.chars(); - chars.next().map_or_else(String::new, |first| { - first.to_uppercase().chain(chars).collect() - }) - } + _ => capitalized(level), } } +/// `text` with its first character upper-cased. A level is whatever the +/// environment or the settings file said, so that character need not be one +/// byte. +fn capitalized(text: &str) -> String { + let mut chars = text.chars(); + chars.next().map_or_else(String::new, |first| { + first.to_uppercase().chain(chars).collect() + }) +} + /// How the model list spells an alias. fn alias_label(alias: &str) -> String { if alias == "default" { @@ -2190,7 +2195,7 @@ fn alias_label(alias: &str) -> String { Some(family) => (family, true), None => (alias, false), }; - let mut name = family[..1].to_uppercase() + &family[1..]; + let mut name = capitalized(family); if long { name.push_str(" · 1M"); } @@ -2275,15 +2280,16 @@ impl TerminalView { /// and resizing their pane each time the box comes and goes would have /// them redraw for a row of padding. pub(super) fn composer_reserved_rows(&self) -> usize { - if !matches!(self.presence(), Presence::Covering(_)) { + let Some(c) = self + .composer + .as_ref() + .filter(|c| matches!(c.presence, Presence::Covering(_))) + else { return 0; - } + }; if !self.agent().is_some_and(makes_room) { return 0; } - let Some(c) = self.composer.as_ref() else { - return 0; - }; let Some(frame) = c.frame_height.get() else { return 0; }; @@ -2619,13 +2625,13 @@ impl TerminalView { Some(a) if !covers(a) => Presence::Docked, Some(_) if asking => Presence::SteppedAside, Some(_) => match &c.area { - Some(area) => Presence::Covering(screen_lines.saturating_sub(area.top + offset)), + Some(area) => { + c.area_rows = screen_lines.saturating_sub(area.top); + Presence::Covering(c.area_rows.saturating_sub(offset)) + } None => Presence::SteppedAside, }, }; - if let Some(area) = &c.area { - c.area_rows = screen_lines.saturating_sub(area.top); - } if let Some(agent) = agent && c.named != Some(agent) @@ -2646,8 +2652,7 @@ impl TerminalView { c.refocus = c.presence == Presence::SteppedAside; self.composer_focused = false; window.focus(&self.focus_handle, cx); - } else if shown && ((c.refocus || c.grab) && !self.composer_focused || !c.typed.is_empty()) - { + } else if shown { let take_focus = (c.refocus || c.grab) && !self.composer_focused; if take_focus { c.refocus = false;