aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md21
-rw-r--r--crates/dinggerd/assets/pi/pi-agent-state.ts72
-rw-r--r--crates/dinggerd/src/hooks.rs46
-rw-r--r--crates/dinggerd/src/importance.rs65
-rw-r--r--crates/dinggerd/src/main.rs67
-rw-r--r--crates/dinggerd/src/notify.rs9
-rw-r--r--crates/dinggerd/src/tmux.rs8
-rw-r--r--crates/dinggerd/src/watcher.rs65
-rw-r--r--menubar/main.swift17
-rw-r--r--menubar/model.swift53
-rw-r--r--menubar/panel.swift2
-rw-r--r--menubar/search.swift4
-rwxr-xr-xtests/e2e.sh146
-rwxr-xr-xtests/pi-live.sh15
14 files changed, 540 insertions, 50 deletions
diff --git a/README.md b/README.md
index a0c4097..13a7b9c 100644
--- a/README.md
+++ b/README.md
@@ -94,6 +94,18 @@ than a poll is still a completion.
skipping the window you already look at.
- **Notifications**: "needs attention" (blocked) and "finished" (done), with
herdr's sounds; silent for completions you are already watching.
+- **Importance**: mark an agent high, normal (the default), or low. Within a
+ status, more important agents are listed first, so a jump goes to them
+ first; status still comes first (a blocked low agent before a done high
+ one). A high agent shows a bold `!` before its name; a low agent's name is
+ lightly dimmed, its second line says "low", and it makes no sound (it
+ still notifies, and waits in line like any other). In pi, type
+ `/importance high|normal|low` (no argument: the current level); pi saves
+ it with the session, so it comes back after `/reload`, a restart, or a
+ restore, and its footer shows a level other than normal. For other agents,
+ run `dingger importance high|normal|low` in the agent's pane (in Claude
+ Code: `! dingger importance high`), or pass `--pane`; it lasts until the
+ agent exits.
## Where state comes from
@@ -108,7 +120,13 @@ than a poll is still a completion.
manifests, for agents without hooks.
All per-pane state lives in tmux pane options (`@agent_state`,
-`@dingger_since`, `@dingger_hook`), so restarting dingger loses nothing.
+`@dingger_since`, `@dingger_hook`, `@dingger_importance`), so restarting
+dingger loses nothing.
+
+The pi extension's reports carry the agent's importance. Other pi
+extensions can follow it on pi's event bus: `dingger:importance` with
+`{ level }`, at session start and on every change; it is saved in the
+session as a custom entry of type `dingger-importance`.
## Usage
@@ -116,6 +134,7 @@ All per-pane state lives in tmux pane options (`@agent_state`,
dingger watch # the daemon (run by launchd, see below)
dingger status # agents in priority order
dingger focus [--pane ID] # jump to an agent (default: the top waiter)
+dingger importance [LEVEL] # an agent's importance: high, normal, low
dingger install pi # install the pi state-reporting extension
dingger install claude # report Claude Code sessions, for restore
dingger install zsh # record typed command lines, for restore
diff --git a/crates/dinggerd/assets/pi/pi-agent-state.ts b/crates/dinggerd/assets/pi/pi-agent-state.ts
index ff893e7..4c21afe 100644
--- a/crates/dinggerd/assets/pi/pi-agent-state.ts
+++ b/crates/dinggerd/assets/pi/pi-agent-state.ts
@@ -69,7 +69,34 @@ function updateSessionFile(ctx: any): void {
}
}
-function report(state: AgentState, message?: string): Promise<void> {
+// The agent's importance (/importance): it orders agents within a status
+// in dingger's list and jump, and a low agent makes no sound. Saved in the
+// session (a custom entry, never sent to the model), so it comes back with
+// the conversation: on /resume, `pi --session`, and dingger's restore.
+// Other extensions can follow it on the event bus: `dingger:importance`,
+// `{ level }`, at session start and on every change.
+type Importance = "low" | "normal" | "high";
+const importanceLevels: Importance[] = ["high", "normal", "low"];
+const importanceEntry = "dingger-importance";
+let importance: Importance = "normal";
+
+function savedImportance(ctx: any): Importance {
+ try {
+ const entries = ctx?.sessionManager?.getEntries?.() ?? [];
+ for (let i = entries.length - 1; i >= 0; i--) {
+ const entry = entries[i];
+ if (entry?.type === "custom" && entry.customType === importanceEntry) {
+ const level = entry.data?.level;
+ return importanceLevels.includes(level) ? level : "normal";
+ }
+ }
+ } catch {
+ // An unreadable session: the default.
+ }
+ return "normal";
+}
+
+function report(state: AgentState | undefined, message?: string): Promise<void> {
return sendRequest({
id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
method: "pane.report_agent",
@@ -77,8 +104,9 @@ function report(state: AgentState, message?: string): Promise<void> {
pane_id: paneId!,
source,
agent: "pi",
- state,
+ ...(state ? { state } : {}),
message,
+ importance,
seq: Date.now() * 1000,
...(sessionFile ? { agent_session_path: sessionFile } : {}),
},
@@ -161,6 +189,43 @@ export default function (pi) {
publishState();
});
+ function showImportance(ctx: any) {
+ ctx?.ui?.setStatus?.("dingger-importance", importance === "normal" ? undefined : `importance: ${importance}`);
+ pi.events.emit("dingger:importance", { level: importance });
+ }
+
+ pi.registerCommand("importance", {
+ description: "Set this agent's importance in dingger: high, normal, or low",
+ // None once a level is typed out, so Enter runs the command at once.
+ getArgumentCompletions: (prefix: string) =>
+ importanceLevels.includes(prefix.trim() as Importance)
+ ? null
+ : importanceLevels
+ .filter((level) => level.startsWith(prefix.trim()))
+ .map((level) => ({ value: level, label: level })),
+ handler: async (args: string, ctx: any) => {
+ const wanted = args.trim();
+ if (!wanted) {
+ ctx.ui.notify(`importance: ${importance}`, "info");
+ return;
+ }
+ if (!importanceLevels.includes(wanted as Importance)) {
+ ctx.ui.notify("importance is high, normal, or low", "error");
+ return;
+ }
+ if (wanted !== importance) {
+ importance = wanted as Importance;
+ pi.appendEntry(importanceEntry, { level: importance });
+ showImportance(ctx);
+ // Without a state: the state stays as last reported.
+ if (enabled()) {
+ void report(undefined);
+ }
+ }
+ ctx.ui.notify(`importance: ${importance}`, "info");
+ },
+ });
+
pi.on("session_start", async (event, ctx) => {
// TUI only: RPC/JSON/print modes are headless.
if (ctx?.mode !== "tui") {
@@ -169,6 +234,9 @@ export default function (pi) {
// A reload can replace this extension mid-run without emitting another agent_start.
agentActive = ctx?.isIdle?.() === false;
updateSessionFile(ctx);
+ // Each session has its own: /new starts at normal, /resume brings its own back.
+ importance = savedImportance(ctx);
+ showImportance(ctx);
publishState(true);
});
diff --git a/crates/dinggerd/src/hooks.rs b/crates/dinggerd/src/hooks.rs
index c467c98..8b24659 100644
--- a/crates/dinggerd/src/hooks.rs
+++ b/crates/dinggerd/src/hooks.rs
@@ -10,6 +10,9 @@
//! or `agent_session_id`, herdr's names), which restore uses to resume it.
//! A report without a state only records the session (Claude's
//! SessionStart hook, `dingger hook claude`); state stays with the screen.
+//! A report may also carry the agent's `importance` (`low`, `normal`,
+//! `high`): pi's extension sends it with every report, `dingger importance`
+//! alone.
//!
//! Reports are state-change *events*: the watcher applies every one in
//! order, so a turn that starts and finishes between two polls is still a
@@ -29,6 +32,8 @@ use std::sync::{Condvar, Mutex, MutexGuard, OnceLock};
use std::time::{Duration, Instant};
use detect_core::AgentState;
+
+use crate::importance::Importance;
use serde::Deserialize;
use tracing::{debug, info, warn};
@@ -47,6 +52,8 @@ struct PaneReports {
pending: Vec<AgentState>,
/// The agent's latest session reference.
session: Option<String>,
+ /// The agent's importance, if reported.
+ importance: Option<Importance>,
/// When the last report arrived.
at: Option<Instant>,
}
@@ -111,6 +118,8 @@ struct HookParams {
agent_session_path: Option<String>,
#[serde(default)]
agent_session_id: Option<String>,
+ #[serde(default)]
+ importance: Option<String>,
}
fn handle_stream(stream: UnixStream) {
@@ -137,16 +146,23 @@ fn handle_stream(stream: UnixStream) {
continue;
}
};
+ let importance = params.importance.as_deref().and_then(|value| {
+ let level = Importance::parse(value);
+ if level.is_none() {
+ debug!(importance = value, "unknown importance");
+ }
+ level
+ });
match state {
- Some(state) => info!(pane = %params.pane_id, ?state, "hook report"),
- None => info!(pane = %params.pane_id, "hook session report"),
+ Some(state) => info!(pane = %params.pane_id, ?state, ?importance, "hook report"),
+ None => info!(pane = %params.pane_id, ?importance, "hook report without state"),
}
- record(params.pane_id, state, params.agent_session_path.or(params.agent_session_id));
+ record(params.pane_id, state, params.agent_session_path.or(params.agent_session_id), importance);
wake();
}
}
-fn record(pane_id: String, state: Option<AgentState>, session: Option<String>) {
+fn record(pane_id: String, state: Option<AgentState>, session: Option<String>, importance: Option<Importance>) {
let mut reports = reports();
let pane = reports.entry(pane_id).or_default();
pane.at = Some(Instant::now());
@@ -157,6 +173,9 @@ fn record(pane_id: String, state: Option<AgentState>, session: Option<String>) {
if let Some(session) = session.filter(|s| !s.is_empty() && !s.chars().any(char::is_control)) {
pane.session = Some(session);
}
+ if importance.is_some() {
+ pane.importance = importance;
+ }
}
/// Start listening; reports accumulate for the watcher to consume.
@@ -204,6 +223,11 @@ pub fn session(pane_id: &str) -> Option<String> {
reports().get(pane_id).and_then(|pane| pane.session.clone())
}
+/// The pane's agent importance, if reported.
+pub fn importance(pane_id: &str) -> Option<Importance> {
+ reports().get(pane_id).and_then(|pane| pane.importance)
+}
+
/// Drop a pane's reports: the agent was not running when the panes were
/// listed at `listed`. A report since then is kept: the agent started
/// after the listing (Claude reports its session once, as it starts).
@@ -225,7 +249,7 @@ mod tests {
#[test]
fn a_session_report_records_only_the_session() {
- record("%t1".into(), None, Some("/p/s.jsonl".into()));
+ record("%t1".into(), None, Some("/p/s.jsonl".into()), None);
assert_eq!(session("%t1").as_deref(), Some("/p/s.jsonl"));
assert_eq!(take_observations("%t1"), None, "state stays with the screen");
assert_eq!(last_state("%t1"), None);
@@ -234,10 +258,20 @@ mod tests {
#[test]
fn a_report_after_the_pane_listing_is_not_dropped() {
let listed = Instant::now();
- record("%t2".into(), None, Some("/p/s.jsonl".into()));
+ record("%t2".into(), None, Some("/p/s.jsonl".into()), None);
drop_reports("%t2", listed);
assert!(session("%t2").is_some(), "the agent started after the listing");
drop_reports("%t2", Instant::now());
assert_eq!(session("%t2"), None, "listed since, without the agent: dropped");
}
+
+ #[test]
+ fn importance_is_kept_until_reported_again() {
+ record("%t3".into(), Some(AgentState::Working), None, Some(Importance::High));
+ record("%t3".into(), Some(AgentState::Idle), None, None);
+ assert_eq!(importance("%t3"), Some(Importance::High), "a report without it keeps it");
+ record("%t3".into(), None, None, Some(Importance::Normal));
+ assert_eq!(importance("%t3"), Some(Importance::Normal));
+ assert_eq!(last_state("%t3"), Some(AgentState::Idle), "a report without state keeps the state");
+ }
}
diff --git a/crates/dinggerd/src/importance.rs b/crates/dinggerd/src/importance.rs
new file mode 100644
index 0000000..2480d3e
--- /dev/null
+++ b/crates/dinggerd/src/importance.rs
@@ -0,0 +1,65 @@
+//! An agent's importance, set by the user: high, normal (the default), or
+//! low. It orders agents within a status (the list, and so the jump), and
+//! a low agent makes no sound.
+//!
+//! It belongs to the agent's conversation: pi's extension saves it in the
+//! session and reports it (`/importance`); other agents get it from
+//! `dingger importance`. The daemon writes it to the pane option
+//! `@dingger_importance` (unset for normal), which goes with the agent.
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
+pub enum Importance {
+ Low,
+ #[default]
+ Normal,
+ High,
+}
+
+impl Importance {
+ pub fn parse(value: &str) -> Option<Importance> {
+ match value.trim() {
+ "low" => Some(Importance::Low),
+ "normal" => Some(Importance::Normal),
+ "high" => Some(Importance::High),
+ _ => None,
+ }
+ }
+
+ /// A pane option's value: unset (empty) or anything unknown is normal.
+ pub fn from_option(value: &str) -> Importance {
+ Importance::parse(value).unwrap_or_default()
+ }
+
+ pub fn label(self) -> &'static str {
+ match self {
+ Importance::Low => "low",
+ Importance::Normal => "normal",
+ Importance::High => "high",
+ }
+ }
+
+ /// The pane option's value: normal leaves it unset.
+ pub fn option_value(self) -> &'static str {
+ match self {
+ Importance::Normal => "",
+ other => other.label(),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn levels_order_and_round_trip_through_the_option() {
+ assert!(Importance::High > Importance::Normal && Importance::Normal > Importance::Low);
+ for level in [Importance::Low, Importance::Normal, Importance::High] {
+ assert_eq!(Importance::from_option(level.option_value()), level);
+ assert_eq!(Importance::parse(level.label()), Some(level));
+ }
+ assert_eq!(Importance::from_option(""), Importance::Normal, "unset is normal");
+ assert_eq!(Importance::from_option("urgent"), Importance::Normal);
+ assert_eq!(Importance::parse("urgent"), None);
+ }
+}
diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs
index b97471c..c50fb80 100644
--- a/crates/dinggerd/src/main.rs
+++ b/crates/dinggerd/src/main.rs
@@ -1,6 +1,7 @@
//! dingger: agent state watching for tmux panes.
mod hooks;
+mod importance;
mod integrations;
mod manifest_update;
mod names;
@@ -71,6 +72,16 @@ enum Command {
},
/// Wake the watcher so focus changes show up immediately (tmux hooks).
Poke,
+ /// Set an agent's importance: it orders agents within a status (the
+ /// list, and the jump), and a low agent makes no sound. In pi, use
+ /// /importance instead: pi saves it with the session.
+ Importance {
+ /// `high`, `normal`, or `low`; without it, print the current one.
+ level: Option<String>,
+ /// The agent's pane (default: the pane this runs in).
+ #[arg(long)]
+ pane: Option<String>,
+ },
/// Restore tmux sessions saved before tmux last stopped (a crash,
/// shutdown, or kill-server): windows, panes, layouts, directories.
/// Only adds what is missing; a bare session of the same name (what a
@@ -177,6 +188,12 @@ fn main() {
std::process::exit(1);
}
}
+ Command::Importance { level, pane } => {
+ if let Err(err) = set_importance(level.as_deref(), pane) {
+ eprintln!("dingger: {err}");
+ std::process::exit(1);
+ }
+ }
Command::Poke => {
// Best effort: a stopped watcher just means nothing to wake.
let _ = hooks::poke();
@@ -284,6 +301,46 @@ fn raise(client: &terminals::Client) {
}
}
+/// `dingger importance`: report the pane's agent importance to the daemon,
+/// which writes it (the daemon is the one writer of pane state).
+fn set_importance(level: Option<&str>, pane: Option<String>) -> Result<(), String> {
+ let target = pane
+ .or_else(|| std::env::var("TMUX_PANE").ok())
+ .ok_or("not in a tmux pane; pass --pane")?;
+ let info = tmux::run(&[
+ "display-message",
+ "-p",
+ "-t",
+ &target,
+ "#{pane_id}\t#{@agent}\t#{@dingger_hook}\t#{@dingger_importance}",
+ ])
+ .map_err(|_| format!("no pane {target}"))?;
+ let fields: Vec<&str> = info.trim_end_matches('\n').split('\t').collect();
+ let [pane_id, agent, hook, written] = fields[..] else {
+ return Err(format!("no pane {target}"));
+ };
+ if agent.is_empty() {
+ return Err(format!("no agent in pane {target}"));
+ }
+ let Some(level) = level else {
+ println!("{}", importance::Importance::from_option(written).label());
+ return Ok(());
+ };
+ let level = importance::Importance::parse(level).ok_or("importance is high, normal, or low")?;
+ // pi's extension reports the importance saved in the session; a value
+ // set here would last only until its next report.
+ if agent == "pi" && !hook.is_empty() {
+ return Err("this pi reports to dingger: use /importance in it (pi saves it with the session)".into());
+ }
+ let message = serde_json::json!({
+ "method": "pane.report_agent",
+ "params": { "pane_id": pane_id, "importance": level.label() },
+ });
+ hooks::send(&message.to_string())?;
+ println!("importance: {}", level.label());
+ Ok(())
+}
+
/// Every agent pane in herdr's priority order (blocked, done, working,
/// idle), oldest-waiting first within a status.
fn status() -> Result<(), String> {
@@ -297,7 +354,8 @@ fn status() -> Result<(), String> {
}
panes.sort_by_key(|pane| {
let status = watcher::Status::from_label(&pane.written_state);
- (watcher::list_order(status, pane.written_since.parse().ok()), pane.display_target.clone())
+ let importance = importance::Importance::from_option(&pane.written_importance);
+ (watcher::list_order(status, importance, pane.written_since.parse().ok()), pane.display_target.clone())
});
let place = |pane: &tmux::Pane| pane.display_target.replacen(':', " · ", 1);
let width = panes.iter().map(|pane| place(pane).chars().count()).max().unwrap_or(5).max(5);
@@ -305,7 +363,12 @@ fn status() -> Result<(), String> {
for pane in &panes {
let state = if pane.written_state.is_empty() { "-" } else { &pane.written_state };
let agent = pane.agent.map(detect_core::agent_label).unwrap_or("-");
- println!("{state:<8} {:<width$} {agent:<7} {}", place(pane), pane.written_name);
+ let name = match importance::Importance::from_option(&pane.written_importance) {
+ importance::Importance::High => format!("! {}", pane.written_name),
+ importance::Importance::Low => format!("{} (low)", pane.written_name),
+ importance::Importance::Normal => pane.written_name.clone(),
+ };
+ println!("{state:<8} {:<width$} {agent:<7} {}", place(pane), name.trim());
}
Ok(())
}
diff --git a/crates/dinggerd/src/notify.rs b/crates/dinggerd/src/notify.rs
index 555299d..11b37b0 100644
--- a/crates/dinggerd/src/notify.rs
+++ b/crates/dinggerd/src/notify.rs
@@ -31,6 +31,15 @@ impl NotificationKind {
static SOUND_SEQ: AtomicU64 = AtomicU64::new(0);
pub fn play_sound(kind: &NotificationKind) {
+ // Test seam: play through a command instead (tests/e2e.sh).
+ if let Ok(command) = std::env::var("DINGGER_SOUND") {
+ let name = match kind {
+ NotificationKind::NeedsAttention => "request",
+ NotificationKind::Finished => "done",
+ };
+ let _ = Command::new("sh").args(["-c", &command, name]).stdin(Stdio::null()).spawn();
+ return;
+ }
// Sounds are embedded; afplay needs a file, so stage the bytes in the
// system temp dir.
let path = std::env::temp_dir().join(format!(
diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs
index f69acd1..9fcccf3 100644
--- a/crates/dinggerd/src/tmux.rs
+++ b/crates/dinggerd/src/tmux.rs
@@ -28,6 +28,8 @@ pub struct Pane {
pub written_hook: String,
pub written_name: String,
pub written_session: String,
+ /// `@dingger_importance`: unset for normal.
+ pub written_importance: String,
}
@@ -108,14 +110,15 @@ pub fn list_panes() -> Result<Vec<Pane>, String> {
"#{pane_index}\t",
"#{pane_current_path}\t",
"#{@agent_name}\t",
- "#{@agent_session}",
+ "#{@agent_session}\t",
+ "#{@dingger_importance}",
);
let out = run_tmux(&["list-panes", "-a", "-F", format])?;
let mut panes: Vec<Pane> = Vec::new();
let mut process_tree = None;
for line in out.lines() {
let fields: Vec<&str> = line.split('\t').collect();
- if fields.len() < 19 {
+ if fields.len() < 20 {
continue;
}
let attended = fields[4] == "1" && fields[5] != "0";
@@ -157,6 +160,7 @@ pub fn list_panes() -> Result<Vec<Pane>, String> {
written_hook: fields[12].to_string(),
written_name: fields[17].to_string(),
written_session: fields[18].to_string(),
+ written_importance: fields[19].to_string(),
});
}
Ok(panes)
diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs
index 9198f60..f9c987e 100644
--- a/crates/dinggerd/src/watcher.rs
+++ b/crates/dinggerd/src/watcher.rs
@@ -31,6 +31,7 @@ use detect_core::{detect_agent_with_osc, AgentState};
use tracing::{debug, info, warn};
use crate::hooks;
+use crate::importance::Importance;
use crate::notify::{play_sound, send_notification, NotificationKind};
use crate::tmux;
@@ -172,15 +173,29 @@ pub fn advance(
}
/// The order agents are listed in: herdr's status priority; within a
-/// status, done and blocked oldest first (the queue: nothing waits
-/// forever), working and idle most recent first; then by place.
-pub fn list_order(status: Status, since: Option<u64>) -> (std::cmp::Reverse<u8>, i128) {
+/// status, the more important first; then done and blocked oldest first
+/// (the queue: nothing waits forever), working and idle most recent first;
+/// then by place.
+pub fn list_order(
+ status: Status,
+ importance: Importance,
+ since: Option<u64>,
+) -> (std::cmp::Reverse<u8>, std::cmp::Reverse<Importance>, i128) {
let since = since.map(i128::from);
let key = match status {
Status::Done | Status::Blocked => since.unwrap_or(i128::MAX),
_ => since.map(|since| -since).unwrap_or(i128::MAX),
};
- (std::cmp::Reverse(status.priority()), key)
+ (std::cmp::Reverse(status.priority()), std::cmp::Reverse(importance), key)
+}
+
+/// A pane's importance: as its agent last reported it, else as written
+/// (it survives a daemon restart); normal without an agent.
+pub fn importance_of(pane: &tmux::Pane) -> Importance {
+ if pane.agent.is_none() {
+ return Importance::Normal;
+ }
+ hooks::importance(&pane.pane_id).unwrap_or_else(|| Importance::from_option(&pane.written_importance))
}
pub struct Watcher {
@@ -330,6 +345,14 @@ impl Watcher {
(None, _) => change("@agent_session", &pane.written_session, String::new()),
_ => {}
}
+ // The importance goes with the agent too.
+ match (pane.agent, hooks::importance(&pane.pane_id)) {
+ (Some(_), Some(level)) => {
+ change("@dingger_importance", &pane.written_importance, level.option_value().to_string())
+ }
+ (None, _) => change("@dingger_importance", &pane.written_importance, String::new()),
+ _ => {}
+ }
if let Err(err) = tmux::set_pane_options(&pane.pane_id, &changes) {
warn!(target = %pane.display_target, %err, "failed to write pane options");
}
@@ -346,7 +369,8 @@ impl Watcher {
if self.notifications && !watching {
send_notification(agent, &kind, &format!("{} ({agent})", pane.display_target));
}
- if self.sounds {
+ // A low agent makes no sound; it still notifies and waits in line.
+ if self.sounds && importance_of(pane) != Importance::Low {
play_sound(&kind);
}
}
@@ -355,7 +379,7 @@ impl Watcher {
/// status line, menu bar, and focus. The queue is ordered like the menu:
/// herdr's priority (blocked before done), oldest first within it.
fn publish(&mut self, panes: &[tmux::Pane]) {
- let mut queue: Vec<(std::cmp::Reverse<u8>, u64, &str)> = Vec::new();
+ let mut queue = Vec::new();
let (mut blocked, mut done, mut working) = (0, 0, 0);
for pane in panes {
let Some(track) = self.tracks.get(&pane.pane_id) else {
@@ -367,16 +391,15 @@ impl Watcher {
Status::Working => working += 1,
Status::Idle | Status::Unknown => {}
}
- // The queue: what needs the user, oldest first.
+ // The queue: what needs the user, in list order.
if matches!(track.status(), Status::Blocked | Status::Done) {
- let since = track.since.unwrap_or(u64::MAX);
- queue.push((std::cmp::Reverse(track.status().priority()), since, pane.pane_id.as_str()));
+ queue.push((list_order(track.status(), importance_of(pane), track.since), pane.pane_id.as_str()));
}
}
queue.sort();
let queue = queue
.iter()
- .map(|(_, _, pane_id)| *pane_id)
+ .map(|(_, pane_id)| *pane_id)
.collect::<Vec<_>>()
.join(",");
@@ -587,7 +610,7 @@ mod tests {
("blocked", Status::Blocked, Some(99)),
("working new", Status::Working, Some(70)),
];
- rows.sort_by_key(|(_, status, since)| list_order(*status, *since));
+ rows.sort_by_key(|(_, status, since)| list_order(*status, Importance::Normal, *since));
let names: Vec<_> = rows.iter().map(|(name, ..)| *name).collect();
assert_eq!(
names,
@@ -596,6 +619,26 @@ mod tests {
}
#[test]
+ fn importance_orders_within_a_status_before_time() {
+ use Importance::*;
+ let mut rows = [
+ ("done old", Status::Done, Normal, Some(10)),
+ ("done new high", Status::Done, High, Some(90)),
+ ("done older low", Status::Done, Low, Some(5)),
+ ("blocked low", Status::Blocked, Low, Some(99)),
+ ("working high", Status::Working, High, Some(1)),
+ ("working new", Status::Working, Normal, Some(80)),
+ ];
+ rows.sort_by_key(|(_, status, importance, since)| list_order(*status, *importance, *since));
+ let names: Vec<_> = rows.iter().map(|(name, ..)| *name).collect();
+ assert_eq!(
+ names,
+ ["blocked low", "done new high", "done old", "done older low", "working high", "working new"],
+ "status first, then importance, then time"
+ );
+ }
+
+ #[test]
fn agent_exit_is_unknown() {
let done = step(step(PaneTrack::NEW, Working, false), Idle, false);
assert_eq!(step(done, Unknown, false).status(), Status::Unknown);
diff --git a/menubar/main.swift b/menubar/main.swift
index 17b9fcc..c37386e 100644
--- a/menubar/main.swift
+++ b/menubar/main.swift
@@ -397,6 +397,21 @@ if arguments.contains("--selftest") {
since: $0.2, path: "~") }.sorted(by: listedBefore).map(\.name)
let expected = ["blocked", "done old", "done new", "working new", "working old", "idle new", "idle old", "idle unknown"]
if listed != expected { failures.append("order: waiting oldest first, the rest most recent first: \(listed)") }
+ let byImportance: [(String, Status, Importance, Int)] = [
+ ("done old", .done, .normal, 10), ("done new high", .done, .high, 90), ("done older low", .done, .low, 5),
+ ("blocked low", .blocked, .low, 99), ("working high", .working, .high, 1), ("working new", .working, .normal, 80),
+ ]
+ let importanceListed = byImportance.map { Row(paneId: $0.0, agent: "pi", name: $0.0, status: $0.1, session: "s",
+ window: "w", since: $0.3, path: "~", importance: $0.2) }
+ .sorted(by: listedBefore).map(\.name)
+ let importanceExpected = ["blocked low", "done new high", "done old", "done older low", "working high", "working new"]
+ if importanceListed != importanceExpected {
+ failures.append("order: status first, then importance, then time: \(importanceListed)")
+ }
+ let high = Row(paneId: "%1", agent: "pi", name: "Fix it", status: .done, session: "s", window: "w", since: 1, importance: .high)
+ if rowTitle(high).string != "✓ ! Fix it" || rowTitlePrefix(high) != "✓ ! " {
+ failures.append("title: a high agent is marked: \(rowTitle(high).string)")
+ }
failures.forEach { print("FAIL \($0)") }
print(failures.isEmpty ? "selftest ok" : "selftest: \(failures.count) failed")
exit(failures.isEmpty ? 0 : 1)
@@ -405,7 +420,7 @@ if arguments.contains("--print") {
let rows = fetchRows()
print("title: \(plainTitle(for: rows))")
for row in rows {
- print("\(row.status.symbol) \(row.status.rawValue)\t\(row.label)\t\(row.subtitle)")
+ print("\(row.status.symbol) \(row.status.rawValue)\t\(row.markedLabel)\t\(row.subtitle)")
}
exit(0)
}
diff --git a/menubar/model.swift b/menubar/model.swift
index 8bed858..760ff31 100644
--- a/menubar/model.swift
+++ b/menubar/model.swift
@@ -65,6 +65,20 @@ enum Status: String {
}
}
+/// The agent's importance (`@dingger_importance`, unset for normal): it
+/// orders agents within a status, and a low agent makes no sound.
+enum Importance: Int {
+ case low = 0, normal = 1, high = 2
+
+ init(option: String) {
+ switch option {
+ case "high": self = .high
+ case "low": self = .low
+ default: self = .normal
+ }
+ }
+}
+
struct Row: Equatable {
let paneId: String
let agent: String
@@ -76,6 +90,7 @@ struct Row: Equatable {
let since: Int
/// The pane's working directory, home as "~".
var path: String = ""
+ var importance: Importance = .normal
var location: String { "\(session) · \(window)" }
var waiting: Bool { status == .blocked || status == .done }
@@ -86,9 +101,14 @@ struct Row: Equatable {
return name.count > 80 ? String(name.prefix(79)) + "…" : name
}
- /// Dim second line: where it runs, which harness, and how long it waits.
+ /// The title's text as plain text: `! ` marks a high agent.
+ var markedLabel: String { (importance == .high ? "! " : "") + label }
+
+ /// Dim second line: where it runs, which harness, whether it is low,
+ /// and how long it waits.
var subtitle: String {
var parts = [location, agent]
+ if importance == .low { parts.append("low") }
if waiting, since != Int.max {
parts.append("\(status.rawValue) \(elapsed(sinceMs: since))")
}
@@ -219,13 +239,15 @@ func runDinggerInLoginShell(_ args: [String]) -> (ok: Bool, output: String) {
private let home = NSHomeDirectory()
-/// Every agent pane: herdr's status priority; within a status, done and
-/// blocked oldest first (the queue), working and idle most recent first
-/// (`@dingger_since`: when the pane entered its status); then by place.
+/// Every agent pane: herdr's status priority; within a status, the more
+/// important first, then done and blocked oldest first (the queue),
+/// working and idle most recent first (`@dingger_since`: when the pane
+/// entered its status); then by place.
func fetchRows() -> [Row] {
let format = [
"#{pane_id}", "#{@agent}", "#{@agent_state}", "#{@dingger_since}", "#{@agent_name}",
"#{session_name}", "#{window_name}", "#{window_panes}", "#{pane_index}", "#{pane_current_path}",
+ "#{@dingger_importance}",
].joined(separator: "\t")
guard let out = tmux(["list-panes", "-a", "-F", format]) else { return [] }
// A pane in several sessions (a session group, a linked window) is
@@ -233,13 +255,14 @@ func fetchRows() -> [Row] {
var listed = Set<String>()
let rows: [Row] = out.split(separator: "\n").compactMap { line in
let f = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init)
- guard f.count >= 10, !f[1].isEmpty, listed.insert(f[0]).inserted else { return nil }
+ guard f.count >= 11, !f[1].isEmpty, listed.insert(f[0]).inserted else { return nil }
let status = Status(rawValue: f[2]) ?? .unknown
guard status != .unknown else { return nil }
let window = (Int(f[7]) ?? 1) > 1 ? "\(f[6]).\(f[8])" : f[6]
return Row(paneId: f[0], agent: f[1], name: f[4], status: status,
session: f[5], window: window, since: Int(f[3]) ?? Int.max,
- path: f[9].hasPrefix(home) ? "~" + f[9].dropFirst(home.count) : f[9])
+ path: f[9].hasPrefix(home) ? "~" + f[9].dropFirst(home.count) : f[9],
+ importance: Importance(option: f[10]))
}
return rows.sorted(by: listedBefore)
}
@@ -247,6 +270,7 @@ func fetchRows() -> [Row] {
/// The list order (see `fetchRows`).
func listedBefore(_ a: Row, _ b: Row) -> Bool {
if a.status.priority != b.status.priority { return a.status.priority > b.status.priority }
+ if a.importance != b.importance { return a.importance.rawValue > b.importance.rawValue }
if a.since != b.since {
// Int.max: never stamped; last either way.
if a.since == Int.max || b.since == Int.max { return a.since < b.since }
@@ -291,8 +315,15 @@ func plainTitle(for rows: [Row]) -> String {
title(for: rows).string
}
-/// Row title: the colored status symbol, then the agent's name. Unnamed
-/// agents show their harness, dimmed, so it never competes with real names.
+/// The title's characters before the name: the status symbol, and `!` for a
+/// high agent.
+func rowTitlePrefix(_ row: Row) -> String {
+ "\(row.status.symbol) " + (row.importance == .high ? "! " : "")
+}
+
+/// Row title: the colored status symbol, a bold `!` for a high agent, then
+/// the agent's name. Unnamed agents show their harness, dimmed, so it never
+/// competes with real names; a low agent's name is lightly dimmed.
func rowTitle(_ row: Row, primary: NSColor = .labelColor,
secondary: NSColor = .secondaryLabelColor) -> NSAttributedString {
let font = NSFont.menuFont(ofSize: 0)
@@ -300,7 +331,11 @@ func rowTitle(_ row: Row, primary: NSColor = .labelColor,
let text = NSMutableAttributedString()
text.append(NSAttributedString(
string: "\(row.status.symbol) ", attributes: [.font: mono, .foregroundColor: row.status.color]))
- let labelColor = row.name.isEmpty ? secondary : primary
+ if row.importance == .high {
+ text.append(NSAttributedString(
+ string: "! ", attributes: [.font: NSFont.boldSystemFont(ofSize: font.pointSize), .foregroundColor: primary]))
+ }
+ let labelColor = row.name.isEmpty ? secondary : row.importance == .low ? primary.withAlphaComponent(0.7) : primary
text.append(NSAttributedString(
string: row.label, attributes: [.font: font, .foregroundColor: labelColor]))
return text
diff --git a/menubar/panel.swift b/menubar/panel.swift
index 6ab85f2..8edab70 100644
--- a/menubar/panel.swift
+++ b/menubar/panel.swift
@@ -153,7 +153,7 @@ struct PanelContent {
}
for (index, row) in rows.enumerated() {
if index == separatorBefore { lines.append("---") }
- lines.append("\(row.status.symbol) \(row.status.rawValue)\t\(row.label)\t\(row.subtitle)")
+ lines.append("\(row.status.symbol) \(row.status.rawValue)\t\(row.markedLabel)\t\(row.subtitle)")
}
if overflow > 0 { lines.append("+\(overflow) more waiting") }
if showsSummary {
diff --git a/menubar/search.swift b/menubar/search.swift
index 21db063..2bc044a 100644
--- a/menubar/search.swift
+++ b/menubar/search.swift
@@ -171,7 +171,7 @@ struct SearchState: Equatable {
for match in visible {
let row = match.row
let marker = row.paneId == selected ? "›" : " "
- lines.append("\(marker) \(row.status.symbol) \(row.status.rawValue)\t\(row.label)\t\(row.subtitle)")
+ lines.append("\(marker) \(row.status.symbol) \(row.status.rawValue)\t\(row.markedLabel)\t\(row.subtitle)")
}
lines.append(footer)
return lines.joined(separator: "\n")
@@ -192,7 +192,7 @@ private func highlightedTitle(_ match: SearchMatch) -> NSAttributedString {
let text = NSMutableAttributedString(
attributedString: rowTitle(match.row, primary: mochaText, secondary: mochaSubtext))
guard !match.labelPositions.isEmpty else { return text }
- let prefix = "\(match.row.status.symbol) ".utf16.count
+ let prefix = rowTitlePrefix(match.row).utf16.count
let font = NSFont.boldSystemFont(ofSize: NSFont.menuFont(ofSize: 0).pointSize)
let label = Array(match.row.label)
var utf16Offset = prefix
diff --git a/tests/e2e.sh b/tests/e2e.sh
index f9fcf18..20ce5dc 100755
--- a/tests/e2e.sh
+++ b/tests/e2e.sh
@@ -43,11 +43,13 @@ cleanup() {
trap cleanup EXIT
# Notifications are on, delivered by a fake notifier that takes 3s like the
-# real one can; the UI must not wait for it.
+# real one can; the UI must not wait for it. Sounds are on, played by a fake
+# player that logs them.
NOTES="$DIR/notifications"
+SOUNDS="$DIR/sounds"
start_daemon() {
- DINGGER_NOTIFIER="sleep 3; echo \"\$0|\$1\" >> $NOTES" RUST_LOG=debug \
- "$DINGGER" watch --interval-secs 30 --no-sounds >>"$DIR/daemon.log" 2>&1 &
+ DINGGER_NOTIFIER="sleep 3; echo \"\$0|\$1\" >> $NOTES" DINGGER_SOUND="echo \"\$0\" >> $SOUNDS" RUST_LOG=debug \
+ "$DINGGER" watch --interval-secs 30 >>"$DIR/daemon.log" 2>&1 &
DAEMON_PID=$!
for _ in $(seq 50); do [ -S "$HOOK" ] && return; sleep 0.1; done
echo "daemon did not start"; cat "$DIR/daemon.log"; exit 1
@@ -223,7 +225,12 @@ hook "$A1" working; hook "$A1" idle
expect "A2 blocked" "opt $A2 @agent_state" blocked
expect "A1 done" "opt $A1 @agent_state" done
t source-file "$ROOT/packaging/tmux/dingger.tmux"
-segment=$(t display -p -c "$CLIENT" '#{T:status-right}' | sed 's/#\[[^]]*\]//g')
+# The counts are published right after the state: poll, like expect.
+for _ in $(seq 40); do
+ segment=$(t display -p -c "$CLIENT" '#{T:status-right}' | sed 's/#\[[^]]*\]//g')
+ case "$segment" in *"×1 ✓1") break ;; esac
+ sleep 0.1
+done
case "$segment" in *"×1"*"✓1"*) pass "segment shows ×1 ✓1 ($segment)";; *) fail "segment: [$segment]";; esac
expect "mouse binding installed" "t list-keys -T root | grep MouseDown1StatusRight | grep -c 'dingger focus'" 1
expect "focus hook installed: learns the terminal window" "t show-hooks -g client-focus-in | grep -c 'dingger terminal focused'" 1
@@ -272,13 +279,43 @@ fi
# --- 12. the shipped pi extension reports for its own pane -------------------
echo "12. pi extension"
cat >"$DIR/ext-harness.mjs" <<'JS'
-// Loads the shipped extension with a fake pi and fires lifecycle events.
+// Loads the shipped extension with a fake pi and fires lifecycle events
+// ("agent_start"), runs commands ("/importance high"), and asks for argument
+// completions ("complete:importance h"). Custom session entries persist in
+// $SESSION_ENTRIES, so a later run reads them back as the session; what the
+// extension shows and emits goes to $EXT_LOG.
+import fs from "node:fs";
const ext = await import(process.env.EXTENSION);
-const on = {};
-ext.default({ on: (name, fn) => (on[name] = fn), events: { on: () => {} } });
-const ctx = { mode: "tui", isIdle: () => true };
-for (const event of process.argv.slice(2)) {
- await on[event]?.({ type: event, reason: "reload" }, ctx);
+const on = {}, commands = {};
+const store = process.env.SESSION_ENTRIES;
+const entries = store && fs.existsSync(store) ? JSON.parse(fs.readFileSync(store, "utf8")) : [];
+const log = (line) => process.env.EXT_LOG && fs.appendFileSync(process.env.EXT_LOG, line + "\n");
+ext.default({
+ on: (name, fn) => (on[name] = fn),
+ registerCommand: (name, command) => (commands[name] = command),
+ appendEntry: (customType, data) => {
+ entries.push({ type: "custom", customType, data });
+ if (store) fs.writeFileSync(store, JSON.stringify(entries));
+ },
+ events: { on: () => {}, emit: (name, data) => log(`event ${name} ${JSON.stringify(data)}`) },
+});
+const ctx = {
+ mode: "tui",
+ isIdle: () => true,
+ sessionManager: { getEntries: () => entries, getSessionFile: () => undefined },
+ ui: { setStatus: (key, text) => log(`status ${key} ${text ?? ""}`), notify: (text) => log(`notify ${text}`) },
+};
+for (const arg of process.argv.slice(2)) {
+ if (arg.startsWith("/")) {
+ const [name, ...rest] = arg.slice(1).split(" ");
+ await commands[name]?.handler(rest.join(" "), ctx);
+ } else if (arg.startsWith("complete:")) {
+ const [name, ...rest] = arg.slice("complete:".length).split(" ");
+ const items = await commands[name]?.getArgumentCompletions?.(rest.join(" "));
+ log(`complete ${(items ?? []).map((item) => item.value).join(" ")}`);
+ } else {
+ await on[arg]?.({ type: arg, reason: "reload" }, ctx);
+ }
await new Promise((r) => setTimeout(r, 300));
}
JS
@@ -793,6 +830,95 @@ renv "$DINGGER" focus --client "$TTY1" --pane "$(r display -p -t '=grp:agent' '#
expect "a window on a session group stays on its own session" "client_session $TTY1" grp-b
expect "showing the agent" "r display -p -c $TTY1 '#{window_name}'" agent
+# --- 21. importance --------------------------------------------------------
+# Fresh agents on the first server (its daemon polls every 30 s: updates
+# arrive by reports and pokes). Nobody looks at session imp.
+echo "21. importance"
+report() { # pane state importance (either may be empty)
+ python3 - "$HOOK" "$1" "$2" "$3" <<'PY'
+import json, socket, sys
+params = {"pane_id": sys.argv[2], "agent": "pi"}
+if sys.argv[3]: params["state"] = sys.argv[3]
+if sys.argv[4]: params["importance"] = sys.argv[4]
+s = socket.socket(socket.AF_UNIX)
+s.connect(sys.argv[1])
+s.sendall((json.dumps({"method": "pane.report_agent", "params": params}) + "\n").encode())
+s.close()
+PY
+}
+t new-session -d -s imp -n i1 -c "$DIR" -x 120 -y 40 "$DIR/bin/pi"
+t new-window -d -t imp: -n i2 -c "$DIR" "$DIR/bin/pi"
+t new-window -d -t imp: -n i3 -c "$DIR" "$DIR/bin/pi"
+t new-window -d -t imp: -n i4 -c "$DIR" "$DIR/bin/pi"
+I1=$(t display -p -t imp:i1 '#{pane_id}'); I2=$(t display -p -t imp:i2 '#{pane_id}')
+I3=$(t display -p -t imp:i3 '#{pane_id}'); I4=$(t display -p -t imp:i4 '#{pane_id}')
+iqueue() { gopt @dingger_queue | tr ',' '\n' | grep -xE "$I1|$I2|$I3" | tr '\n' ' '; }
+for p in "$I1" "$I2" "$I3"; do report "$p" working ""; expect "imp: $p working" "opt $p @agent_state" working; done
+for p in "$I1" "$I2" "$I3"; do report "$p" idle ""; expect "imp: $p done" "opt $p @agent_state" done; sleep 0.05; done
+expect "same importance: oldest done first" "iqueue" "$I1 $I2 $I3 "
+report "$I3" "" high
+expect "a high agent's pane option" "opt $I3 @dingger_importance" high
+expect "importance before time: the high agent first" "iqueue" "$I3 $I1 $I2 "
+expect "and its status unchanged (a report without state)" "opt $I3 @agent_state" done
+report "$I1" "" low
+expect "a low agent last" "iqueue" "$I3 $I2 $I1 "
+report "$I3" "" normal
+expect "normal leaves the option unset" "opt $I3 @dingger_importance" ""
+expect "and the order by time again" "iqueue" "$I2 $I3 $I1 "
+report "$I3" "" high
+expect "imp: high again" "iqueue" "$I3 $I2 $I1 "
+report "$I1" blocked ""
+expect "imp: a low agent blocked" "opt $I1 @agent_state" blocked
+expect "status before importance: the blocked low agent first" "iqueue" "$I1 $I3 $I2 "
+report "$I1" working ""
+expect "imp: working again" "opt $I1 @agent_state" working
+# Let every alert so far land (the fake notifier takes 3 s).
+sleep 3.5; : >"$SOUNDS"; : >"$NOTES"
+report "$I1" working ""; report "$I1" idle ""
+expect "a low agent finishing" "opt $I1 @agent_state" done
+sleep 0.5
+expect "makes no sound" "cat $SOUNDS" ""
+expect "but still notifies" "grep -c '^pi finished|imp:i1' $NOTES" 1 60
+report "$I2" working ""; report "$I2" idle ""
+expect "a normal agent finishing sounds" "cat $SOUNDS" done
+mb() { DINGGER_TMUX_SOCKET="$SOCK" DINGGER_BIN="$DINGGER" "$MENUBAR" "$@"; }
+expect "menu: a high agent is marked" "mb --print | grep -F 'imp · i3' | cut -f2" "! pi"
+expect "menu: a low agent says so" "mb --print | grep -F 'imp · i1' | cut -f3" "imp · i1 · pi · low · done now"
+expect "menu: high, normal, low among the done" \
+ "mb --print | grep -F 'imp · ' | grep '^✓' | cut -f3 | cut -d' ' -f3 | tr '\n' ' '" "i3 i2 i1 "
+expect "dingger importance shows it" "$DINGGER importance --pane $I1" low
+expect "a pi that reports itself is set in pi" \
+ "$DINGGER importance high --pane $I1 2>&1" "dingger: this pi reports to dingger: use /importance in it (pi saves it with the session)"
+expect "dingger importance needs an agent" "$DINGGER importance high --pane \$(t display -p -t viewer: '#{pane_id}') 2>&1 | cut -c1-24" "dingger: no agent in pan"
+$DINGGER poke; expect "imp: the plain pi is identified" "opt $I4 @agent" pi
+expect "dingger importance sets another agent's" "$DINGGER importance high --pane $I4" "importance: high"
+expect "the daemon writes it" "opt $I4 @dingger_importance" high
+t respawn-pane -k -t "$I4" "sleep 100000"; $DINGGER poke
+expect "it goes with the agent" "opt $I4 @dingger_importance" ""
+kill "$DAEMON_PID"; sleep 0.3; start_daemon; $DINGGER poke
+sleep 1
+expect "a daemon restart keeps it" "opt $I3 @dingger_importance" high
+expect "and the order" "iqueue" "$I3 $I2 $I1 "
+if command -v node >/dev/null; then
+ ENTRIES="$DIR/entries.json"; EXTLOG="$DIR/ext.log"
+ iext() { SESSION_ENTRIES="$ENTRIES" EXT_LOG="$EXTLOG" ext "$@"; }
+ iext "$I2" session_start "/importance high"
+ expect "/importance high reaches dingger" "opt $I2 @dingger_importance" high
+ expect "saved in the session" "grep -c '\"customType\":\"dingger-importance\",\"data\":{\"level\":\"high\"}' $ENTRIES" 1
+ expect "shown in pi's footer" "grep -c '^status dingger-importance importance: high$' $EXTLOG" 1
+ expect "announced to other extensions" "grep -c '^event dingger:importance {\"level\":\"high\"}$' $EXTLOG" 1
+ report "$I2" "" normal
+ expect "imp: set back by hand" "opt $I2 @dingger_importance" ""
+ iext "$I2" session_start
+ expect "a new pi on the session reports it again" "opt $I2 @dingger_importance" high
+ # pi starts a session before any command runs.
+ : >"$EXTLOG"; iext "$I2" session_start "/importance urgent" "complete:importance h" "/importance normal"
+ expect "an unknown level is refused" "grep -c '^notify importance is high, normal, or low$' $EXTLOG" 1
+ expect "levels complete" "grep '^complete' $EXTLOG" "complete high"
+ expect "normal reaches dingger" "opt $I2 @dingger_importance" ""
+ expect "and leaves the footer" "grep -c '^status dingger-importance $' $EXTLOG" 1
+fi
+
# Every daemon this run started must stop with it.
kill "$DAEMON_PID" "$DAEMON2_PID" 2>/dev/null
expect "the test daemons stop" "ps -o pid= -p $DAEMON_PID,$DAEMON2_PID | wc -l | tr -d ' '" 0
diff --git a/tests/pi-live.sh b/tests/pi-live.sh
index de35bc5..3f4b4b8 100755
--- a/tests/pi-live.sh
+++ b/tests/pi-live.sh
@@ -7,8 +7,9 @@
# tests/pi-live.sh <pi binary> [dingger binary]
#
# Covers: identification, the extension's state and session reports, a turn
-# (working, then done), /reload, an extension's blocked event, screen
-# detection for a pi without the extension, restore (`pi --session`), exit.
+# (working, then done), /reload, an extension's blocked event, /importance,
+# screen detection for a pi without the extension, restore (`pi --session`,
+# importance included), exit.
set -u
PI_BIN=$(cd "$(dirname "$1")" && pwd)/$(basename "$1")
ROOT=$(cd "$(dirname "$0")/.." && pwd)
@@ -65,7 +66,9 @@ ln -s "$PI_BIN" "$D/bin/pi"
# Plain shells: a login shell would read the user's profile, which puts the
# user's own pi first on PATH.
printf 'set -g default-shell /bin/sh\nset -g default-command /bin/sh\n' >"$D/tmux.conf"
-export PATH="$D/bin:$PATH" PI_OFFLINE=1 PI_SKIP_VERSION_CHECK=1 PI_TELEMETRY=0
+# Every pi here uses the test's agent directory, including one a restore
+# starts (`pi --session`, without the command line's own variables).
+export PATH="$D/bin:$PATH" PI_CODING_AGENT_DIR="$D/agent" PI_OFFLINE=1 PI_SKIP_VERSION_CHECK=1 PI_TELEMETRY=0
export DINGGER_TMUX_SOCKET="$R" DINGGER_AGENT_SOCKET="$D/hook.sock" DINGGER_STATE_DIR="$D/state" DINGGER_TMUX_CONFIG="$D/tmux.conf"
t -f "$D/tmux.conf" new-session -d -s main -n agent -x 140 -y 40 -c "$D/work"
t new-window -d -t main: -n plain -c "$D/work2"
@@ -104,6 +107,11 @@ type_line agent "/ask"
sleep 1.5
note "during a pi confirm dialog dingger shows: $(st agent) (pi gives extensions no dialog event)"
t send-keys -t main:agent Escape
+sleep 0.5
+type_line agent "/importance high"
+expect "/importance high: the pane option" "t show -pv -t main:agent @dingger_importance 2>/dev/null" high 50
+expect "and pi's footer (its last line)" "t capture-pane -p -t main:agent | grep -v '^ *$' | tail -1 | sed 's/^ *//'" "importance: high" 30
+expect "saved in the session file" "grep -c '\"customType\":\"dingger-importance\"' \"\$(t show -pv -t main:agent @agent_session)\"" 1 30
echo "2. screen detection (pi without the extension)"
t send-keys -t main:plain "PI_CODING_AGENT_DIR=$D/agent-plain pi $NO_MCP" Enter
@@ -125,6 +133,7 @@ expect "restore resumes it by its session file" "grep -c -- \"resume .*pi --sess
expect "resumed 1 of 1 agents" "grep -c '^resumed 1 of 1 agents' $D/restore.out" 1
expect "it runs again, reporting the same session" "t show -pv -t main:agent @agent_session 2>/dev/null" "$SESSION" 150
expect "with its conversation" "t capture-pane -p -t main:agent -S - | grep -c 'slow 3 hello'" 1 50
+expect "and its importance" "t show -pv -t main:agent @dingger_importance 2>/dev/null" high 50
echo "4. exit"
t send-keys -t main:agent C-d