diff --git a/AGENTS.md b/AGENTS.md index 2f31e06..d6e50b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - `src/main.rs`: start. `src/lib.rs`: the modules, public so tests and examples reach them. `src/app.rs`: fonts, settings, integration, then the main window, at the size the settings keep, which it keeps as it settles. `src/menus.rs`: the menu bar, the keys of Quit, the font's size and Minimize, and what they do. `src/blur.rs`: what the window shows of the desktop, from the opacity and blur settings, set as they change; `blur/macos` gives gpui's NSVisualEffectView a material that blurs; `blur/wayland` asks a Wayland compositor for blur behind the window's surface through `ext_background_effect_v1`; `blur/x11` names the window's pane in `_KDE_NET_WM_BLUR_BEHIND_REGION`. `src/workspace.rs`: the window's content, Ely's `ColumnShell` with the sessions, terminal and right columns, their toggles, the right column's Git and Files switch, the title, and the sessions' lifecycle. `src/workspace/widths.rs`: the side columns' widths, which a drag on their border sets and the settings keep once it rests. `src/workspace/events.rs`: what a session's terminal reports: title, folder, bell, a command's end, the program's exit. `src/sessions.rs`: a session and its row. `src/files.rs`: the Files panel, the shown session's folder as Ely's `Tree` with Git's marks. `src/git.rs`: git through its command line: Files' marks, and the Git panel's status, stage, unstage, discard, commit and log; `src/git/diff.rs` reads git's diff into Ely's stretches. `src/git/panel.rs`: the Git panel's state and actions; `src/git/panel/view.rs`: how it draws. - `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`; `window` is the settings window, `window/appearance` its Appearance page, `window/terminal` its Terminal page. `tests/fonts.rs` brings its own main: gpui builds its Mac platform only on the main thread. - `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance; `omarchy`, on Linux, wears Omarchy 4's current theme and follows its switches. -- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7, 9, 99, 133, 777 and 7501, full resets and the mouse modes before the parser; `marks` reads OSC 133's prompt marks; `status` reads OSC 7501 and keeps a terminal's records; `notify` reads OSC 9, 777 and 99, the notifications and OSC 9;4's progress, and gathers OSC 99's chunks; `prompts` turns each mark into an OSC 8 link the parser keeps in a prompt's cells, and finds the prompt to jump to; `herdr` follows herdr's focused pane through herdr's socket while herdr's client runs in the session: `herdr/unix` finds the client leading the pty's foreground, `herdr/windows` under the session's shell, and asks its named pipe. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `zoom` holds the points Bigger and Smaller add to the Size setting, unsaved. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget`, and Ely's `find_all` over the grid's shown text. +- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7, 9, 99, 133, 777 and 7501, full resets and the mouse modes before the parser; `marks` reads OSC 133's prompt marks; `status` reads OSC 7501 and keeps a terminal's records; `notify` reads OSC 9, 777 and 99, the notifications and OSC 9;4's progress, and gathers OSC 99's chunks; `prompts` turns each mark into an OSC 8 link the parser keeps in a prompt's cells, and finds the prompt to jump to; `herdr` follows herdr's focused pane and its agents' states through herdr's socket while herdr's client runs in the session: `herdr/unix` finds the client leading the pty's foreground, `herdr/windows` under the session's shell, and asks its named pipe. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `zoom` holds the points Bigger and Smaller add to the Size setting, unsaved. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget`, and Ely's `find_all` over the grid's shown text. - `assets/shell-integration`: ELYTerminal's own scripts (not Ghostty's, which are GPL): zsh through `ZDOTDIR`, bash through `--rcfile`, fish through `XDG_DATA_DIRS`, PowerShell through `-NoExit -Command`. Each prompt sends OSC 7 and, except in fish, whose `fish_title` stays, a title; each marks prompts and commands with OSC 133 where the shell does not. - `assets/fonts`: JetBrains Mono 2.304's bold and italic faces (OFL; Ely bundles regular), and Nerd Fonts 3.5.1's JetBrains Mono NF Mono Regular, which draws icons a font lacks. - `assets/themes`: Ghostty theme files copied verbatim from iTerm2-Color-Schemes (MIT, its `LICENSE` beside them). @@ -100,3 +100,4 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - 2026-10-16: the view jumps between prompts, which the owner asked for, as Ghostty jumps: Cmd-Up and Cmd-Down, with Shift or without, on macOS, Ctrl-Shift-Up and Ctrl-Shift-Down elsewhere, scroll to the prompt that starts before or after the view's top; a prompt on the screen's last page means the bottom, which the view cannot pass, and with none there nothing moves. alacritty's rows hold no marks, and the tap, which reads before the parser, never sees the cursor, so the tap rewrites what goes to the parser: an OSC that may be a mark is held whole until it ends and read as `marks` reads it, and a prompt's start becomes an OSC 8 link of the terminal's own, its target `\x7f`, which no program names, its id `\x7f` and the prompt's number; the prompt's end closes it. The parser stamps each cell the prompt prints with the link, SGR 0 keeps it, and it rides the cells through scroll, reflow and history; a prompt starts on the first row that shows its number, blank rows within it passed. A program's own link keeps its target and takes the prompt's number in its id: one opened inside a prompt carries it, and a prompt that starts inside one opens it again with the number and gives back its own id at the prompt's end, its target kept whole as the scanner keeps it; its end inside a prompt takes the prompt's link up again. A cursor restore that brings back a link passes the rewrite by. An OSC held, a body of 4096 bytes at most as the scanner keeps it and twice that with the controls vte skips, shows nothing until it ends; controls vte runs inside an escape go on before an ESC that waits, and only an ESC from ground waits: one that ends an OSC goes on with it, so what follows runs where the parser stands. A rewrite turns a mark of 8 bytes inside a program's link into that link again, its target up to 4096 bytes, so the tap reads about 2 KB of the buffer's room at a time, which moved 3 MB through a pty as fast as larger reads. Links, Cmd-press among them, pass over the prompt's link. The tap's OSC scanner ends a body as vte does, at BEL, ESC, CAN or SUB, and skips the controls vte skips, so a mark and its link agree. - 2026-10-17: programs say what they are doing through OSC 7501, revision 0.4 of the Program Status Protocol, which the owner asked for, read as the spec reads it: pairs `key=value` joined by `:`, a malformed pair skipped, an unknown key passed by, and every pair of a known key checked, a clear's too, before the last of each key is kept. A sequence over 4096 bytes, counted from OSC through ST with the controls its body skips, a key over 16, an app over 32, an id outside its grammar or limits (128 bytes, 32 a segment, 8 levels), a title or msg over its limits, not base64, not UTF-8 or holding a control character, and a missing or unknown state each drop it whole; kind counts only with blocked, and progress, 0 to 100, only with working and blocked, each absent when unknown. Each terminal keeps its records by id, the root's empty: a report replaces its record whole, clear drops the record and those under it, or all without an id, and past 256 the record updated longest ago goes. A new prompt drops working, blocked and idle records, as kitty does, and keeps done and error; a full reset, which the tap's scanner sees as `ESC c`, through the controls, DEL and high bytes vte passes by in an escape, drops all, and DECSTR nothing. The query `OSC 7501 ; ?` is answered with every state and kind; the view writes the answer when the session hears the query, ahead of the parser's answers to what came later in the same read, so a DA1 sent after the query, as the spec suggests, comes back second, and one sent before it in the same read comes back after it. - 2026-10-17: programs ask for notifications through OSC 9, 777 and 99 and report progress through OSC 9;4, which the owner asked for. OSC 9 is ConEmu's command where Ghostty reads one, 9;1 to 9;12 each by its own shape, else iTerm2's notification, its text the title; OSC 777 is rxvt's `notify;title;body`, another extension logged and passed by. OSC 99 is kitty's: `k=v` pairs before the payload, a malformed pair passed by; `i`, of `[A-Za-z0-9_-+.]` alone or the sequence drops, gathers chunks while `d=0`, 32 notifications and 16384 bytes of text at most; a payload holds 2048 bytes plain or 4096 encoded at most; plain text is UTF-8 without a control character; `e=1` reads it as base64 group by group, so chunks split before encoding or after join, and it carries any UTF-8, newlines too, checked once the text is whole; a chunk that breaks a rule drops what its notification gathered; metadata holds 1024 bytes at most, and the tap keeps an OSC 99 body to 5124 bytes, room for metadata and a full payload, where other bodies stop at 4096; `p` takes title, body, close, ? and alive, and other payloads pass by; `o`, `a`, `c` and `w` are kept for showing; a lone body is the title, and one with neither passes by. The query is answered with what is read so far, `p=title,body,?,alive:o=always`, and alive with no id, since none shows yet; an answer names the asker's id, or 0. OSC 9;4's states, remove, set, error, indeterminate and pause, read as Ghostty reads them, the progress clamped to 100, stand as the root record: set and pause working with their progress, indeterminate working without, error an error, remove none; this lasts until a program reports through OSC 7501, and again after a full reset, as the spec asks, so a mapped 9;4 never wipes a real report. Notifications go to the workspace, which logs them. +- 2026-10-17: inside herdr, which passes no OSC 7501 on, herdr's agents are the session's records. The snapshot the follower asks for lists them; one in a state herdr knows, idle, working, blocked or done, is the record `herdr/PANE`, and unknown and any state herdr adds later pass by. herdr names no kind and no progress; the app is the agent, the title its name, else the name herdr shows for it, and the message its terminal title less the spinner herdr strips, else the title its metadata sets; herdr's done is idle and not yet seen. A snapshot that lists other agents replaces them whole, so a full reset or a clear that dropped them sees them back at the next; they go when herdr leaves the foreground, and a snapshot that lands after the shell reported a folder is dropped, its agents with it. A change of their states is logged; a herdr whose snapshot lacks `agents` lists none. diff --git a/src/term/herdr.rs b/src/term/herdr.rs index 2074608..2482d2e 100644 --- a/src/term/herdr.rs +++ b/src/term/herdr.rs @@ -10,7 +10,10 @@ use anyhow::Context as _; use gpui::{Context, Task}; use serde::Deserialize; -use super::view::{TerminalEvent, TerminalView}; +use super::{ + status::{HERDR, Record, State}, + view::{TerminalEvent, TerminalView}, +}; #[cfg(unix)] mod unix; @@ -27,7 +30,7 @@ const CHECK: Duration = Duration::from_millis(500); /// How long herdr may take to answer. const WAIT: Duration = Duration::from_secs(1); -/// The socket request for herdr's session: its focus and its panes' folders. +/// The socket request for herdr's session: its focus, its panes' folders and its agents. const SNAPSHOT: &[u8] = b"{\"id\":\"elyterminal\",\"method\":\"session.snapshot\",\"params\":{}}\n"; /// A program's arguments, and its variables as the system holds them. @@ -38,6 +41,7 @@ pub fn follow(cx: &mut Context) -> Task<()> { cx.spawn(async move |view, cx| { let mut seen: Option = None; let (mut socket, mut shown, mut retry, mut failed) = (None, None, false, String::new()); + let mut states: Vec<(String, State)> = Vec::new(); loop { cx.background_executor().timer(CHECK).await; let Ok((pid, lead, reports)) = view.update(cx, |view, _| { @@ -68,7 +72,13 @@ pub fn follow(cx: &mut Context) -> Task<()> { retry = true; failed.clear(); if socket.take().is_some() { - log::info!("terminal {pid}: herdr left the foreground"); + log::info!( + "terminal {pid}: herdr left the foreground, its agents' records with it" + ); + states.clear(); + if view.update(cx, |view, _| view.records.herdr(&[])).is_err() { + break; + } } } if retry { @@ -95,34 +105,49 @@ pub fn follow(cx: &mut Context) -> Task<()> { let Some(path) = socket.clone() else { continue; }; - match cx + let (folder, agents) = match cx .background_executor() - .spawn(async move { folder(&path) }) + .spawn(async move { snapshot(&path) }) .await { - Ok(Some(folder)) if shown.as_ref() != Some(&folder) => { - failed.clear(); - let report = folder.clone(); - // The shell reports only once herdr has left; its folder then stands. - let Ok(still) = view.update(cx, |view, cx| { - let still = view.reports == reports; - if still { - cx.emit(TerminalEvent::Folder(report)); - } - still - }) else { - break; - }; - if still { - log::info!( - "terminal {pid}: herdr's focused pane is in {}", - folder.display() - ); - shown = Some(folder); + Ok(shows) => shows, + Err(error) => { + failure(&mut failed, pid, &error); + continue; + } + }; + failed.clear(); + let report = folder.filter(|folder| shown.as_ref() != Some(folder)); + // The shell reports only once herdr has left; its folder then stands. + let Ok(still) = view.update(cx, |view, cx| { + let still = view.reports == reports; + if still { + view.records.herdr(&agents); + if let Some(folder) = report.clone() { + cx.emit(TerminalEvent::Folder(folder)); } } - Ok(_) => failed.clear(), - Err(error) => failure(&mut failed, pid, &error), + still + }) else { + break; + }; + if !still { + continue; + } + let now: Vec<(String, State)> = agents + .into_iter() + .map(|(id, record)| (id, record.state)) + .collect(); + if now != states { + log::info!("terminal {pid}: herdr's agents are {now:?}"); + states = now; + } + if let Some(folder) = report { + log::info!( + "terminal {pid}: herdr's focused pane is in {}", + folder.display() + ); + shown = Some(folder); } } }) @@ -237,8 +262,8 @@ fn session(args: &[String]) -> Option { named } -/// The folder of the pane herdr's server has focused; none while it has none or knows none. -fn folder(socket: &Path) -> anyhow::Result> { +/// What herdr's server shows: the folder of the pane it has focused, none while it has none or knows none, and its agents. +fn snapshot(socket: &Path) -> anyhow::Result { let ask = || -> io::Result { let mut stream = connect(socket)?; stream.write_all(SNAPSHOT)?; @@ -247,9 +272,12 @@ fn folder(socket: &Path) -> anyhow::Result> { Ok(reply) }; let reply = ask().with_context(|| format!("herdr: asking {}", socket.display()))?; - focused(&reply).with_context(|| format!("herdr: {}", socket.display())) + shows(&reply).with_context(|| format!("herdr: {}", socket.display())) } +/// The focused pane's folder, and herdr's agents as records. +type Shows = (Option, Vec<(String, Record)>); + #[derive(Deserialize)] struct Reply { result: Option, @@ -265,6 +293,9 @@ struct Snapshot { struct Session { focused_pane_id: Option, panes: Vec, + /// A herdr before agents lists none. + #[serde(default)] + agents: Vec, } #[derive(Deserialize)] @@ -273,21 +304,45 @@ struct Pane { cwd: Option, } +#[derive(Deserialize)] +struct Agent { + pane_id: String, + agent_status: AgentStatus, + agent: Option, + display_agent: Option, + name: Option, + title: Option, + terminal_title_stripped: Option, +} + +/// An agent's state, as herdr names it. +#[derive(Deserialize)] +#[serde(rename_all = "lowercase")] +enum AgentStatus { + Idle, + Working, + Blocked, + Done, + #[serde(other)] + Unknown, +} + #[derive(Deserialize)] struct Failure { code: String, message: String, } -/// The focused pane's folder in herdr's `session.snapshot` reply. -pub(super) fn focused(reply: &str) -> anyhow::Result> { +/// What herdr's `session.snapshot` reply shows. +pub(super) fn shows(reply: &str) -> anyhow::Result { let reply: Reply = serde_json::from_str(reply).context("its reply does not parse")?; if let Some(Failure { code, message }) = reply.error { anyhow::bail!("{code}: {message}"); } let session = reply.result.context("its reply holds no result")?.snapshot; + let agents = records(session.agents); let Some(id) = session.focused_pane_id else { - return Ok(None); + return Ok((None, agents)); }; let pane = session .panes @@ -301,7 +356,30 @@ pub(super) fn focused(reply: &str) -> anyhow::Result> { cwd.display() ); } - Ok(pane.cwd) + Ok((pane.cwd, agents)) +} + +/// Each agent whose state herdr knows, as the record of its pane: herdr names no kind and no progress. +fn records(agents: Vec) -> Vec<(String, Record)> { + let record = |agent: Agent| { + let state = match agent.agent_status { + AgentStatus::Idle => State::Idle, + AgentStatus::Working => State::Working, + AgentStatus::Blocked => State::Blocked, + AgentStatus::Done => State::Done, + AgentStatus::Unknown => return None, + }; + let record = Record { + state, + kind: None, + progress: None, + app: agent.agent, + title: agent.name.or(agent.display_agent), + msg: agent.terminal_title_stripped.or(agent.title), + }; + Some((format!("{HERDR}{}", agent.pane_id), record)) + }; + agents.into_iter().filter_map(record).collect() } #[cfg(test)] diff --git a/src/term/herdr/tests.rs b/src/term/herdr/tests.rs index 24d21a0..e8a7d98 100644 --- a/src/term/herdr/tests.rs +++ b/src/term/herdr/tests.rs @@ -73,7 +73,8 @@ fn reply(focused_pane: &str, cwd: &str) -> String { #[test] fn the_focused_pane_s_folder_comes_from_herdr_s_snapshot() { - let read = |focused_pane: &str, cwd: &str| focused(&reply(focused_pane, cwd)); + let read = + |focused_pane: &str, cwd: &str| shows(&reply(focused_pane, cwd)).map(|(folder, _)| folder); let folder = format!("{ROOT}srv/b"); assert_eq!( read(r#""w1:p2""#, &format!("\"{folder}\"")).expect("a folder"), @@ -90,9 +91,80 @@ fn the_focused_pane_s_folder_comes_from_herdr_s_snapshot() { assert!(error.to_string().contains("relative"), "{error}"); let failed = r#"{"id":"elyterminal","error":{"code":"invalid_request","message":"no such method"}}"#; - let error = focused(failed).expect_err("an error"); + let error = shows(failed).expect_err("an error"); assert_eq!(error.to_string(), "invalid_request: no such method"); - assert!(focused("").is_err()); + assert!(shows("").is_err()); +} + +#[test] +fn herdr_s_agents_whose_state_it_knows_are_records_by_pane() { + let agent = |pane: &str, status: &str, more: &str| { + format!( + r#"{{"terminal_id":"t","agent_status":"{status}","workspace_id":"w1","tab_id":"w1:t1","pane_id":"{pane}","focused":false,"revision":1{more}}}"# + ) + }; + let agents = [ + agent( + "w1:p1", + "working", + r#","agent":"codex","display_agent":"Codex","name":"review","title":"codex","terminal_title_stripped":"Reading files""#, + ), + agent( + "w1:p2", + "blocked", + r#","agent":"claude","display_agent":"Claude Code","title":"claude""#, + ), + agent("w1:p3", "unknown", ""), + agent("w1:p4", "napping", ""), + agent("w1:p5", "done", ""), + agent("w1:p6", "idle", ""), + ]; + let reply = reply("null", "null").replace( + r#""agents":[]"#, + &format!(r#""agents":[{}]"#, agents.join(",")), + ); + let (folder, records) = shows(&reply).expect("a snapshot"); + assert_eq!(folder, None); + let said: Vec<_> = records + .iter() + .map(|(id, record)| { + ( + id.as_str(), + record.state, + record.app.as_deref(), + record.title.as_deref(), + record.msg.as_deref(), + ) + }) + .collect(); + assert_eq!( + said, + [ + ( + "herdr/w1:p1", + State::Working, + Some("codex"), + Some("review"), + Some("Reading files") + ), + ( + "herdr/w1:p2", + State::Blocked, + Some("claude"), + Some("Claude Code"), + Some("claude") + ), + ("herdr/w1:p5", State::Done, None, None, None), + ("herdr/w1:p6", State::Idle, None, None, None), + ] + ); + assert!( + records + .iter() + .all(|(_, record)| record.kind.is_none() && record.progress.is_none()) + ); + let older = reply.replace(&format!(r#","agents":[{}]"#, agents.join(",")), ""); + assert_eq!(shows(&older).expect("a herdr before agents").1, []); } #[cfg(unix)] @@ -118,13 +190,13 @@ fn herdr_answers_over_its_socket() { .expect("a reply"); request }); - assert_eq!(folder(&socket).expect("an answer"), Some("/srv".into())); + assert_eq!(snapshot(&socket).expect("an answer").0, Some("/srv".into())); assert_eq!(server.join().expect("the server").as_bytes(), SNAPSHOT); fs::remove_dir_all(&dir).expect("the test folder goes"); - let error = folder(&socket).expect_err("no server"); + let error = snapshot(&socket).expect_err("no server"); assert!(format!("{error:#}").contains("asking"), "{error:#}"); assert!(starting(&error), "{error:#}"); - assert!(!starting(&focused("").expect_err("no reply"))); + assert!(!starting(&shows("").expect_err("no reply"))); } /// macOS hides the variables of its own programs, such as `/bin/sleep`, so the test reads itself. @@ -325,9 +397,12 @@ fn herdr_answers_over_its_pipe() { stream.sync_all().expect("the client reads the reply"); request }); - assert_eq!(folder(&socket).expect("an answer"), Some("C:/srv".into())); + assert_eq!( + snapshot(&socket).expect("an answer").0, + Some("C:/srv".into()) + ); assert_eq!(server.join().expect("the server").as_bytes(), SNAPSHOT); - let error = folder(&socket).expect_err("no server"); + let error = snapshot(&socket).expect_err("no server"); assert!(format!("{error:#}").contains("asking"), "{error:#}"); assert!(starting(&error), "{error:#}"); } diff --git a/src/term/status.rs b/src/term/status.rs index 42c2ee5..927fc09 100644 --- a/src/term/status.rs +++ b/src/term/status.rs @@ -57,6 +57,9 @@ const ID: usize = 128; const SEGMENT: usize = 32; const LEVELS: usize = 8; +/// The ids herdr's agents take, one by pane. +pub(super) const HERDR: &str = "herdr/"; + /// Records a terminal keeps; past them, the one updated longest ago goes. pub const RECORDS: usize = 256; @@ -229,6 +232,21 @@ impl Records { } } + /// herdr's agents in place of the last ones, unless they are the same. + pub fn herdr(&mut self, agents: &[(String, Record)]) { + let held = self.list.iter().filter(|(id, ..)| id.starts_with(HERDR)); + if held + .map(|(id, record, _)| (id, record)) + .eq(agents.iter().map(|(id, record)| (id, record))) + { + return; + } + self.list.retain(|(id, ..)| !id.starts_with(HERDR)); + for (id, record) in agents { + self.put(id.clone(), record.clone()); + } + } + /// A full reset: every record goes, and OSC 9;4 maps again. pub fn reset(&mut self) { self.list.clear(); diff --git a/src/term/status/tests.rs b/src/term/status/tests.rs index 8a75875..9661940 100644 --- a/src/term/status/tests.rs +++ b/src/term/status/tests.rs @@ -140,6 +140,32 @@ fn records_replace_and_clear_as_the_spec_says() { assert!(records.list.is_empty()); } +#[test] +fn herdr_s_agents_replace_their_own_records_alone() { + let mut records = Records::default(); + records.report("build".into(), record(State::Working, None)); + let codex = [( + "herdr/w1:p1".to_string(), + record(State::Working, Some("codex")), + )]; + records.herdr(&codex); + let clock = records.clock; + records.herdr(&codex); + assert_eq!(records.clock, clock, "the same agents change nothing"); + let claude = [( + "herdr/w1:p2".to_string(), + record(State::Blocked, Some("claude")), + )]; + records.herdr(&claude); + let ids: Vec<&str> = records.list.iter().map(|(id, ..)| id.as_str()).collect(); + assert_eq!(ids, ["build", "herdr/w1:p2"]); + records.reset(); + records.herdr(&claude); + assert_eq!(records.list.len(), 1, "the next snapshot puts them back"); + records.herdr(&[]); + assert!(records.list.is_empty()); +} + #[test] fn a_new_prompt_leaves_done_and_error() { let mut records = Records::default(); diff --git a/tasks/acceptance.md b/tasks/acceptance.md index b20fb4a..98fc5db 100644 --- a/tasks/acceptance.md +++ b/tasks/acceptance.md @@ -56,6 +56,7 @@ What no check reached: no real KWin drew the blur; of Wayland desktops, only Hyp | Program status, OSC 7501 revision 0.4: reports checked as the spec checks them, records per terminal, a new prompt and a full reset, the support query | T36 | `term::status` tests: the spec's examples read as it means them; a malformed pair is skipped, the last of a key wins, unknown keys pass; a sequence over any limit, with bad base64, UTF-8 or a control character, or without a known state drops whole, a clear too, and an earlier pair of a key counts though the last wins; the sequence's length counts the controls its body skips; kind and progress count only with their states; records replace whole, clear with those under them, and past 256 the one updated longest ago goes; a new prompt keeps done and error alone. `term::tap`: a full reset found between bodies, and through DEL and high bytes in its escape. `term::pty`: a report, a query and a full reset reach the session in order through the event loop. In the app on macOS, a program that sent the query and then DA1 read the answer with every state and kind, then DA1's | Pass | | Progress, OSC 9;4, as the root record until an OSC 7501 report; notifications, OSC 9, 777 and 99, read | T37 | `term::notify` tests: OSC 9 reads as Ghostty reads it, ConEmu's commands no notifications, 9;4's progress clamped; OSC 777's notify; OSC 99 gathers chunks by id, base64 split before encoding or after and a character across chunks among them, keeps the newlines base64 carries, reads options, close, query and alive, and drops what breaks its rules with what it gathered; its answers name the asker's id. `term::tap`: an OSC 99 body keeps its metadata and a full payload. `term::status`: 9;4 maps to the root record until a report, and again after a full reset. `term::pty`: OSC 9, 777, chunked 99 and 9;4 reach the session in order, and a full encoded chunk with the lines base64 carries reaches it as one notification. In the app on macOS, a program read OSC 99's query and alive answers before DA1's, and four notifications reached the workspace's log | Pass | | Inside herdr, the folder of herdr's focused pane, read from its socket, on macOS, Linux and Windows | T29, T32 | `term::herdr` tests: the socket found as herdr 0.9 finds it, only the terminal client followed and a remote one refused, the snapshot read, a socket round trip, a process's path, arguments and variables; `the_foreground_is_the_program_s_own_group`; on X11 with herdr 0.9.5 run in the session: its start showed the focused pane's folder, a cd in the pane moved the panel, the focus moved to a pane in `/etc` and back moved it each time, and the client's end gave the panel back to bash's report, then `exec herdr` from that bash, with `HERDR_SOCKET_PATH` relative to its folder, was followed through the socket there; on macOS under `shot`, herdr 0.9.5 as the program and again by `exec` from `sh` with a relative socket, its pane's cd moved the panel; on Windows, `term::herdr` tests on the CI runner: the folder under `APPDATA` and `USERPROFILE`, the `herdr.exe` nearest under the shell, this test's name, arguments and variables read through the live process list and `sysinfo`, a round trip over a named pipe; no real herdr has run on Windows yet | Partial | +| Inside herdr, herdr's agent states are the session's records | T38 | `term::herdr` tests: each agent in a state herdr knows is its pane's record, its app, title and message from herdr's fields, unknown and new states passed by, and a snapshot without agents lists none. `term::status`: herdr's agents replace their own records alone, the same agents change nothing, and the next snapshot puts them back after a full reset | Pass | | Colors, styles, five underlines, wide characters, combining marks | T06 | `term::frame`, `term::ink`, `term::paint` tests | Pass | | Box drawing, blocks and powerline shapes as geometry | T07 | `term::boxes` tests | Pass | | JetBrains Mono, any installed font, Nerd Font icons | T06, T17 | `tests/fonts.rs` | Pass |