pi-note 0.13.2 → 0.15.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 +32 -62
- package/footer-status.ts +40 -0
- package/icons/add.txt +1 -0
- package/index.ts +138 -52
- package/package.json +2 -2
- package/preview-bridge.ts +23 -2
- package/preview-click.cjs +2 -2
- package/task-suggestion.ts +22 -13
- package/pr-watch.ts +0 -236
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
|
|
|
@@ -186,32 +184,26 @@ When allowed by `taskOfferPolicy`, the agent uses `pinote_propose` instead of
|
|
|
186
184
|
asking in chat. The pin footer shows a pending suggestion, distinct from a selected task:
|
|
187
185
|
|
|
188
186
|
```text
|
|
189
|
-
📌
|
|
187
|
+
📌 ✕ + · [tag] Task title...
|
|
188
|
+
📌 ✓ · [tag] Task title...
|
|
190
189
|
```
|
|
191
190
|
|
|
192
|
-

|
|
192
|
+

|
|
193
|
+
|
|
194
|
+
- Muted **✕** dismisses without creating a note; further offers are suppressed until a new session.
|
|
195
|
+
- Accent **+** creates, starts, and selects the suggested task.
|
|
196
|
+
- Green **✓** completes the selected task, starts a clean unselected session, and reloads Pi without a completion prompt. Menu **Done** and `/pi-note-done` do the same.
|
|
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. Add and Dismiss preserve editor drafts; Done clears the new session's editor, so save unfinished input before completing a task.
|
|
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. Failed or stale writes never restart Pi. If another extension cancels the new session, the task remains completed and Pi reports the cancellation. The reload runs through the new session's fresh context; retired contexts are not reused.
|
|
205
|
+
|
|
206
|
+
An existing suggestion is retained rather than replaced. Pending suggestions survive `/reload` and session resume, including their full text and tag; new links replace stale capabilities. Accepted or dismissed suggestions never reappear on reload. New sessions and forks start without a suggestion; selecting a task or navigating the tree clears it. Suggestion state is local, display-only session metadata, not model context. Pi saves the session after the first submitted message; quitting before that can still lose unsaved session state. 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
|
|
215
207
|
tag name when it fits.
|
|
216
208
|
Task text starts with a short, action-oriented title (aim for at most 60 characters).
|
|
217
209
|
Put context, URLs, commands, and acceptance criteria after a blank line; the title
|
|
@@ -225,7 +217,7 @@ list, append its label to `Bar`, one label per line. Do not list `PR` in `Bar`.
|
|
|
225
217
|
Agents use `pinote_update_current`; there is no separate `add_to_bottom_bar` tool.
|
|
226
218
|
This is guidance, not truncation or a storage limit.
|
|
227
219
|
|
|
228
|
-
Values are Markdown strings; no fields are required. `PR`
|
|
220
|
+
Values are Markdown strings; no fields are required. `PR` can display a clickable link without GitHub polling.
|
|
229
221
|
`Bar` chooses extra footer fields unless configuration overrides it:
|
|
230
222
|
|
|
231
223
|
```markdown
|
|
@@ -322,40 +314,18 @@ Pi's final ellipsis is plain text; click the remaining task text to preview it.
|
|
|
322
314
|
This extension does not replace other footers. Other fields, such as `Jira` and
|
|
323
315
|
`CWD` in the example, stay off the footer unless selected.
|
|
324
316
|
|
|
325
|
-
### PR
|
|
326
|
-
|
|
327
|
-
Set `PR` with `pinote_update_current` to one `https://github.com/owner/repo/pull/123`
|
|
328
|
-
or Markdown link. Legacy footer settings show
|
|
329
|
-
field settings can change or hide
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
No polling runs in print/RPC mode or after Pi exits.
|
|
338
|
-
|
|
339
|
-
On merge, Pi waits until idle and asks **Mark this task completed?** In Kitty,
|
|
340
|
-
indeterminate progress animates the tab while the prompt awaits input (with Kitty's
|
|
341
|
-
default progress-aware tab title or a working/ready renderer). Progress clears on
|
|
342
|
-
response, cancellation, or shutdown; other terminals and redirected output are untouched.
|
|
343
|
-
Confirmation uses the same guarded CLI Done operation as `/pi-note`; declining leaves
|
|
344
|
-
the task unchanged. If it is already done, Pi says **PR #123 was merged and the task is
|
|
345
|
-
already completed**, without completing it again. Completion clears the selection
|
|
346
|
-
and hides the footer link, even with polling disabled. The background watch remains
|
|
347
|
-
until another task is selected, the link is removed, or Pi exits. Removed/scheduled tasks are no longer watched.
|
|
348
|
-
|
|
349
|
-
The watched task ID/link and merge acknowledgements are saved in the Pi session,
|
|
350
|
-
so `/reload` and session resume retain completed-task watches without repeating
|
|
351
|
-
acknowledged prompts. A new session can notify again for a
|
|
352
|
-
selected task. Task or PR changes during confirmation cannot complete a different
|
|
353
|
-
task. No completion-hook system is added; this uses Pinote's existing Done flow.
|
|
354
|
-
A separate branch-based PR-status extension may show a duplicate link; disable it
|
|
355
|
-
if you only want task-linked PRs.
|
|
356
|
-
|
|
357
|
-
Task data stays local except for GitHub status requests. Tools work without a TUI,
|
|
358
|
-
but `/pi-note` needs an idle TUI.
|
|
317
|
+
### PR links without polling
|
|
318
|
+
|
|
319
|
+
Set `PR` with `pinote_update_current` to one `https://github.com/owner/repo/pull/123`
|
|
320
|
+
URL or Markdown link. Legacy footer settings show clickable **PR #123**; global
|
|
321
|
+
field settings can change or hide it. PR and other field links are rendered locally
|
|
322
|
+
for the active selection and disappear when that selection is cleared.
|
|
323
|
+
|
|
324
|
+
Pinote no longer polls GitHub, runs `gh`, watches completed tasks, or prompts on
|
|
325
|
+
PR merges. No GitHub authentication is required. `PINOTE_PR_POLL_SECONDS` is no
|
|
326
|
+
longer used. Other separately installed Pi PR/Git extensions are unaffected.
|
|
327
|
+
|
|
328
|
+
Tools work without a TUI, but `/pi-note` needs an idle TUI.
|
|
359
329
|
This package does not synchronize databases or paths between machines. Note text
|
|
360
330
|
and fields loaded into Pi are sent to the configured model when used as context;
|
|
361
331
|
avoid secrets. Agent commands do not send desktop notifications.
|
package/footer-status.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { footerChips, footerStatusKey, legacyFooterStatusKey, renderFooterLinks } from "./footer-links.ts";
|
|
3
|
+
import type { FooterConfig } from "./footer-config.ts";
|
|
4
|
+
|
|
5
|
+
export type FooterTask = {
|
|
6
|
+
id: number;
|
|
7
|
+
state: string;
|
|
8
|
+
updated_at: string;
|
|
9
|
+
agent_notes: Record<string, string>;
|
|
10
|
+
};
|
|
11
|
+
type PR = { url: string; number: string };
|
|
12
|
+
|
|
13
|
+
// Accept one bare URL or Markdown link, never arbitrary hosts or terminal escapes.
|
|
14
|
+
export function taskPR(task: FooterTask | null): PR | undefined {
|
|
15
|
+
const field = task?.agent_notes.PR?.trim();
|
|
16
|
+
if (!field) return;
|
|
17
|
+
const url = /^\[[^\r\n]*\]\(([^\s]+)\)$/u.exec(field)?.[1] ?? field;
|
|
18
|
+
const match = /^https:\/\/github\.com\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_.-]+)\/pull\/([1-9][0-9]*)\/?$/u.exec(url);
|
|
19
|
+
if (!match || [".", ".."].includes(match[2]) || !Number.isSafeInteger(Number(match[3]))) return;
|
|
20
|
+
return { url: `https://github.com/${match[1]}/${match[2]}/pull/${match[3]}`, number: match[3] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function clearTaskFooter(ctx: ExtensionContext): void {
|
|
24
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
25
|
+
ctx.ui.setStatus(legacyFooterStatusKey, undefined);
|
|
26
|
+
ctx.ui.setStatus(footerStatusKey, undefined);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function updateTaskFooter(
|
|
30
|
+
ctx: ExtensionContext, current: FooterTask | null, config?: FooterConfig,
|
|
31
|
+
): void {
|
|
32
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
33
|
+
ctx.ui.setStatus(legacyFooterStatusKey, undefined);
|
|
34
|
+
const active = current && ["active", "in_progress"].includes(current.state) ? current : null;
|
|
35
|
+
const pr = active ? taskPR(active) : undefined;
|
|
36
|
+
const value = renderFooterLinks(
|
|
37
|
+
footerChips(active, pr, config), (text) => ctx.ui.theme.fg("mdLink", text),
|
|
38
|
+
);
|
|
39
|
+
ctx.ui.setStatus(footerStatusKey, value);
|
|
40
|
+
}
|
package/icons/add.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
+
|
package/index.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
|
-
import { getAgentDir, getMarkdownTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import { getAgentDir, getMarkdownTheme, type ExtensionAPI, type ExtensionContext, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Container, Editor, Markdown, Text, truncateToWidth, type Keybinding } from "@earendil-works/pi-tui";
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import { stripVTControlCharacters } from "node:util";
|
|
6
7
|
import { Type } from "typebox";
|
|
7
|
-
import {
|
|
8
|
+
import { updateTaskFooter, clearTaskFooter } from "./footer-status.ts";
|
|
8
9
|
import { defaultFooterConfig, defaultHandoffPrompt, effectiveFooterFields, loadFooterConfig, parseHandoffPrompt, readFooterDocument, saveFooterConfig, type FooterConfig, type PinoteSettings } from "./footer-config.ts";
|
|
9
10
|
import { FooterSettings, TaskMenu, withSettingsTab } from "./footer-settings.ts";
|
|
10
11
|
import { TaskPicker, taskState, taskTag } from "./task-picker.ts";
|
|
11
12
|
import { bundledCLIVersion, cliMenu, detectCLI, setupCLI, setupHint, versionAtLeast, type CLIAction } from "./setup.ts";
|
|
12
13
|
import { createPreviewBridge } from "./preview-bridge.ts";
|
|
13
|
-
import { renderSuggestion } from "./task-suggestion.ts";
|
|
14
|
+
import { renderSuggestion, renderSelectedTask } from "./task-suggestion.ts";
|
|
14
15
|
|
|
15
16
|
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
17
|
const offerGuidance = {
|
|
@@ -18,7 +19,7 @@ const offerGuidance = {
|
|
|
18
19
|
"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
20
|
never: "Do not offer to create a pinote task. Create one only when the user explicitly requests it.",
|
|
20
21
|
};
|
|
21
|
-
const consentGuidance = "The suggestion bar's
|
|
22
|
+
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
23
|
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
24
|
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
25
|
type Task = {
|
|
@@ -126,13 +127,37 @@ export default function (pi: ExtensionAPI) {
|
|
|
126
127
|
view.addChild(new Markdown(clean(entry.data.markdown), 0, 0, getMarkdownTheme()));
|
|
127
128
|
return view;
|
|
128
129
|
});
|
|
130
|
+
const runtimeToken = randomBytes(16).toString("hex");
|
|
131
|
+
let completionDispatch: {
|
|
132
|
+
token: string; selection: string; task: Task; started: () => void; finish: (accepted: boolean) => void;
|
|
133
|
+
} | undefined;
|
|
134
|
+
const cancelCompletionDispatch = () => completionDispatch?.finish(false);
|
|
129
135
|
let previewBridge: ReturnType<typeof createPreviewBridge> | undefined;
|
|
136
|
+
const suggestionType = "pinote-suggestion";
|
|
130
137
|
let suggestion: { text: string; tag?: string } | undefined;
|
|
131
138
|
let offerDeclined = false;
|
|
132
|
-
const
|
|
139
|
+
const saveSuggestion = () => pi.appendEntry(suggestionType, { suggestion: suggestion ?? null, declined: offerDeclined });
|
|
140
|
+
const clearSuggestion = (ctx: ExtensionContext, persist = true) => {
|
|
141
|
+
const hadSuggestion = suggestion !== undefined;
|
|
133
142
|
previewBridge?.clearSuggestion();
|
|
134
|
-
if (
|
|
143
|
+
if (hadSuggestion && ctx.hasUI && ctx.mode === "tui") ctx.ui.setStatus("pinote", undefined);
|
|
135
144
|
suggestion = undefined;
|
|
145
|
+
if (persist && hadSuggestion) saveSuggestion();
|
|
146
|
+
};
|
|
147
|
+
const restoreSuggestion = (ctx: ExtensionContext) => {
|
|
148
|
+
suggestion = undefined;
|
|
149
|
+
offerDeclined = false;
|
|
150
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
151
|
+
if (entry.type !== "custom" || entry.customType !== suggestionType) continue;
|
|
152
|
+
const data = entry.data;
|
|
153
|
+
if (!record(data) || typeof data.declined !== "boolean") continue;
|
|
154
|
+
const value = data.suggestion;
|
|
155
|
+
suggestion = record(value) && text(value.text) && value.text.trim() &&
|
|
156
|
+
(value.tag === undefined || (text(value.tag) && value.tag.trim() && value.tag.length <= 64))
|
|
157
|
+
? { text: value.text, ...(value.tag === undefined ? {} : { tag: value.tag as string }) } : undefined;
|
|
158
|
+
offerDeclined = data.declined;
|
|
159
|
+
}
|
|
160
|
+
if (selectedId !== null || offerDeclined) suggestion = undefined;
|
|
136
161
|
};
|
|
137
162
|
let alive = true;
|
|
138
163
|
let epoch = 0;
|
|
@@ -228,7 +253,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
228
253
|
const current = task(await run(["agent", "get", String(id)], signal, canUse));
|
|
229
254
|
if (!canUse() || selectedId !== id) return null;
|
|
230
255
|
if (!current || current.id !== id || !["active", "in_progress"].includes(current.state)) {
|
|
231
|
-
if (canUse() && selectedId === id) remember(null);
|
|
256
|
+
if (!pending && canUse() && selectedId === id) remember(null);
|
|
232
257
|
return null;
|
|
233
258
|
}
|
|
234
259
|
return current;
|
|
@@ -267,38 +292,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
267
292
|
const previewId = current?.id ?? null;
|
|
268
293
|
const previewBranch = branchEpoch;
|
|
269
294
|
previewBridge?.setTask(previewId, (signal) => alive && generation === epoch && previewBranch === branchEpoch &&
|
|
270
|
-
selectedId === previewId ? preview(ctx, signal) : Promise.resolve(false)
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
295
|
+
selectedId === previewId ? preview(ctx, signal) : Promise.resolve(false), current ? {
|
|
296
|
+
revision: current.updated_at,
|
|
297
|
+
invoke: () => alive && generation === epoch && previewBranch === branchEpoch && selectedId === previewId
|
|
298
|
+
? dispatchCompletion(ctx, current, `${runtimeToken}:${generation}:${previewBranch}:${previewId}`) : Promise.resolve(false),
|
|
299
|
+
} : undefined);
|
|
300
|
+
ctx.ui.setStatus("pinote", current ? renderSelectedTask(current, footerConfig.titleWidth, ctx.ui.theme, {
|
|
301
|
+
preview: previewBridge?.url(), done: previewBridge?.doneUrl(),
|
|
302
|
+
}) : suggestionStatus(ctx));
|
|
303
|
+
updateTaskFooter(ctx, current, footerConfig);
|
|
276
304
|
}
|
|
277
305
|
} catch {
|
|
278
306
|
if (alive && generation === epoch && serial === refreshSerial) {
|
|
279
307
|
previewBridge?.invalidate();
|
|
308
|
+
clearTaskFooter(ctx);
|
|
280
309
|
ctx.ui.setStatus("pinote", suggestionStatus(ctx) ?? `${noteIcon} Pinote unavailable · ${cliState === "upgrade" ? "/pi-note-upgrade" : cliState === "setup" ? "/pi-note-setup" : "/pi-note"}`);
|
|
281
310
|
}
|
|
282
311
|
}
|
|
283
312
|
};
|
|
284
|
-
const watcher = createPRWatcher(pi, {
|
|
285
|
-
selected,
|
|
286
|
-
footerConfig: () => footerConfig,
|
|
287
|
-
get: async (id) => requiredTask(await run(["agent", "get", String(id)]), id),
|
|
288
|
-
done: async (current, canAct) => {
|
|
289
|
-
const result = requiredTask(await run([
|
|
290
|
-
"agent", "done", String(current.id), "--expected-updated-at", current.updated_at,
|
|
291
|
-
], undefined, canAct), current.id);
|
|
292
|
-
if (result.state !== "done") throw new Error(compatible);
|
|
293
|
-
},
|
|
294
|
-
claim: () => {
|
|
295
|
-
if (pending) return;
|
|
296
|
-
const operation = Symbol();
|
|
297
|
-
pending = operation;
|
|
298
|
-
return () => { if (pending === operation) pending = undefined; };
|
|
299
|
-
},
|
|
300
|
-
refresh,
|
|
301
|
-
});
|
|
302
313
|
const preview = async (ctx: ExtensionContext, requestSignal?: AbortSignal): Promise<boolean> => {
|
|
303
314
|
if (!ctx.hasUI || ctx.mode !== "tui" || !ctx.isIdle() || pending || !validId(selectedId)) return false;
|
|
304
315
|
const generation = epoch;
|
|
@@ -327,6 +338,76 @@ export default function (pi: ExtensionAPI) {
|
|
|
327
338
|
if (pending === operation) pending = undefined;
|
|
328
339
|
}
|
|
329
340
|
};
|
|
341
|
+
const dispatchCompletion = (ctx: ExtensionContext, displayed: Task, selection: string): Promise<boolean> => {
|
|
342
|
+
if (!ctx.isIdle() || pending || completionDispatch) return Promise.resolve(false);
|
|
343
|
+
const token = `${selection}:${randomBytes(8).toString("hex")}`;
|
|
344
|
+
return new Promise((resolve) => {
|
|
345
|
+
const finish = (accepted: boolean) => {
|
|
346
|
+
clearTimeout(timer);
|
|
347
|
+
if (completionDispatch?.token === token) completionDispatch = undefined;
|
|
348
|
+
resolve(accepted);
|
|
349
|
+
};
|
|
350
|
+
const timer = setTimeout(() => {
|
|
351
|
+
finish(false);
|
|
352
|
+
if (alive && activeContext === ctx) ctx.ui.notify("Pinote completion could not start. Use /pi-note-done.", "warning");
|
|
353
|
+
}, 4000);
|
|
354
|
+
completionDispatch = { token, selection, task: displayed, started: () => clearTimeout(timer), finish };
|
|
355
|
+
try { pi.sendUserMessage(`/pi-note-done ${token}`, { expandPromptTemplates: true }); }
|
|
356
|
+
catch { finish(false); }
|
|
357
|
+
});
|
|
358
|
+
};
|
|
359
|
+
const completeSelected = async (args: string, ctx: ExtensionCommandContext, displayed?: Task) => {
|
|
360
|
+
const token = args.trim();
|
|
361
|
+
const request = token ? completionDispatch : undefined;
|
|
362
|
+
if (token && (request?.token !== token || request.selection !== `${runtimeToken}:${epoch}:${branchEpoch}:${selectedId}`)) return;
|
|
363
|
+
if (!alive || !ctx.hasUI || ctx.mode !== "tui" || !ctx.isIdle() || pending || setupAbort || !validId(selectedId)) {
|
|
364
|
+
request?.finish(false);
|
|
365
|
+
if (ctx.hasUI) ctx.ui.notify("Select a task and wait until Pi is idle and the Pinote operation has finished.", "warning");
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const generation = epoch;
|
|
369
|
+
const branch = branchEpoch;
|
|
370
|
+
const id = selectedId;
|
|
371
|
+
const operation = Symbol();
|
|
372
|
+
pending = operation;
|
|
373
|
+
request?.started();
|
|
374
|
+
refreshSerial++;
|
|
375
|
+
const canAct = () => alive && generation === epoch && branch === branchEpoch && selectedId === id && ctx.isIdle();
|
|
376
|
+
let wrote = false;
|
|
377
|
+
try {
|
|
378
|
+
const current = request?.task ?? displayed ?? await currentTask(undefined, canAct);
|
|
379
|
+
if (!current || current.id !== id || !canAct()) return;
|
|
380
|
+
const completed = requiredTask(await run([
|
|
381
|
+
"agent", "done", String(id), "--expected-updated-at", current.updated_at,
|
|
382
|
+
], undefined, canAct), id);
|
|
383
|
+
wrote = true;
|
|
384
|
+
if (completed.state !== "done") throw new Error(compatible);
|
|
385
|
+
if (!canAct()) throw new Error("The session or selection changed. Completion may have committed; read the task before retrying.");
|
|
386
|
+
request?.finish(true);
|
|
387
|
+
remember(null, branch);
|
|
388
|
+
await refresh(ctx);
|
|
389
|
+
if (!alive || generation !== epoch || branch !== branchEpoch || selectedId !== null || !ctx.isIdle()) {
|
|
390
|
+
throw new Error("Task completed, but the session changed. Start a new session manually.");
|
|
391
|
+
}
|
|
392
|
+
// Only a fresh command context may clear the new editor and reload the runtime.
|
|
393
|
+
const result = await ctx.newSession({ withSession: async (fresh) => {
|
|
394
|
+
fresh.ui.setEditorText("");
|
|
395
|
+
await fresh.reload();
|
|
396
|
+
} });
|
|
397
|
+
if (result.cancelled) ctx.ui.notify("Task completed; new session was cancelled.", "warning");
|
|
398
|
+
} catch (error) {
|
|
399
|
+
if (alive && generation === epoch) ctx.ui.notify(
|
|
400
|
+
`Pinote completion: ${clean(String(error))}${wrote ? " Check the task before retrying." : ""}`, "error");
|
|
401
|
+
} finally {
|
|
402
|
+
request?.finish(false);
|
|
403
|
+
if (pending === operation) pending = undefined;
|
|
404
|
+
if (alive && generation === epoch) await refresh(ctx);
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
pi.registerCommand("pi-note-done", {
|
|
408
|
+
description: "Complete the selected task, start a clean session, and reload Pi",
|
|
409
|
+
handler: (args, ctx) => completeSelected(args, ctx),
|
|
410
|
+
});
|
|
330
411
|
pi.registerCommand("pi-note-preview", {
|
|
331
412
|
description: "Display the selected task locally without sending it to the model",
|
|
332
413
|
handler: async (args, ctx) => {
|
|
@@ -337,10 +418,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
337
418
|
});
|
|
338
419
|
pi.on("session_start", async (_event, ctx) => {
|
|
339
420
|
setupAbort?.abort();
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
clearSuggestion(activeContext ?? ctx);
|
|
343
|
-
offerDeclined = false;
|
|
421
|
+
cancelCompletionDispatch();
|
|
422
|
+
clearTaskFooter(activeContext ?? ctx);
|
|
423
|
+
clearSuggestion(activeContext ?? ctx, false);
|
|
344
424
|
alive = false;
|
|
345
425
|
epoch++;
|
|
346
426
|
branchEpoch++;
|
|
@@ -352,6 +432,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
352
432
|
alive = true;
|
|
353
433
|
activeContext = ctx;
|
|
354
434
|
selectedId = readSelection(ctx);
|
|
435
|
+
restoreSuggestion(ctx);
|
|
436
|
+
if (_event.reason === "new" || _event.reason === "fork") {
|
|
437
|
+
// A fork may include an ancestor proposal whose consent was consumed elsewhere.
|
|
438
|
+
clearSuggestion(ctx, false);
|
|
439
|
+
offerDeclined = false;
|
|
440
|
+
if (ctx.sessionManager.getBranch().some((entry) => entry.type === "custom" && entry.customType === suggestionType)) saveSuggestion();
|
|
441
|
+
}
|
|
355
442
|
footerConfig = loadFooterConfig((message) => {
|
|
356
443
|
if (ctx.hasUI) ctx.ui.notify(message, "warning");
|
|
357
444
|
});
|
|
@@ -376,13 +463,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
376
463
|
if (!setupAbort) pending = undefined;
|
|
377
464
|
await checkCLI(ctx, true);
|
|
378
465
|
if (!alive || generation !== epoch) return;
|
|
379
|
-
if (
|
|
466
|
+
if (suggestion) previewBridge?.setSuggestion((choice) => respondToSuggestion(choice, ctx));
|
|
380
467
|
await refresh(ctx);
|
|
381
468
|
});
|
|
382
469
|
pi.on("session_tree", async (_event, ctx) => {
|
|
383
470
|
if (!alive) return;
|
|
384
471
|
branchEpoch++;
|
|
385
|
-
|
|
472
|
+
cancelCompletionDispatch();
|
|
473
|
+
clearSuggestion(ctx, false);
|
|
474
|
+
// Never resurrect an already consumed proposal by navigating behind its tombstone.
|
|
475
|
+
saveSuggestion();
|
|
386
476
|
previewBridge?.invalidate();
|
|
387
477
|
const generation = branchEpoch;
|
|
388
478
|
const id = readSelection(ctx);
|
|
@@ -395,14 +485,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
395
485
|
pi.on("agent_end", async (_event, ctx) => { await refresh(ctx); });
|
|
396
486
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
397
487
|
setupAbort?.abort();
|
|
398
|
-
|
|
488
|
+
cancelCompletionDispatch();
|
|
489
|
+
clearSuggestion(ctx, false);
|
|
399
490
|
alive = false;
|
|
400
491
|
activeContext = undefined;
|
|
401
492
|
selectedId = null;
|
|
402
493
|
epoch++;
|
|
403
494
|
branchEpoch++;
|
|
404
495
|
refreshSerial++;
|
|
405
|
-
|
|
496
|
+
clearTaskFooter(ctx);
|
|
406
497
|
if (ctx.hasUI && ctx.mode === "tui") ctx.ui.setStatus("pinote", undefined);
|
|
407
498
|
const bridge = previewBridge;
|
|
408
499
|
previewBridge = undefined;
|
|
@@ -426,7 +517,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
426
517
|
const generation = epoch;
|
|
427
518
|
const controller = new AbortController();
|
|
428
519
|
setupAbort = controller;
|
|
429
|
-
|
|
520
|
+
clearTaskFooter(ctx);
|
|
430
521
|
const currentSession = () => alive && generation === epoch;
|
|
431
522
|
try {
|
|
432
523
|
await setupCLI(pi, ctx, name === "pi-note-upgrade",
|
|
@@ -441,7 +532,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
441
532
|
const generation = epoch;
|
|
442
533
|
await checkCLI(latestContext, !currentSession());
|
|
443
534
|
if (!alive || generation !== epoch || setupAbort) return;
|
|
444
|
-
watcher.start(latestContext);
|
|
445
535
|
await refresh(latestContext);
|
|
446
536
|
}
|
|
447
537
|
}
|
|
@@ -505,13 +595,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
505
595
|
if (!action || !canAct()) return;
|
|
506
596
|
if (action === "Settings") { settings = true; continue; }
|
|
507
597
|
if (action === "Done" && current) {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
"agent", "done", String(current.id), "--expected-updated-at", current.updated_at,
|
|
511
|
-
], undefined, canAct), current.id);
|
|
512
|
-
if (completed.state !== "done") throw new Error(compatible);
|
|
513
|
-
if (currentSession() && branch === branchEpoch) remember(null, branch);
|
|
514
|
-
if (currentSession()) ctx.ui.notify(`Pinote #${current.id} completed.`, "info");
|
|
598
|
+
if (pending === operation) pending = undefined;
|
|
599
|
+
await completeSelected("", ctx, current);
|
|
515
600
|
return;
|
|
516
601
|
}
|
|
517
602
|
let id: number;
|
|
@@ -559,7 +644,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
559
644
|
name: "pinote_update_current",
|
|
560
645
|
label: "Pinote update current",
|
|
561
646
|
promptGuidelines: [handoffGuidance],
|
|
562
|
-
description: "Patch arbitrary agent handoff fields on this session's current pinote task. No ID argument. Read first with pinote_get_current; pass its updated_at as expected_updated_at. Fails when no task is selected or the selection changes during the operation. set merges label/value pairs without replacing other fields or task text; values are Markdown, e.g. PR: [Fix #42](https://github.com/org/repo/pull/42). Set PR to one GitHub pull-request URL or Markdown link to show PR #N in the footer
|
|
647
|
+
description: "Patch arbitrary agent handoff fields on this session's current pinote task. No ID argument. Read first with pinote_get_current; pass its updated_at as expected_updated_at. Fails when no task is selected or the selection changes during the operation. set merges label/value pairs without replacing other fields or task text; values are Markdown, e.g. PR: [Fix #42](https://github.com/org/repo/pull/42). Set PR to one GitHub pull-request URL or Markdown link to show PR #N in the footer. Pinote does not poll GitHub or prompt on PR merges. Read pinote_fields for the user's global field names, labels, link switches, formats, and widths; set those field names when their values are relevant. Fields without values stay hidden. When footer.fields is unset, set Bar to newline-separated field labels to show those Markdown fields in the footer; links in the text are clickable. remove deletes named fields. A stale revision fails; read again before retrying. Does not complete the task or change its tag.",
|
|
563
648
|
parameters: Type.Object({
|
|
564
649
|
expected_updated_at: Type.String({ minLength: 1 }),
|
|
565
650
|
set: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
@@ -649,7 +734,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
649
734
|
}
|
|
650
735
|
};
|
|
651
736
|
pi.registerCommand("pi-note-yes", {
|
|
652
|
-
description: "Create, start and select the suggested task (same as
|
|
737
|
+
description: "Create, start and select the suggested task (same as +)",
|
|
653
738
|
handler: async (args, ctx) => {
|
|
654
739
|
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
655
740
|
if (args.trim() || !await respondToSuggestion("yes", ctx)) ctx.ui.notify("No available suggestion, or a Pinote operation is busy. Retry when it finishes.", "warning");
|
|
@@ -666,7 +751,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
666
751
|
name: "pinote_propose",
|
|
667
752
|
label: "Pinote suggest task",
|
|
668
753
|
promptGuidelines: createGuidance,
|
|
669
|
-
description: "Show a suggested task beside the pin icon in the footer, without creating it or asking in chat.
|
|
754
|
+
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.",
|
|
670
755
|
parameters: Type.Object({
|
|
671
756
|
text: Type.String({ minLength: 1, description: "Short action-oriented title, then optional details after a blank line." }),
|
|
672
757
|
tag: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
|
|
@@ -685,6 +770,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
685
770
|
if (offerDeclined) return result("dismissed; continue without a note and do not offer again");
|
|
686
771
|
if (suggestion) return result("pending; existing suggestion retained, continue work without asking again");
|
|
687
772
|
suggestion = { text: params.text, ...(params.tag === undefined ? {} : { tag: params.tag.trim() }) };
|
|
773
|
+
saveSuggestion();
|
|
688
774
|
previewBridge?.setSuggestion((choice) => respondToSuggestion(choice, ctx));
|
|
689
775
|
ctx.ui.setStatus("pinote", suggestionStatus(ctx));
|
|
690
776
|
return result("pending; user can click ✓ to add and select or ✕ to dismiss, continue work without asking again");
|
|
@@ -717,7 +803,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
717
803
|
pending = operation;
|
|
718
804
|
refreshSerial++;
|
|
719
805
|
const canAct = () => alive && generation === epoch && branch === branchEpoch && pending === operation && selectedId === previousSelection;
|
|
720
|
-
// Chat consent consumes the same suggestion as clicking
|
|
806
|
+
// Chat consent consumes the same suggestion as clicking +, even if start fails.
|
|
721
807
|
clearSuggestion(ctx);
|
|
722
808
|
try {
|
|
723
809
|
const created = requiredTask(await run(argv, signal, canAct, "0.4.0"));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-note",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
29
|
"index.ts",
|
|
30
|
-
"
|
|
30
|
+
"footer-status.ts",
|
|
31
31
|
"footer-links.ts",
|
|
32
32
|
"footer-config.ts",
|
|
33
33
|
"footer-settings.ts",
|
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
|
@@ -6,27 +6,36 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
|
6
6
|
const pin = readFileSync(new URL("./icons/note.txt", import.meta.url), "utf8").trim();
|
|
7
7
|
const check = readFileSync(new URL("./icons/check.txt", import.meta.url), "utf8").trim();
|
|
8
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";
|
|
9
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">;
|
|
10
15
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
): string {
|
|
15
|
-
const cue = `${pin} ${theme.fg("accent", "Suggested:")}`;
|
|
16
|
-
const actions = (compact: boolean) => `${theme.fg("success", link(compact ? check : `${check} Add`, urls.yes))} ${theme.fg("muted", link(compact ? cross : `${cross} Dismiss`, urls.no))}`;
|
|
17
|
-
let prefix = `${cue} ${actions(false)}`;
|
|
18
|
-
if (visibleWidth(prefix) > width) prefix = `${cue} ${actions(true)}`;
|
|
19
|
-
// Never show unexplained action icons when even the suggestion cue cannot fit.
|
|
20
|
-
if (visibleWidth(prefix) > width) return truncateToWidth(cue, 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, "");
|
|
21
19
|
const remaining = width - visibleWidth(prefix) - 3; // separator: " · "
|
|
22
|
-
if (remaining < 4) return prefix;
|
|
20
|
+
if (remaining < 4) return prefix.trimEnd();
|
|
23
21
|
const clean = (value: string) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f-\x9f]/gu, " ").replace(/\s+/gu, " ").trim();
|
|
24
22
|
const tag = `[${clean(task.tag ?? "Untagged")}]`;
|
|
25
23
|
const title = clean(task.text.split("\n", 1)[0]);
|
|
26
|
-
// Drop the tag before sacrificing the title to a long tag or a small budget.
|
|
27
24
|
const tagWidth = visibleWidth(tag) + 1;
|
|
28
25
|
const label = remaining >= tagWidth + Math.min(12, visibleWidth(title))
|
|
29
26
|
? `${theme.fg("muted", tag)} ${theme.fg("text", truncateToWidth(title, remaining - tagWidth, "..."))}`
|
|
30
27
|
: theme.fg("text", truncateToWidth(title, remaining, "..."));
|
|
31
|
-
return `${prefix}${theme.fg("muted", " · ")}${label}`;
|
|
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);
|
|
32
41
|
}
|
package/pr-watch.ts
DELETED
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { footerChips, footerStatusKey, legacyFooterStatusKey, renderFooterLinks, type FooterChip } from "./footer-links.ts";
|
|
3
|
-
import type { FooterConfig } from "./footer-config.ts";
|
|
4
|
-
|
|
5
|
-
export type WatchedTask = {
|
|
6
|
-
id: number;
|
|
7
|
-
state: string;
|
|
8
|
-
updated_at: string;
|
|
9
|
-
agent_notes: Record<string, string>;
|
|
10
|
-
};
|
|
11
|
-
type PR = { url: string; number: string };
|
|
12
|
-
type Dependencies = {
|
|
13
|
-
selected: (ctx: ExtensionContext) => Promise<WatchedTask | null>;
|
|
14
|
-
get: (id: number) => Promise<WatchedTask>;
|
|
15
|
-
done: (task: WatchedTask, canAct: () => boolean) => Promise<void>;
|
|
16
|
-
claim: () => (() => void) | undefined;
|
|
17
|
-
refresh: (ctx: ExtensionContext) => Promise<void>;
|
|
18
|
-
footerConfig?: () => FooterConfig;
|
|
19
|
-
};
|
|
20
|
-
const entryType = "pinote-pr-acknowledged";
|
|
21
|
-
const watchEntryType = "pinote-pr-watched";
|
|
22
|
-
|
|
23
|
-
// Accept one bare URL or Markdown link, never arbitrary hosts or terminal escapes.
|
|
24
|
-
export function taskPR(task: WatchedTask | null): PR | undefined {
|
|
25
|
-
const field = task?.agent_notes.PR?.trim();
|
|
26
|
-
if (!field) return;
|
|
27
|
-
const url = /^\[[^\r\n]*\]\(([^\s]+)\)$/u.exec(field)?.[1] ?? field;
|
|
28
|
-
const match = /^https:\/\/github\.com\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_.-]+)\/pull\/([1-9][0-9]*)\/?$/u.exec(url);
|
|
29
|
-
if (!match || [".", ".."].includes(match[2]) || !Number.isSafeInteger(Number(match[3]))) return;
|
|
30
|
-
return { url: `https://github.com/${match[1]}/${match[2]}/pull/${match[3]}`, number: match[3] };
|
|
31
|
-
}
|
|
32
|
-
const keyFor = (task: WatchedTask, pr: PR) => JSON.stringify([task.id, pr.url.toLowerCase()]);
|
|
33
|
-
const watchKey = (task: WatchedTask | null) => {
|
|
34
|
-
const pr = taskPR(task);
|
|
35
|
-
return task && pr ? keyFor(task, pr) : undefined;
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
async function confirmCompletion(ctx: ExtensionContext, pr: PR, signal: AbortSignal) {
|
|
39
|
-
let timer: ReturnType<typeof setInterval> | undefined;
|
|
40
|
-
const report = () => process.stdout.write("\x1b]9;4;3\x1b\\");
|
|
41
|
-
const stop = () => {
|
|
42
|
-
if (timer === undefined) return;
|
|
43
|
-
clearInterval(timer);
|
|
44
|
-
timer = undefined;
|
|
45
|
-
process.stdout.write("\x1b]9;4;0\x1b\\");
|
|
46
|
-
};
|
|
47
|
-
if (process.stdout.isTTY && process.env.KITTY_WINDOW_ID && !signal.aborted) {
|
|
48
|
-
report();
|
|
49
|
-
// Refresh Kitty's progress timeout and redraw the tab while input is pending.
|
|
50
|
-
timer = setInterval(report, 125);
|
|
51
|
-
timer.unref();
|
|
52
|
-
signal.addEventListener("abort", stop, { once: true });
|
|
53
|
-
}
|
|
54
|
-
try {
|
|
55
|
-
return await ctx.ui.confirm(`PR #${pr.number} was merged`, "Mark this task completed?", { signal });
|
|
56
|
-
} finally {
|
|
57
|
-
signal.removeEventListener("abort", stop);
|
|
58
|
-
stop();
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function createPRWatcher(pi: ExtensionAPI, deps: Dependencies) {
|
|
63
|
-
let ctx: ExtensionContext | undefined;
|
|
64
|
-
let watched: WatchedTask | null = null;
|
|
65
|
-
let visiblePR: PR | undefined;
|
|
66
|
-
let visibleLinks: FooterChip[] = [];
|
|
67
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
68
|
-
let controller = new AbortController();
|
|
69
|
-
let generation = 0;
|
|
70
|
-
let revision = 0;
|
|
71
|
-
let running = false;
|
|
72
|
-
let interval = 60_000;
|
|
73
|
-
let warned = false;
|
|
74
|
-
let acknowledged = new Set<string>();
|
|
75
|
-
|
|
76
|
-
const remember = (current: WatchedTask | null) => {
|
|
77
|
-
const changed = watchKey(current) !== watchKey(watched);
|
|
78
|
-
watched = current;
|
|
79
|
-
if (changed && ctx) pi.appendEntry(watchEntryType, {
|
|
80
|
-
cwd: ctx.cwd, task: current ? { id: current.id, url: taskPR(current)!.url } : null,
|
|
81
|
-
});
|
|
82
|
-
return changed;
|
|
83
|
-
};
|
|
84
|
-
const paint = () => {
|
|
85
|
-
const current = ctx;
|
|
86
|
-
if (!current) return;
|
|
87
|
-
// Clear the old key first so a reload cannot show PR twice.
|
|
88
|
-
current.ui.setStatus(legacyFooterStatusKey, undefined);
|
|
89
|
-
current.ui.setStatus(footerStatusKey, renderFooterLinks(
|
|
90
|
-
visibleLinks, (value) => current.ui.theme.fg("mdLink", value),
|
|
91
|
-
));
|
|
92
|
-
};
|
|
93
|
-
const schedule = (delay = interval) => {
|
|
94
|
-
if (!ctx || !interval || running) return;
|
|
95
|
-
clearTimeout(timer);
|
|
96
|
-
timer = setTimeout(() => { void poll(); }, delay);
|
|
97
|
-
timer.unref();
|
|
98
|
-
};
|
|
99
|
-
const update = (context: ExtensionContext, current: WatchedTask | null) => {
|
|
100
|
-
if (!ctx || ctx.cwd !== context.cwd) return;
|
|
101
|
-
revision++;
|
|
102
|
-
// Display only the active selection, independently of the retained merge watch.
|
|
103
|
-
visiblePR = current && ["active", "in_progress"].includes(current.state) ? taskPR(current) : undefined;
|
|
104
|
-
visibleLinks = footerChips(current, visiblePR, deps.footerConfig?.());
|
|
105
|
-
// Completion clears selection. Keep the last task until poll verifies its state.
|
|
106
|
-
const changed = current ? remember(taskPR(current) ? current : null) : false;
|
|
107
|
-
paint();
|
|
108
|
-
if (changed) schedule(0);
|
|
109
|
-
};
|
|
110
|
-
const latest = async (context: ExtensionContext, previous: WatchedTask | null) => {
|
|
111
|
-
const current = await deps.selected(context);
|
|
112
|
-
if (current) return taskPR(current) ? current : null;
|
|
113
|
-
if (!previous) return null;
|
|
114
|
-
const old = await deps.get(previous.id);
|
|
115
|
-
return old.state === "done" && taskPR(old) ? old : null;
|
|
116
|
-
};
|
|
117
|
-
async function poll() {
|
|
118
|
-
if (!ctx || running) return;
|
|
119
|
-
const context = ctx;
|
|
120
|
-
const epoch = generation;
|
|
121
|
-
const signal = controller.signal;
|
|
122
|
-
const valid = () => ctx === context && generation === epoch && !signal.aborted;
|
|
123
|
-
running = true;
|
|
124
|
-
try {
|
|
125
|
-
await deps.refresh(context);
|
|
126
|
-
if (!valid()) return;
|
|
127
|
-
const snapshot = revision;
|
|
128
|
-
const candidate = await latest(context, watched);
|
|
129
|
-
if (!valid() || revision !== snapshot) return;
|
|
130
|
-
remember(candidate);
|
|
131
|
-
paint();
|
|
132
|
-
const pr = taskPR(candidate);
|
|
133
|
-
if (!candidate || !pr) return;
|
|
134
|
-
const key = keyFor(candidate, pr);
|
|
135
|
-
const same = () => valid() && watched !== null && taskPR(watched)?.url === pr.url && watched.id === candidate.id;
|
|
136
|
-
if (acknowledged.has(key)) return;
|
|
137
|
-
const result = await pi.exec("gh", ["pr", "view", pr.url, "--json", "state,url"], {
|
|
138
|
-
cwd: context.cwd, timeout: 10_000, signal,
|
|
139
|
-
});
|
|
140
|
-
if (!same()) return;
|
|
141
|
-
if (result.code !== 0 || result.killed) throw new Error("GitHub CLI failed");
|
|
142
|
-
const data = JSON.parse(result.stdout);
|
|
143
|
-
// GitHub canonicalizes owner/repository casing in its response URL.
|
|
144
|
-
if (typeof data.url !== "string" || data.url.toLowerCase() !== pr.url.toLowerCase() ||
|
|
145
|
-
!["OPEN", "CLOSED", "MERGED"].includes(data.state)) throw new Error("Invalid PR response");
|
|
146
|
-
warned = false;
|
|
147
|
-
if (data.state !== "MERGED" || !context.isIdle()) return;
|
|
148
|
-
const release = deps.claim();
|
|
149
|
-
if (!release) return;
|
|
150
|
-
try {
|
|
151
|
-
const current = await latest(context, candidate);
|
|
152
|
-
if (!same() || !current || current.id !== candidate.id || taskPR(current)?.url !== pr.url || !context.isIdle()) return;
|
|
153
|
-
const acknowledge = () => {
|
|
154
|
-
pi.appendEntry(entryType, { cwd: context.cwd, key });
|
|
155
|
-
acknowledged.add(key);
|
|
156
|
-
};
|
|
157
|
-
const alreadyDone = () => {
|
|
158
|
-
context.ui.notify(`PR #${pr.number} was merged and the task is already completed.`, "info");
|
|
159
|
-
acknowledge();
|
|
160
|
-
};
|
|
161
|
-
if (current.state === "done") { alreadyDone(); return; }
|
|
162
|
-
if (!["active", "in_progress"].includes(current.state)) return;
|
|
163
|
-
const confirmed = await confirmCompletion(context, pr, signal);
|
|
164
|
-
if (!same() || !context.isIdle()) return;
|
|
165
|
-
// Re-read selection, link, and revision after the dialog; never complete a changed task.
|
|
166
|
-
const fresh = await latest(context, current);
|
|
167
|
-
if (!same() || !fresh || fresh.id !== current.id || taskPR(fresh)?.url !== pr.url) return;
|
|
168
|
-
if (fresh.state === "done") { alreadyDone(); return; }
|
|
169
|
-
if (fresh.updated_at !== current.updated_at) {
|
|
170
|
-
context.ui.notify("Pinote task changed while confirming. The merge will be checked again.", "warning");
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
if (confirmed) {
|
|
174
|
-
await deps.done(fresh, () => same() && context.isIdle());
|
|
175
|
-
if (!valid()) return;
|
|
176
|
-
context.ui.notify(`Pinote #${fresh.id} completed.`, "info");
|
|
177
|
-
}
|
|
178
|
-
acknowledge();
|
|
179
|
-
} finally {
|
|
180
|
-
release();
|
|
181
|
-
if (valid()) await deps.refresh(context);
|
|
182
|
-
}
|
|
183
|
-
} catch {
|
|
184
|
-
if (valid() && !warned) {
|
|
185
|
-
warned = true;
|
|
186
|
-
context.ui.notify("Pinote PR check failed. Check gh authentication and note availability; polling will retry.", "warning");
|
|
187
|
-
}
|
|
188
|
-
} finally {
|
|
189
|
-
// A previous session's request must not affect a replacement watcher's timer.
|
|
190
|
-
if (valid()) { running = false; schedule(); }
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
return {
|
|
194
|
-
update,
|
|
195
|
-
start(context: ExtensionContext) {
|
|
196
|
-
this.stop();
|
|
197
|
-
if (!context.hasUI || context.mode !== "tui") return;
|
|
198
|
-
const seconds = Number(process.env.PINOTE_PR_POLL_SECONDS ?? "60");
|
|
199
|
-
interval = Number.isInteger(seconds) && (seconds === 0 || seconds >= 10 && seconds <= 86400)
|
|
200
|
-
? seconds * 1000 : 60_000;
|
|
201
|
-
if (interval !== seconds * 1000) context.ui.notify("Invalid PINOTE_PR_POLL_SECONDS; using 60 seconds (allowed: 0 or 10–86400).", "warning");
|
|
202
|
-
ctx = context;
|
|
203
|
-
const branch = context.sessionManager.getBranch();
|
|
204
|
-
// Persist only the identity; latest() must verify the real task state before polling.
|
|
205
|
-
for (const entry of branch) {
|
|
206
|
-
if (entry.type !== "custom" || entry.customType !== watchEntryType) continue;
|
|
207
|
-
const data = entry.data as { cwd?: unknown; task?: { id?: unknown; url?: unknown } | null } | undefined;
|
|
208
|
-
if (data?.cwd !== context.cwd) continue;
|
|
209
|
-
if (data.task === null) { watched = null; continue; }
|
|
210
|
-
if (!data.task || !Number.isSafeInteger(data.task.id) || Number(data.task.id) < 1 || typeof data.task.url !== "string") continue;
|
|
211
|
-
const restored = { id: Number(data.task.id), state: "unknown", updated_at: "", agent_notes: { PR: data.task.url } };
|
|
212
|
-
if (taskPR(restored)) watched = restored;
|
|
213
|
-
}
|
|
214
|
-
acknowledged = new Set(branch.flatMap((entry) => {
|
|
215
|
-
if (entry.type !== "custom" || entry.customType !== entryType) return [];
|
|
216
|
-
const data = entry.data as { cwd?: unknown; key?: unknown } | undefined;
|
|
217
|
-
return data?.cwd === context.cwd && typeof data.key === "string" ? [data.key] : [];
|
|
218
|
-
}));
|
|
219
|
-
schedule(0);
|
|
220
|
-
},
|
|
221
|
-
stop() {
|
|
222
|
-
generation++;
|
|
223
|
-
controller.abort();
|
|
224
|
-
controller = new AbortController();
|
|
225
|
-
clearTimeout(timer);
|
|
226
|
-
ctx?.ui.setStatus(legacyFooterStatusKey, undefined);
|
|
227
|
-
ctx?.ui.setStatus(footerStatusKey, undefined);
|
|
228
|
-
ctx = undefined;
|
|
229
|
-
watched = null;
|
|
230
|
-
visiblePR = undefined;
|
|
231
|
-
visibleLinks = [];
|
|
232
|
-
running = false;
|
|
233
|
-
warned = false;
|
|
234
|
-
},
|
|
235
|
-
};
|
|
236
|
-
}
|