pi-note 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,7 +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 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.
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. Icons are bold; their apparent weight depends on the terminal font, and emoji may look unchanged.
72
72
 
73
73
  ### Display-only preview
74
74
 
@@ -185,25 +185,25 @@ asking in chat. The pin footer shows a pending suggestion, distinct from a selec
185
185
 
186
186
  ```text
187
187
  📌 ✕ + · [tag] Task title...
188
- 📌 ✓ · [tag] Task title...
188
+ 📌 ✓ · [tag] Task title...
189
189
  ```
190
190
 
191
191
  ![Suggested task controls](../docs/images/pi-task-suggestion.png)
192
192
  ![Selected task with Done control](../docs/images/pi-task-selected.png)
193
193
 
194
- - Muted **✕** dismisses without creating a note; further offers are suppressed until a new session or reload.
194
+ - Muted **✕** dismisses without creating a note; further offers are suppressed until a new session.
195
195
  - Accent **+** creates, starts, and selects the suggested task.
196
- - Green **✓** completes the selected task and clears the selection on success.
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
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.
198
+ Done occupies the former ✕ position, not the + position, so clicking Add twice cannot accidentally complete the new task. The selected row removes the former + cell's padding. The selected task's title is a separate preview link.
199
199
 
200
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
201
 
202
- Controls use the [Kitty preview handler](#display-only-preview), with no additional configuration. Fullscreen Kitty uses **Ctrl+Shift+click**. Keyboard alternatives are `/pi-note-no`, `/pi-note-yes`, and `/pi-note-done`. No action submits input or starts an agent turn; editor drafts stay unchanged.
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
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.
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
205
 
206
- An existing suggestion is retained rather than replaced. Selection, tree navigation, reload and shutdown invalidate its links; repeated acceptance cannot duplicate a note. A busy Pinote operation blocks acceptance. Noninteractive agents still ask in chat before using `pinote_add` with `select: true`. An existing selection is not replaced unless the user asks to switch. Reuse a saved
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
207
207
  tag name when it fits.
208
208
  Task text starts with a short, action-oriented title (aim for at most 60 characters).
209
209
  Put context, URLs, commands, and acceptance criteria after a blank line; the title
@@ -217,7 +217,7 @@ list, append its label to `Bar`, one label per line. Do not list `PR` in `Bar`.
217
217
  Agents use `pinote_update_current`; there is no separate `add_to_bottom_bar` tool.
218
218
  This is guidance, not truncation or a storage limit.
219
219
 
220
- Values are Markdown strings; no fields are required. `PR` enables the watcher below.
220
+ Values are Markdown strings; no fields are required. `PR` can display a clickable link without GitHub polling.
221
221
  `Bar` chooses extra footer fields unless configuration overrides it:
222
222
 
223
223
  ```markdown
@@ -314,40 +314,18 @@ Pi's final ellipsis is plain text; click the remaining task text to preview it.
314
314
  This extension does not replace other footers. Other fields, such as `Jira` and
315
315
  `CWD` in the example, stay off the footer unless selected.
316
316
 
317
- ### PR merge watcher
318
-
319
- Set `PR` with `pinote_update_current` to one `https://github.com/owner/repo/pull/123` URL
320
- or Markdown link. Legacy footer settings show a clickable **PR #123**; global
321
- field settings can change or hide that presentation without disabling the watcher.
322
- Other hosts, multiple links, and prose are not watched.
323
-
324
- Interactive Pi checks only the selected task's PR using authenticated `gh`
325
- (`gh auth login`). Polling defaults to 60 seconds; launch Pi with
326
- `PINOTE_PR_POLL_SECONDS=120 pi` to change it, or `0` to disable polling.
327
- Allowed intervals are 10–86400 seconds. Configured links remain visible when polling is disabled.
328
- Network/authentication failures warn once until recovery and retry next interval.
329
- No polling runs in print/RPC mode or after Pi exits.
330
-
331
- On merge, Pi waits until idle and asks **Mark this task completed?** In Kitty,
332
- indeterminate progress animates the tab while the prompt awaits input (with Kitty's
333
- default progress-aware tab title or a working/ready renderer). Progress clears on
334
- response, cancellation, or shutdown; other terminals and redirected output are untouched.
335
- Confirmation uses the same guarded CLI Done operation as `/pi-note`; declining leaves
336
- the task unchanged. If it is already done, Pi says **PR #123 was merged and the task is
337
- already completed**, without completing it again. Completion clears the selection
338
- and hides the footer link, even with polling disabled. The background watch remains
339
- until another task is selected, the link is removed, or Pi exits. Removed/scheduled tasks are no longer watched.
340
-
341
- The watched task ID/link and merge acknowledgements are saved in the Pi session,
342
- so `/reload` and session resume retain completed-task watches without repeating
343
- acknowledged prompts. A new session can notify again for a
344
- selected task. Task or PR changes during confirmation cannot complete a different
345
- task. No completion-hook system is added; this uses Pinote's existing Done flow.
346
- A separate branch-based PR-status extension may show a duplicate link; disable it
347
- if you only want task-linked PRs.
348
-
349
- Task data stays local except for GitHub status requests. Tools work without a TUI,
350
- 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.
351
329
  This package does not synchronize databases or paths between machines. Note text
352
330
  and fields loaded into Pi are sent to the configured model when used as context;
353
331
  avoid secrets. Agent commands do not send desktop notifications.
@@ -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/index.ts CHANGED
@@ -1,10 +1,11 @@
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 { createPRWatcher } from "./pr-watch.ts";
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";
@@ -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 clearSuggestion = (ctx: ExtensionContext) => {
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 (suggestion && ctx.hasUI && ctx.mode === "tui") ctx.ui.setStatus("pinote", undefined);
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;
@@ -270,38 +295,21 @@ export default function (pi: ExtensionAPI) {
270
295
  selectedId === previewId ? preview(ctx, signal) : Promise.resolve(false), current ? {
271
296
  revision: current.updated_at,
272
297
  invoke: () => alive && generation === epoch && previewBranch === branchEpoch && selectedId === previewId
273
- ? completeTask(ctx, current) : Promise.resolve(false),
298
+ ? dispatchCompletion(ctx, current, `${runtimeToken}:${generation}:${previewBranch}:${previewId}`) : Promise.resolve(false),
274
299
  } : undefined);
275
300
  ctx.ui.setStatus("pinote", current ? renderSelectedTask(current, footerConfig.titleWidth, ctx.ui.theme, {
276
301
  preview: previewBridge?.url(), done: previewBridge?.doneUrl(),
277
302
  }) : suggestionStatus(ctx));
278
- watcher.update(ctx, current);
303
+ updateTaskFooter(ctx, current, footerConfig);
279
304
  }
280
305
  } catch {
281
306
  if (alive && generation === epoch && serial === refreshSerial) {
282
307
  previewBridge?.invalidate();
283
- ctx.ui.setStatus("pinote", suggestionStatus(ctx) ?? `${noteIcon} Pinote unavailable · ${cliState === "upgrade" ? "/pi-note-upgrade" : cliState === "setup" ? "/pi-note-setup" : "/pi-note"}`);
308
+ clearTaskFooter(ctx);
309
+ ctx.ui.setStatus("pinote", suggestionStatus(ctx) ?? `${ctx.ui.theme.bold(noteIcon)} Pinote unavailable · ${cliState === "upgrade" ? "/pi-note-upgrade" : cliState === "setup" ? "/pi-note-setup" : "/pi-note"}`);
284
310
  }
285
311
  }
286
312
  };
287
- const watcher = createPRWatcher(pi, {
288
- selected,
289
- footerConfig: () => footerConfig,
290
- get: async (id) => requiredTask(await run(["agent", "get", String(id)]), id),
291
- done: async (current, canAct) => {
292
- const result = requiredTask(await run([
293
- "agent", "done", String(current.id), "--expected-updated-at", current.updated_at,
294
- ], undefined, canAct), current.id);
295
- if (result.state !== "done") throw new Error(compatible);
296
- },
297
- claim: () => {
298
- if (pending) return;
299
- const operation = Symbol();
300
- pending = operation;
301
- return () => { if (pending === operation) pending = undefined; };
302
- },
303
- refresh,
304
- });
305
313
  const preview = async (ctx: ExtensionContext, requestSignal?: AbortSignal): Promise<boolean> => {
306
314
  if (!ctx.hasUI || ctx.mode !== "tui" || !ctx.isIdle() || pending || !validId(selectedId)) return false;
307
315
  const generation = epoch;
@@ -330,44 +338,75 @@ export default function (pi: ExtensionAPI) {
330
338
  if (pending === operation) pending = undefined;
331
339
  }
332
340
  };
333
- const completeTask = async (ctx: ExtensionContext, displayed?: Task): Promise<boolean> => {
334
- if (!alive || !ctx.hasUI || ctx.mode !== "tui" || !ctx.isIdle() || pending || setupAbort || !validId(selectedId)) return false;
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
+ }
335
368
  const generation = epoch;
336
369
  const branch = branchEpoch;
337
370
  const id = selectedId;
338
371
  const operation = Symbol();
339
372
  pending = operation;
373
+ request?.started();
340
374
  refreshSerial++;
341
375
  const canAct = () => alive && generation === epoch && branch === branchEpoch && selectedId === id && ctx.isIdle();
342
376
  let wrote = false;
343
377
  try {
344
- const current = displayed ?? await currentTask(undefined, canAct);
345
- if (!current || current.id !== id || !canAct()) return false;
378
+ const current = request?.task ?? displayed ?? await currentTask(undefined, canAct);
379
+ if (!current || current.id !== id || !canAct()) return;
346
380
  const completed = requiredTask(await run([
347
381
  "agent", "done", String(id), "--expected-updated-at", current.updated_at,
348
382
  ], undefined, canAct), id);
349
383
  wrote = true;
350
384
  if (completed.state !== "done") throw new Error(compatible);
351
385
  if (!canAct()) throw new Error("The session or selection changed. Completion may have committed; read the task before retrying.");
386
+ request?.finish(true);
352
387
  remember(null, branch);
353
- ctx.ui.notify(`Pinote #${id} completed.`, "info");
354
- return true;
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");
355
398
  } catch (error) {
356
399
  if (alive && generation === epoch) ctx.ui.notify(
357
400
  `Pinote completion: ${clean(String(error))}${wrote ? " Check the task before retrying." : ""}`, "error");
358
- return false;
359
401
  } finally {
402
+ request?.finish(false);
360
403
  if (pending === operation) pending = undefined;
361
404
  if (alive && generation === epoch) await refresh(ctx);
362
405
  }
363
406
  };
364
407
  pi.registerCommand("pi-note-done", {
365
- description: "Complete the selected task (same as the footer ✓)",
366
- handler: async (args, ctx) => {
367
- if (!ctx.hasUI || ctx.mode !== "tui") return;
368
- if (args.trim()) { ctx.ui.notify("Usage: /pi-note-done", "warning"); return; }
369
- if (!await completeTask(ctx)) ctx.ui.notify("Select a task and wait until Pi is idle and Pinote is available before completing it.", "warning");
370
- },
408
+ description: "Complete the selected task, start a clean session, and reload Pi",
409
+ handler: (args, ctx) => completeSelected(args, ctx),
371
410
  });
372
411
  pi.registerCommand("pi-note-preview", {
373
412
  description: "Display the selected task locally without sending it to the model",
@@ -379,10 +418,9 @@ export default function (pi: ExtensionAPI) {
379
418
  });
380
419
  pi.on("session_start", async (_event, ctx) => {
381
420
  setupAbort?.abort();
382
- // Retire old polling before bridge teardown/startup yields to its scheduled callbacks.
383
- watcher.stop();
384
- clearSuggestion(activeContext ?? ctx);
385
- offerDeclined = false;
421
+ cancelCompletionDispatch();
422
+ clearTaskFooter(activeContext ?? ctx);
423
+ clearSuggestion(activeContext ?? ctx, false);
386
424
  alive = false;
387
425
  epoch++;
388
426
  branchEpoch++;
@@ -394,6 +432,13 @@ export default function (pi: ExtensionAPI) {
394
432
  alive = true;
395
433
  activeContext = ctx;
396
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
+ }
397
442
  footerConfig = loadFooterConfig((message) => {
398
443
  if (ctx.hasUI) ctx.ui.notify(message, "warning");
399
444
  });
@@ -418,13 +463,16 @@ export default function (pi: ExtensionAPI) {
418
463
  if (!setupAbort) pending = undefined;
419
464
  await checkCLI(ctx, true);
420
465
  if (!alive || generation !== epoch) return;
421
- if (!setupAbort) watcher.start(ctx);
466
+ if (suggestion) previewBridge?.setSuggestion((choice) => respondToSuggestion(choice, ctx));
422
467
  await refresh(ctx);
423
468
  });
424
469
  pi.on("session_tree", async (_event, ctx) => {
425
470
  if (!alive) return;
426
471
  branchEpoch++;
427
- clearSuggestion(ctx);
472
+ cancelCompletionDispatch();
473
+ clearSuggestion(ctx, false);
474
+ // Never resurrect an already consumed proposal by navigating behind its tombstone.
475
+ saveSuggestion();
428
476
  previewBridge?.invalidate();
429
477
  const generation = branchEpoch;
430
478
  const id = readSelection(ctx);
@@ -437,14 +485,15 @@ export default function (pi: ExtensionAPI) {
437
485
  pi.on("agent_end", async (_event, ctx) => { await refresh(ctx); });
438
486
  pi.on("session_shutdown", async (_event, ctx) => {
439
487
  setupAbort?.abort();
440
- clearSuggestion(ctx);
488
+ cancelCompletionDispatch();
489
+ clearSuggestion(ctx, false);
441
490
  alive = false;
442
491
  activeContext = undefined;
443
492
  selectedId = null;
444
493
  epoch++;
445
494
  branchEpoch++;
446
495
  refreshSerial++;
447
- watcher.stop();
496
+ clearTaskFooter(ctx);
448
497
  if (ctx.hasUI && ctx.mode === "tui") ctx.ui.setStatus("pinote", undefined);
449
498
  const bridge = previewBridge;
450
499
  previewBridge = undefined;
@@ -468,7 +517,7 @@ export default function (pi: ExtensionAPI) {
468
517
  const generation = epoch;
469
518
  const controller = new AbortController();
470
519
  setupAbort = controller;
471
- watcher.stop();
520
+ clearTaskFooter(ctx);
472
521
  const currentSession = () => alive && generation === epoch;
473
522
  try {
474
523
  await setupCLI(pi, ctx, name === "pi-note-upgrade",
@@ -483,7 +532,6 @@ export default function (pi: ExtensionAPI) {
483
532
  const generation = epoch;
484
533
  await checkCLI(latestContext, !currentSession());
485
534
  if (!alive || generation !== epoch || setupAbort) return;
486
- watcher.start(latestContext);
487
535
  await refresh(latestContext);
488
536
  }
489
537
  }
@@ -547,13 +595,8 @@ export default function (pi: ExtensionAPI) {
547
595
  if (!action || !canAct()) return;
548
596
  if (action === "Settings") { settings = true; continue; }
549
597
  if (action === "Done" && current) {
550
- refreshSerial++;
551
- const completed = requiredTask(await run([
552
- "agent", "done", String(current.id), "--expected-updated-at", current.updated_at,
553
- ], undefined, canAct), current.id);
554
- if (completed.state !== "done") throw new Error(compatible);
555
- if (currentSession() && branch === branchEpoch) remember(null, branch);
556
- if (currentSession()) ctx.ui.notify(`Pinote #${current.id} completed.`, "info");
598
+ if (pending === operation) pending = undefined;
599
+ await completeSelected("", ctx, current);
557
600
  return;
558
601
  }
559
602
  let id: number;
@@ -601,7 +644,7 @@ export default function (pi: ExtensionAPI) {
601
644
  name: "pinote_update_current",
602
645
  label: "Pinote update current",
603
646
  promptGuidelines: [handoffGuidance],
604
- 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 and watch for merge confirmation in interactive Pi. 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.",
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.",
605
648
  parameters: Type.Object({
606
649
  expected_updated_at: Type.String({ minLength: 1 }),
607
650
  set: Type.Optional(Type.Record(Type.String(), Type.String())),
@@ -727,6 +770,7 @@ export default function (pi: ExtensionAPI) {
727
770
  if (offerDeclined) return result("dismissed; continue without a note and do not offer again");
728
771
  if (suggestion) return result("pending; existing suggestion retained, continue work without asking again");
729
772
  suggestion = { text: params.text, ...(params.tag === undefined ? {} : { tag: params.tag.trim() }) };
773
+ saveSuggestion();
730
774
  previewBridge?.setSuggestion((choice) => respondToSuggestion(choice, ctx));
731
775
  ctx.ui.setStatus("pinote", suggestionStatus(ctx));
732
776
  return result("pending; user can click ✓ to add and select or ✕ to dismiss, continue work without asking again");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
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
- "pr-watch.ts",
30
+ "footer-status.ts",
31
31
  "footer-links.ts",
32
32
  "footer-config.ts",
33
33
  "footer-settings.ts",
@@ -11,11 +11,11 @@ const add = readFileSync(new URL("./icons/add.txt", import.meta.url), "utf8").tr
11
11
  const gap = "\u00a0";
12
12
  const link = (label: string, url?: string) => url ? `\x1b]8;;${url}\x07${label}\x1b]8;;\x07` : label;
13
13
  type Task = { text: string; tag?: string | null };
14
- type Colors = Pick<Theme, "fg">;
14
+ type Colors = Pick<Theme, "fg" | "bold">;
15
15
 
16
16
  function renderTask(task: Task, width: number, theme: Colors, prefix: string, previewUrl?: string): string {
17
17
  // All controls or none: never let a narrow budget reveal only one pending choice.
18
- if (visibleWidth(prefix) > width) return truncateToWidth(pin, width, "");
18
+ if (visibleWidth(prefix) > width) return truncateToWidth(theme.bold(pin), width, "");
19
19
  const remaining = width - visibleWidth(prefix) - 3; // separator: " · "
20
20
  if (remaining < 4) return prefix.trimEnd();
21
21
  const clean = (value: string) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f-\x9f]/gu, " ").replace(/\s+/gu, " ").trim();
@@ -30,12 +30,12 @@ function renderTask(task: Task, width: number, theme: Colors, prefix: string, pr
30
30
 
31
31
  export function renderSuggestion(task: Task, width: number, theme: Colors, urls: { yes?: string; no?: string }): string {
32
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))}`);
33
+ `${theme.bold(pin)} ${theme.fg("muted", link(theme.bold(cross), urls.no))}${gap.repeat(2)}${theme.fg("accent", link(theme.bold(add), urls.yes))}`);
34
34
  }
35
35
 
36
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.
37
+ // Done stays in the former '✕' cell, never the Add cell.
38
38
  return renderTask(task, width, theme,
39
- `${pin} ${theme.fg("success", link(check, urls.done))}${gap.repeat(visibleWidth(cross) + 2 + visibleWidth(add) - visibleWidth(check))}`,
39
+ `${theme.bold(pin)} ${theme.fg("success", link(theme.bold(check), urls.done))}`,
40
40
  urls.preview);
41
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
- }