pi-note 0.12.1 → 0.13.1

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,9 @@ 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 eye, pin and tag). Overflow ends with `...`.
73
- The glyphs are bundled in `icons/eye.txt` and `icons/note.txt`; they use terminal fonts, not a Nerd Font or icon theme.
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.
74
74
 
75
75
  ### Display-only preview
76
76
 
@@ -82,12 +82,13 @@ modify the note, editor draft or task selection, and never starts a model call.
82
82
  The agent can still read the task separately with `pinote_get_current`.
83
83
  Previewing requires Pi to be idle with no other Pinote operation open.
84
84
 
85
- On Linux, the eye is a clickable OSC 8 link to a private per-session Unix socket.
85
+ On Linux, the task text (pin, tag and title) is a clickable OSC 8 link to a
86
+ private per-session Unix socket. Overflow at the configured title width stays linked.
86
87
  The package ships [a Kitty configuration example](kitty/open-actions.conf).
87
88
  Append its block to `~/.config/kitty/open-actions.conf`, preserving existing
88
89
  actions. Replace `/absolute/path/to/pi-note` with the installed package directory
89
90
  containing `preview-click.cjs`; for this checkout, that directory is `pi/`.
90
- Keep `${URL}` literal: Kitty substitutes the clicked eye's link.
91
+ Keep `${URL}` literal: Kitty substitutes the clicked task's link.
91
92
 
92
93
  ```conf
93
94
  protocol pi-note-preview
@@ -97,12 +98,12 @@ action launch --type=background node /absolute/path/to/pi-note/preview-click.cjs
97
98
  Do not replace or symlink your entire Kitty configuration to the example: it
98
99
  contains only Pinote's handler. Package installation does not edit Kitty files.
99
100
 
100
- Reload Kitty with **Ctrl+Shift+F5**. In Pi's regular mode, click the eye; in
101
+ Reload Kitty with **Ctrl+Shift+F5**. In Pi's regular mode, click the task text; in
101
102
  fullscreen mode, use **Ctrl+Shift+click** so Kitty handles the link instead of
102
103
  Pi's system URL opener. The helper sends only an authenticated selection
103
104
  identifier, never note content or a model prompt. Stale links after task switching, tree navigation, reload or session exit
104
105
  are rejected. Other terminals can use `/pi-note-preview`; if the socket cannot
105
- start, the eye is omitted and that command remains available.
106
+ start, the task remains visible without a link and that command remains available.
106
107
 
107
108
  Each Pi session remembers its own task, including several sessions in one folder.
108
109
  Resume restores that session's task; a new session starts unselected and does not import
@@ -170,6 +171,10 @@ user-level configuration only; project-local files are not read.
170
171
  populating relevant values with `pinote_update_current`. Empty/missing values
171
172
  stay hidden; this tool does not edit configuration.
172
173
  - `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
176
+ its title. Requires a TUI with no selected task. The agent continues your work
177
+ while the suggestion awaits your choice.
173
178
  - `pinote_add`: create an active task. Optional `tag`. `select: true` starts and
174
179
  remembers it for this session only; use that only after the user agrees.
175
180
 
@@ -177,10 +182,24 @@ Version 0.7.0 replaces `pinote_get`/`pinote_update` with these current-task tool
177
182
  and removes `pinote_tag`, without aliases. Agents cannot read/update tasks by ID
178
183
  or retag existing tasks; use `/pi-note` to select an existing task.
179
184
 
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.
185
+ 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
202
+ tag name when it fits.
184
203
  Task text starts with a short, action-oriented title (aim for at most 60 characters).
185
204
  Put context, URLs, commands, and acceptance criteria after a blank line; the title
186
205
  should not contain implementation details. This is agent guidance, not a storage limit.
@@ -233,7 +252,7 @@ Add `footer` to `~/.pi/agent/pi-note.json` (or `pi-note.json` under
233
252
  }
234
253
  ```
235
254
 
236
- Widths are terminal columns, including the eye, pin, tags or field labels, and must be
255
+ Widths are terminal columns, including the pin, tags or field labels, and must be
237
256
  integers from 3 to 1000. Defaults: `titleWidth: 60`, `fieldWidth: 60`,
238
257
  `maxFields: 4` (allowed 0–64). Each field can override `fieldWidth` with `width`.
239
258
  `fields` selects task field names in order, regardless of a task's `Bar`.
@@ -285,8 +304,9 @@ By default, at most four extra fields are shown, each truncated to 60 columns.
285
304
  Overflow ends with `...`, including cuts between Markdown/link segments. Removing
286
305
  a listed field hides it even if configuration or `Bar` still names it. Terminal
287
306
  OSC 8 support is required for clicking links. Pi joins footer statuses on one
288
- line and truncates the combined line with `...` when the terminal is narrow;
289
- this extension does not replace other footers. Other fields, such as `Jira` and
307
+ line and truncates the combined line with `...` when the terminal is narrow.
308
+ Pi's final ellipsis is plain text; click the remaining task text to preview it.
309
+ This extension does not replace other footers. Other fields, such as `Jira` and
290
310
  `CWD` in the example, stay off the footer unless selected.
291
311
 
292
312
  ### PR merge watcher
@@ -0,0 +1 @@
1
+ ✓
@@ -0,0 +1 @@
1
+ ✕
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { getAgentDir, getMarkdownTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { Container, Editor, Markdown, Text, truncateToWidth, visibleWidth, type Keybinding } from "@earendil-works/pi-tui";
2
+ import { Container, Editor, Markdown, Text, truncateToWidth, type Keybinding } from "@earendil-works/pi-tui";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { stripVTControlCharacters } from "node:util";
@@ -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 = {
@@ -32,7 +33,6 @@ type Task = {
32
33
  type Summary = Pick<Task, "id" | "text" | "state" | "tag">;
33
34
  // The standard Pi footer accepts text, so ship a portable terminal glyph, not a theme icon.
34
35
  const noteIcon = readFileSync(new URL("./icons/note.txt", import.meta.url), "utf8").trim();
35
- const eyeIcon = readFileSync(new URL("./icons/eye.txt", import.meta.url), "utf8").trim();
36
36
  const previewType = "pinote-preview";
37
37
  const compatible = "Incompatible note CLI response. Install pinote 0.3.0+ and check note on PATH.";
38
38
  const validId = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) > 0;
@@ -127,6 +127,13 @@ export default function (pi: ExtensionAPI) {
127
127
  return view;
128
128
  });
129
129
  let previewBridge: ReturnType<typeof createPreviewBridge> | undefined;
130
+ let suggestion: { text: string; tag?: string } | undefined;
131
+ let offerDeclined = false;
132
+ const clearSuggestion = (ctx: ExtensionContext) => {
133
+ previewBridge?.clearSuggestion();
134
+ if (suggestion && ctx.hasUI && ctx.mode === "tui") ctx.ui.setWidget("pinote-suggestion", undefined);
135
+ suggestion = undefined;
136
+ };
130
137
  let alive = true;
131
138
  let epoch = 0;
132
139
  let refreshSerial = 0;
@@ -197,6 +204,7 @@ export default function (pi: ExtensionAPI) {
197
204
  const remember = (id: number | null, branch = branchEpoch) => {
198
205
  if (!alive || branch !== branchEpoch || selectedId === id) return;
199
206
  previewBridge?.invalidate();
207
+ if (activeContext) clearSuggestion(activeContext);
200
208
  selectedId = id;
201
209
  pi.appendEntry(selectionType, { id });
202
210
  };
@@ -256,10 +264,9 @@ export default function (pi: ExtensionAPI) {
256
264
  previewBridge?.setTask(previewId, (signal) => alive && generation === epoch && previewBranch === branchEpoch &&
257
265
  selectedId === previewId ? preview(ctx, signal) : Promise.resolve(false));
258
266
  const url = previewBridge?.url();
259
- // Keep the eye outside title truncation, including the minimum title width.
260
- const eye = url ? `\x1b]8;;${url}\x07${eyeIcon}\x1b]8;;\x07 ` : "";
261
- const width = Math.max(0, footerConfig.titleWidth - (eye ? visibleWidth(eyeIcon) + 1 : 0));
262
- ctx.ui.setStatus("pinote", current ? eye + truncateToWidth(`${noteIcon} ${taskTag(current)} ${firstLine(current)}`, width, "...") : undefined);
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);
263
270
  watcher.update(ctx, current);
264
271
  }
265
272
  } catch {
@@ -327,6 +334,8 @@ export default function (pi: ExtensionAPI) {
327
334
  setupAbort?.abort();
328
335
  // Retire old polling before bridge teardown/startup yields to its scheduled callbacks.
329
336
  watcher.stop();
337
+ clearSuggestion(activeContext ?? ctx);
338
+ offerDeclined = false;
330
339
  alive = false;
331
340
  epoch++;
332
341
  branchEpoch++;
@@ -354,7 +363,7 @@ export default function (pi: ExtensionAPI) {
354
363
  } catch {
355
364
  await bridge.stop();
356
365
  if (previewBridge === bridge) previewBridge = undefined;
357
- ctx.ui.notify("Pinote eye link unavailable; use /pi-note-preview.", "warning");
366
+ ctx.ui.notify("Pinote task link unavailable; use /pi-note-preview.", "warning");
358
367
  }
359
368
  }
360
369
  if (!alive || generation !== epoch) return;
@@ -368,6 +377,7 @@ export default function (pi: ExtensionAPI) {
368
377
  pi.on("session_tree", async (_event, ctx) => {
369
378
  if (!alive) return;
370
379
  branchEpoch++;
380
+ clearSuggestion(ctx);
371
381
  previewBridge?.invalidate();
372
382
  const generation = branchEpoch;
373
383
  const id = readSelection(ctx);
@@ -380,6 +390,7 @@ export default function (pi: ExtensionAPI) {
380
390
  pi.on("agent_end", async (_event, ctx) => { await refresh(ctx); });
381
391
  pi.on("session_shutdown", async (_event, ctx) => {
382
392
  setupAbort?.abort();
393
+ clearSuggestion(ctx);
383
394
  alive = false;
384
395
  activeContext = undefined;
385
396
  selectedId = null;
@@ -593,6 +604,93 @@ export default function (pi: ExtensionAPI) {
593
604
  return { content: [{ type: "text" as const, text: JSON.stringify(value) }], details: value };
594
605
  },
595
606
  });
607
+ const respondToSuggestion = async (choice: "yes" | "no", ctx: ExtensionContext): Promise<boolean> => {
608
+ const proposed = suggestion;
609
+ if (!alive || !proposed || selectedId !== null) return false;
610
+ if (choice === "no") {
611
+ offerDeclined = true;
612
+ clearSuggestion(ctx);
613
+ return true;
614
+ }
615
+ if (pending || setupAbort) return false;
616
+ const generation = epoch;
617
+ const branch = branchEpoch;
618
+ const operation = Symbol();
619
+ pending = operation;
620
+ refreshSerial++;
621
+ const canAct = () => alive && generation === epoch && branch === branchEpoch && selectedId === null && pending === operation;
622
+ // Consume consent before yielding: repeat clicks cannot create duplicate notes.
623
+ clearSuggestion(ctx);
624
+ let created: Task | undefined;
625
+ try {
626
+ const argv = ["agent", "add", `--text=${proposed.text}`];
627
+ if (proposed.tag !== undefined) argv.push(`--tag=${proposed.tag}`);
628
+ // Accepted mutations drain even if the click helper disconnects.
629
+ created = requiredTask(await run(argv, undefined, canAct, "0.4.0"));
630
+ if (!canAct()) throw new Error("The session or selection changed; the note was created but not selected.");
631
+ const chosen = await startTask(created.id, canAct);
632
+ if (!chosen || !alive || generation !== epoch || branch !== branchEpoch || selectedId !== chosen.id) {
633
+ throw new Error("The session or selection changed; the note may have started but was not selected here.");
634
+ }
635
+ ctx.ui.notify(`Pinote #${chosen.id} created and selected.`, "info");
636
+ return true;
637
+ } catch (error) {
638
+ if (alive && generation === epoch) ctx.ui.notify(
639
+ `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");
640
+ return false;
641
+ } finally {
642
+ if (pending === operation) pending = undefined;
643
+ if (alive && generation === epoch) await refresh(ctx);
644
+ }
645
+ };
646
+ pi.registerCommand("pi-note-yes", {
647
+ description: "Create, start and select the suggested task (same as ✓)",
648
+ handler: async (args, ctx) => {
649
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
650
+ if (args.trim() || !await respondToSuggestion("yes", ctx)) ctx.ui.notify("No available suggestion, or a Pinote operation is busy. Retry when it finishes.", "warning");
651
+ },
652
+ });
653
+ pi.registerCommand("pi-note-no", {
654
+ description: "Dismiss the suggested task without creating a note (same as ✕)",
655
+ handler: async (args, ctx) => {
656
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
657
+ if (args.trim() || !await respondToSuggestion("no", ctx)) ctx.ui.notify("No available suggestion. Usage: /pi-note-no", "warning");
658
+ },
659
+ });
660
+ pi.registerTool({
661
+ name: "pinote_propose",
662
+ label: "Pinote suggest task",
663
+ 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.",
665
+ parameters: Type.Object({
666
+ text: Type.String({ minLength: 1, description: "Short action-oriented title, then optional details after a blank line." }),
667
+ tag: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
668
+ }, { additionalProperties: false }),
669
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
670
+ if (!text(params.text) || !params.text.trim()) throw new Error("text must be a non-empty note.");
671
+ 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.");
672
+ if (!alive || !ctx.hasUI || ctx.mode !== "tui") throw new Error("Task suggestions need an interactive TUI. Ask in chat before using pinote_add.");
673
+ const generation = epoch;
674
+ const branch = branchEpoch;
675
+ const current = await selected(ctx, signal);
676
+ if (!alive || generation !== epoch || branch !== branchEpoch) throw new Error("Pinote operation cancelled: the session changed.");
677
+ if (current || selectedId !== null) throw new Error("A task is already selected; keep it unless the user asks to switch.");
678
+ if (pending || setupAbort) throw new Error("A pinote operation is already open. Retry after it finishes.");
679
+ const result = (status: string) => ({ content: [{ type: "text" as const, text: JSON.stringify({ status }) }], details: { status } });
680
+ if (offerDeclined) return result("dismissed; continue without a note and do not offer again");
681
+ if (suggestion) return result("pending; existing suggestion retained, continue work without asking again");
682
+ suggestion = { text: params.text, ...(params.tag === undefined ? {} : { tag: params.tag.trim() }) };
683
+ 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" });
691
+ return result("pending; user can click ✓ to add and select or ✕ to dismiss, continue work without asking again");
692
+ },
693
+ });
596
694
  pi.registerTool({
597
695
  name: "pinote_add",
598
696
  label: "Pinote add",
@@ -613,17 +711,22 @@ export default function (pi: ExtensionAPI) {
613
711
  if (params.tag !== undefined) argv.push(`--tag=${params.tag}`);
614
712
  const generation = epoch;
615
713
  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.");
714
+ if (params.select !== true) return toolResult(await writeTask(ctx, signal, argv, undefined, "0.4.0"));
619
715
  if (pending) throw new Error("A pinote operation is already open. Retry after it finishes.");
620
716
  const operation = Symbol();
717
+ const previousSelection = selectedId;
621
718
  pending = operation;
719
+ refreshSerial++;
720
+ const canAct = () => alive && generation === epoch && branch === branchEpoch && pending === operation && selectedId === previousSelection;
721
+ // Chat consent consumes the same suggestion as clicking ✓, even if start fails.
722
+ clearSuggestion(ctx);
622
723
  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.");
724
+ const created = requiredTask(await run(argv, signal, canAct, "0.4.0"));
725
+ if (!canAct()) throw new Error("Pinote operation cancelled: the session changed. The note was created; check /pi-note before retrying.");
726
+ const chosen = await startTask(created.id, canAct, signal);
727
+ if (!chosen || !alive || generation !== epoch || branch !== branchEpoch || selectedId !== chosen.id) {
728
+ throw new Error("Pinote operation cancelled: the session changed. The note may have started; check /pi-note before retrying.");
729
+ }
627
730
  return toolResult(chosen);
628
731
  } finally {
629
732
  if (pending === operation) pending = undefined;
@@ -1,5 +1,5 @@
1
1
  # Append this block to ~/.config/kitty/open-actions.conf; preserve existing actions.
2
2
  # Replace /absolute/path/to/pi-note with the directory containing preview-click.cjs.
3
- # Keep ${URL} literal: Kitty substitutes the clicked eye's link.
3
+ # Keep ${URL} literal: Kitty substitutes the clicked task's link.
4
4
  protocol pi-note-preview
5
5
  action launch --type=background node /absolute/path/to/pi-note/preview-click.cjs ${URL}
package/package.json CHANGED
@@ -1,21 +1,51 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.12.1",
3
+ "version": "0.13.1",
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
+ }
package/icons/eye.txt DELETED
@@ -1 +0,0 @@
1
- 👁