pi-note 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,9 +68,7 @@ task, so existing values can be configured without creating another task.
68
68
  Type in the task picker to filter by text, ID, tag, or state (all words must match).
69
69
  Use ↑/↓ and Enter to select, or Esc to cancel. Rows show **●** for in progress or
70
70
  **○** for active, followed by the tag (`[Untagged]` when absent).
71
- The normal Pi status area shows `📌 [tag] Task title` without replacing other footers.
72
- The footer omits the task ID and state and shows only the first line, truncated to the configured width (default 60 terminal columns including the pin and tag). Overflow ends with `...`.
73
- The pin glyph is bundled in `icons/note.txt`; it uses terminal fonts, not a Nerd Font or icon theme.
71
+ The normal Pi status area shows selected-task controls and title without replacing other footers. The footer omits the task ID and state and shows only the first line, bounded by `footer.titleWidth` (default 60 terminal columns including the pin, controls, tag and title). Overflow ends with `...`. The title remains clickable for preview; task controls are separate. At very small widths controls are hidden (pin only). The pin and controls are bundled in `icons/note.txt`, `icons/check.txt`, `icons/cross.txt`, and `icons/add.txt`; they use terminal fonts, not a Nerd Font or icon theme.
74
72
 
75
73
  ### Display-only preview
76
74
 
@@ -171,8 +169,8 @@ user-level configuration only; project-local files are not read.
171
169
  populating relevant values with `pinote_update_current`. Empty/missing values
172
170
  stay hidden; this tool does not edit configuration.
173
171
  - `pinote_tags`: list saved tag names.
174
- - `pinote_propose`: show a nonmodal suggested-task bar below the input without
175
- creating a note. Optional `tag`; full text is retained, but the bar shows only
172
+ - `pinote_propose`: show a suggested task beside the pin icon in the footer without
173
+ creating a note. Optional `tag`; full text is retained, but the footer shows only
176
174
  its title. Requires a TUI with no selected task. The agent continues your work
177
175
  while the suggestion awaits your choice.
178
176
  - `pinote_add`: create an active task. Optional `tag`. `select: true` starts and
@@ -183,22 +181,29 @@ and removes `pinote_tag`, without aliases. Agents cannot read/update tasks by ID
183
181
  or retag existing tasks; use `/pi-note` to select an existing task.
184
182
 
185
183
  When allowed by `taskOfferPolicy`, the agent uses `pinote_propose` instead of
186
- asking in chat. A bar below the input shows `[tag] Task title ✓ ✕`:
187
-
188
- - **✓** creates the note, starts it, and selects it for this session.
189
- - **✕** hides the suggestion without creating a note; further offers are suppressed
190
- until a new session or reload.
191
-
192
- Neither action submits input or starts an agent turn; editor drafts stay unchanged.
193
- The icons reuse the [Kitty preview handler](#display-only-preview), with no additional
194
- configuration. Fullscreen Kitty uses **Ctrl+Shift+click**. Keyboard alternatives are
195
- `/pi-note-yes` and `/pi-note-no`, including when clickable links are unavailable.
196
- An existing suggestion is retained rather than replaced. Selection, tree navigation,
197
- reload and shutdown invalidate its links; repeated acceptance cannot duplicate a note.
198
- A busy Pinote operation blocks acceptance; retry after it finishes. On an error, check
199
- `/pi-note` before retrying, because a write may already have committed.
200
- Noninteractive agents still ask in chat before using `pinote_add` with `select: true`.
201
- An existing selection is not replaced unless the user asks to switch. Reuse a saved
184
+ asking in chat. The pin footer shows a pending suggestion, distinct from a selected task:
185
+
186
+ ```text
187
+ 📌 ✕ + · [tag] Task title...
188
+ 📌 ✓ · [tag] Task title...
189
+ ```
190
+
191
+ ![Suggested task controls](../docs/images/pi-task-suggestion.png)
192
+ ![Selected task with Done control](../docs/images/pi-task-selected.png)
193
+
194
+ - Muted **✕** dismisses without creating a note; further offers are suppressed until a new session or reload.
195
+ - Accent **+** creates, starts, and selects the suggested task.
196
+ - Green **✓** completes the selected task and clears the selection on success.
197
+
198
+ Done occupies the former ✕ position, not the + position, so clicking Add twice cannot accidentally complete the new task. The former + cell stays blank. The selected task's title is a separate preview link.
199
+
200
+ The configured `footer.titleWidth` bounds the entire row (default 60 columns). Controls stay first; the tag is dropped before truncating the first-line title with `...`. Tiny widths hide controls (pin only). Pi can still clip the combined status row on narrow terminals.
201
+
202
+ Controls use the [Kitty preview handler](#display-only-preview), with no additional configuration. Fullscreen Kitty uses **Ctrl+Shift+click**. Keyboard alternatives are `/pi-note-no`, `/pi-note-yes`, and `/pi-note-done`. No action submits input or starts an agent turn; editor drafts stay unchanged.
203
+
204
+ Completion requires an idle Pi session and no pending Pinote operation. It rejects stale selected-task links and externally changed revisions. Accepted mutations drain even if the helper disconnects. On an error, check `/pi-note` before retrying, because a write may already have committed.
205
+
206
+ An existing suggestion is retained rather than replaced. Selection, tree navigation, reload and shutdown invalidate its links; repeated acceptance cannot duplicate a note. A busy Pinote operation blocks acceptance. Noninteractive agents still ask in chat before using `pinote_add` with `select: true`. An existing selection is not replaced unless the user asks to switch. Reuse a saved
202
207
  tag name when it fits.
203
208
  Task text starts with a short, action-oriented title (aim for at most 60 characters).
204
209
  Put context, URLs, commands, and acceptance criteria after a blank line; the title
@@ -216,7 +221,7 @@ Values are Markdown strings; no fields are required. `PR` enables the watcher be
216
221
  `Bar` chooses extra footer fields unless configuration overrides it:
217
222
 
218
223
  ```markdown
219
- # Agent
224
+ ## Agent
220
225
  PR: [Task selection #42](https://github.com/org/repo/pull/42)
221
226
  Dashboard: [Metrics](http://127.0.0.1:3000/d/app)
222
227
  Next: Address review comments
package/icons/add.txt ADDED
@@ -0,0 +1 @@
1
+ +
package/index.ts CHANGED
@@ -10,7 +10,7 @@ import { FooterSettings, TaskMenu, withSettingsTab } from "./footer-settings.ts"
10
10
  import { TaskPicker, taskState, taskTag } from "./task-picker.ts";
11
11
  import { bundledCLIVersion, cliMenu, detectCLI, setupCLI, setupHint, versionAtLeast, type CLIAction } from "./setup.ts";
12
12
  import { createPreviewBridge } from "./preview-bridge.ts";
13
- import { renderSuggestion } from "./task-suggestion.ts";
13
+ import { renderSuggestion, renderSelectedTask } from "./task-suggestion.ts";
14
14
 
15
15
  const handoffGuidance = "Keep agent notes to at most three short bullets total: outcome, blocker, next action, only when relevant. Replace stale notes; omit narration, repeated task text, and routine test logs. Keep a GitHub pull request in the PR field. Read pinote_fields to learn the user's globally configured footer field names and presentation. Populate those fields with meaningful task values when relevant using pinote_update_current; fields without values stay hidden. Footer fields and widths are chosen by the user's pi-note.json config. Set configured fields with Markdown values; links are clickable. When footer.fields is unset, Bar selects labels, one per line. Do not list PR in Bar. Remove a field to hide it. Do not modify config without user approval.";
16
16
  const offerGuidance = {
@@ -18,7 +18,7 @@ const offerGuidance = {
18
18
  "github-remote": "When the user gives work and no pinote task is selected, first verify with Git that the current repository has a remote whose URL host is github.com (HTTPS or SSH). Only then use pinote_propose to show one suggested `[tag] task` in the bottom bar instead of asking in chat. In noninteractive modes, ask before creating it. Local paths, other hosts, and GitHub-looking URL paths do not qualify. If no GitHub remote is verified, do not offer a task; explicit user requests to create one remain allowed.",
19
19
  never: "Do not offer to create a pinote task. Create one only when the user explicitly requests it.",
20
20
  };
21
- const consentGuidance = "The suggestion bar's check creates, starts and selects the note; its cross dismisses it. Continue the requested work while the suggestion is pending; do not ask again in chat or create a note yourself. On a chat no, continue without a note; on a chat yes, call pinote_add with select true. Never add or select without consent. If a task is already selected, do not replace it unless the user asks to switch.";
21
+ const consentGuidance = "The suggestion bar's plus creates, starts and selects the note; its cross dismisses it. Continue the requested work while the suggestion is pending; do not ask again in chat or create a note yourself. On a chat no, continue without a note; on a chat yes, call pinote_add with select true. Never add or select without consent. If a task is already selected, do not replace it unless the user asks to switch.";
22
22
  const titleGuidance = "Start task text with a short, action-oriented summary (aim for at most 60 characters). Put context, URLs, commands, and acceptance criteria after a blank line. Never put implementation details in the title.";
23
23
  const tagGuidance = "Reuse a pinote_tags name when it fits. One tag; case-sensitive, trimmed, at most 64 characters. Tags are set when creating tasks with pinote_add; agent tools cannot retag existing tasks. Do not use pinote_update_current for tags or task text.";
24
24
  type Task = {
@@ -131,7 +131,7 @@ export default function (pi: ExtensionAPI) {
131
131
  let offerDeclined = false;
132
132
  const clearSuggestion = (ctx: ExtensionContext) => {
133
133
  previewBridge?.clearSuggestion();
134
- if (suggestion && ctx.hasUI && ctx.mode === "tui") ctx.ui.setWidget("pinote-suggestion", undefined);
134
+ if (suggestion && ctx.hasUI && ctx.mode === "tui") ctx.ui.setStatus("pinote", undefined);
135
135
  suggestion = undefined;
136
136
  };
137
137
  let alive = true;
@@ -142,6 +142,11 @@ export default function (pi: ExtensionAPI) {
142
142
  let activeContext: ExtensionContext | undefined;
143
143
  let cliState: CLIAction | undefined;
144
144
  let footerConfig = { ...defaultFooterConfig };
145
+ const suggestionStatus = (ctx: ExtensionContext) => suggestion ? renderSuggestion(
146
+ suggestion, footerConfig.titleWidth, ctx.ui.theme, {
147
+ yes: previewBridge?.suggestionUrl("yes"), no: previewBridge?.suggestionUrl("no"),
148
+ },
149
+ ) : undefined;
145
150
  const checkCLI = async (ctx: ExtensionContext, suggest = false) => {
146
151
  if (!ctx.hasUI || ctx.mode !== "tui" || setupAbort) return;
147
152
  const generation = epoch;
@@ -223,7 +228,7 @@ export default function (pi: ExtensionAPI) {
223
228
  const current = task(await run(["agent", "get", String(id)], signal, canUse));
224
229
  if (!canUse() || selectedId !== id) return null;
225
230
  if (!current || current.id !== id || !["active", "in_progress"].includes(current.state)) {
226
- if (canUse() && selectedId === id) remember(null);
231
+ if (!pending && canUse() && selectedId === id) remember(null);
227
232
  return null;
228
233
  }
229
234
  return current;
@@ -262,17 +267,20 @@ export default function (pi: ExtensionAPI) {
262
267
  const previewId = current?.id ?? null;
263
268
  const previewBranch = branchEpoch;
264
269
  previewBridge?.setTask(previewId, (signal) => alive && generation === epoch && previewBranch === branchEpoch &&
265
- selectedId === previewId ? preview(ctx, signal) : Promise.resolve(false));
266
- const url = previewBridge?.url();
267
- // Truncate before linking so the task label, including configured overflow, is clickable.
268
- const label = current ? truncateToWidth(`${noteIcon} ${taskTag(current)} ${firstLine(current)}`, footerConfig.titleWidth, "...") : undefined;
269
- ctx.ui.setStatus("pinote", label && url ? `\x1b]8;;${url}\x07${label}\x1b]8;;\x07` : label);
270
+ selectedId === previewId ? preview(ctx, signal) : Promise.resolve(false), current ? {
271
+ revision: current.updated_at,
272
+ invoke: () => alive && generation === epoch && previewBranch === branchEpoch && selectedId === previewId
273
+ ? completeTask(ctx, current) : Promise.resolve(false),
274
+ } : undefined);
275
+ ctx.ui.setStatus("pinote", current ? renderSelectedTask(current, footerConfig.titleWidth, ctx.ui.theme, {
276
+ preview: previewBridge?.url(), done: previewBridge?.doneUrl(),
277
+ }) : suggestionStatus(ctx));
270
278
  watcher.update(ctx, current);
271
279
  }
272
280
  } catch {
273
281
  if (alive && generation === epoch && serial === refreshSerial) {
274
282
  previewBridge?.invalidate();
275
- ctx.ui.setStatus("pinote", `${noteIcon} Pinote unavailable · ${cliState === "upgrade" ? "/pi-note-upgrade" : cliState === "setup" ? "/pi-note-setup" : "/pi-note"}`);
283
+ ctx.ui.setStatus("pinote", suggestionStatus(ctx) ?? `${noteIcon} Pinote unavailable · ${cliState === "upgrade" ? "/pi-note-upgrade" : cliState === "setup" ? "/pi-note-setup" : "/pi-note"}`);
276
284
  }
277
285
  }
278
286
  };
@@ -322,6 +330,45 @@ export default function (pi: ExtensionAPI) {
322
330
  if (pending === operation) pending = undefined;
323
331
  }
324
332
  };
333
+ const completeTask = async (ctx: ExtensionContext, displayed?: Task): Promise<boolean> => {
334
+ if (!alive || !ctx.hasUI || ctx.mode !== "tui" || !ctx.isIdle() || pending || setupAbort || !validId(selectedId)) return false;
335
+ const generation = epoch;
336
+ const branch = branchEpoch;
337
+ const id = selectedId;
338
+ const operation = Symbol();
339
+ pending = operation;
340
+ refreshSerial++;
341
+ const canAct = () => alive && generation === epoch && branch === branchEpoch && selectedId === id && ctx.isIdle();
342
+ let wrote = false;
343
+ try {
344
+ const current = displayed ?? await currentTask(undefined, canAct);
345
+ if (!current || current.id !== id || !canAct()) return false;
346
+ const completed = requiredTask(await run([
347
+ "agent", "done", String(id), "--expected-updated-at", current.updated_at,
348
+ ], undefined, canAct), id);
349
+ wrote = true;
350
+ if (completed.state !== "done") throw new Error(compatible);
351
+ if (!canAct()) throw new Error("The session or selection changed. Completion may have committed; read the task before retrying.");
352
+ remember(null, branch);
353
+ ctx.ui.notify(`Pinote #${id} completed.`, "info");
354
+ return true;
355
+ } catch (error) {
356
+ if (alive && generation === epoch) ctx.ui.notify(
357
+ `Pinote completion: ${clean(String(error))}${wrote ? " Check the task before retrying." : ""}`, "error");
358
+ return false;
359
+ } finally {
360
+ if (pending === operation) pending = undefined;
361
+ if (alive && generation === epoch) await refresh(ctx);
362
+ }
363
+ };
364
+ pi.registerCommand("pi-note-done", {
365
+ description: "Complete the selected task (same as the footer ✓)",
366
+ handler: async (args, ctx) => {
367
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
368
+ if (args.trim()) { ctx.ui.notify("Usage: /pi-note-done", "warning"); return; }
369
+ if (!await completeTask(ctx)) ctx.ui.notify("Select a task and wait until Pi is idle and Pinote is available before completing it.", "warning");
370
+ },
371
+ });
325
372
  pi.registerCommand("pi-note-preview", {
326
373
  description: "Display the selected task locally without sending it to the model",
327
374
  handler: async (args, ctx) => {
@@ -644,7 +691,7 @@ export default function (pi: ExtensionAPI) {
644
691
  }
645
692
  };
646
693
  pi.registerCommand("pi-note-yes", {
647
- description: "Create, start and select the suggested task (same as ✓)",
694
+ description: "Create, start and select the suggested task (same as +)",
648
695
  handler: async (args, ctx) => {
649
696
  if (!ctx.hasUI || ctx.mode !== "tui") return;
650
697
  if (args.trim() || !await respondToSuggestion("yes", ctx)) ctx.ui.notify("No available suggestion, or a Pinote operation is busy. Retry when it finishes.", "warning");
@@ -661,7 +708,7 @@ export default function (pi: ExtensionAPI) {
661
708
  name: "pinote_propose",
662
709
  label: "Pinote suggest task",
663
710
  promptGuidelines: createGuidance,
664
- description: "Show a suggested task in a nonmodal bar below the input, without creating it or asking in chat. ✓ creates, starts and selects it; ✕ dismisses it. Continue the requested work while awaiting consent. Requires an interactive TUI with no selected task. An existing suggestion is retained; a dismissed offer is not repeated. In noninteractive modes ask in chat, then use pinote_add only after consent.",
711
+ description: "Show a suggested task beside the pin icon in the footer, without creating it or asking in chat. + creates, starts and selects it; ✕ dismisses it. Continue the requested work while awaiting consent. Requires an interactive TUI with no selected task. An existing suggestion is retained; a dismissed offer is not repeated. In noninteractive modes ask in chat, then use pinote_add only after consent.",
665
712
  parameters: Type.Object({
666
713
  text: Type.String({ minLength: 1, description: "Short action-oriented title, then optional details after a blank line." }),
667
714
  tag: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
@@ -681,13 +728,7 @@ export default function (pi: ExtensionAPI) {
681
728
  if (suggestion) return result("pending; existing suggestion retained, continue work without asking again");
682
729
  suggestion = { text: params.text, ...(params.tag === undefined ? {} : { tag: params.tag.trim() }) };
683
730
  previewBridge?.setSuggestion((choice) => respondToSuggestion(choice, ctx));
684
- const proposed = suggestion;
685
- ctx.ui.setWidget("pinote-suggestion", (_tui, theme) => ({
686
- render: (width) => [renderSuggestion(proposed, width, theme, {
687
- yes: previewBridge?.suggestionUrl("yes"), no: previewBridge?.suggestionUrl("no"),
688
- })],
689
- invalidate() {},
690
- }), { placement: "belowEditor" });
731
+ ctx.ui.setStatus("pinote", suggestionStatus(ctx));
691
732
  return result("pending; user can click ✓ to add and select or ✕ to dismiss, continue work without asking again");
692
733
  },
693
734
  });
@@ -718,7 +759,7 @@ export default function (pi: ExtensionAPI) {
718
759
  pending = operation;
719
760
  refreshSerial++;
720
761
  const canAct = () => alive && generation === epoch && branch === branchEpoch && pending === operation && selectedId === previousSelection;
721
- // Chat consent consumes the same suggestion as clicking ✓, even if start fails.
762
+ // Chat consent consumes the same suggestion as clicking +, even if start fails.
722
763
  clearSuggestion(ctx);
723
764
  try {
724
765
  const created = requiredTask(await run(argv, signal, canAct, "0.4.0"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/preview-bridge.ts CHANGED
@@ -16,10 +16,11 @@ export function createPreviewBridge() {
16
16
  let taskId: number | null = null;
17
17
  let callback: ((signal: AbortSignal) => Promise<boolean>) | undefined;
18
18
  let nonce: string | undefined;
19
+ let completion: { revision: string; nonce: string; invoke: () => Promise<boolean> } | undefined;
19
20
  let suggestionCallback: ((choice: "yes" | "no") => Promise<boolean>) | undefined;
20
21
  let suggestionNonce: string | undefined;
21
22
  const clients = new Set<Socket>();
22
- const invalidate = () => { taskId = null; callback = undefined; nonce = undefined; };
23
+ const invalidate = () => { taskId = null; callback = undefined; nonce = undefined; completion = undefined; };
23
24
  const invalidateSuggestion = () => { suggestionCallback = undefined; suggestionNonce = undefined; };
24
25
  const cleanup = async () => {
25
26
  ready = false;
@@ -66,6 +67,7 @@ export function createPreviewBridge() {
66
67
  dispatched = true;
67
68
  const input = data.toString("utf8");
68
69
  const previewMatch = /^preview ([0-9a-f]{32}) ([1-9][0-9]*) ([0-9a-f]{32})\n$/u.exec(input);
70
+ const completionMatch = /^done ([0-9a-f]{32}) ([1-9][0-9]*) ([0-9a-f]{32})\n$/u.exec(input);
69
71
  const suggestionMatch = /^(yes|no) ([0-9a-f]{32}) 1 ([0-9a-f]{32})\n$/u.exec(input);
70
72
  const id = previewMatch ? Number(previewMatch[2]) : NaN;
71
73
  if (previewMatch && previewMatch[0] === input && previewMatch[1] === capability && Number.isSafeInteger(id) &&
@@ -78,6 +80,18 @@ export function createPreviewBridge() {
78
80
  () => { if (!client.destroyed) client.end("rejected\n"); });
79
81
  return;
80
82
  }
83
+ if (completionMatch && completionMatch[0] === input && completionMatch[1] === capability &&
84
+ Number(completionMatch[2]) === taskId && completionMatch[3] === completion?.nonce && completion) {
85
+ client.setTimeout(65000);
86
+ const acceptedCompletion = completion;
87
+ const acceptedId = taskId;
88
+ // Accepted writes drain even if the helper disconnects.
89
+ Promise.resolve().then(() => !closed && taskId === acceptedId && completion === acceptedCompletion
90
+ ? acceptedCompletion.invoke() : false)
91
+ .then((accepted) => { if (!client.destroyed) client.end(accepted === true ? "accepted\n" : "rejected\n"); },
92
+ () => { if (!client.destroyed) client.end("rejected\n"); });
93
+ return;
94
+ }
81
95
  if (suggestionMatch && suggestionMatch[0] === input && suggestionMatch[2] === capability && suggestionMatch[3] === suggestionNonce && suggestionCallback) {
82
96
  client.setTimeout(65000);
83
97
  const invoke = suggestionCallback;
@@ -111,10 +125,13 @@ export function createPreviewBridge() {
111
125
  })();
112
126
  return starting;
113
127
  },
114
- setTask(id: number | null, preview: (signal: AbortSignal) => Promise<boolean>): void {
128
+ setTask(id: number | null, preview: (signal: AbortSignal) => Promise<boolean>, done?: { revision: string; invoke: () => Promise<boolean> }): void {
115
129
  if (closed) return;
116
130
  if (id !== null && (!Number.isSafeInteger(id) || id <= 0)) throw new Error("Invalid preview task");
117
131
  if (id !== taskId) nonce = id === null ? undefined : randomBytes(16).toString("hex");
132
+ if (id === null || !done) completion = undefined;
133
+ else completion = { revision: done.revision, invoke: done.invoke,
134
+ nonce: id === taskId && completion?.revision === done.revision ? completion.nonce : randomBytes(16).toString("hex") };
118
135
  taskId = id;
119
136
  callback = id === null ? undefined : preview;
120
137
  },
@@ -123,6 +140,10 @@ export function createPreviewBridge() {
123
140
  if (!ready || closed || !socketPath || taskId === null || !nonce) return;
124
141
  return `pi-note-preview://${basename(socketPath, ".sock")}/${capability}/${taskId}/${nonce}`;
125
142
  },
143
+ doneUrl(): string | undefined {
144
+ if (!ready || closed || !socketPath || taskId === null || !completion) return;
145
+ return `pi-note-preview://${basename(socketPath, ".sock")}/${capability}/${taskId}/${completion.nonce}/done`;
146
+ },
126
147
  setSuggestion(suggestion: (choice: "yes" | "no") => Promise<boolean>): void {
127
148
  if (closed) return;
128
149
  suggestionCallback = suggestion;
package/preview-click.cjs CHANGED
@@ -4,12 +4,12 @@ const net = require('node:net');
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
6
 
7
- const urlPattern = /^pi-note-preview:\/\/([1-9][0-9]*)-([0-9a-f]{8})\/([0-9a-f]{32})\/([1-9][0-9]*)\/([0-9a-f]{32})(?:\/(yes|no))?$/;
7
+ const urlPattern = /^pi-note-preview:\/\/([1-9][0-9]*)-([0-9a-f]{8})\/([0-9a-f]{32})\/([1-9][0-9]*)\/([0-9a-f]{32})(?:\/(yes|no|done))?$/;
8
8
 
9
9
  function parse(value) {
10
10
  if (typeof value !== 'string' || value.length > 180) throw new Error('Preview or suggestion unavailable');
11
11
  const m = urlPattern.exec(value);
12
- if (!m || m[0] !== value || (m[6] && m[4] !== '1')) throw new Error('Preview or suggestion unavailable');
12
+ if (!m || m[0] !== value || (m[6] && m[6] !== 'done' && m[4] !== '1')) throw new Error('Preview or suggestion unavailable');
13
13
  const pid = Number(m[1]), id = Number(m[4]);
14
14
  if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(id) || pid <= 0 || id <= 0) throw new Error('Preview or suggestion unavailable');
15
15
  return { pid, tag: m[2], capability: m[3], id, nonce: m[5], choice: m[6] };
@@ -3,18 +3,39 @@ import { stripVTControlCharacters } from "node:util";
3
3
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
4
  import type { Theme } from "@earendil-works/pi-coding-agent";
5
5
 
6
+ const pin = readFileSync(new URL("./icons/note.txt", import.meta.url), "utf8").trim();
6
7
  const check = readFileSync(new URL("./icons/check.txt", import.meta.url), "utf8").trim();
7
8
  const cross = readFileSync(new URL("./icons/cross.txt", import.meta.url), "utf8").trim();
9
+ const add = readFileSync(new URL("./icons/add.txt", import.meta.url), "utf8").trim();
10
+ // Pi's default footer collapses ASCII spaces; NBSP preserves the control cells.
11
+ const gap = "\u00a0";
8
12
  const link = (label: string, url?: string) => url ? `\x1b]8;;${url}\x07${label}\x1b]8;;\x07` : label;
13
+ type Task = { text: string; tag?: string | null };
14
+ type Colors = Pick<Theme, "fg">;
9
15
 
10
- export function renderSuggestion(
11
- task: { text: string; tag?: string }, width: number, theme: Pick<Theme, "fg">,
12
- urls: { yes?: string; no?: string },
13
- ): string {
14
- const actions = `${theme.fg("success", link(check, urls.yes))} ${theme.fg("error", link(cross, urls.no))}`;
15
- const actionsWidth = visibleWidth(actions);
16
- if (width <= actionsWidth + 2) return truncateToWidth(actions, width, "");
16
+ function renderTask(task: Task, width: number, theme: Colors, prefix: string, previewUrl?: string): string {
17
+ // All controls or none: never let a narrow budget reveal only one pending choice.
18
+ if (visibleWidth(prefix) > width) return truncateToWidth(pin, width, "");
19
+ const remaining = width - visibleWidth(prefix) - 3; // separator: " · "
20
+ if (remaining < 4) return prefix.trimEnd();
17
21
  const clean = (value: string) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f-\x9f]/gu, " ").replace(/\s+/gu, " ").trim();
18
- const label = `[${clean(task.tag ?? "Untagged")}] ${clean(task.text.split("\n", 1)[0])}`;
19
- return `${theme.fg("muted", truncateToWidth(label, Math.max(0, width - actionsWidth - 2), "..."))} ${actions}`;
22
+ const tag = `[${clean(task.tag ?? "Untagged")}]`;
23
+ const title = clean(task.text.split("\n", 1)[0]);
24
+ const tagWidth = visibleWidth(tag) + 1;
25
+ const label = remaining >= tagWidth + Math.min(12, visibleWidth(title))
26
+ ? `${theme.fg("muted", tag)} ${theme.fg("text", truncateToWidth(title, remaining - tagWidth, "..."))}`
27
+ : theme.fg("text", truncateToWidth(title, remaining, "..."));
28
+ return `${prefix}${theme.fg("muted", " · ")}${link(label, previewUrl)}`;
29
+ }
30
+
31
+ export function renderSuggestion(task: Task, width: number, theme: Colors, urls: { yes?: string; no?: string }): string {
32
+ return renderTask(task, width, theme,
33
+ `${pin} ${theme.fg("muted", link(cross, urls.no))}${gap.repeat(2)}${theme.fg("accent", link(add, urls.yes))}`);
34
+ }
35
+
36
+ export function renderSelectedTask(task: Task, width: number, theme: Colors, urls: { preview?: string; done?: string }): string {
37
+ // Reserve the former '+' cell: accepting twice at the same position cannot click Done.
38
+ return renderTask(task, width, theme,
39
+ `${pin} ${theme.fg("success", link(check, urls.done))}${gap.repeat(visibleWidth(cross) + 2 + visibleWidth(add) - visibleWidth(check))}`,
40
+ urls.preview);
20
41
  }