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 +22 -4
- package/icons/check.txt +1 -0
- package/icons/cross.txt +1 -0
- package/index.ts +115 -10
- package/package.json +37 -7
- package/preview-bridge.ts +40 -8
- package/preview-click.cjs +12 -10
- package/task-suggestion.ts +20 -0
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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.
|
package/icons/check.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
✓
|
package/icons/cross.txt
ADDED
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
624
|
-
|
|
625
|
-
);
|
|
626
|
-
if (!chosen
|
|
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.
|
|
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": [
|
|
8
|
-
|
|
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": {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"
|
|
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
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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 >
|
|
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(
|
|
42
|
+
err ? reject(new Error(errorMessage)) : resolve(result);
|
|
41
43
|
};
|
|
42
|
-
const timer = setTimeout(() => finish(new Error()), 6000);
|
|
43
|
-
client.on('connect', () => client.write(
|
|
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
|
+
}
|