aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-30 18:59:43 +0200
committerIgor Soarez <igor@soarez.org>2026-10-30 18:59:43 +0200
commit959f0d46cac1a09d0821da599d8dff54d1147892 (patch)
tree499b38261ccfdc88f60ea8cbac7d279f202709c1
parentb0cfd10b80443d3f67d263ff4db177bb53cc90ed (diff)
mark forgotten drafts in pi agents' input boxes, and keep them across a crash or restore
-rw-r--r--README.md22
-rw-r--r--ROADMAP.md6
-rw-r--r--crates/dinggerd/assets/pi/pi-agent-state.ts104
-rw-r--r--crates/dinggerd/src/hooks.rs48
-rw-r--r--crates/dinggerd/src/main.rs9
-rw-r--r--crates/dinggerd/src/tmux.rs31
-rw-r--r--crates/dinggerd/src/watcher.rs64
-rw-r--r--menubar/model.swift45
-rw-r--r--menubar/panel.swift63
-rw-r--r--menubar/search.swift15
-rwxr-xr-xtests/e2e.sh73
-rwxr-xr-xtests/pi-live.sh23
12 files changed, 447 insertions, 56 deletions
diff --git a/README.md b/README.md
index 56c5a11..9f3a9af 100644
--- a/README.md
+++ b/README.md
@@ -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`.
diff --git a/ROADMAP.md b/ROADMAP.md
index d4d125f..d8f3563 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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"