pi-note 0.12.0 → 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,27 @@ 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.
202
+ Task text starts with a short, action-oriented title (aim for at most 60 characters).
203
+ Put context, URLs, commands, and acceptance criteria after a blank line; the title
204
+ should not contain implementation details. This is agent guidance, not a storage limit.
184
205
 
185
206
  Agents are guided to keep notes to three short bullets total: relevant outcome,
186
207
  blocker, and next action. Replace stale notes; omit narration, repeated task text,
@@ -0,0 +1 @@
1
+ ✓
@@ -0,0 +1 @@
1
+ ✕
package/index.ts CHANGED
@@ -10,14 +10,16 @@ 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.";
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.";
21
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.";
22
24
  type Task = {
23
25
  id: number;
@@ -74,7 +76,7 @@ function creationGuidance(): { guidelines: string[]; error?: string } {
74
76
  policy = "never";
75
77
  error = cause instanceof Error ? cause.message : String(cause);
76
78
  }
77
- return { guidelines: [`${offerGuidance[policy]} ${consentGuidance}`, tagGuidance], error };
79
+ return { guidelines: [`${offerGuidance[policy]} ${consentGuidance}`, titleGuidance, tagGuidance], error };
78
80
  }
79
81
 
80
82
  function handoffPrompt(): string {
@@ -126,6 +128,13 @@ export default function (pi: ExtensionAPI) {
126
128
  return view;
127
129
  });
128
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
+ };
129
138
  let alive = true;
130
139
  let epoch = 0;
131
140
  let refreshSerial = 0;
@@ -196,6 +205,7 @@ export default function (pi: ExtensionAPI) {
196
205
  const remember = (id: number | null, branch = branchEpoch) => {
197
206
  if (!alive || branch !== branchEpoch || selectedId === id) return;
198
207
  previewBridge?.invalidate();
208
+ if (activeContext) clearSuggestion(activeContext);
199
209
  selectedId = id;
200
210
  pi.appendEntry(selectionType, { id });
201
211
  };
@@ -326,6 +336,8 @@ export default function (pi: ExtensionAPI) {
326
336
  setupAbort?.abort();
327
337
  // Retire old polling before bridge teardown/startup yields to its scheduled callbacks.
328
338
  watcher.stop();
339
+ clearSuggestion(activeContext ?? ctx);
340
+ offerDeclined = false;
329
341
  alive = false;
330
342
  epoch++;
331
343
  branchEpoch++;
@@ -367,6 +379,7 @@ export default function (pi: ExtensionAPI) {
367
379
  pi.on("session_tree", async (_event, ctx) => {
368
380
  if (!alive) return;
369
381
  branchEpoch++;
382
+ clearSuggestion(ctx);
370
383
  previewBridge?.invalidate();
371
384
  const generation = branchEpoch;
372
385
  const id = readSelection(ctx);
@@ -379,6 +392,7 @@ export default function (pi: ExtensionAPI) {
379
392
  pi.on("agent_end", async (_event, ctx) => { await refresh(ctx); });
380
393
  pi.on("session_shutdown", async (_event, ctx) => {
381
394
  setupAbort?.abort();
395
+ clearSuggestion(ctx);
382
396
  alive = false;
383
397
  activeContext = undefined;
384
398
  selectedId = null;
@@ -592,13 +606,100 @@ export default function (pi: ExtensionAPI) {
592
606
  return { content: [{ type: "text" as const, text: JSON.stringify(value) }], details: value };
593
607
  },
594
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
+ });
595
696
  pi.registerTool({
596
697
  name: "pinote_add",
597
698
  label: "Pinote add",
598
699
  promptGuidelines: createGuidance,
599
- description: "Create an active pinote task. Optional tag is registered if new. Set select true only after the user agrees to make it this session's active task; that starts it and remembers it for this session only. Omitting select leaves the current selection unchanged. Does not refresh the desktop notification or bind the project directory.",
700
+ description: "Create an active pinote task. Start text with a short task title; put details after a blank line. Optional tag is registered if new. Set select true only after the user agrees to make it this session's active task; that starts it and remembers it for this session only. Omitting select leaves the current selection unchanged. Does not refresh the desktop notification or bind the project directory.",
600
701
  parameters: Type.Object({
601
- text: Type.String({ minLength: 1 }),
702
+ text: Type.String({ minLength: 1, description: "Short action-oriented title (aim for at most 60 characters), then optional blank line and details." }),
602
703
  tag: Type.Optional(Type.String({ minLength: 1 })),
603
704
  select: Type.Optional(Type.Boolean()),
604
705
  }),
@@ -612,17 +713,22 @@ export default function (pi: ExtensionAPI) {
612
713
  if (params.tag !== undefined) argv.push(`--tag=${params.tag}`);
613
714
  const generation = epoch;
614
715
  const branch = branchEpoch;
615
- const created = await writeTask(ctx, signal, argv, undefined, "0.4.0");
616
- if (params.select !== true) return toolResult(created);
617
- 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"));
618
717
  if (pending) throw new Error("A pinote operation is already open. Retry after it finishes.");
619
718
  const operation = Symbol();
719
+ const previousSelection = selectedId;
620
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);
621
725
  try {
622
- const chosen = await startTask(
623
- created.id, () => alive && generation === epoch && branch === branchEpoch && pending === operation, signal,
624
- );
625
- 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
+ }
626
732
  return toolResult(chosen);
627
733
  } finally {
628
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.0",
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
+ }