T38: inside herdr, which passes no OSC 7501 on, herdr's agents in a state it knows are the session's records, one a pane, their app, title and message from herdr's fields; a snapshot that lists other agents replaces them whole, and they go when herdr leaves the foreground
CI / platforms (ubuntu-24.04) (push) Successful in 1m10s
CI / platforms (windows-latest) (push) Successful in 1m40s
CI / macos (push) Successful in 10m31s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-17 11:51:56 -04:00
1 parent 72acbe4bb0
commit a175b18743
6 files changed
+241 -42

No files matched your search

+111 -33
View File
@@ -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<TerminalView>) -> Task<()> {
cx.spawn(async move |view, cx| {
let mut seen: Option<Program> = 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<TerminalView>) -> 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<TerminalView>) -> 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<String> {
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<Option<PathBuf>> {
/// 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<Shows> {
let ask = || -> io::Result<String> {
let mut stream = connect(socket)?;
stream.write_all(SNAPSHOT)?;
@@ -247,9 +272,12 @@ fn folder(socket: &Path) -> anyhow::Result<Option<PathBuf>> {
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<PathBuf>, Vec<(String, Record)>);
#[derive(Deserialize)]
struct Reply {
result: Option<Snapshot>,
@@ -265,6 +293,9 @@ struct Snapshot {
struct Session {
focused_pane_id: Option<String>,
panes: Vec<Pane>,
/// A herdr before agents lists none.
#[serde(default)]
agents: Vec<Agent>,
}
#[derive(Deserialize)]
@@ -273,21 +304,45 @@ struct Pane {
cwd: Option<PathBuf>,
}
#[derive(Deserialize)]
struct Agent {
pane_id: String,
agent_status: AgentStatus,
agent: Option<String>,
display_agent: Option<String>,
name: Option<String>,
title: Option<String>,
terminal_title_stripped: Option<String>,
}
/// 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<Option<PathBuf>> {
/// What herdr's `session.snapshot` reply shows.
pub(super) fn shows(reply: &str) -> anyhow::Result<Shows> {
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<Option<PathBuf>> {
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<Agent>) -> 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)]
+83 -8
View File
@@ -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:#}");
}
+18
View File
@@ -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();
+26
View File
@@ -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();