pi-note 0.12.1 → 0.13.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
@@ -170,6 +170,10 @@ user-level configuration only; project-local files are not read.
170
170
  populating relevant values with `pinote_update_current`. Empty/missing values
171
171
  stay hidden; this tool does not edit configuration.
172
172
  - `pinote_tags`: list saved tag names.
173
+ - `pinote_propose`: show a nonmodal suggested-task bar below the input without
174
+ creating a note. Optional `tag`; full text is retained, but the bar shows only
175
+ its title. Requires a TUI with no selected task. The agent continues your work
176
+ while the suggestion awaits your choice.
173
177
  - `pinote_add`: create an active task. Optional `tag`. `select: true` starts and
174
178
  remembers it for this session only; use that only after the user agrees.
175
179
 
@@ -177,10 +181,24 @@ Version 0.7.0 replaces `pinote_get`/`pinote_update` with these current-task tool
177
181
  and removes `pinote_tag`, without aliases. Agents cannot read/update tasks by ID
178
182
  or retag existing tasks; use `/pi-note` to select an existing task.
179
183
 
180
- When allowed by `taskOfferPolicy`, the agent proposes one note as `[tag] text`
181
- and asks before creating it. No continues without a note. Yes calls
182
- `pinote_add` with `select: true`. An existing selection is not replaced unless the
183
- user asks to switch. Reuse a saved tag name when it fits.
184
+ When allowed by `taskOfferPolicy`, the agent uses `pinote_propose` instead of
185
+ asking in chat. A bar below the input shows `[tag] Task title ✓ ✕`:
186
+
187
+ - **✓** creates the note, starts it, and selects it for this session.
188
+ - **✕** hides the suggestion without creating a note; further offers are suppressed
189
+ until a new session or reload.
190
+
191
+ Neither action submits input or starts an agent turn; editor drafts stay unchanged.
192
+ The icons reuse the [Kitty preview handler](#display-only-preview), with no additional
193
+ configuration. Fullscreen Kitty uses **Ctrl+Shift+click**. Keyboard alternatives are
194
+ `/pi-note-yes` and `/pi-note-no`, including when clickable links are unavailable.
195
+ An existing suggestion is retained rather than replaced. Selection, tree navigation,
196
+ reload and shutdown invalidate its links; repeated acceptance cannot duplicate a note.
197
+ A busy Pinote operation blocks acceptance; retry after it finishes. On an error, check
198
+ `/pi-note` before retrying, because a write may already have committed.
199
+ Noninteractive agents still ask in chat before using `pinote_add` with `select: true`.
200
+ An existing selection is not replaced unless the user asks to switch. Reuse a saved
201
+ tag name when it fits.
184
202
  Task text starts with a short, action-oriented title (aim for at most 60 characters).
185
203
  Put context, URLs, commands, and acceptance criteria after a blank line; the title
186
204
  should not contain implementation details. This is agent guidance, not a storage limit.
@@ -0,0 +1 @@
1
+ ✓
@@ -0,0 +1 @@
1
+ ✕
package/index.ts CHANGED
@@ -10,14 +10,15 @@ 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
14
 
14
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.";
15
16
  const offerGuidance = {
16
- always: "When the user gives work and no pinote task is selected, propose one note as `[tag] text` and ask before creating it.",
17
- "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 propose one note as `[tag] text` and 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.",
17
+ always: "When the user gives work and no pinote task is selected, 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.",
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.",
18
19
  never: "Do not offer to create a pinote task. Create one only when the user explicitly requests it.",
19
20
  };
20
- const consentGuidance = "On no, continue without a note. On yes, call pinote_add with select true so it becomes this session's active task. Never add or select without a yes. If a task is already selected, do not replace it unless the user asks to switch.";
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
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.";
22
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.";
23
24
  type Task = {
@@ -127,6 +128,13 @@ export default function (pi: ExtensionAPI) {
127
128
  return view;
128
129
  });
129
130
  let previewBridge: ReturnType<typeof createPreviewBridge> | undefined;
131
+ let suggestion: { text: string; tag?: string } | undefined;
132
+ let offerDeclined = false;
133
+ const clearSuggestion = (ctx: ExtensionContext) => {
134
+ previewBridge?.clearSuggestion();
135
+ if (suggestion && ctx.hasUI && ctx.mode === "tui") ctx.ui.setWidget("pinote-suggestion", undefined);
136
+ suggestion = undefined;
137
+ };
130
138
  let alive = true;
131
139
  let epoch = 0;
132
140
  let refreshSerial = 0;
@@ -197,6 +205,7 @@ export default function (pi: ExtensionAPI) {
197
205
  const remember = (id: number | null, branch = branchEpoch) => {
198
206
  if (!alive || branch !== branchEpoch || selectedId === id) return;
199
207
  previewBridge?.invalidate();
208
+ if (activeContext) clearSuggestion(activeContext);
200
209
  selectedId = id;
201
210
  pi.appendEntry(selectionType, { id });
202
211
  };
@@ -327,6 +336,8 @@ export default function (pi: ExtensionAPI) {
327
336
  setupAbort?.abort();
328
337
  // Retire old polling before bridge teardown/startup yields to its scheduled callbacks.
329
338
  watcher.stop();
339
+ clearSuggestion(activeContext ?? ctx);
340
+ offerDeclined = false;
330
341
  alive = false;
331
342
  epoch++;
332
343
  branchEpoch++;
@@ -368,6 +379,7 @@ export default function (pi: ExtensionAPI) {
368
379
  pi.on("session_tree", async (_event, ctx) => {
369
380
  if (!alive) return;
370
381
  branchEpoch++;
382
+ clearSuggestion(ctx);
371
383
  previewBridge?.invalidate();
372
384
  const generation = branchEpoch;
373
385
  const id = readSelection(ctx);
@@ -380,6 +392,7 @@ export default function (pi: ExtensionAPI) {
380
392
  pi.on("agent_end", async (_event, ctx) => { await refresh(ctx); });
381
393
  pi.on("session_shutdown", async (_event, ctx) => {
382
394
  setupAbort?.abort();
395
+ clearSuggestion(ctx);
383
396
  alive = false;
384
397
  activeContext = undefined;
385
398
  selectedId = null;
@@ -593,6 +606,93 @@ export default function (pi: ExtensionAPI) {
593
606
  return { content: [{ type: "text" as const, text: JSON.stringify(value) }], details: value };
594
607
  },
595
608
  });
609
+ const respondToSuggestion = async (choice: "yes" | "no", ctx: ExtensionContext): Promise<boolean> => {
610
+ const proposed = suggestion;
611
+ if (!alive || !proposed || selectedId !== null) return false;
612
+ if (choice === "no") {
613
+ offerDeclined = true;
614
+ clearSuggestion(ctx);
615
+ return true;
616
+ }
617
+ if (pending || setupAbort) return false;
618
+ const generation = epoch;
619
+ const branch = branchEpoch;
620
+ const operation = Symbol();
621
+ pending = operation;
622
+ refreshSerial++;
623
+ const canAct = () => alive && generation === epoch && branch === branchEpoch && selectedId === null && pending === operation;
624
+ // Consume consent before yielding: repeat clicks cannot create duplicate notes.
625
+ clearSuggestion(ctx);
626
+ let created: Task | undefined;
627
+ try {
628
+ const argv = ["agent", "add", `--text=${proposed.text}`];
629
+ if (proposed.tag !== undefined) argv.push(`--tag=${proposed.tag}`);
630
+ // Accepted mutations drain even if the click helper disconnects.
631
+ created = requiredTask(await run(argv, undefined, canAct, "0.4.0"));
632
+ if (!canAct()) throw new Error("The session or selection changed; the note was created but not selected.");
633
+ const chosen = await startTask(created.id, canAct);
634
+ if (!chosen || !alive || generation !== epoch || branch !== branchEpoch || selectedId !== chosen.id) {
635
+ throw new Error("The session or selection changed; the note may have started but was not selected here.");
636
+ }
637
+ ctx.ui.notify(`Pinote #${chosen.id} created and selected.`, "info");
638
+ return true;
639
+ } catch (error) {
640
+ if (alive && generation === epoch) ctx.ui.notify(
641
+ `Pinote suggestion: ${clean(String(error))}${created ? ` Note #${created.id} was created; use /pi-note to select it. Do not add it again.` : " A write may have committed; check /pi-note before retrying."}`, "error");
642
+ return false;
643
+ } finally {
644
+ if (pending === operation) pending = undefined;
645
+ if (alive && generation === epoch) await refresh(ctx);
646
+ }
647
+ };
648
+ pi.registerCommand("pi-note-yes", {
649
+ description: "Create, start and select the suggested task (same as ✓)",
650
+ handler: async (args, ctx) => {
651
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
652
+ if (args.trim() || !await respondToSuggestion("yes", ctx)) ctx.ui.notify("No available suggestion, or a Pinote operation is busy. Retry when it finishes.", "warning");
653
+ },
654
+ });
655
+ pi.registerCommand("pi-note-no", {
656
+ description: "Dismiss the suggested task without creating a note (same as ✕)",
657
+ handler: async (args, ctx) => {
658
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
659
+ if (args.trim() || !await respondToSuggestion("no", ctx)) ctx.ui.notify("No available suggestion. Usage: /pi-note-no", "warning");
660
+ },
661
+ });
662
+ pi.registerTool({
663
+ name: "pinote_propose",
664
+ label: "Pinote suggest task",
665
+ promptGuidelines: createGuidance,
666
+ 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.",
667
+ parameters: Type.Object({
668
+ text: Type.String({ minLength: 1, description: "Short action-oriented title, then optional details after a blank line." }),
669
+ tag: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
670
+ }, { additionalProperties: false }),
671
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
672
+ if (!text(params.text) || !params.text.trim()) throw new Error("text must be a non-empty note.");
673
+ if (params.tag !== undefined && (!text(params.tag) || !params.tag.trim() || params.tag.trim().length > 64)) throw new Error("tag must be a non-empty name of at most 64 characters.");
674
+ if (!alive || !ctx.hasUI || ctx.mode !== "tui") throw new Error("Task suggestions need an interactive TUI. Ask in chat before using pinote_add.");
675
+ const generation = epoch;
676
+ const branch = branchEpoch;
677
+ const current = await selected(ctx, signal);
678
+ if (!alive || generation !== epoch || branch !== branchEpoch) throw new Error("Pinote operation cancelled: the session changed.");
679
+ if (current || selectedId !== null) throw new Error("A task is already selected; keep it unless the user asks to switch.");
680
+ if (pending || setupAbort) throw new Error("A pinote operation is already open. Retry after it finishes.");
681
+ const result = (status: string) => ({ content: [{ type: "text" as const, text: JSON.stringify({ status }) }], details: { status } });
682
+ if (offerDeclined) return result("dismissed; continue without a note and do not offer again");
683
+ if (suggestion) return result("pending; existing suggestion retained, continue work without asking again");
684
+ suggestion = { text: params.text, ...(params.tag === undefined ? {} : { tag: params.tag.trim() }) };
685
+ previewBridge?.setSuggestion((choice) => respondToSuggestion(choice, ctx));
686
+ const proposed = suggestion;
687
+ ctx.ui.setWidget("pinote-suggestion", (_tui, theme) => ({
688
+ render: (width) => [renderSuggestion(proposed, width, theme, {
689
+ yes: previewBridge?.suggestionUrl("yes"), no: previewBridge?.suggestionUrl("no"),
690
+ })],
691
+ invalidate() {},
692
+ }), { placement: "belowEditor" });
693
+ return result("pending; user can click ✓ to add and select or ✕ to dismiss, continue work without asking again");
694
+ },
695
+ });
596
696
  pi.registerTool({
597
697
  name: "pinote_add",
598
698
  label: "Pinote add",
@@ -613,17 +713,22 @@ export default function (pi: ExtensionAPI) {
613
713
  if (params.tag !== undefined) argv.push(`--tag=${params.tag}`);
614
714
  const generation = epoch;
615
715
  const branch = branchEpoch;
616
- const created = await writeTask(ctx, signal, argv, undefined, "0.4.0");
617
- if (params.select !== true) return toolResult(created);
618
- if (!alive || generation !== epoch || branch !== branchEpoch) throw new Error("Pinote operation cancelled: the session changed.");
716
+ if (params.select !== true) return toolResult(await writeTask(ctx, signal, argv, undefined, "0.4.0"));
619
717
  if (pending) throw new Error("A pinote operation is already open. Retry after it finishes.");
620
718
  const operation = Symbol();
719
+ const previousSelection = selectedId;
621
720
  pending = operation;
721
+ refreshSerial++;
722
+ const canAct = () => alive && generation === epoch && branch === branchEpoch && pending === operation && selectedId === previousSelection;
723
+ // Chat consent consumes the same suggestion as clicking ✓, even if start fails.
724
+ clearSuggestion(ctx);
622
725
  try {
623
- const chosen = await startTask(
624
- created.id, () => alive && generation === epoch && branch === branchEpoch && pending === operation, signal,
625
- );
626
- if (!chosen) throw new Error("Pinote operation cancelled: the session changed.");
726
+ const created = requiredTask(await run(argv, signal, canAct, "0.4.0"));
727
+ if (!canAct()) throw new Error("Pinote operation cancelled: the session changed. The note was created; check /pi-note before retrying.");
728
+ const chosen = await startTask(created.id, canAct, signal);
729
+ if (!chosen || !alive || generation !== epoch || branch !== branchEpoch || selectedId !== chosen.id) {
730
+ throw new Error("Pinote operation cancelled: the session changed. The note may have started; check /pi-note before retrying.");
731
+ }
627
732
  return toolResult(chosen);
628
733
  } finally {
629
734
  if (pending === operation) pending = undefined;
package/package.json CHANGED
@@ -1,21 +1,51 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
- "keywords": ["pi-package", "tasks", "notes"],
8
- "engines": { "node": ">=22.19.0" },
7
+ "keywords": [
8
+ "pi-package",
9
+ "tasks",
10
+ "notes"
11
+ ],
12
+ "engines": {
13
+ "node": ">=22.19.0"
14
+ },
9
15
  "repository": {
10
16
  "type": "git",
11
17
  "url": "git+https://github.com/kvidzibo/pinote.git",
12
18
  "directory": "pi"
13
19
  },
14
20
  "homepage": "https://github.com/kvidzibo/pinote/tree/main/pi#readme",
15
- "bugs": { "url": "https://github.com/kvidzibo/pinote/issues" },
16
- "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
17
- "files": ["index.ts", "pr-watch.ts", "footer-links.ts", "footer-config.ts", "footer-settings.ts", "task-picker.ts", "setup.ts", "preview-bridge.ts", "preview-click.cjs", "icons", "kitty", "README.md", "LICENSE"],
18
- "pi": { "extensions": ["./index.ts"] },
21
+ "bugs": {
22
+ "url": "https://github.com/kvidzibo/pinote/issues"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "registry": "https://registry.npmjs.org/"
27
+ },
28
+ "files": [
29
+ "index.ts",
30
+ "pr-watch.ts",
31
+ "footer-links.ts",
32
+ "footer-config.ts",
33
+ "footer-settings.ts",
34
+ "task-picker.ts",
35
+ "task-suggestion.ts",
36
+ "setup.ts",
37
+ "preview-bridge.ts",
38
+ "preview-click.cjs",
39
+ "icons",
40
+ "kitty",
41
+ "README.md",
42
+ "LICENSE"
43
+ ],
44
+ "pi": {
45
+ "extensions": [
46
+ "./index.ts"
47
+ ]
48
+ },
19
49
  "scripts": {
20
50
  "check": "tsc --noEmit",
21
51
  "test:unit": "node --test --experimental-strip-types tests/*.test.ts",
package/preview-bridge.ts CHANGED
@@ -16,8 +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 suggestionCallback: ((choice: "yes" | "no") => Promise<boolean>) | undefined;
20
+ let suggestionNonce: string | undefined;
19
21
  const clients = new Set<Socket>();
20
22
  const invalidate = () => { taskId = null; callback = undefined; nonce = undefined; };
23
+ const invalidateSuggestion = () => { suggestionCallback = undefined; suggestionNonce = undefined; };
21
24
  const cleanup = async () => {
22
25
  ready = false;
23
26
  for (const client of clients) client.destroy();
@@ -62,14 +65,32 @@ export function createPreviewBridge() {
62
65
  if (!data.includes(10)) return;
63
66
  dispatched = true;
64
67
  const input = data.toString("utf8");
65
- const match = /^preview ([0-9a-f]{32}) ([1-9][0-9]*) ([0-9a-f]{32})\n$/u.exec(input);
66
- const id = match ? Number(match[2]) : NaN;
67
- if (!match || match[0] !== input || match[1] !== capability || !Number.isSafeInteger(id) ||
68
- id !== taskId || match[3] !== nonce || !callback) { client.end("rejected\n"); return; }
69
- const invoke = callback;
70
- Promise.resolve().then(() => !closed && !request.signal.aborted && taskId === id && nonce === match[3] ? invoke(request.signal) : false)
71
- .then((accepted) => { if (!client.destroyed) client.end(accepted === true ? "accepted\n" : "rejected\n"); },
72
- () => { if (!client.destroyed) client.end("rejected\n"); });
68
+ const previewMatch = /^preview ([0-9a-f]{32}) ([1-9][0-9]*) ([0-9a-f]{32})\n$/u.exec(input);
69
+ const suggestionMatch = /^(yes|no) ([0-9a-f]{32}) 1 ([0-9a-f]{32})\n$/u.exec(input);
70
+ const id = previewMatch ? Number(previewMatch[2]) : NaN;
71
+ if (previewMatch && previewMatch[0] === input && previewMatch[1] === capability && Number.isSafeInteger(id) &&
72
+ id === taskId && previewMatch[3] === nonce && callback) {
73
+ const invoke = callback;
74
+ const requestNonce = nonce;
75
+ Promise.resolve().then(() => !closed && !request.signal.aborted && taskId === id && nonce === requestNonce && callback === invoke
76
+ ? invoke(request.signal) : false)
77
+ .then((accepted) => { if (!client.destroyed) client.end(accepted === true ? "accepted\n" : "rejected\n"); },
78
+ () => { if (!client.destroyed) client.end("rejected\n"); });
79
+ return;
80
+ }
81
+ if (suggestionMatch && suggestionMatch[0] === input && suggestionMatch[2] === capability && suggestionMatch[3] === suggestionNonce && suggestionCallback) {
82
+ client.setTimeout(65000);
83
+ const invoke = suggestionCallback;
84
+ const requestNonce = suggestionNonce;
85
+ const choice = suggestionMatch[1] as "yes" | "no";
86
+ // Add/start/select and refresh can run eleven 5s CLI calls; accepted mutations drain on disconnect.
87
+ Promise.resolve().then(() => !closed && suggestionNonce === requestNonce && suggestionCallback === invoke
88
+ ? invoke(choice) : false)
89
+ .then((accepted) => { if (!client.destroyed) client.end(accepted === true ? "accepted\n" : "rejected\n"); },
90
+ () => { if (!client.destroyed) client.end("rejected\n"); });
91
+ return;
92
+ }
93
+ client.end("rejected\n");
73
94
  });
74
95
  });
75
96
  server = instance;
@@ -102,9 +123,20 @@ export function createPreviewBridge() {
102
123
  if (!ready || closed || !socketPath || taskId === null || !nonce) return;
103
124
  return `pi-note-preview://${basename(socketPath, ".sock")}/${capability}/${taskId}/${nonce}`;
104
125
  },
126
+ setSuggestion(suggestion: (choice: "yes" | "no") => Promise<boolean>): void {
127
+ if (closed) return;
128
+ suggestionCallback = suggestion;
129
+ suggestionNonce = randomBytes(16).toString("hex");
130
+ },
131
+ clearSuggestion(): void { invalidateSuggestion(); },
132
+ suggestionUrl(choice: "yes" | "no"): string | undefined {
133
+ if ((choice !== "yes" && choice !== "no") || !ready || closed || !socketPath || !suggestionNonce || !suggestionCallback) return;
134
+ return `pi-note-preview://${basename(socketPath, ".sock")}/${capability}/1/${suggestionNonce}/${choice}`;
135
+ },
105
136
  async stop(): Promise<void> {
106
137
  closed = true;
107
138
  invalidate();
139
+ invalidateSuggestion();
108
140
  if (!stopping) stopping = (async () => { await starting?.catch(() => {}); await cleanup(); })();
109
141
  return stopping;
110
142
  },
package/preview-click.cjs CHANGED
@@ -4,15 +4,15 @@ 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})$/;
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))?$/;
8
8
 
9
9
  function parse(value) {
10
- if (typeof value !== 'string' || value.length > 160) throw new Error('Preview unavailable');
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) throw new Error('Preview unavailable');
12
+ if (!m || m[0] !== value || (m[6] && m[4] !== '1')) throw new Error('Preview or suggestion unavailable');
13
13
  const pid = Number(m[1]), id = Number(m[4]);
14
- if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(id) || pid <= 0 || id <= 0) throw new Error('Preview unavailable');
15
- return { pid, tag: m[2], capability: m[3], id, nonce: m[5] };
14
+ if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(id) || pid <= 0 || id <= 0) throw new Error('Preview or suggestion unavailable');
15
+ return { pid, tag: m[2], capability: m[3], id, nonce: m[5], choice: m[6] };
16
16
  }
17
17
  function socketPathFor(value) {
18
18
  const p = parse(value);
@@ -30,17 +30,19 @@ function verify(file) {
30
30
  function preview(value) {
31
31
  let p, file;
32
32
  try { p = parse(value); file = socketPathFor(value); verify(file); }
33
- catch { return Promise.reject(new Error('Preview unavailable')); }
33
+ catch { return Promise.reject(new Error('Preview or suggestion unavailable')); }
34
34
  return new Promise((resolve, reject) => {
35
35
  const client = net.createConnection(file);
36
36
  let response = Buffer.alloc(0), done = false;
37
+ const message = p.choice ? `${p.choice} ${p.capability} ${p.id} ${p.nonce}\n` : `preview ${p.capability} ${p.id} ${p.nonce}\n`;
38
+ const errorMessage = 'Preview or suggestion unavailable';
37
39
  const finish = (err, result) => {
38
40
  if (done) return;
39
41
  done = true; clearTimeout(timer); client.destroy();
40
- err ? reject(new Error('Preview unavailable')) : resolve(result);
42
+ err ? reject(new Error(errorMessage)) : resolve(result);
41
43
  };
42
- const timer = setTimeout(() => finish(new Error()), 6000);
43
- client.on('connect', () => client.write(`preview ${p.capability} ${p.id} ${p.nonce}\n`));
44
+ const timer = setTimeout(() => finish(new Error()), p.choice ? 70000 : 6000);
45
+ client.on('connect', () => client.write(message));
44
46
  client.on('data', chunk => {
45
47
  response = Buffer.concat([response, chunk]);
46
48
  if (response.length > 64) return finish(new Error());
@@ -57,7 +59,7 @@ if (require.main === module) {
57
59
  process.exitCode = 2;
58
60
  } else {
59
61
  preview(process.argv[2]).catch(() => {
60
- console.error('Pinote preview unavailable; select a task and wait until Pi is idle.');
62
+ console.error('Pinote preview or suggestion unavailable; select a task and wait until Pi is idle.');
61
63
  process.exitCode = 1;
62
64
  });
63
65
  }
@@ -0,0 +1,20 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { stripVTControlCharacters } from "node:util";
3
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+ import type { Theme } from "@earendil-works/pi-coding-agent";
5
+
6
+ const check = readFileSync(new URL("./icons/check.txt", import.meta.url), "utf8").trim();
7
+ const cross = readFileSync(new URL("./icons/cross.txt", import.meta.url), "utf8").trim();
8
+ const link = (label: string, url?: string) => url ? `\x1b]8;;${url}\x07${label}\x1b]8;;\x07` : label;
9
+
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, "");
17
+ 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}`;
20
+ }