pi-note 0.13.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -22
- package/icons/add.txt +1 -0
- package/index.ts +61 -20
- package/package.json +1 -1
- package/preview-bridge.ts +23 -2
- package/preview-click.cjs +2 -2
- package/task-suggestion.ts +30 -9
package/README.md
CHANGED
|
@@ -68,9 +68,7 @@ task, so existing values can be configured without creating another task.
|
|
|
68
68
|
Type in the task picker to filter by text, ID, tag, or state (all words must match).
|
|
69
69
|
Use ↑/↓ and Enter to select, or Esc to cancel. Rows show **●** for in progress or
|
|
70
70
|
**○** for active, followed by the tag (`[Untagged]` when absent).
|
|
71
|
-
The normal Pi status area shows
|
|
72
|
-
The footer omits the task ID and state and shows only the first line, truncated to the configured width (default 60 terminal columns including the pin and tag). Overflow ends with `...`.
|
|
73
|
-
The pin glyph is bundled in `icons/note.txt`; it uses terminal fonts, not a Nerd Font or icon theme.
|
|
71
|
+
The normal Pi status area shows selected-task controls and title without replacing other footers. The footer omits the task ID and state and shows only the first line, bounded by `footer.titleWidth` (default 60 terminal columns including the pin, controls, tag and title). Overflow ends with `...`. The title remains clickable for preview; task controls are separate. At very small widths controls are hidden (pin only). The pin and controls are bundled in `icons/note.txt`, `icons/check.txt`, `icons/cross.txt`, and `icons/add.txt`; they use terminal fonts, not a Nerd Font or icon theme.
|
|
74
72
|
|
|
75
73
|
### Display-only preview
|
|
76
74
|
|
|
@@ -171,8 +169,8 @@ user-level configuration only; project-local files are not read.
|
|
|
171
169
|
populating relevant values with `pinote_update_current`. Empty/missing values
|
|
172
170
|
stay hidden; this tool does not edit configuration.
|
|
173
171
|
- `pinote_tags`: list saved tag names.
|
|
174
|
-
- `pinote_propose`: show a
|
|
175
|
-
creating a note. Optional `tag`; full text is retained, but the
|
|
172
|
+
- `pinote_propose`: show a suggested task beside the pin icon in the footer without
|
|
173
|
+
creating a note. Optional `tag`; full text is retained, but the footer shows only
|
|
176
174
|
its title. Requires a TUI with no selected task. The agent continues your work
|
|
177
175
|
while the suggestion awaits your choice.
|
|
178
176
|
- `pinote_add`: create an active task. Optional `tag`. `select: true` starts and
|
|
@@ -183,22 +181,29 @@ and removes `pinote_tag`, without aliases. Agents cannot read/update tasks by ID
|
|
|
183
181
|
or retag existing tasks; use `/pi-note` to select an existing task.
|
|
184
182
|
|
|
185
183
|
When allowed by `taskOfferPolicy`, the agent uses `pinote_propose` instead of
|
|
186
|
-
asking in chat.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
184
|
+
asking in chat. The pin footer shows a pending suggestion, distinct from a selected task:
|
|
185
|
+
|
|
186
|
+
```text
|
|
187
|
+
📌 ✕ + · [tag] Task title...
|
|
188
|
+
📌 ✓ · [tag] Task title...
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+

|
|
192
|
+

|
|
193
|
+
|
|
194
|
+
- Muted **✕** dismisses without creating a note; further offers are suppressed until a new session or reload.
|
|
195
|
+
- Accent **+** creates, starts, and selects the suggested task.
|
|
196
|
+
- Green **✓** completes the selected task and clears the selection on success.
|
|
197
|
+
|
|
198
|
+
Done occupies the former ✕ position, not the + position, so clicking Add twice cannot accidentally complete the new task. The former + cell stays blank. The selected task's title is a separate preview link.
|
|
199
|
+
|
|
200
|
+
The configured `footer.titleWidth` bounds the entire row (default 60 columns). Controls stay first; the tag is dropped before truncating the first-line title with `...`. Tiny widths hide controls (pin only). Pi can still clip the combined status row on narrow terminals.
|
|
201
|
+
|
|
202
|
+
Controls use the [Kitty preview handler](#display-only-preview), with no additional configuration. Fullscreen Kitty uses **Ctrl+Shift+click**. Keyboard alternatives are `/pi-note-no`, `/pi-note-yes`, and `/pi-note-done`. No action submits input or starts an agent turn; editor drafts stay unchanged.
|
|
203
|
+
|
|
204
|
+
Completion requires an idle Pi session and no pending Pinote operation. It rejects stale selected-task links and externally changed revisions. Accepted mutations drain even if the helper disconnects. On an error, check `/pi-note` before retrying, because a write may already have committed.
|
|
205
|
+
|
|
206
|
+
An existing suggestion is retained rather than replaced. Selection, tree navigation, reload and shutdown invalidate its links; repeated acceptance cannot duplicate a note. A busy Pinote operation blocks acceptance. Noninteractive agents still ask in chat before using `pinote_add` with `select: true`. An existing selection is not replaced unless the user asks to switch. Reuse a saved
|
|
202
207
|
tag name when it fits.
|
|
203
208
|
Task text starts with a short, action-oriented title (aim for at most 60 characters).
|
|
204
209
|
Put context, URLs, commands, and acceptance criteria after a blank line; the title
|
|
@@ -216,7 +221,7 @@ Values are Markdown strings; no fields are required. `PR` enables the watcher be
|
|
|
216
221
|
`Bar` chooses extra footer fields unless configuration overrides it:
|
|
217
222
|
|
|
218
223
|
```markdown
|
|
219
|
-
|
|
224
|
+
## Agent
|
|
220
225
|
PR: [Task selection #42](https://github.com/org/repo/pull/42)
|
|
221
226
|
Dashboard: [Metrics](http://127.0.0.1:3000/d/app)
|
|
222
227
|
Next: Address review comments
|
package/icons/add.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
+
|
package/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { FooterSettings, TaskMenu, withSettingsTab } from "./footer-settings.ts"
|
|
|
10
10
|
import { TaskPicker, taskState, taskTag } from "./task-picker.ts";
|
|
11
11
|
import { bundledCLIVersion, cliMenu, detectCLI, setupCLI, setupHint, versionAtLeast, type CLIAction } from "./setup.ts";
|
|
12
12
|
import { createPreviewBridge } from "./preview-bridge.ts";
|
|
13
|
-
import { renderSuggestion } from "./task-suggestion.ts";
|
|
13
|
+
import { renderSuggestion, renderSelectedTask } from "./task-suggestion.ts";
|
|
14
14
|
|
|
15
15
|
const handoffGuidance = "Keep agent notes to at most three short bullets total: outcome, blocker, next action, only when relevant. Replace stale notes; omit narration, repeated task text, and routine test logs. Keep a GitHub pull request in the PR field. Read pinote_fields to learn the user's globally configured footer field names and presentation. Populate those fields with meaningful task values when relevant using pinote_update_current; fields without values stay hidden. Footer fields and widths are chosen by the user's pi-note.json config. Set configured fields with Markdown values; links are clickable. When footer.fields is unset, Bar selects labels, one per line. Do not list PR in Bar. Remove a field to hide it. Do not modify config without user approval.";
|
|
16
16
|
const offerGuidance = {
|
|
@@ -18,7 +18,7 @@ const offerGuidance = {
|
|
|
18
18
|
"github-remote": "When the user gives work and no pinote task is selected, first verify with Git that the current repository has a remote whose URL host is github.com (HTTPS or SSH). Only then use pinote_propose to show one suggested `[tag] task` in the bottom bar instead of asking in chat. In noninteractive modes, ask before creating it. Local paths, other hosts, and GitHub-looking URL paths do not qualify. If no GitHub remote is verified, do not offer a task; explicit user requests to create one remain allowed.",
|
|
19
19
|
never: "Do not offer to create a pinote task. Create one only when the user explicitly requests it.",
|
|
20
20
|
};
|
|
21
|
-
const consentGuidance = "The suggestion bar's
|
|
21
|
+
const consentGuidance = "The suggestion bar's plus creates, starts and selects the note; its cross dismisses it. Continue the requested work while the suggestion is pending; do not ask again in chat or create a note yourself. On a chat no, continue without a note; on a chat yes, call pinote_add with select true. Never add or select without consent. If a task is already selected, do not replace it unless the user asks to switch.";
|
|
22
22
|
const titleGuidance = "Start task text with a short, action-oriented summary (aim for at most 60 characters). Put context, URLs, commands, and acceptance criteria after a blank line. Never put implementation details in the title.";
|
|
23
23
|
const tagGuidance = "Reuse a pinote_tags name when it fits. One tag; case-sensitive, trimmed, at most 64 characters. Tags are set when creating tasks with pinote_add; agent tools cannot retag existing tasks. Do not use pinote_update_current for tags or task text.";
|
|
24
24
|
type Task = {
|
|
@@ -131,7 +131,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
131
131
|
let offerDeclined = false;
|
|
132
132
|
const clearSuggestion = (ctx: ExtensionContext) => {
|
|
133
133
|
previewBridge?.clearSuggestion();
|
|
134
|
-
if (suggestion && ctx.hasUI && ctx.mode === "tui") ctx.ui.
|
|
134
|
+
if (suggestion && ctx.hasUI && ctx.mode === "tui") ctx.ui.setStatus("pinote", undefined);
|
|
135
135
|
suggestion = undefined;
|
|
136
136
|
};
|
|
137
137
|
let alive = true;
|
|
@@ -142,6 +142,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
142
142
|
let activeContext: ExtensionContext | undefined;
|
|
143
143
|
let cliState: CLIAction | undefined;
|
|
144
144
|
let footerConfig = { ...defaultFooterConfig };
|
|
145
|
+
const suggestionStatus = (ctx: ExtensionContext) => suggestion ? renderSuggestion(
|
|
146
|
+
suggestion, footerConfig.titleWidth, ctx.ui.theme, {
|
|
147
|
+
yes: previewBridge?.suggestionUrl("yes"), no: previewBridge?.suggestionUrl("no"),
|
|
148
|
+
},
|
|
149
|
+
) : undefined;
|
|
145
150
|
const checkCLI = async (ctx: ExtensionContext, suggest = false) => {
|
|
146
151
|
if (!ctx.hasUI || ctx.mode !== "tui" || setupAbort) return;
|
|
147
152
|
const generation = epoch;
|
|
@@ -223,7 +228,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
223
228
|
const current = task(await run(["agent", "get", String(id)], signal, canUse));
|
|
224
229
|
if (!canUse() || selectedId !== id) return null;
|
|
225
230
|
if (!current || current.id !== id || !["active", "in_progress"].includes(current.state)) {
|
|
226
|
-
if (canUse() && selectedId === id) remember(null);
|
|
231
|
+
if (!pending && canUse() && selectedId === id) remember(null);
|
|
227
232
|
return null;
|
|
228
233
|
}
|
|
229
234
|
return current;
|
|
@@ -262,17 +267,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
262
267
|
const previewId = current?.id ?? null;
|
|
263
268
|
const previewBranch = branchEpoch;
|
|
264
269
|
previewBridge?.setTask(previewId, (signal) => alive && generation === epoch && previewBranch === branchEpoch &&
|
|
265
|
-
selectedId === previewId ? preview(ctx, signal) : Promise.resolve(false)
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
+
selectedId === previewId ? preview(ctx, signal) : Promise.resolve(false), current ? {
|
|
271
|
+
revision: current.updated_at,
|
|
272
|
+
invoke: () => alive && generation === epoch && previewBranch === branchEpoch && selectedId === previewId
|
|
273
|
+
? completeTask(ctx, current) : Promise.resolve(false),
|
|
274
|
+
} : undefined);
|
|
275
|
+
ctx.ui.setStatus("pinote", current ? renderSelectedTask(current, footerConfig.titleWidth, ctx.ui.theme, {
|
|
276
|
+
preview: previewBridge?.url(), done: previewBridge?.doneUrl(),
|
|
277
|
+
}) : suggestionStatus(ctx));
|
|
270
278
|
watcher.update(ctx, current);
|
|
271
279
|
}
|
|
272
280
|
} catch {
|
|
273
281
|
if (alive && generation === epoch && serial === refreshSerial) {
|
|
274
282
|
previewBridge?.invalidate();
|
|
275
|
-
ctx.ui.setStatus("pinote", `${noteIcon} Pinote unavailable · ${cliState === "upgrade" ? "/pi-note-upgrade" : cliState === "setup" ? "/pi-note-setup" : "/pi-note"}`);
|
|
283
|
+
ctx.ui.setStatus("pinote", suggestionStatus(ctx) ?? `${noteIcon} Pinote unavailable · ${cliState === "upgrade" ? "/pi-note-upgrade" : cliState === "setup" ? "/pi-note-setup" : "/pi-note"}`);
|
|
276
284
|
}
|
|
277
285
|
}
|
|
278
286
|
};
|
|
@@ -322,6 +330,45 @@ export default function (pi: ExtensionAPI) {
|
|
|
322
330
|
if (pending === operation) pending = undefined;
|
|
323
331
|
}
|
|
324
332
|
};
|
|
333
|
+
const completeTask = async (ctx: ExtensionContext, displayed?: Task): Promise<boolean> => {
|
|
334
|
+
if (!alive || !ctx.hasUI || ctx.mode !== "tui" || !ctx.isIdle() || pending || setupAbort || !validId(selectedId)) return false;
|
|
335
|
+
const generation = epoch;
|
|
336
|
+
const branch = branchEpoch;
|
|
337
|
+
const id = selectedId;
|
|
338
|
+
const operation = Symbol();
|
|
339
|
+
pending = operation;
|
|
340
|
+
refreshSerial++;
|
|
341
|
+
const canAct = () => alive && generation === epoch && branch === branchEpoch && selectedId === id && ctx.isIdle();
|
|
342
|
+
let wrote = false;
|
|
343
|
+
try {
|
|
344
|
+
const current = displayed ?? await currentTask(undefined, canAct);
|
|
345
|
+
if (!current || current.id !== id || !canAct()) return false;
|
|
346
|
+
const completed = requiredTask(await run([
|
|
347
|
+
"agent", "done", String(id), "--expected-updated-at", current.updated_at,
|
|
348
|
+
], undefined, canAct), id);
|
|
349
|
+
wrote = true;
|
|
350
|
+
if (completed.state !== "done") throw new Error(compatible);
|
|
351
|
+
if (!canAct()) throw new Error("The session or selection changed. Completion may have committed; read the task before retrying.");
|
|
352
|
+
remember(null, branch);
|
|
353
|
+
ctx.ui.notify(`Pinote #${id} completed.`, "info");
|
|
354
|
+
return true;
|
|
355
|
+
} catch (error) {
|
|
356
|
+
if (alive && generation === epoch) ctx.ui.notify(
|
|
357
|
+
`Pinote completion: ${clean(String(error))}${wrote ? " Check the task before retrying." : ""}`, "error");
|
|
358
|
+
return false;
|
|
359
|
+
} finally {
|
|
360
|
+
if (pending === operation) pending = undefined;
|
|
361
|
+
if (alive && generation === epoch) await refresh(ctx);
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
pi.registerCommand("pi-note-done", {
|
|
365
|
+
description: "Complete the selected task (same as the footer ✓)",
|
|
366
|
+
handler: async (args, ctx) => {
|
|
367
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
368
|
+
if (args.trim()) { ctx.ui.notify("Usage: /pi-note-done", "warning"); return; }
|
|
369
|
+
if (!await completeTask(ctx)) ctx.ui.notify("Select a task and wait until Pi is idle and Pinote is available before completing it.", "warning");
|
|
370
|
+
},
|
|
371
|
+
});
|
|
325
372
|
pi.registerCommand("pi-note-preview", {
|
|
326
373
|
description: "Display the selected task locally without sending it to the model",
|
|
327
374
|
handler: async (args, ctx) => {
|
|
@@ -644,7 +691,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
644
691
|
}
|
|
645
692
|
};
|
|
646
693
|
pi.registerCommand("pi-note-yes", {
|
|
647
|
-
description: "Create, start and select the suggested task (same as
|
|
694
|
+
description: "Create, start and select the suggested task (same as +)",
|
|
648
695
|
handler: async (args, ctx) => {
|
|
649
696
|
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
650
697
|
if (args.trim() || !await respondToSuggestion("yes", ctx)) ctx.ui.notify("No available suggestion, or a Pinote operation is busy. Retry when it finishes.", "warning");
|
|
@@ -661,7 +708,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
661
708
|
name: "pinote_propose",
|
|
662
709
|
label: "Pinote suggest task",
|
|
663
710
|
promptGuidelines: createGuidance,
|
|
664
|
-
description: "Show a suggested task
|
|
711
|
+
description: "Show a suggested task beside the pin icon in the footer, without creating it or asking in chat. + creates, starts and selects it; ✕ dismisses it. Continue the requested work while awaiting consent. Requires an interactive TUI with no selected task. An existing suggestion is retained; a dismissed offer is not repeated. In noninteractive modes ask in chat, then use pinote_add only after consent.",
|
|
665
712
|
parameters: Type.Object({
|
|
666
713
|
text: Type.String({ minLength: 1, description: "Short action-oriented title, then optional details after a blank line." }),
|
|
667
714
|
tag: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
|
|
@@ -681,13 +728,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
681
728
|
if (suggestion) return result("pending; existing suggestion retained, continue work without asking again");
|
|
682
729
|
suggestion = { text: params.text, ...(params.tag === undefined ? {} : { tag: params.tag.trim() }) };
|
|
683
730
|
previewBridge?.setSuggestion((choice) => respondToSuggestion(choice, ctx));
|
|
684
|
-
|
|
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" });
|
|
731
|
+
ctx.ui.setStatus("pinote", suggestionStatus(ctx));
|
|
691
732
|
return result("pending; user can click ✓ to add and select or ✕ to dismiss, continue work without asking again");
|
|
692
733
|
},
|
|
693
734
|
});
|
|
@@ -718,7 +759,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
718
759
|
pending = operation;
|
|
719
760
|
refreshSerial++;
|
|
720
761
|
const canAct = () => alive && generation === epoch && branch === branchEpoch && pending === operation && selectedId === previousSelection;
|
|
721
|
-
// Chat consent consumes the same suggestion as clicking
|
|
762
|
+
// Chat consent consumes the same suggestion as clicking +, even if start fails.
|
|
722
763
|
clearSuggestion(ctx);
|
|
723
764
|
try {
|
|
724
765
|
const created = requiredTask(await run(argv, signal, canAct, "0.4.0"));
|
package/package.json
CHANGED
package/preview-bridge.ts
CHANGED
|
@@ -16,10 +16,11 @@ export function createPreviewBridge() {
|
|
|
16
16
|
let taskId: number | null = null;
|
|
17
17
|
let callback: ((signal: AbortSignal) => Promise<boolean>) | undefined;
|
|
18
18
|
let nonce: string | undefined;
|
|
19
|
+
let completion: { revision: string; nonce: string; invoke: () => Promise<boolean> } | undefined;
|
|
19
20
|
let suggestionCallback: ((choice: "yes" | "no") => Promise<boolean>) | undefined;
|
|
20
21
|
let suggestionNonce: string | undefined;
|
|
21
22
|
const clients = new Set<Socket>();
|
|
22
|
-
const invalidate = () => { taskId = null; callback = undefined; nonce = undefined; };
|
|
23
|
+
const invalidate = () => { taskId = null; callback = undefined; nonce = undefined; completion = undefined; };
|
|
23
24
|
const invalidateSuggestion = () => { suggestionCallback = undefined; suggestionNonce = undefined; };
|
|
24
25
|
const cleanup = async () => {
|
|
25
26
|
ready = false;
|
|
@@ -66,6 +67,7 @@ export function createPreviewBridge() {
|
|
|
66
67
|
dispatched = true;
|
|
67
68
|
const input = data.toString("utf8");
|
|
68
69
|
const previewMatch = /^preview ([0-9a-f]{32}) ([1-9][0-9]*) ([0-9a-f]{32})\n$/u.exec(input);
|
|
70
|
+
const completionMatch = /^done ([0-9a-f]{32}) ([1-9][0-9]*) ([0-9a-f]{32})\n$/u.exec(input);
|
|
69
71
|
const suggestionMatch = /^(yes|no) ([0-9a-f]{32}) 1 ([0-9a-f]{32})\n$/u.exec(input);
|
|
70
72
|
const id = previewMatch ? Number(previewMatch[2]) : NaN;
|
|
71
73
|
if (previewMatch && previewMatch[0] === input && previewMatch[1] === capability && Number.isSafeInteger(id) &&
|
|
@@ -78,6 +80,18 @@ export function createPreviewBridge() {
|
|
|
78
80
|
() => { if (!client.destroyed) client.end("rejected\n"); });
|
|
79
81
|
return;
|
|
80
82
|
}
|
|
83
|
+
if (completionMatch && completionMatch[0] === input && completionMatch[1] === capability &&
|
|
84
|
+
Number(completionMatch[2]) === taskId && completionMatch[3] === completion?.nonce && completion) {
|
|
85
|
+
client.setTimeout(65000);
|
|
86
|
+
const acceptedCompletion = completion;
|
|
87
|
+
const acceptedId = taskId;
|
|
88
|
+
// Accepted writes drain even if the helper disconnects.
|
|
89
|
+
Promise.resolve().then(() => !closed && taskId === acceptedId && completion === acceptedCompletion
|
|
90
|
+
? acceptedCompletion.invoke() : false)
|
|
91
|
+
.then((accepted) => { if (!client.destroyed) client.end(accepted === true ? "accepted\n" : "rejected\n"); },
|
|
92
|
+
() => { if (!client.destroyed) client.end("rejected\n"); });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
81
95
|
if (suggestionMatch && suggestionMatch[0] === input && suggestionMatch[2] === capability && suggestionMatch[3] === suggestionNonce && suggestionCallback) {
|
|
82
96
|
client.setTimeout(65000);
|
|
83
97
|
const invoke = suggestionCallback;
|
|
@@ -111,10 +125,13 @@ export function createPreviewBridge() {
|
|
|
111
125
|
})();
|
|
112
126
|
return starting;
|
|
113
127
|
},
|
|
114
|
-
setTask(id: number | null, preview: (signal: AbortSignal) => Promise<boolean>): void {
|
|
128
|
+
setTask(id: number | null, preview: (signal: AbortSignal) => Promise<boolean>, done?: { revision: string; invoke: () => Promise<boolean> }): void {
|
|
115
129
|
if (closed) return;
|
|
116
130
|
if (id !== null && (!Number.isSafeInteger(id) || id <= 0)) throw new Error("Invalid preview task");
|
|
117
131
|
if (id !== taskId) nonce = id === null ? undefined : randomBytes(16).toString("hex");
|
|
132
|
+
if (id === null || !done) completion = undefined;
|
|
133
|
+
else completion = { revision: done.revision, invoke: done.invoke,
|
|
134
|
+
nonce: id === taskId && completion?.revision === done.revision ? completion.nonce : randomBytes(16).toString("hex") };
|
|
118
135
|
taskId = id;
|
|
119
136
|
callback = id === null ? undefined : preview;
|
|
120
137
|
},
|
|
@@ -123,6 +140,10 @@ export function createPreviewBridge() {
|
|
|
123
140
|
if (!ready || closed || !socketPath || taskId === null || !nonce) return;
|
|
124
141
|
return `pi-note-preview://${basename(socketPath, ".sock")}/${capability}/${taskId}/${nonce}`;
|
|
125
142
|
},
|
|
143
|
+
doneUrl(): string | undefined {
|
|
144
|
+
if (!ready || closed || !socketPath || taskId === null || !completion) return;
|
|
145
|
+
return `pi-note-preview://${basename(socketPath, ".sock")}/${capability}/${taskId}/${completion.nonce}/done`;
|
|
146
|
+
},
|
|
126
147
|
setSuggestion(suggestion: (choice: "yes" | "no") => Promise<boolean>): void {
|
|
127
148
|
if (closed) return;
|
|
128
149
|
suggestionCallback = suggestion;
|
package/preview-click.cjs
CHANGED
|
@@ -4,12 +4,12 @@ const net = require('node:net');
|
|
|
4
4
|
const fs = require('node:fs');
|
|
5
5
|
const path = require('node:path');
|
|
6
6
|
|
|
7
|
-
const urlPattern = /^pi-note-preview:\/\/([1-9][0-9]*)-([0-9a-f]{8})\/([0-9a-f]{32})\/([1-9][0-9]*)\/([0-9a-f]{32})(?:\/(yes|no))?$/;
|
|
7
|
+
const urlPattern = /^pi-note-preview:\/\/([1-9][0-9]*)-([0-9a-f]{8})\/([0-9a-f]{32})\/([1-9][0-9]*)\/([0-9a-f]{32})(?:\/(yes|no|done))?$/;
|
|
8
8
|
|
|
9
9
|
function parse(value) {
|
|
10
10
|
if (typeof value !== 'string' || value.length > 180) throw new Error('Preview or suggestion unavailable');
|
|
11
11
|
const m = urlPattern.exec(value);
|
|
12
|
-
if (!m || m[0] !== value || (m[6] && m[4] !== '1')) throw new Error('Preview or suggestion unavailable');
|
|
12
|
+
if (!m || m[0] !== value || (m[6] && m[6] !== 'done' && m[4] !== '1')) throw new Error('Preview or suggestion unavailable');
|
|
13
13
|
const pid = Number(m[1]), id = Number(m[4]);
|
|
14
14
|
if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(id) || pid <= 0 || id <= 0) throw new Error('Preview or suggestion unavailable');
|
|
15
15
|
return { pid, tag: m[2], capability: m[3], id, nonce: m[5], choice: m[6] };
|
package/task-suggestion.ts
CHANGED
|
@@ -3,18 +3,39 @@ import { stripVTControlCharacters } from "node:util";
|
|
|
3
3
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
4
4
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
|
|
6
|
+
const pin = readFileSync(new URL("./icons/note.txt", import.meta.url), "utf8").trim();
|
|
6
7
|
const check = readFileSync(new URL("./icons/check.txt", import.meta.url), "utf8").trim();
|
|
7
8
|
const cross = readFileSync(new URL("./icons/cross.txt", import.meta.url), "utf8").trim();
|
|
9
|
+
const add = readFileSync(new URL("./icons/add.txt", import.meta.url), "utf8").trim();
|
|
10
|
+
// Pi's default footer collapses ASCII spaces; NBSP preserves the control cells.
|
|
11
|
+
const gap = "\u00a0";
|
|
8
12
|
const link = (label: string, url?: string) => url ? `\x1b]8;;${url}\x07${label}\x1b]8;;\x07` : label;
|
|
13
|
+
type Task = { text: string; tag?: string | null };
|
|
14
|
+
type Colors = Pick<Theme, "fg">;
|
|
9
15
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
):
|
|
14
|
-
|
|
15
|
-
const actionsWidth = visibleWidth(actions);
|
|
16
|
-
if (width <= actionsWidth + 2) return truncateToWidth(actions, width, "");
|
|
16
|
+
function renderTask(task: Task, width: number, theme: Colors, prefix: string, previewUrl?: string): string {
|
|
17
|
+
// All controls or none: never let a narrow budget reveal only one pending choice.
|
|
18
|
+
if (visibleWidth(prefix) > width) return truncateToWidth(pin, width, "");
|
|
19
|
+
const remaining = width - visibleWidth(prefix) - 3; // separator: " · "
|
|
20
|
+
if (remaining < 4) return prefix.trimEnd();
|
|
17
21
|
const clean = (value: string) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f-\x9f]/gu, " ").replace(/\s+/gu, " ").trim();
|
|
18
|
-
const
|
|
19
|
-
|
|
22
|
+
const tag = `[${clean(task.tag ?? "Untagged")}]`;
|
|
23
|
+
const title = clean(task.text.split("\n", 1)[0]);
|
|
24
|
+
const tagWidth = visibleWidth(tag) + 1;
|
|
25
|
+
const label = remaining >= tagWidth + Math.min(12, visibleWidth(title))
|
|
26
|
+
? `${theme.fg("muted", tag)} ${theme.fg("text", truncateToWidth(title, remaining - tagWidth, "..."))}`
|
|
27
|
+
: theme.fg("text", truncateToWidth(title, remaining, "..."));
|
|
28
|
+
return `${prefix}${theme.fg("muted", " · ")}${link(label, previewUrl)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function renderSuggestion(task: Task, width: number, theme: Colors, urls: { yes?: string; no?: string }): string {
|
|
32
|
+
return renderTask(task, width, theme,
|
|
33
|
+
`${pin} ${theme.fg("muted", link(cross, urls.no))}${gap.repeat(2)}${theme.fg("accent", link(add, urls.yes))}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function renderSelectedTask(task: Task, width: number, theme: Colors, urls: { preview?: string; done?: string }): string {
|
|
37
|
+
// Reserve the former '+' cell: accepting twice at the same position cannot click Done.
|
|
38
|
+
return renderTask(task, width, theme,
|
|
39
|
+
`${pin} ${theme.fg("success", link(check, urls.done))}${gap.repeat(visibleWidth(cross) + 2 + visibleWidth(add) - visibleWidth(check))}`,
|
|
40
|
+
urls.preview);
|
|
20
41
|
}
|