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
This commit is contained in:
6 files changed
+241
-42
No files matched your search
+111
-33
@@ -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
@@ -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:#}");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user