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 +34 -14
- package/icons/check.txt +1 -0
- package/icons/cross.txt +1 -0
- package/index.ts +120 -17
- package/kitty/open-actions.conf +1 -1
- package/package.json +37 -7
- package/preview-bridge.ts +40 -8
- package/preview-click.cjs +12 -10
- package/task-suggestion.ts +20 -0
- package/icons/eye.txt +0 -1
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
|
|
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
|
|
73
|
-
The
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
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
|
-
|
|
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
|
package/icons/check.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
✓
|
package/icons/cross.txt
ADDED
|
@@ -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,
|
|
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,
|
|
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 = {
|
|
@@ -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
|
-
//
|
|
260
|
-
const
|
|
261
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
624
|
-
|
|
625
|
-
);
|
|
626
|
-
if (!chosen
|
|
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;
|
package/kitty/open-actions.conf
CHANGED
|
@@ -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
|
|
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.
|
|
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": [
|
|
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
|
+
}
|
package/icons/eye.txt
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
👁
|