mark forgotten drafts in pi agents' input boxes, and keep them across a crash or restore
@@ -53,7 +53,8 @@ - **Jump shortcut**: **⌃⌥⌘U** jumps to the top waiting agent from anywhere,
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 @@ marked: a working, blocked, or done one is left alone. Claude Code has no
`/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 @@ manifests, for agents without hooks.
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 @@ conversation text in session files (needs an index).
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:
@@ -111,6 +111,55 @@ }
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 @@ message,
importance, extension: extensionVersion, ...(controlListening ? { control: controlPath } : {}), + draft: draft.slice(0, reportedDraftChars), seq: Date.now() * 1000, ...(sessionFile ? { agent_session_path: sessionFile } : {}), },@@ -269,10 +319,37 @@ }
}, }); - // 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 @@ control.unref();
} 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 @@ startControl();
// 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 @@ latestCtx = ctx;
agentActive = true; updateSessionFile(ctx); publishState(); + // The prompt left the input box. + checkDraftSoon(); }); pi.on("agent_settled", (_event, ctx) => {@@ -389,5 +489,7 @@ }
agentActive = false; publishState(); + // Escape puts queued messages back into the input box. + checkDraftSoon(); }); }
@@ -27,6 +27,9 @@ //! "reload"}`; it answers whether it does): `dingger reload-agents` sends
//! `{"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 @@ importance: Option<Importance>,
/// 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 @@ #[serde(default)]
extension: Option<String>, #[serde(default)] control: Option<String>, + #[serde(default)] + draft: Option<String>, } fn handle_stream(stream: UnixStream) {@@ -231,6 +238,7 @@ session: params.agent_session_path.or(params.agent_session_id),
importance, extension: params.extension, control: params.control, + draft: params.draft, }; record(params.pane_id, state, reported); wake();@@ -244,6 +252,22 @@ session: Option<String>,
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 @@ }
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.@@ -326,6 +353,12 @@
/// The version of the pane's extension and its control socket, if reported. 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@@ -405,6 +438,21 @@ record("%t4".into(), None, Reported { importance: Some(Importance::Low), ..Default::default() });
assert_eq!(extension("%t4"), (Some("v1".into()), Some("/s/pi-1.sock".into()))); record("%t4".into(), None, Reported { extension: Some("v2".into()), ..Default::default() }); assert_eq!(extension("%t4").0.as_deref(), Some("v2")); + } + + #[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]
@@ -443,7 +443,8 @@ Err("not marked: the daemon did not apply it".into())
} /// 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 @@ }
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 @@ println!("{:<8} {:<width$} {:<7} NAME", "STATUS", "WHERE", "AGENT");
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(()) }
@@ -32,6 +32,8 @@ /// `@dingger_importance`: unset for normal.
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 @@ "#{@agent_name}\t",
"#{@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 @@ written_session: fields[18].to_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 @@
/// 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> {
@@ -26,6 +26,7 @@ //! - `@dingger_ack` one-shot "the user jumped here" event from `dingger focus`
//! - `@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 @@ }
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 @@ change("@dingger_extension", &pane.written_extension, String::new());
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 @@ Status::Idle | Status::Unknown => {}
} // 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 @@ ("done old", Status::Done, Some(30)),
("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,12 +845,33 @@ ("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)); + 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, ["blocked low", "done new high", "done old", "done older low", "working high", "working new"], "status first, then importance, then time" + ); + } + + #[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" ); }
@@ -285,13 +285,15 @@ // 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. +// 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 @@ });
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("/")) {@@ -1031,6 +1038,66 @@ expect "the current version: not counted" "gopt @dingger_outdated" 0
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
@@ -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 idle and off screen, with an empty input box"
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" gone
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" 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 +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"