diff options
| -rw-r--r-- | README.md | 22 | ||||
| -rw-r--r-- | ROADMAP.md | 6 | ||||
| -rw-r--r-- | crates/dinggerd/assets/pi/pi-agent-state.ts | 104 | ||||
| -rw-r--r-- | crates/dinggerd/src/hooks.rs | 48 | ||||
| -rw-r--r-- | crates/dinggerd/src/main.rs | 9 | ||||
| -rw-r--r-- | crates/dinggerd/src/tmux.rs | 31 | ||||
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 64 | ||||
| -rw-r--r-- | menubar/model.swift | 45 | ||||
| -rw-r--r-- | menubar/panel.swift | 63 | ||||
| -rw-r--r-- | menubar/search.swift | 15 | ||||
| -rwxr-xr-x | tests/e2e.sh | 73 | ||||
| -rwxr-xr-x | tests/pi-live.sh | 23 |
12 files changed, 447 insertions, 56 deletions
@@ -53,7 +53,8 @@ than a poll is still a completion. like clicking the tmux status line. - **Agent search**: **⌃⌥⇧⌘U** opens a Spotlight-style search over every agent. Type to fuzzy-filter (each word must match, letters in order, the - name, session, window, harness, status, or directory); ↑↓ or ⌃N ⌃P / ⌃J ⌃K + name, session, window, harness, status, or directory; or, as written, + a forgotten draft); ↑↓ or ⌃N ⌃P / ⌃J ⌃K select, ↩ or a click jumps, esc closes, ⌃U clears, ⌘U (or a right-click) marks the selected idle agent unread. With nothing typed, agents are in the menu's order, so ↩ jumps to the top waiting agent. It @@ -119,6 +120,18 @@ than a poll is still a completion. `/unread`, and `! dingger unread` does not work in it: Claude (2.1.311) answers a `!` command with a model turn, so the agent is working and the mark is refused. Use the panel or search for Claude agents. +- **Forgotten drafts** (pi): an agent with text left in its input box shows + `✎` before its name wherever you are not looking at it (where you look, + you may be typing it). Idle agents with one come first among idle agents; + the compact panel counts them (`✎ 2 drafts`, a click jumps to the + first); search finds an agent by its draft's words (as written, not + fuzzily); `dingger status` marks them too. No sound, no notification. + The extension reads the input box half a second after you stop typing, + and keeps each session's draft in a file + (`~/.local/state/dingger/drafts/`) that puts it back in the input box + when the session comes back: after a crash, a restore, or `pi --session`. + The draft is kept on the pane as one line (`@dingger_draft`), never in + dingger's log. ## Where state comes from @@ -135,8 +148,8 @@ than a poll is still a completion. All per-pane state lives in tmux pane options (`@agent_state`, `@dingger_since`, `@dingger_hook`, `@dingger_importance`, -`@dingger_extension`, `@dingger_control`), so restarting dingger loses -nothing. +`@dingger_extension`, `@dingger_control`, `@dingger_draft`), so +restarting dingger loses nothing. ### Keeping pi agents on the current extension @@ -243,7 +256,8 @@ own pi agent directory: no network, no credentials, your pi untouched. It covers identification, the extension's state and session reports, a turn, `/reload`, an extension's blocked event, screen detection without the extension, restore, reloading an agent on an old extension (and refusing -while busy or with a draft), and exit. To try a release without installing it: +while busy or with a draft), drafts (reported, cleared, kept across a +crash and put back by restore), and exit. To try a release without installing it: `npm install --prefix /tmp/pi-next @earendil-works/pi-coding-agent@<version>` (through `sfw`), then `tests/pi-live.sh /tmp/pi-next/node_modules/.bin/pi`. @@ -91,8 +91,10 @@ Ideas, not yet prioritized: 18. **Warn about uncommitted work.** Mark agents whose directory has uncommitted git changes; mainly a safety check before closing a stale agent (item 11). -19. **Forgotten drafts.** Mark agents with a half-typed message in their - input box. +19. ~~**Forgotten drafts.**~~ Done for pi (see the README): marked where + you are not looking, first among idle agents, counted in the compact + panel, found by search, and kept across a crash or restore. Claude Code + would need screen matching of its input box. Known gaps, left for now: diff --git a/crates/dinggerd/assets/pi/pi-agent-state.ts b/crates/dinggerd/assets/pi/pi-agent-state.ts index aae40cc..e4e357d 100644 --- a/crates/dinggerd/assets/pi/pi-agent-state.ts +++ b/crates/dinggerd/assets/pi/pi-agent-state.ts @@ -111,6 +111,55 @@ function savedImportance(ctx: any): Importance { return "normal"; } +// The draft in the input box, as last reported: dingger marks a draft you +// left behind. Each session's draft is also kept in a file of its own, so +// it comes back when the session does after a crash, a restart, or +// `pi --session`. +let draft = ""; +const draftDir = path.join(path.dirname(socketPath), "drafts"); +// Enough to find the agent by it; the file keeps all of it. +const reportedDraftChars = 500; + +function draftFile(): string | undefined { + return sessionFile ? path.join(draftDir, `${path.basename(sessionFile)}.draft`) : undefined; +} + +function saveDraft(text: string): void { + const file = draftFile(); + if (!file) { + return; + } + try { + if (text.trim()) { + fs.mkdirSync(draftDir, { recursive: true }); + fs.writeFileSync(file, text); + } else { + fs.unlinkSync(file); + } + } catch { + // None to remove, or nowhere to keep it. + } +} + +// The session's saved draft, into an empty input box. +function restoreDraft(ctx: any): void { + const file = draftFile(); + if (!file) { + return; + } + try { + if ((ctx.ui.getEditorText() ?? "") !== "") { + return; + } + const saved = fs.readFileSync(file, "utf8"); + if (saved.trim()) { + ctx.ui.setEditorText(saved); + } + } catch { + // No saved draft. + } +} + function requestId(): string { return `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`; } @@ -128,6 +177,7 @@ async function report(state: AgentState | undefined, message?: string): Promise< importance, extension: extensionVersion, ...(controlListening ? { control: controlPath } : {}), + draft: draft.slice(0, reportedDraftChars), seq: Date.now() * 1000, ...(sessionFile ? { agent_session_path: sessionFile } : {}), }, @@ -269,10 +319,37 @@ export default function (pi) { }, }); - // The latest context, for the control socket's checks. + // The latest context, for the control socket's checks and the input box. let latestCtx: any; let control: net.Server | undefined; + // Read the input box once typing pauses; report and save it when it changed. + let draftTimer: ReturnType<typeof setTimeout> | undefined; + let stopWatchingInput: (() => void) | undefined; + + function checkDraft(): void { + let text: string; + try { + text = latestCtx?.ui?.getEditorText?.() ?? ""; + } catch { + return; + } + if (text === draft) { + return; + } + draft = text; + saveDraft(text); + void report(undefined); + } + + function checkDraftSoon(): void { + if (draftTimer) { + clearTimeout(draftTimer); + } + draftTimer = setTimeout(checkDraft, 500); + draftTimer.unref?.(); + } + // Run only by the control socket below, which checked that pi is idle // with an empty input box: extensions can reload pi only from a command. pi.registerCommand("dingger-reload", { @@ -345,6 +422,17 @@ export default function (pi) { } pi.on("session_shutdown", () => { + // The input box as it is now: a quit right after typing comes before + // the next check. + if (draftTimer) { + checkDraft(); + } + stopWatchingInput?.(); + stopWatchingInput = undefined; + if (draftTimer) { + clearTimeout(draftTimer); + draftTimer = undefined; + } if (!control) { return; } @@ -368,6 +456,16 @@ export default function (pi) { // A reload can replace this extension mid-run without emitting another agent_start. agentActive = ctx?.isIdle?.() === false; updateSessionFile(ctx); + restoreDraft(ctx); + // Before the first report, which carries it. + draft = ""; + checkDraft(); + stopWatchingInput?.(); + // Every key reaches this before the input box: read the box after it. + stopWatchingInput = ctx.ui.onTerminalInput?.(() => { + checkDraftSoon(); + return undefined; + }); // Each session has its own: /new starts at normal, /resume brings its own back. importance = savedImportance(ctx); announceImportance(); @@ -379,6 +477,8 @@ export default function (pi) { agentActive = true; updateSessionFile(ctx); publishState(); + // The prompt left the input box. + checkDraftSoon(); }); pi.on("agent_settled", (_event, ctx) => { @@ -389,5 +489,7 @@ export default function (pi) { agentActive = false; publishState(); + // Escape puts queued messages back into the input box. + checkDraftSoon(); }); } diff --git a/crates/dinggerd/src/hooks.rs b/crates/dinggerd/src/hooks.rs index 0074544..072735f 100644 --- a/crates/dinggerd/src/hooks.rs +++ b/crates/dinggerd/src/hooks.rs @@ -27,6 +27,9 @@ //! `{"method":"agents.reload_outdated"}`, and the watcher reloads each agent //! on an old extension once it is idle and off screen. //! +//! It also carries the `draft` in the agent's input box ("" for none), for +//! marking a draft you left behind; never logged. +//! //! Each line is answered with `{"ok":true}`. //! //! `{"method":"poke"}` only wakes the watcher; `dingger focus` and tmux's @@ -69,6 +72,8 @@ struct PaneReports { /// The version of the extension that reported, and its control socket. extension: Option<String>, control: Option<String>, + /// The draft in the agent's input box, as one line ("" for none). + draft: Option<String>, /// When the last report arrived. at: Option<Instant>, } @@ -165,6 +170,8 @@ struct HookParams { extension: Option<String>, #[serde(default)] control: Option<String>, + #[serde(default)] + draft: Option<String>, } fn handle_stream(stream: UnixStream) { @@ -231,6 +238,7 @@ fn handle_stream(stream: UnixStream) { importance, extension: params.extension, control: params.control, + draft: params.draft, }; record(params.pane_id, state, reported); wake(); @@ -244,6 +252,22 @@ struct Reported { importance: Option<Importance>, extension: Option<String>, control: Option<String>, + draft: Option<String>, +} + +/// The longest draft kept: enough to find an agent by it. +const DRAFT_CHARS: usize = 200; + +/// A draft as one line, at most [`DRAFT_CHARS`] long; "" for none. +fn draft_line(draft: &str) -> String { + draft + .split(|c: char| c.is_whitespace() || c.is_control()) + .filter(|word| !word.is_empty()) + .collect::<Vec<_>>() + .join(" ") + .chars() + .take(DRAFT_CHARS) + .collect() } /// A value fit for a tmux option: not empty, no control characters. @@ -271,6 +295,9 @@ fn record(pane_id: String, state: Option<AgentState>, reported: Reported) { if let Some(control) = option_value(reported.control) { pane.control = Some(control); } + if let Some(draft) = reported.draft { + pane.draft = Some(draft_line(&draft)); + } } /// Start listening; reports accumulate for the watcher to consume. @@ -328,6 +355,12 @@ pub fn extension(pane_id: &str) -> (Option<String>, Option<String>) { reports().get(pane_id).map(|pane| (pane.extension.clone(), pane.control.clone())).unwrap_or_default() } +/// The draft in the pane's input box ("" for none), if its extension +/// reported one. +pub fn draft(pane_id: &str) -> Option<String> { + reports().get(pane_id).and_then(|pane| pane.draft.clone()) +} + /// Ask the extension behind `control` to reload; its answer: `Ok(())` when /// it does, else why not (busy, a draft, or no answer). pub fn request_reload(control: &str) -> Result<(), String> { @@ -408,6 +441,21 @@ mod tests { } #[test] + fn a_draft_is_kept_as_one_short_line_until_reported_again() { + let draft = |text: &str| Reported { draft: Some(text.into()), ..Default::default() }; + record("%t5".into(), Some(AgentState::Idle), Reported::default()); + assert_eq!(super::draft("%t5"), None, "an extension that does not report drafts"); + record("%t5".into(), None, draft(" fix the\n\tsidecar;\x1b[1m ")); + assert_eq!(super::draft("%t5").as_deref(), Some("fix the sidecar; [1m")); + record("%t5".into(), Some(AgentState::Working), Reported::default()); + assert_eq!(super::draft("%t5").as_deref(), Some("fix the sidecar; [1m"), "kept by a report without one"); + record("%t5".into(), None, draft(&"x".repeat(500))); + assert_eq!(super::draft("%t5").map(|d| d.chars().count()), Some(DRAFT_CHARS)); + record("%t5".into(), None, draft("")); + assert_eq!(super::draft("%t5").as_deref(), Some(""), "cleared"); + } + + #[test] fn a_reload_answer_says_whether_it_reloads() { let dir = std::env::temp_dir().join(format!("dingger-reload-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs index 1fc5cd2..44db683 100644 --- a/crates/dinggerd/src/main.rs +++ b/crates/dinggerd/src/main.rs @@ -443,7 +443,8 @@ fn mark_unread(pane: Option<String>) -> Result<(), String> { } /// Every agent pane in herdr's priority order (blocked, done, working, -/// idle), oldest-waiting first within a status. +/// idle), oldest-waiting first within a status. ✎ marks a draft left in +/// the agent's input box. fn status() -> Result<(), String> { let mut panes: Vec<_> = tmux::list_panes()? .into_iter() @@ -456,7 +457,8 @@ fn status() -> Result<(), String> { panes.sort_by_key(|pane| { let status = watcher::Status::from_label(&pane.written_state); let importance = importance::Importance::from_option(&pane.written_importance); - (watcher::list_order(status, importance, pane.written_since.parse().ok()), pane.display_target.clone()) + let since = pane.written_since.parse().ok(); + (watcher::list_order(status, watcher::forgotten_draft(pane), importance, since), 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); @@ -464,12 +466,13 @@ 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("-"); + let draft = if watcher::forgotten_draft(pane) { "✎ " } else { "" }; 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()); + println!("{state:<8} {:<width$} {agent:<7} {draft}{}", place(pane), name.trim()); } Ok(()) } diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs index b743256..510c54b 100644 --- a/crates/dinggerd/src/tmux.rs +++ b/crates/dinggerd/src/tmux.rs @@ -32,6 +32,8 @@ pub struct Pane { pub written_importance: String, pub written_extension: String, pub written_control: String, + /// The draft in the agent's input box, as one line. + pub written_draft: String, } @@ -115,14 +117,15 @@ pub fn list_panes() -> Result<Vec<Pane>, String> { "#{@agent_session}\t", "#{@dingger_importance}\t", "#{@dingger_extension}\t", - "#{@dingger_control}", + "#{@dingger_control}\t", + "#{@dingger_draft}", ); 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() < 22 { + if fields.len() < 23 { continue; } let attended = fields[4] == "1" && fields[5] != "0"; @@ -167,6 +170,7 @@ pub fn list_panes() -> Result<Vec<Pane>, String> { written_importance: fields[19].to_string(), written_extension: fields[20].to_string(), written_control: fields[21].to_string(), + written_draft: fields[22].to_string(), }); } Ok(panes) @@ -242,20 +246,33 @@ pub fn set_pane_option(pane_id: &str, name: &str, value: &str) -> Result<(), Str /// Set (`Some`) or unset (`None`) several pane options in one tmux command /// list, applied as a unit. pub fn set_pane_options(pane_id: &str, changes: &[(&str, Option<String>)]) -> Result<(), String> { - let mut args: Vec<&str> = Vec::new(); + let mut args: Vec<String> = Vec::new(); for (name, value) in changes { if !args.is_empty() { - args.push(";"); + args.push(";".into()); } match value { - Some(value) => args.extend(["set-option", "-p", "-t", pane_id, name, value.as_str()]), - None => args.extend(["set-option", "-p", "-u", "-t", pane_id, name]), + Some(value) => { + args.extend(["set-option", "-p", "-t", pane_id, name].map(String::from)); + args.push(command_list_value(value)); + } + None => args.extend(["set-option", "-p", "-u", "-t", pane_id, name].map(String::from)), } } if args.is_empty() { return Ok(()); } - run_tmux(&args).map(|_| ()) + run_tmux(&args.iter().map(String::as_str).collect::<Vec<_>>()).map(|_| ()) +} + +/// A value as an argument in a tmux command list: tmux ends a command at an +/// argument ending in `;` (dropping it) unless it ends in `\;`, and free +/// text (a name, a draft) may end in one. +fn command_list_value(value: &str) -> String { + match value.strip_suffix(';') { + Some(rest) => format!("{rest}\\;"), + None => value.to_string(), + } } pub fn unset_pane_option(pane_id: &str, name: &str) -> Result<(), String> { diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs index 1c57609..b0acc1d 100644 --- a/crates/dinggerd/src/watcher.rs +++ b/crates/dinggerd/src/watcher.rs @@ -26,6 +26,7 @@ //! - `@agent_session` the agent's session reference, for restore //! - `@dingger_extension`, `@dingger_control` the version of pi's extension //! and its control socket, for reloading agents on an old one +//! - `@dingger_draft` the draft in the agent's input box, as one line use std::collections::HashMap; @@ -194,21 +195,36 @@ pub fn mark_unread(track: PaneTrack, attended: bool, now: u64) -> PaneTrack { PaneTrack { seen: false, held: attended, since: Some(now), ..track } } -/// The order agents are listed in: herdr's status priority; within a -/// 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. +/// The order agents are listed in: herdr's status priority; idle agents +/// with a forgotten draft before the other idle ones; within a 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, + forgotten_draft: bool, importance: Importance, since: Option<u64>, -) -> (std::cmp::Reverse<u8>, std::cmp::Reverse<Importance>, i128) { +) -> (std::cmp::Reverse<u8>, bool, 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()), std::cmp::Reverse(importance), key) + let draft_last = !(forgotten_draft && status == Status::Idle); + (std::cmp::Reverse(status.priority()), draft_last, std::cmp::Reverse(importance), key) +} + +/// A draft left in an agent's input box while the user looks elsewhere +/// (where they look, they may be typing it). +pub fn forgotten_draft(pane: &tmux::Pane) -> bool { + pane.agent.is_some() && !pane.attended && !draft_of(pane).is_empty() +} + +/// The pane's draft: as last reported, else as written (it survives a +/// daemon restart). +fn draft_of(pane: &tmux::Pane) -> String { + hooks::draft(&pane.pane_id).unwrap_or_else(|| pane.written_draft.clone()) } /// A pane's importance: as its agent last reported it, else as written @@ -493,6 +509,12 @@ impl Watcher { change("@dingger_control", &pane.written_control, String::new()); } } + // The draft goes with the agent too. + match (pane.agent, hooks::draft(&pane.pane_id)) { + (Some(_), Some(draft)) => change("@dingger_draft", &pane.written_draft, draft), + (None, _) => change("@dingger_draft", &pane.written_draft, 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"); } @@ -539,7 +561,10 @@ impl Watcher { } // The queue: what needs the user, in list order. if matches!(track.status(), Status::Blocked | Status::Done) { - queue.push((list_order(track.status(), importance_of(pane), track.since), pane.pane_id.as_str())); + queue.push(( + list_order(track.status(), forgotten_draft(pane), importance_of(pane), track.since), + pane.pane_id.as_str(), + )); } } queue.sort(); @@ -801,7 +826,7 @@ mod tests { ("blocked", Status::Blocked, Some(99)), ("working new", Status::Working, Some(70)), ]; - rows.sort_by_key(|(_, status, since)| list_order(*status, Importance::Normal, *since)); + rows.sort_by_key(|(_, status, since)| list_order(*status, false, Importance::Normal, *since)); let names: Vec<_> = rows.iter().map(|(name, ..)| *name).collect(); assert_eq!( names, @@ -820,7 +845,7 @@ mod tests { ("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)); + rows.sort_by_key(|(_, status, importance, since)| list_order(*status, false, *importance, *since)); let names: Vec<_> = rows.iter().map(|(name, ..)| *name).collect(); assert_eq!( names, @@ -830,6 +855,27 @@ mod tests { } #[test] + fn a_forgotten_draft_lifts_an_idle_agent_only() { + use Importance::*; + let mut rows = [ + ("idle new", Status::Idle, false, High, Some(90)), + ("idle draft old low", Status::Idle, true, Low, Some(5)), + ("idle draft new", Status::Idle, true, Normal, Some(50)), + ("done old", Status::Done, false, Normal, Some(10)), + ("done draft new", Status::Done, true, Normal, Some(60)), + ("working draft", Status::Working, true, Normal, Some(1)), + ("working new", Status::Working, false, Normal, Some(80)), + ]; + rows.sort_by_key(|(_, status, draft, importance, since)| list_order(*status, *draft, *importance, *since)); + let names: Vec<_> = rows.iter().map(|(name, ..)| *name).collect(); + assert_eq!( + names, + ["done old", "done draft new", "working new", "working draft", "idle draft new", "idle draft old low", "idle new"], + "status first; among idle agents, a draft before importance" + ); + } + + #[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/model.swift b/menubar/model.swift index e4d179c..3ed1a7e 100644 --- a/menubar/model.swift +++ b/menubar/model.swift @@ -93,8 +93,14 @@ struct Row: Equatable { /// The pane's working directory, home as "~". var path: String = "" var importance: Importance = .normal + /// The draft in the agent's input box, as one line (`@dingger_draft`). + var draft: String = "" + /// The user looks at this pane: its window is active in an attached session. + var attended: Bool = false var location: String { "\(session) · \(window)" } + /// A draft left behind: where the user looks, they may be typing it. + var forgottenDraft: Bool { !draft.isEmpty && !attended } var waiting: Bool { status == .blocked || status == .done } /// Only an agent you have seen can be marked unread (done again). var canMarkUnread: Bool { status == .idle } @@ -105,8 +111,9 @@ struct Row: Equatable { return name.count > 80 ? String(name.prefix(79)) + "…" : name } - /// The title's text as plain text: `! ` marks a high agent. - var markedLabel: String { (importance == .high ? "! " : "") + label } + /// The title's text as plain text: `! ` marks a high agent, `✎ ` a + /// forgotten draft. + var markedLabel: String { (importance == .high ? "! " : "") + (forgottenDraft ? "✎ " : "") + label } /// Dim second line: where it runs, which harness, whether it is low, /// and how long it waits. @@ -274,30 +281,35 @@ func runDinggerInLoginShell(_ args: [String]) -> (ok: Bool, output: String) { private let home = NSHomeDirectory() -/// Every agent pane: herdr's status priority; within a status, the more -/// important first, then done and blocked oldest first (the queue), +/// Every agent pane: herdr's status priority; idle agents with a forgotten +/// draft before the other idle ones; 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}", + "#{@dingger_importance}", "#{window_active}", "#{session_attached}", "#{@dingger_draft}", ].joined(separator: "\t") guard let out = tmux(["list-panes", "-a", "-F", format]) else { return [] } + let lines = out.split(separator: "\n").map { + $0.split(separator: "\t", omittingEmptySubsequences: false).map(String.init) + } + // Looked at in any of its sessions, as the daemon counts it. + let attended = Set(lines.filter { $0.count >= 14 && $0[11] == "1" && $0[12] != "0" }.map { $0[0] }) // A pane in several sessions (a session group, a linked window) is // listed once, under its first session, as the daemon counts it. 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 >= 11, !f[1].isEmpty, listed.insert(f[0]).inserted else { return nil } + let rows: [Row] = lines.compactMap { f in + guard f.count >= 14, !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], - importance: Importance(option: f[10])) + importance: Importance(option: f[10]), draft: f[13], attended: attended.contains(f[0])) } return rows.sorted(by: listedBefore) } @@ -305,6 +317,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.status == .idle, a.forgottenDraft != b.forgottenDraft { return a.forgottenDraft } if a.importance != b.importance { return a.importance.rawValue > b.importance.rawValue } if a.since != b.since { // Int.max: never stamped; last either way. @@ -357,12 +370,15 @@ func plainTitle(for rows: [Row]) -> String { title(for: rows).string } -/// The title's characters before the name: the status symbol and the -/// importance column. +/// The title's characters before the name: the status symbol, the +/// importance column, and the draft mark. func rowTitlePrefix(_ row: Row) -> String { - "\(row.status.symbol) " + importanceMark(row) + " " + "\(row.status.symbol) " + importanceMark(row) + " " + draftMark(row) } +/// `✎ ` before the name of an agent with a forgotten draft. +private func draftMark(_ row: Row) -> String { row.forgottenDraft ? "✎ " : "" } + /// The importance column: `!` for a high agent, a blank otherwise, so every /// name starts in the same place. private func importanceMark(_ row: Row) -> String { row.importance == .high ? "!" : " " } @@ -371,7 +387,7 @@ private func importanceMark(_ row: Row) -> String { row.importance == .high ? "! let lowImportanceAlpha: CGFloat = 0.5 /// Row title: the colored status symbol, the importance column (a peach `!` -/// for a high agent), then the agent's name. Unnamed agents show their +/// for a high agent), `✎` for a forgotten draft, then the agent's name. Unnamed agents show their /// harness, dimmed, so it never competes with real names; a low agent's name /// is dimmed. func rowTitle(_ row: Row, primary: NSColor = .labelColor, @@ -385,6 +401,9 @@ func rowTitle(_ row: Row, primary: NSColor = .labelColor, string: importanceMark(row) + " ", attributes: [.font: NSFont.monospacedSystemFont(ofSize: font.pointSize, weight: .heavy), .foregroundColor: herdrPeach])) + if row.forgottenDraft { + text.append(NSAttributedString(string: draftMark(row), attributes: [.font: font, .foregroundColor: primary])) + } let labelColor = row.name.isEmpty ? secondary : row.importance == .low ? primary.withAlphaComponent(lowImportanceAlpha) : primary text.append(NSAttributedString( diff --git a/menubar/panel.swift b/menubar/panel.swift index 85cc81e..5362d32 100644 --- a/menubar/panel.swift +++ b/menubar/panel.swift @@ -5,7 +5,8 @@ // - On or off (the global shortcut and the menu toggle it; off stays off), // and while on: shown always, or only when agents are waiting. // - Layout: compact (waiting agents in full, working and idle as one summary -// line); when shown always, a toggle switches to the full list. +// line, with a count of forgotten drafts that jumps to the first); when +// shown always, a toggle switches to the full list. // - Always dark: herdr's mocha palette with fixed colors on a dark tint, so // text reads the same over any desktop. @@ -85,6 +86,8 @@ struct PanelContent { let full: Bool /// Whether the panel offers the compact/full toggle (shown always). let canToggle: Bool + /// Agents with a forgotten draft not shown as rows, in list order. + let drafts: [Row] /// Sessions to restore, shown first. let offer: RestoreOffer? @@ -107,6 +110,8 @@ struct PanelContent { } working = all.filter { $0.status == .working }.count idle = all.filter { $0.status == .idle }.count + let shown = Set(rows.map(\.paneId)) + drafts = all.filter { $0.forgottenDraft && !shown.contains($0.paneId) } self.full = full self.canToggle = canToggle } @@ -137,6 +142,11 @@ struct PanelContent { var toggleLabel: String { full ? "Show less ▴" : "Show all ▾" } + /// "✎ 2 drafts", when some agents not shown have one. + var draftsLabel: String? { + drafts.isEmpty ? nil : "✎ \(drafts.count) \(drafts.count == 1 ? "draft" : "drafts")" + } + /// The offer row's title: what restore will do, or that it runs. var offerTitle: String? { guard let offer else { return nil } @@ -157,7 +167,8 @@ struct PanelContent { } if overflow > 0 { lines.append("+\(overflow) more waiting") } if showsSummary { - lines.append(summary.string + (canToggle ? "\t[\(toggleLabel)]" : "")) + lines.append(summary.string + (draftsLabel.map { "\t[\($0)]" } ?? "") + + (canToggle ? "\t[\(toggleLabel)]" : "")) } return lines.joined(separator: "\n") } @@ -369,7 +380,7 @@ private final class OfferView: ClickView { } } -/// "Show all ▾" / "Show less ▴". +/// Clickable caption text: "Show all ▾" / "Show less ▴", "Not now", "✎ 2 drafts". private final class ToggleView: ClickView { private let label: NSTextField @@ -550,17 +561,29 @@ final class PanelController { return box } - /// Summary on the left, the compact/full toggle on the right. + /// Summary on the left; on the right, the forgotten drafts (a click + /// jumps to the first) and the compact/full toggle. private func summaryLine(_ content: PanelContent) -> NSView { let box = caption(content.summary) - guard content.canToggle else { return box } - let toggle = ToggleView(text: content.toggleLabel, onClick: onToggleFull) - toggle.translatesAutoresizingMaskIntoConstraints = false - box.addSubview(toggle) - NSLayoutConstraint.activate([ - toggle.trailingAnchor.constraint(equalTo: box.trailingAnchor, constant: -8), - toggle.centerYAnchor.constraint(equalTo: box.centerYAnchor, constant: 1), - ]) + var trailing = box.trailingAnchor + var gap: CGFloat = -8 + var constraints: [NSLayoutConstraint] = [] + let onClick = self.onClick + let draftsView = zip(content.draftsLabel.map { [$0] } ?? [], content.drafts.prefix(1)).map { label, first in + ToggleView(text: label, onClick: { onClick(first.paneId) }) + } + let toggleView = content.canToggle ? [ToggleView(text: content.toggleLabel, onClick: onToggleFull)] : [] + for view in (toggleView + draftsView) { + view.translatesAutoresizingMaskIntoConstraints = false + box.addSubview(view) + constraints += [ + view.trailingAnchor.constraint(equalTo: trailing, constant: gap), + view.centerYAnchor.constraint(equalTo: box.centerYAnchor, constant: 1), + ] + trailing = view.leadingAnchor + gap = -14 + } + NSLayoutConstraint.activate(constraints) return box } @@ -695,6 +718,22 @@ func panelSelftest() -> [String] { check(full.plainText.hasSuffix("[Show less ▴]") && !compact.plainText.contains("Show"), "toggle offered only when allowed, labelled by layout") + // Forgotten drafts: a draft the user is not looking at. + var drafty = [row("d", .done), row("w", .working), row("i1", .idle), row("i2", .idle), row("i3", .idle)] + drafty[0].draft = "x"; drafty[1].draft = "x"; drafty[3].draft = "x"; drafty[4].draft = "x" + drafty[4].attended = true + check(drafty.map(\.forgottenDraft) == [true, true, false, true, false], "draft: forgotten unless looked at") + check(drafty.sorted(by: listedBefore).map(\.paneId) == ["d", "w", "i2", "i1", "i3"], + "draft: an idle agent with one first among idle agents only") + check(rowTitlePrefix(drafty[3]) == "○ ✎ " && rowTitle(drafty[3]).string == "○ ✎ i2" + && rowTitle(drafty[2]).string == "○ i1", "draft: ✎ before the name") + let draftPanel = PanelContent(rows: drafty.sorted(by: listedBefore), canToggle: true) + check(draftPanel.drafts.map(\.paneId) == ["w", "i2"] && draftPanel.draftsLabel == "✎ 2 drafts", + "draft: compact counts those not shown, in list order") + check(draftPanel.plainText.hasSuffix("○ 3 idle\t[✎ 2 drafts]\t[Show all ▾]"), "draft: the count before the toggle") + check(PanelContent(rows: drafty, full: true).draftsLabel == nil, "draft: none counted when every row shows") + check(PanelContent(rows: [drafty[3]]).draftsLabel == "✎ 1 draft", "draft: one") + let offer = RestoreOffer(saved_at: 0, sessions: [ .init(name: "a", windows: 2, panes: 3, agents: 2), .init(name: "b", windows: 1, panes: 1, agents: 0), ], agents: 2, interrupted: 1) diff --git a/menubar/search.swift b/menubar/search.swift index c62c612..ed9adaa 100644 --- a/menubar/search.swift +++ b/menubar/search.swift @@ -72,12 +72,15 @@ func searchFields(_ row: Row) -> [String] { } /// Rows matching every word of `query`, best first; ties keep the given -/// order. An empty query returns every row in order. +/// order. An empty query returns every row in order. A word also matches +/// the draft in an agent's input box, as written (not fuzzily: a long text +/// would match almost anything), ranking below the other fields. func search(_ rows: [Row], _ query: String) -> [SearchMatch] { let terms = query.split(whereSeparator: \.isWhitespace).map { lowered(String($0)) } var matches: [(match: SearchMatch, order: Int)] = [] for (order, row) in rows.enumerated() { let fields = searchFields(row) + let draft = String(lowered(row.draft)) var total = 0 var labelPositions = Set<Int>() var matchedAll = true @@ -88,6 +91,9 @@ func search(_ rows: [Row], _ query: String) -> [SearchMatch] { best = (found.score, field, found.positions) } } + if best == nil, draft.contains(String(term)) { + best = (term.count * 8, fields.count, []) + } guard let best else { matchedAll = false; break } total += best.score if best.field == 0 { labelPositions.formUnion(best.positions) } @@ -476,6 +482,13 @@ func searchSelftest() -> [String] { check(ids(search(rows, "lgsc")) == ["%1"], "query: fuzzy name") check(ids(search(rows, "langgraph")) == ["%1", "%3"], "query: directories match too") check(ids(search(rows, "temporal demo")) == ["%2"], "query: every word must match") + var drafted = rows + drafted[3].draft = "also check the Sidecar restart" + check(ids(search(drafted, "sidecar")) == ["%4"], "query: a draft's words match") + check(ids(search(drafted, "scr")).first == "%1" && !ids(search(drafted, "sdcr")).contains("%4"), + "query: a draft matches as written, not fuzzily") + drafted[0].draft = "deploy" + check(ids(search(drafted, "deploy")) == ["%3", "%1"], "query: a draft ranks below a name") check(ids(search(rows, "done")) == ["%1"], "query: status word") check(ids(search(rows, "notes")) == ["%4"], "query: window") check(ids(search(rows, "zzz")).isEmpty, "query: no match") diff --git a/tests/e2e.sh b/tests/e2e.sh index 7f72e3c..e73dc73 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -285,13 +285,15 @@ cat >"$DIR/ext-harness.mjs" <<'JS' // ("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. +// extension shows and emits goes to $EXT_LOG. The input box holds +// $EDITOR_TEXT; the session file is $SESSION_FILE. import fs from "node:fs"; const ext = await import(process.env.EXTENSION); 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"); +let editor = process.env.EDITOR_TEXT ?? ""; ext.default({ on: (name, fn) => (on[name] = fn), registerCommand: (name, command) => (commands[name] = command), @@ -304,8 +306,13 @@ ext.default({ const ctx = { mode: "tui", isIdle: () => true, - sessionManager: { getEntries: () => entries, getSessionFile: () => undefined }, - ui: { setStatus: (key, text) => log(`status ${key} ${text ?? ""}`), notify: (text) => log(`notify ${text}`) }, + sessionManager: { getEntries: () => entries, getSessionFile: () => process.env.SESSION_FILE }, + ui: { + setStatus: (key, text) => log(`status ${key} ${text ?? ""}`), + notify: (text) => log(`notify ${text}`), + getEditorText: () => editor, + setEditorText: (text) => { editor = text; log(`editor ${text}`); }, + }, }; for (const arg of process.argv.slice(2)) { if (arg.startsWith("/")) { @@ -1032,6 +1039,66 @@ expect "the menu: only those by hand, not clickable" "mb --print | grep '^⟳'" "$(printf '⟳ %s Agents Need /reload\ttheir dingger extension is too old to reload them\t(disabled)' "$BY_HAND")" t kill-session -t ext +# --- 24. forgotten drafts ----------------------------------------------------- +# pi's extension reports the draft in its input box; dingger marks it where +# the user is not looking, and keeps it in a file per session that comes back +# with the session. (Typing into a real pi: tests/pi-live.sh.) +echo "24. forgotten drafts" +draft_report() { # pane draft + python3 - "$HOOK" "$1" "$2" <<'PY' +import json, socket, sys +params = {"pane_id": sys.argv[2], "agent": "pi", "draft": sys.argv[3]} +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 dr -n d1 -c "$DIR" "$DIR/bin/pi" +t new-window -d -t dr: -n d2 -c "$DIR" "$DIR/bin/pi" +t new-window -d -t dr: -n away -c "$DIR" "sleep 100000" +D1=$(t display -p -t dr:d1 '#{pane_id}'); D2=$(t display -p -t dr:d2 '#{pane_id}') +look "$(t display -p -t dr:away '#{pane_id}')" +for p in "$D1" "$D2"; do report "$p" working ""; report "$p" idle ""; expect "dr: $p done" "opt $p @agent_state" done; done +look "$D1"; sleep 1; look "$D2"; sleep 1; look "$(t display -p -t dr:away '#{pane_id}')" +expect "dr: both idle" "opt $D1 @agent_state; opt $D2 @agent_state" "idle +idle" +draft_report "$D2" "$(printf 'fix the\n sidecar;')" +expect "a draft, as one line, its trailing ; kept" "opt $D2 @dingger_draft" "fix the sidecar;" +expect "pane options after it still written" "opt $D2 @agent_state" idle +drow() { mb --print | grep -F "dr · $1 ·" | cut -f2; } +expect "menu: marked ✎" "drow d2" "✎ pi" +expect "first among idle agents" "mb --print | grep -F 'dr · d' | cut -f3 | cut -d' ' -f3 | tr '\n' ' '" "d2 d1 " +expect "search finds it by its draft" "mb --print-search sidecar | head -1 | cut -f3" "dr · d2 · pi" +expect "dingger status marks it" "$DINGGER status | grep -F 'dr · d2' | grep -c '✎ '" 1 +look "$D2" +expect "where you look, no mark: you may be typing it" "drow d2" "pi" +expect "nor in dingger status" "$DINGGER status | grep -F 'dr · d2' | grep -c '✎ '" 0 +look "$D1" +draft_report "$D2" "" +expect "a cleared draft: gone" "opt $D2 @dingger_draft" "" +expect "menu: no mark" "drow d2" "pi" +draft_report "$D2" ";" +expect "a draft of just ;" "opt $D2 @dingger_draft" ";" +report "$D2" working "" +expect "a report without a draft keeps it" "opt $D2 @dingger_draft" ";" +if command -v node >/dev/null; then + DRAFTS="$(dirname "$HOOK")/drafts" + EDITOR_TEXT="half a thought" SESSION_FILE="$DIR/sessions/s1.jsonl" iext "$D1" session_start + expect "the extension reports its draft" "opt $D1 @dingger_draft" "half a thought" + expect "and keeps it for the session" "cat $DRAFTS/s1.jsonl.draft" "half a thought" + : >"$EXTLOG"; SESSION_FILE="$DIR/sessions/s1.jsonl" iext "$D1" session_start + expect "the session again: its draft back in the input box" "grep '^editor' $EXTLOG" "editor half a thought" + expect "and reported" "opt $D1 @dingger_draft" "half a thought" + : >"$EXTLOG"; EDITOR_TEXT="typed meanwhile" SESSION_FILE="$DIR/sessions/s1.jsonl" iext "$D1" session_start + expect "an input box with text keeps it" "grep -c '^editor' $EXTLOG" 0 + expect "and that is the draft now" "cat $DRAFTS/s1.jsonl.draft" "typed meanwhile" + : >"$EXTLOG"; SESSION_FILE="$DIR/sessions/s2.jsonl" iext "$D1" session_start + expect "another session: none of it" "grep -c '^editor' $EXTLOG" 0 + expect "dr: no draft reported" "opt $D1 @dingger_draft" "" +fi +t kill-session -t dr + # 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 4ea4caf..6dff42f 100755 --- a/tests/pi-live.sh +++ b/tests/pi-live.sh @@ -169,6 +169,10 @@ expect "and the turn ends" "st agent" done 80 t send-keys -t main:agent -l "half typed" sleep 0.5 expect "a draft: it refuses" "ask_reload" '{"ok":false,"reason":"draft"}' +draft() { t show -pv -t main:agent @dingger_draft 2>/dev/null; } +draft_file() { echo "$D/drafts/$(basename "$(t show -pv -t main:agent @agent_session)").draft"; } +expect "the draft reaches dingger" "draft" "half typed" 30 +expect "and is kept for the session" "cat \"\$(draft_file)\"" "half typed" 30 # Through dingger: refused for the draft, retried until it can. "$DINGGER" reload-agents >"$D/reload.out" expect "reload-agents: not yet" "head -1 $D/reload.out" "reloaded 0 of 1 agent on an old dingger extension" @@ -176,6 +180,8 @@ expect "it says why it waits" "sed -n 2p $D/reload.out" "the other 1 reload once expect "the daemon was told no: a draft" "sed 's/\x1b\[[0-9;]*m//g' $D/daemon.log | grep -c 'reload: not now target=main:agent reason=draft'" 1 30 expect "the draft is untouched" "t capture-pane -p -t main:agent | grep -c 'half typed'" 1 for _ in $(seq 10); do t send-keys -t main:agent BSpace; done +expect "deleted: no draft" "draft" "" 30 +expect "nor its file" "[ -e \"\$(draft_file)\" ] && echo left || echo gone" gone 30 expect "once the input box is empty: reloaded" "t show -pv -t main:agent @dingger_extension 2>/dev/null" "$CURRENT" 150 expect "by the daemon" "reloads reloading" 1 30 expect "a new instance (a new socket)" "[ \"\$(ctl)\" != \"$SOCK1\" ] && [ -S \"\$(ctl)\" ] && echo yes" yes @@ -183,9 +189,13 @@ expect "the old socket is gone" "[ -e \"$SOCK1\" ] && echo left || echo gone" go expect "nothing left to reload" "t show -gv @dingger_outdated 2>/dev/null" 0 30 expect "still done (unseen)" "st agent" done expect "no message reached the model" "grep -c dingger-reload \"\$(t show -pv -t main:agent @agent_session)\"" 0 -type_line agent "slow 1 after" +t send-keys -t main:agent -l "slow 1 after" +expect "typed, then a pause: a draft" "draft" "slow 1 after" 30 +t send-keys -t main:agent Enter expect "it still works: working" "st agent" working 50 expect "and done" "st agent" done 60 +expect "a message sent is no draft" "draft" "" 30 +expect "nor kept" "[ -e \"\$(draft_file)\" ] && echo left || echo gone" gone 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 @@ -200,6 +210,10 @@ echo "3. restore" SESSION=$(t show -pv -t main:agent @agent_session) latest() { ls "$D"/state/snapshots/*.json 2>/dev/null | tail -1; } expect "the snapshot has the agent's session" "grep -c \"\\\"session\\\": \\\"$SESSION\\\"\" \"\$(latest)\" 2>/dev/null" 1 80 +# A draft left behind when tmux goes away. +# (`\;`: send-keys would end its command at a trailing ;) +t send-keys -t main:agent -l 'an unsent idea\;' +expect "a draft before the crash" "t show -pv -t main:agent @dingger_draft 2>/dev/null" "an unsent idea;" 30 t kill-server expect "the layout becomes the restore candidate" "python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[\"status\"])' $D/state/restore.json 2>/dev/null" offered 80 "$DINGGER" restore >"$D/restore.out" 2>&1 @@ -208,10 +222,17 @@ expect "resumed 1 of 1 agents" "grep -c '^resumed 1 of 1 agents' $D/restore.out" 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 +expect "and its draft, back in the input box" "t capture-pane -p -t main:agent | grep -c '^an unsent idea;'" 1 50 +expect "reported again" "t show -pv -t main:agent @dingger_draft 2>/dev/null" "an unsent idea;" 30 echo "4. exit" +LAST_DRAFT=$(draft_file) +expect "its draft is kept" "cat $LAST_DRAFT" "an unsent idea;" +# Deleted, then quit at once: before the input box is read again. +for _ in $(seq 20); do t send-keys -t main:agent BSpace; done t send-keys -t main:agent C-d expect "the agent is gone from the pane" "t show -pv -t main:agent @agent 2>/dev/null" "" 100 +expect "a draft deleted before quitting is not kept" "[ -e $LAST_DRAFT ] && echo left || echo gone" gone 30 echo echo "$PASSES passed, $FAILS failed" |
