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;