pi-note 0.13.2 → 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
 
@@ -186,32 +184,26 @@ When allowed by `taskOfferPolicy`, the agent uses `pinote_propose` instead of
186
184
  asking in chat. The pin footer shows a pending suggestion, distinct from a selected task:
187
185
 
188
186
  ```text
189
- 📌 Suggested: ✓ Add ✕ Dismiss · [tag] Task title...
187
+ 📌 ✕ + · [tag] Task title...
188
+ 📌 ✓ · [tag] Task title...
190
189
  ```
191
190
 
192
- ![Suggested task beside the footer pin](../docs/images/pi-task-suggestion.png)
193
-
194
- - **✓ Add** creates the note, starts it, and selects it for this session.
195
- - **✕ Dismiss** hides the suggestion without creating a note; further offers are suppressed
196
- until a new session or reload.
197
-
198
- The configured `footer.titleWidth` bounds the whole suggestion (default 60 columns).
199
- Controls precede the variable text so long titles cannot push them off the right edge.
200
- The tag is omitted when space is tight; the first-line title truncates with `...`.
201
- Smaller budgets shorten controls to ✓ and ✕; tiny budgets show only the suggestion cue.
202
- Pi can still clip the combined status row on narrow terminals; use the commands below
203
- if the controls are not visible. Accepting replaces the suggestion with the selected task.
204
-
205
- Neither action submits input or starts an agent turn; editor drafts stay unchanged.
206
- The icons reuse the [Kitty preview handler](#display-only-preview), with no additional
207
- configuration. Fullscreen Kitty uses **Ctrl+Shift+click**. Keyboard alternatives are
208
- `/pi-note-yes` and `/pi-note-no`, including when clickable links are unavailable.
209
- An existing suggestion is retained rather than replaced. Selection, tree navigation,
210
- reload and shutdown invalidate its links; repeated acceptance cannot duplicate a note.
211
- A busy Pinote operation blocks acceptance; retry after it finishes. On an error, check
212
- `/pi-note` before retrying, because a write may already have committed.
213
- Noninteractive agents still ask in chat before using `pinote_add` with `select: true`.
214
- An existing selection is not replaced unless the user asks to switch. Reuse a saved
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
215
207
  tag name when it fits.
216
208
  Task text starts with a short, action-oriented title (aim for at most 60 characters).
217
209
  Put context, URLs, commands, and acceptance criteria after a blank line; the title
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 = {
@@ -228,7 +228,7 @@ export default function (pi: ExtensionAPI) {
228
228
  const current = task(await run(["agent", "get", String(id)], signal, canUse));
229
229
  if (!canUse() || selectedId !== id) return null;
230
230
  if (!current || current.id !== id || !["active", "in_progress"].includes(current.state)) {
231
- if (canUse() && selectedId === id) remember(null);
231
+ if (!pending && canUse() && selectedId === id) remember(null);
232
232
  return null;
233
233
  }
234
234
  return current;
@@ -267,11 +267,14 @@ export default function (pi: ExtensionAPI) {
267
267
  const previewId = current?.id ?? null;
268
268
  const previewBranch = branchEpoch;
269
269
  previewBridge?.setTask(previewId, (signal) => alive && generation === epoch && previewBranch === branchEpoch &&
270
- selectedId === previewId ? preview(ctx, signal) : Promise.resolve(false));
271
- const url = previewBridge?.url();
272
- // Truncate before linking so the task label, including configured overflow, is clickable.
273
- const label = current ? truncateToWidth(`${noteIcon} ${taskTag(current)} ${firstLine(current)}`, footerConfig.titleWidth, "...") : undefined;
274
- ctx.ui.setStatus("pinote", current ? (label && url ? `\x1b]8;;${url}\x07${label}\x1b]8;;\x07` : label) : suggestionStatus(ctx));
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));
275
278
  watcher.update(ctx, current);
276
279
  }
277
280
  } catch {
@@ -327,6 +330,45 @@ export default function (pi: ExtensionAPI) {
327
330
  if (pending === operation) pending = undefined;
328
331
  }
329
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
+ });
330
372
  pi.registerCommand("pi-note-preview", {
331
373
  description: "Display the selected task locally without sending it to the model",
332
374
  handler: async (args, ctx) => {
@@ -649,7 +691,7 @@ export default function (pi: ExtensionAPI) {
649
691
  }
650
692
  };
651
693
  pi.registerCommand("pi-note-yes", {
652
- description: "Create, start and select the suggested task (same as ✓)",
694
+ description: "Create, start and select the suggested task (same as +)",
653
695
  handler: async (args, ctx) => {
654
696
  if (!ctx.hasUI || ctx.mode !== "tui") return;
655
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");
@@ -666,7 +708,7 @@ export default function (pi: ExtensionAPI) {
666
708
  name: "pinote_propose",
667
709
  label: "Pinote suggest task",
668
710
  promptGuidelines: createGuidance,
669
- 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.",
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.",
670
712
  parameters: Type.Object({
671
713
  text: Type.String({ minLength: 1, description: "Short action-oriented title, then optional details after a blank line." }),
672
714
  tag: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
@@ -717,7 +759,7 @@ export default function (pi: ExtensionAPI) {
717
759
  pending = operation;
718
760
  refreshSerial++;
719
761
  const canAct = () => alive && generation === epoch && branch === branchEpoch && pending === operation && selectedId === previousSelection;
720
- // 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.
721
763
  clearSuggestion(ctx);
722
764
  try {
723
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.2",
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] };
@@ -6,27 +6,36 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
6
6
  const pin = readFileSync(new URL("./icons/note.txt", import.meta.url), "utf8").trim();
7
7
  const check = readFileSync(new URL("./icons/check.txt", import.meta.url), "utf8").trim();
8
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";
9
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">;
10
15
 
11
- export function renderSuggestion(
12
- task: { text: string; tag?: string }, width: number, theme: Pick<Theme, "fg">,
13
- urls: { yes?: string; no?: string },
14
- ): string {
15
- const cue = `${pin} ${theme.fg("accent", "Suggested:")}`;
16
- const actions = (compact: boolean) => `${theme.fg("success", link(compact ? check : `${check} Add`, urls.yes))} ${theme.fg("muted", link(compact ? cross : `${cross} Dismiss`, urls.no))}`;
17
- let prefix = `${cue} ${actions(false)}`;
18
- if (visibleWidth(prefix) > width) prefix = `${cue} ${actions(true)}`;
19
- // Never show unexplained action icons when even the suggestion cue cannot fit.
20
- if (visibleWidth(prefix) > width) return truncateToWidth(cue, 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, "");
21
19
  const remaining = width - visibleWidth(prefix) - 3; // separator: " · "
22
- if (remaining < 4) return prefix;
20
+ if (remaining < 4) return prefix.trimEnd();
23
21
  const clean = (value: string) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f-\x9f]/gu, " ").replace(/\s+/gu, " ").trim();
24
22
  const tag = `[${clean(task.tag ?? "Untagged")}]`;
25
23
  const title = clean(task.text.split("\n", 1)[0]);
26
- // Drop the tag before sacrificing the title to a long tag or a small budget.
27
24
  const tagWidth = visibleWidth(tag) + 1;
28
25
  const label = remaining >= tagWidth + Math.min(12, visibleWidth(title))
29
26
  ? `${theme.fg("muted", tag)} ${theme.fg("text", truncateToWidth(title, remaining - tagWidth, "..."))}`
30
27
  : theme.fg("text", truncateToWidth(title, remaining, "..."));
31
- return `${prefix}${theme.fg("muted", " · ")}${label}`;
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);
32
41
  }