pi-note 0.15.1 → 0.16.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
@@ -9,10 +9,9 @@ The extension and Python app share a repository but install separately.
9
9
 
10
10
  Requires Pi 0.99.1+, Node.js 22.19+ and **pinote 0.3.0+** (`note` on PATH). Add and tag listing need **pinote 0.4.0+**.
11
11
  From this checkout, install the extension with `pi install ./pi`, then run
12
- `/reload` in interactive Pi. Use `/pi-note-setup` if the CLI is missing, or the
13
- suggested `/pi-note-upgrade` if it is older than the bundled CLI. The planned npm package name is
14
- `pi-note`; after its first release use `pi install npm:pi-note` instead.
15
- It is not published yet. npm installation does not run Python installers.
12
+ `/reload` in interactive Pi. The planned npm package name is `pi-note`; after its
13
+ first release use `pi install npm:pi-note` instead. It is not published yet. npm
14
+ installation does not run Python installers.
16
15
 
17
16
  ### CLI setup and upgrades
18
17
 
@@ -20,21 +19,19 @@ Install [uv](https://docs.astral.sh/uv/getting-started/installation/) first and
20
19
  restart Pi with `uv` on PATH. At startup the extension checks `note --version`
21
20
  against its bundled CLI version (currently 0.4.0), without network requests:
22
21
 
23
- - Missing/unrecognized CLI: show `/pi-note-setup` in the slash-command menu.
24
- - Older CLI: show and suggest `/pi-note-upgrade`.
25
- - Equal/newer CLI: hide both commands; no upgrade notification.
22
+ - Missing/unrecognized CLI: `/pi-note` opens Settings directly, where **Install CLI** is available.
23
+ - Older CLI: `/pi-note` opens Settings directly, where **Upgrade CLI** is available.
24
+ - Equal/newer CLI: neither installation action is shown; no upgrade notification.
26
25
 
27
- Both commands ask before installing into uv's isolated tool environment.
28
- The menu refreshes after setup/upgrade; restart Pi or `/reload` after external
29
- CLI changes. Manually typing either command rechecks the version and never
30
- reinstalls an equal/newer CLI or intentionally downgrades it.
26
+ Install and upgrade recheck the CLI version and ask before installing into uv's
27
+ isolated tool environment. Restart Pi or `/reload` after external CLI changes;
28
+ installation actions never reinstall an equal/newer CLI or intentionally downgrade it.
31
29
  Python 3.11+ is required; uv can download a suitable interpreter when missing.
32
- The command downloads a pinned GitHub source archive and build dependencies,
30
+ Installation downloads a pinned GitHub source archive and build dependencies,
33
31
  not a similarly named PyPI package. It does not install GTK dependencies, edit
34
32
  shell configuration, or change tasks. This checks the Python CLI bundled with
35
33
  the installed extension, not the latest npm/GitHub release. Update the extension
36
- first to receive a newer bundled CLI. `/pi-note-setup --upgrade` is replaced by
37
- `/pi-note-upgrade`.
34
+ first to receive a newer bundled CLI.
38
35
 
39
36
  Back up your database before upgrading and update an optional GUI separately.
40
37
  For checkout development, use `uv tool install --reinstall .` at the repo root
@@ -45,7 +42,8 @@ put that directory before older `note` executables on PATH, and restart Pi.
45
42
  Check `note --version`, then restart Pi or `/reload`. Network/build failures and
46
43
  conflicting executables are reported without forcibly overwriting another
47
44
  installer's commands. Fix the reported cause and retry; installs time out after
48
- three minutes. Setup/upgrade commands are hidden while installation runs.
45
+ three minutes. Installation actions are unavailable while installation runs.
46
+ Setup hints direct users to `/pi-note` → Settings.
49
47
  Missing uv is reported with its installation link, not installed
50
48
  automatically. The [GTK desktop app](../README.md#optional-gtk-checklist) is optional
51
49
  and still requires separate system dependencies.
@@ -56,15 +54,20 @@ standalone `settings/pi/extensions/pinote.ts` entry if installed; load only one
56
54
 
57
55
  ## Use
58
56
 
59
- `/pi-note` picks a task, marks it in progress, and adds this default prompt to the editor:
57
+ When the CLI is ready and no task is selected, `/pi-note` opens the task picker.
58
+ It marks the chosen task in progress and adds this default prompt to the editor:
60
59
  “Read the current Pinote task. Summarize your understanding, but don’t start work yet.”
60
+ If the CLI is missing or older than the bundled version, `/pi-note` opens Settings
61
+ directly so the relevant CLI installation action remains accessible. **Tab** opens
62
+ Settings from the picker or task view; **Tab** from Settings returns to tasks.
61
63
  The agent fetches current task text and saved handoff fields when you submit;
62
64
  they are not copied into the draft. The default prompt asks for a summary, not implementation.
63
65
  Existing drafts are preserved; nothing is submitted automatically.
64
- With a selected task it offers **Continue**, **Done**, **Switch task**, and **Settings**.
65
- Press **Tab** from the task menu or picker to open global settings; Tab
66
- returns to tasks, keeping saved changes. Settings also lists fields found on the current
67
- task, so existing values can be configured without creating another task.
66
+ With a selected task, `/pi-note` offers **Continue**, **Done**, **Switch task**, and
67
+ **Settings**. Settings provides **Preview task**, **Complete task (new session)**,
68
+ **Accept suggestion**, and **Dismiss suggestion** when applicable, plus
69
+ **Install CLI** or **Upgrade CLI** only when needed. Settings also lists fields found
70
+ on the current task, so existing values can be configured without creating another task.
68
71
  Type in the task picker to filter by text, ID, tag, or state (all words must match).
69
72
  Use ↑/↓ and Enter to select, or Esc to cancel. Rows show **●** for in progress or
70
73
  **○** for active, followed by the tag (`[Untagged]` when absent).
@@ -72,8 +75,8 @@ The normal Pi status area shows selected-task controls and title without replaci
72
75
 
73
76
  ### Display-only preview
74
77
 
75
- Run `/pi-note-preview` to print the selected task's full Markdown text and saved
76
- agent fields in the transcript. The preview is labeled **display only · not sent
78
+ Use **Preview task** in Settings to print the selected task's full Markdown text
79
+ and saved agent fields in the transcript. The preview is labeled **display only · not sent
77
80
  to model**: it is a local custom session entry, excluded from subsequent model
78
81
  requests, compaction and branch summaries. It survives session resume but does not
79
82
  modify the note, editor draft or task selection, and never starts a model call.
@@ -100,8 +103,8 @@ Reload Kitty with **Ctrl+Shift+F5**. In Pi's regular mode, click the task text;
100
103
  fullscreen mode, use **Ctrl+Shift+click** so Kitty handles the link instead of
101
104
  Pi's system URL opener. The helper sends only an authenticated selection
102
105
  identifier, never note content or a model prompt. Stale links after task switching, tree navigation, reload or session exit
103
- are rejected. Other terminals can use `/pi-note-preview`; if the socket cannot
104
- start, the task remains visible without a link and that command remains available.
106
+ are rejected. The **Preview task** Settings action is also available when the
107
+ socket cannot start; the task remains visible without a link.
105
108
 
106
109
  Each Pi session remembers its own task, including several sessions in one folder.
107
110
  Resume restores that session's task; a new session starts unselected and does not import
@@ -129,9 +132,16 @@ Configure `~/.pi/agent/pi-note.json` (or the agent directory set by
129
132
 
130
133
  `taskOfferPolicy` controls the agent's task-offer guidance:
131
134
 
132
- - `always` (default): offer a task when given work with no task selected.
133
- - `github-remote`: first verify with Git that the repository has an HTTPS or SSH
134
- remote hosted on `github.com`; otherwise do not offer. Local paths and other
135
+ With no selected task, suggest a note only when implementation is expected to
136
+ span multiple turns. Skip questions, investigation, recon, and small edits (such
137
+ as changing a few configuration lines). If investigation develops into substantial
138
+ implementation, suggest then. No plan is required: a short title identifies the
139
+ work. Explicit user requests to create a task remain allowed under every policy.
140
+
141
+ - `always` (default): offer for qualifying implementation in any repository.
142
+ - `github-remote`: for qualifying implementation, first verify with Git that the
143
+ repository has an HTTPS or SSH remote hosted on `github.com`; otherwise do not
144
+ offer. Local paths and other
135
145
  hosts do not qualify. This is agent guidance, not a network or PR-access check.
136
146
  - `never`: do not offer tasks; explicit requests to create one remain allowed.
137
147
 
@@ -152,8 +162,11 @@ field needs no `/reload`. Its value is literal text, not a prompt template; use
152
162
  `\n` in JSON strings for multiple lines. It must be a nonblank string without control
153
163
  characters other than tabs and newlines. A missing file or key uses the default.
154
164
  Invalid/unreadable configuration reports an error without starting/switching a
155
- task or changing the editor. **Done** and agent tools are unaffected. This is
156
- user-level configuration only; project-local files are not read.
165
+ task or changing the editor. Menu **Done**, footer completion, and agent tools are
166
+ unaffected. If configuration is invalid or unreadable, Settings shows only task,
167
+ suggestion, and CLI actions; repair `pi-note.json` before editing preferences.
168
+ Invalid configuration is never replaced with defaults. This is user-level
169
+ configuration only; project-local files are not read.
157
170
 
158
171
  ### Agent tools
159
172
 
@@ -193,13 +206,13 @@ asking in chat. The pin footer shows a pending suggestion, distinct from a selec
193
206
 
194
207
  - Muted **✕** dismisses without creating a note; further offers are suppressed until a new session.
195
208
  - 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.
209
+ - Green **✓** completes the selected task, starts a clean unselected session, and reloads Pi without a completion prompt. Menu **Done** and **Complete task** in Settings do the same.
197
210
 
198
211
  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
212
 
200
213
  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
214
 
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.
215
+ Controls use the [Kitty preview handler](#display-only-preview), with no additional configuration. Fullscreen Kitty uses **Ctrl+Shift+click**. The Settings actions **Accept suggestion** and **Dismiss suggestion** perform the corresponding footer actions. No action submits input or starts an agent turn. Add and Dismiss preserve editor drafts; **Complete task** starts a clean session and reloads Pi, discarding the editor draft, so save unfinished input before completing a task.
203
216
 
204
217
  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
218
 
@@ -4,6 +4,9 @@ import { defaultFooterField, parseFooterConfig, parseHandoffPrompt, type PinoteS
4
4
 
5
5
  type Matches = (data: string, action: string) => boolean;
6
6
  type Row = { label: string; action: () => void };
7
+ export type SettingsAction = "preview" | "done" | "yes" | "no" | "setup" | "upgrade";
8
+ export type SettingsResult = "tasks" | SettingsAction | undefined;
9
+ export type SettingsOption = { label: string; result: SettingsAction };
7
10
  const isTab = (data: string) => matchesKey(data, "tab") || matchesKey(data, "shift+tab");
8
11
  const display = (value: string) => value.replace(/[\x00-\x1f\x7f-\x9f]/gu, " ");
9
12
  const actions = ["Continue", "Done", "Switch task", "Settings"];
@@ -44,7 +47,9 @@ export class FooterSettings {
44
47
  private knownFields: string[];
45
48
  private theme: Theme;
46
49
  private matches: Matches;
47
- private done: (result: "tasks" | undefined) => void;
50
+ private done: (result: SettingsResult) => void;
51
+ private options: SettingsOption[];
52
+ private configurationError?: string;
48
53
  private save: (settings: PinoteSettings) => void;
49
54
  private requestRender: () => void;
50
55
  private index = 0;
@@ -54,8 +59,10 @@ export class FooterSettings {
54
59
  private hasFocus = false;
55
60
 
56
61
  constructor(config: PinoteSettings, knownFields: string[], theme: Theme, matches: Matches,
57
- done: (result: "tasks" | undefined) => void, requestRender: () => void, createEditor: () => Editor,
58
- save: (settings: PinoteSettings) => void) {
62
+ done: (result: SettingsResult) => void, requestRender: () => void, createEditor: () => Editor,
63
+ save: (settings: PinoteSettings) => void, options: SettingsOption[] = [], configurationError?: string) {
64
+ this.options = options;
65
+ this.configurationError = configurationError;
59
66
  this.draft = structuredClone(config.footer);
60
67
  this.prompt = config.handoffPrompt;
61
68
  this.createEditor = createEditor;
@@ -97,7 +104,12 @@ export class FooterSettings {
97
104
  this.field = structuredClone(this.draft.fields!.find((field) => field.name === name) ?? defaultFooterField(name));
98
105
  this.index = 0; this.error = "";
99
106
  }
107
+ private actionRows(): Row[] {
108
+ return [...this.options.map(({ label, result }) => ({ label, action: () => this.done(result) })),
109
+ { label: "Return to tasks", action: () => this.done("tasks") }];
110
+ }
100
111
  private rootRows(): Row[] {
112
+ if (this.configurationError) return this.actionRows();
101
113
  const rows: Row[] = (["titleWidth", "fieldWidth", "maxFields"] as const).map((key) => ({
102
114
  label: `${{ titleWidth: "Title width", fieldWidth: "Default field width", maxFields: "Max fields" }[key]}: ${this.draft[key]}`,
103
115
  action: () => this.ask(key, String(this.draft[key]), (value) => {
@@ -123,8 +135,8 @@ export class FooterSettings {
123
135
  this.persistField(field);
124
136
  this.index = 0;
125
137
  }
126
- }) },
127
- { label: "Return to tasks", action: () => this.done("tasks") });
138
+ }) });
139
+ rows.push(...this.actionRows());
128
140
  return rows;
129
141
  }
130
142
  private fieldRows(): Row[] {
@@ -183,6 +195,7 @@ export class FooterSettings {
183
195
  }
184
196
  render(width: number) {
185
197
  const lines = [this.theme.fg("accent", "Pinote — Global Settings"), this.theme.fg("dim", "Tasks (Tab) · Settings · Fields")];
198
+ if (this.configurationError) lines.push(this.theme.fg("warning", this.configurationError));
186
199
  if (this.editor) {
187
200
  lines.push(this.theme.fg("accent", "Task prompt"), ...this.editor.render(width),
188
201
  this.theme.fg("dim", "Enter save · Shift+Enter/Ctrl+J newline · Ctrl+C clear · Esc cancel edit"));
@@ -197,10 +210,11 @@ export class FooterSettings {
197
210
  lines.push(...rows.slice(start, start + 8).map((row, offset) => offset + start === this.index
198
211
  ? this.theme.fg("accent", `> ${row.label}`) : ` ${row.label}`));
199
212
  if (rows.length > 8) lines.push(this.theme.fg("dim", `${this.index + 1}/${rows.length}`));
200
- lines.push(this.theme.fg("dim", "↑↓ navigate · Enter edit · Esc back"));
213
+ lines.push(this.theme.fg("dim", "↑↓ navigate · Enter select · Esc back"));
201
214
  }
202
215
  if (this.error) lines.push(this.theme.fg("error", this.error));
203
- lines.push(this.theme.fg("dim", "Changes save automatically · Tab: tasks"));
216
+ lines.push(this.theme.fg("dim", this.configurationError
217
+ ? "Fix pi-note.json to edit settings · Tab: tasks" : "Changes save automatically · Tab: tasks"));
204
218
  return lines.map((line) => truncateToWidth(line, width));
205
219
  }
206
220
  invalidate() { this.input?.widget.invalidate(); this.editor?.invalidate(); }
package/index.ts CHANGED
@@ -7,16 +7,17 @@ import { stripVTControlCharacters } from "node:util";
7
7
  import { Type } from "typebox";
8
8
  import { updateTaskFooter, clearTaskFooter } from "./footer-status.ts";
9
9
  import { defaultFooterConfig, defaultHandoffPrompt, effectiveFooterFields, loadFooterConfig, parseHandoffPrompt, readFooterDocument, saveFooterConfig, type FooterConfig, type PinoteSettings } from "./footer-config.ts";
10
- import { FooterSettings, TaskMenu, withSettingsTab } from "./footer-settings.ts";
10
+ import { FooterSettings, TaskMenu, withSettingsTab, type SettingsOption, type SettingsResult } from "./footer-settings.ts";
11
11
  import { TaskPicker, taskState, taskTag } from "./task-picker.ts";
12
- import { bundledCLIVersion, cliMenu, detectCLI, setupCLI, setupHint, versionAtLeast, type CLIAction } from "./setup.ts";
12
+ import { bundledCLIVersion, detectCLI, setupCLI, setupHint, versionAtLeast, type CLIAction } from "./setup.ts";
13
13
  import { createPreviewBridge } from "./preview-bridge.ts";
14
14
  import { renderSuggestion, renderSelectedTask } from "./task-suggestion.ts";
15
15
 
16
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.";
17
+ const implementationOfferGuidance = "When no pinote task is selected, suggest one only when implementation is expected to span multiple turns. Skip suggestions for questions, investigation, recon, and small edits such as changing a few configuration lines. If investigation develops into substantial implementation, suggest then. No plan is required; use a short title describing the work. Explicit user requests to create one remain allowed.";
17
18
  const offerGuidance = {
18
- always: "When the user gives work and no pinote task is selected, use pinote_propose to show one suggested `[tag] task` in the bottom bar instead of asking in chat. In noninteractive modes, ask before creating it.",
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
+ always: `${implementationOfferGuidance} For eligible work, 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.`,
20
+ "github-remote": `${implementationOfferGuidance} For eligible work, 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.`,
20
21
  never: "Do not offer to create a pinote task. Create one only when the user explicitly requests it.",
21
22
  };
22
23
  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.";
@@ -179,7 +180,7 @@ export default function (pi: ExtensionAPI) {
179
180
  if (!alive || generation !== epoch || setupAbort) return;
180
181
  cliState = action;
181
182
  if (suggest && action === "upgrade") {
182
- ctx.ui.notify(`Pinote CLI ${bundledCLIVersion} is bundled with this extension. Run /pi-note-upgrade to update your older CLI.`, "info");
183
+ ctx.ui.notify(`Pinote CLI ${bundledCLIVersion} is bundled with this extension. Open /pi-note → Settings → Upgrade CLI to update your older CLI.`, "info");
183
184
  }
184
185
  };
185
186
 
@@ -306,7 +307,7 @@ export default function (pi: ExtensionAPI) {
306
307
  if (alive && generation === epoch && serial === refreshSerial) {
307
308
  previewBridge?.invalidate();
308
309
  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"}`);
310
+ ctx.ui.setStatus("pinote", suggestionStatus(ctx) ?? `${ctx.ui.theme.bold(noteIcon)} Pinote unavailable · /pi-note → Settings`);
310
311
  }
311
312
  }
312
313
  };
@@ -349,10 +350,10 @@ export default function (pi: ExtensionAPI) {
349
350
  };
350
351
  const timer = setTimeout(() => {
351
352
  finish(false);
352
- if (alive && activeContext === ctx) ctx.ui.notify("Pinote completion could not start. Use /pi-note-done.", "warning");
353
+ if (alive && activeContext === ctx) ctx.ui.notify("Pinote completion could not start. Open /pi-note → Settings → Complete task.", "warning");
353
354
  }, 4000);
354
355
  completionDispatch = { token, selection, task: displayed, started: () => clearTimeout(timer), finish };
355
- try { pi.sendUserMessage(`/pi-note-done ${token}`, { expandPromptTemplates: true }); }
356
+ try { pi.sendUserMessage(`/pi-note ${token}`, { expandPromptTemplates: true }); }
356
357
  catch { finish(false); }
357
358
  });
358
359
  };
@@ -404,18 +405,6 @@ export default function (pi: ExtensionAPI) {
404
405
  if (alive && generation === epoch) await refresh(ctx);
405
406
  }
406
407
  };
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
- });
411
- pi.registerCommand("pi-note-preview", {
412
- description: "Display the selected task locally without sending it to the model",
413
- handler: async (args, ctx) => {
414
- if (!ctx.hasUI || ctx.mode !== "tui") return;
415
- if (args.trim()) { ctx.ui.notify("Usage: /pi-note-preview", "warning"); return; }
416
- if (!await preview(ctx)) ctx.ui.notify("Select a task with /pi-note and wait until Pi is idle before previewing.", "warning");
417
- },
418
- });
419
408
  pi.on("session_start", async (_event, ctx) => {
420
409
  setupAbort?.abort();
421
410
  cancelCompletionDispatch();
@@ -445,7 +434,6 @@ export default function (pi: ExtensionAPI) {
445
434
  cliState = undefined;
446
435
  if (ctx.hasUI && ctx.mode === "tui") {
447
436
  if (offerConfigError) ctx.ui.notify(`Pinote task offers disabled: ${clean(offerConfigError)} Fix the configuration and /reload.`, "warning");
448
- ctx.ui.addAutocompleteProvider((current) => cliMenu(current, () => setupAbort ? undefined : cliState));
449
437
  }
450
438
  if (ctx.hasUI && ctx.mode === "tui") {
451
439
  const bridge = createPreviewBridge();
@@ -455,7 +443,7 @@ export default function (pi: ExtensionAPI) {
455
443
  } catch {
456
444
  await bridge.stop();
457
445
  if (previewBridge === bridge) previewBridge = undefined;
458
- ctx.ui.notify("Pinote task link unavailable; use /pi-note-preview.", "warning");
446
+ ctx.ui.notify("Pinote task link unavailable; open /pi-note → Settings → Preview task.", "warning");
459
447
  }
460
448
  }
461
449
  if (!alive || generation !== epoch) return;
@@ -500,51 +488,49 @@ export default function (pi: ExtensionAPI) {
500
488
  if (bridge) await bridge.stop();
501
489
  });
502
490
 
503
- const registerInstallCommand = (name: "pi-note-setup" | "pi-note-upgrade") => pi.registerCommand(name, {
504
- description: name === "pi-note-setup" ? "Install the missing Python CLI with uv" : "Upgrade an older Python CLI to the bundled version",
505
- handler: async (args, ctx) => {
506
- if (!ctx.hasUI || ctx.mode !== "tui") return;
507
- if (args.trim()) {
508
- ctx.ui.notify(`Usage: /${name}`, "warning");
509
- return;
510
- }
511
- if (pending || !ctx.isIdle()) {
512
- ctx.ui.notify("Wait until Pi is idle and the pinote operation has finished.", "warning");
513
- return;
514
- }
515
- const operation = Symbol();
516
- pending = operation;
517
- const generation = epoch;
518
- const controller = new AbortController();
519
- setupAbort = controller;
520
- clearTaskFooter(ctx);
521
- const currentSession = () => alive && generation === epoch;
522
- try {
523
- await setupCLI(pi, ctx, name === "pi-note-upgrade",
524
- () => currentSession() && ctx.isIdle(), controller.signal);
525
- } catch (error) {
526
- if (currentSession()) ctx.ui.notify(`Pinote setup: ${clean(String(error))}`, "error");
527
- } finally {
528
- if (pending === operation) pending = undefined;
529
- if (setupAbort === controller) setupAbort = undefined;
530
- const latestContext = activeContext ?? (currentSession() ? ctx : undefined);
531
- if (alive && latestContext) {
532
- const generation = epoch;
533
- await checkCLI(latestContext, !currentSession());
534
- if (!alive || generation !== epoch || setupAbort) return;
535
- await refresh(latestContext);
536
- }
491
+ const installCLI = async (upgrade: boolean, ctx: ExtensionCommandContext) => {
492
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
493
+ if (pending || !ctx.isIdle()) {
494
+ ctx.ui.notify("Wait until Pi is idle and the pinote operation has finished.", "warning");
495
+ return;
496
+ }
497
+ const operation = Symbol();
498
+ pending = operation;
499
+ const generation = epoch;
500
+ const controller = new AbortController();
501
+ setupAbort = controller;
502
+ clearTaskFooter(ctx);
503
+ const currentSession = () => alive && generation === epoch;
504
+ try {
505
+ await setupCLI(pi, ctx, upgrade,
506
+ () => currentSession() && ctx.isIdle(), controller.signal);
507
+ } catch (error) {
508
+ if (currentSession()) ctx.ui.notify(`Pinote setup: ${clean(String(error))}`, "error");
509
+ } finally {
510
+ if (pending === operation) pending = undefined;
511
+ if (setupAbort === controller) setupAbort = undefined;
512
+ const latestContext = activeContext ?? (currentSession() ? ctx : undefined);
513
+ if (alive && latestContext) {
514
+ const generation = epoch;
515
+ await checkCLI(latestContext, !currentSession());
516
+ if (!alive || generation !== epoch || setupAbort) return;
517
+ await refresh(latestContext);
537
518
  }
538
- },
539
- });
540
- registerInstallCommand("pi-note-setup");
541
- registerInstallCommand("pi-note-upgrade");
519
+ }
520
+ };
542
521
 
543
522
  pi.registerCommand("pi-note", {
544
523
  description: "Continue, complete, switch tasks, or edit global settings (Tab)",
545
524
  handler: async (args, ctx) => {
546
525
  if (!ctx.hasUI || ctx.mode !== "tui") return;
547
- if (args.trim()) { ctx.ui.notify("Usage: /pi-note", "warning"); return; }
526
+ // Footer completion needs a command context, but no extra slash command.
527
+ // Only the current one-use capability may dispatch; stale tokens remain inert.
528
+ const token = args.trim();
529
+ if (/^[a-f0-9]{32}:\d+:\d+:\d+:[a-f0-9]{16}$/u.test(token)) {
530
+ await completeSelected(token, ctx);
531
+ return;
532
+ }
533
+ if (token) { ctx.ui.notify("Usage: /pi-note", "warning"); return; }
548
534
  if (pending || !ctx.isIdle()) {
549
535
  ctx.ui.notify("Wait until Pi is idle and the pinote operation has finished.", "warning");
550
536
  return;
@@ -556,19 +542,46 @@ export default function (pi: ExtensionAPI) {
556
542
  const currentSession = () => alive && generation === epoch;
557
543
  const canAct = () => currentSession() && ctx.isIdle() && branch === branchEpoch;
558
544
  try {
559
- const current = await selected(ctx);
545
+ await checkCLI(ctx);
560
546
  if (!canAct()) return;
561
- let settings = false;
547
+ const current = cliState === "ready" ? await selected(ctx) : null;
548
+ if (!canAct()) return;
549
+ let settings = cliState !== "ready";
562
550
  while (canAct()) {
551
+ if (!settings && cliState !== "ready") {
552
+ ctx.ui.notify(setupHint, "warning");
553
+ return;
554
+ }
563
555
  if (settings) {
564
- const document = readFooterDocument();
565
- const root = document.raw === null ? {} : JSON.parse(document.raw);
566
- const config: PinoteSettings = {
567
- footer: { ...document.config, fields: effectiveFooterFields(document.config, current?.agent_notes) },
568
- handoffPrompt: "handoffPrompt" in root ? parseHandoffPrompt(root.handoffPrompt) : defaultHandoffPrompt,
569
- };
570
- let expectedRaw = document.raw;
571
- const edited = await ctx.ui.custom<"tasks" | undefined>((tui, theme, kb, done) =>
556
+ let config: PinoteSettings = { footer: { ...defaultFooterConfig, fields: [] }, handoffPrompt: defaultHandoffPrompt };
557
+ let expectedRaw: string | null = null;
558
+ let configurationError: string | undefined;
559
+ try {
560
+ const document = readFooterDocument();
561
+ const root = document.raw === null ? {} : JSON.parse(document.raw);
562
+ config = {
563
+ footer: { ...document.config, fields: effectiveFooterFields(document.config, current?.agent_notes) },
564
+ handoffPrompt: "handoffPrompt" in root ? parseHandoffPrompt(root.handoffPrompt) : defaultHandoffPrompt,
565
+ };
566
+ expectedRaw = document.raw;
567
+ } catch (error) {
568
+ // Broken preferences must not strand task actions or CLI recovery.
569
+ // Offer actions only; never save defaults over invalid user data.
570
+ configurationError = clean(error instanceof Error ? error.message : String(error));
571
+ }
572
+ const options: SettingsOption[] = [
573
+ ...(current ? [
574
+ { label: "Preview task", result: "preview" as const },
575
+ { label: "Complete task (new session)", result: "done" as const },
576
+ ] : []),
577
+ ...(suggestion ? [
578
+ { label: "Accept suggestion", result: "yes" as const },
579
+ { label: "Dismiss suggestion", result: "no" as const },
580
+ ] : []),
581
+ ...(cliState === "setup" ? [{ label: "Install CLI", result: "setup" as const }]
582
+ : cliState === "upgrade" ? [{ label: "Upgrade CLI", result: "upgrade" as const }] : []),
583
+ ];
584
+ const edited = await ctx.ui.custom<SettingsResult>((tui, theme, kb, done) =>
572
585
  new FooterSettings(config, Object.keys(current?.agent_notes ?? {}).filter((name) => name !== "Bar"),
573
586
  theme, (data, action) => kb.matches(data, action as Keybinding), done, () => tui.requestRender(),
574
587
  () => new Editor(tui, {
@@ -578,14 +591,22 @@ export default function (pi: ExtensionAPI) {
578
591
  noMatch: (line) => theme.fg("warning", line) },
579
592
  }), (updated) => {
580
593
  if (!canAct()) throw new Error("This session changed. Reopen settings before editing.");
594
+ if (configurationError) throw new Error(configurationError);
581
595
  const saved = saveFooterConfig(updated.footer, expectedRaw, updated.handoffPrompt);
582
596
  expectedRaw = saved.raw;
583
597
  footerConfig = saved.config;
584
598
  void refresh(ctx);
585
- }));
599
+ }, options, configurationError));
586
600
  if (!canAct() || edited === undefined) return;
587
- settings = false;
588
- continue;
601
+ if (edited === "tasks") { settings = false; continue; }
602
+ if (!options.some((option) => option.result === edited)) return;
603
+ if (pending === operation) pending = undefined;
604
+ if (edited === "preview") {
605
+ if (!await preview(ctx)) ctx.ui.notify("The task is no longer available for preview.", "warning");
606
+ } else if (edited === "done") await completeSelected("", ctx, current ?? undefined);
607
+ else if (edited === "yes" || edited === "no") await respondToSuggestion(edited, ctx);
608
+ else await installCLI(edited === "upgrade", ctx);
609
+ return;
589
610
  }
590
611
  const action = current
591
612
  ? await ctx.ui.custom<string | undefined>((tui, theme, kb, done) =>
@@ -733,20 +754,6 @@ export default function (pi: ExtensionAPI) {
733
754
  if (alive && generation === epoch) await refresh(ctx);
734
755
  }
735
756
  };
736
- pi.registerCommand("pi-note-yes", {
737
- description: "Create, start and select the suggested task (same as +)",
738
- handler: async (args, ctx) => {
739
- if (!ctx.hasUI || ctx.mode !== "tui") return;
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");
741
- },
742
- });
743
- pi.registerCommand("pi-note-no", {
744
- description: "Dismiss the suggested task without creating a note (same as ✕)",
745
- handler: async (args, ctx) => {
746
- if (!ctx.hasUI || ctx.mode !== "tui") return;
747
- if (args.trim() || !await respondToSuggestion("no", ctx)) ctx.ui.notify("No available suggestion. Usage: /pi-note-no", "warning");
748
- },
749
- });
750
757
  pi.registerTool({
751
758
  name: "pinote_propose",
752
759
  label: "Pinote suggest task",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.15.1",
3
+ "version": "0.16.1",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/setup.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { stripVTControlCharacters } from "node:util";
3
- import type { AutocompleteProvider } from "@earendil-works/pi-tui";
4
3
 
5
4
  // Immutable source: the Python app is not yet published to PyPI.
6
5
  export const cliSource = "https://github.com/kvidzibo/pinote/archive/1597baf60bff8bc96893be40781f152a001623d1.tar.gz";
7
6
  export const bundledCLIVersion = "0.4.0";
8
- export const setupHint = "Use /pi-note-setup for a missing CLI or /pi-note-upgrade for an older CLI; note must be on PATH.";
7
+ export const setupHint = "Open /pi-note → Settings to install or upgrade the CLI; note must be on PATH.";
9
8
  export type CLIAction = "setup" | "upgrade" | "ready";
10
9
 
11
10
  export function cliAction(version: string): CLIAction {
@@ -27,23 +26,6 @@ export async function detectCLI(pi: ExtensionAPI, signal?: AbortSignal): Promise
27
26
  } catch { return "setup"; }
28
27
  }
29
28
 
30
- export function cliMenu(current: AutocompleteProvider, action: () => CLIAction | undefined): AutocompleteProvider {
31
- return {
32
- triggerCharacters: current.triggerCharacters,
33
- async getSuggestions(...args) {
34
- const suggestions = await current.getSuggestions(...args);
35
- if (!suggestions || !/^\/\S*$/u.test(suggestions.prefix)) return suggestions;
36
- const items = suggestions.items.filter(({ value }) => {
37
- const name = value.replace(/^\//u, "");
38
- return name === "pi-note-setup" ? action() === "setup"
39
- : name === "pi-note-upgrade" ? action() === "upgrade" : true;
40
- });
41
- return items.length ? { ...suggestions, items } : null;
42
- },
43
- applyCompletion: (...args) => current.applyCompletion(...args),
44
- shouldTriggerFileCompletion: (...args) => current.shouldTriggerFileCompletion?.(...args) ?? true,
45
- };
46
- }
47
29
  export function versionAtLeast(version: string, minimum: string): boolean {
48
30
  const match = /^pinote (\d+)\.(\d+)\.(\d+)$/u.exec(version.trim());
49
31
  if (!match) return false;
@@ -72,7 +54,7 @@ export async function setupCLI(
72
54
  ctx.ui.notify(`Pinote CLI is ready (bundled version ${bundledCLIVersion} or newer). Use /pi-note.`, "info");
73
55
  return;
74
56
  }
75
- const command = action === "upgrade" ? "/pi-note-upgrade" : "/pi-note-setup";
57
+ const command = `/pi-note → Settings → ${action === "upgrade" ? "Upgrade CLI" : "Install CLI"}`;
76
58
  if (upgrade !== (action === "upgrade")) {
77
59
  ctx.ui.notify(`Use ${command} ${action === "upgrade" ? "to upgrade the older CLI" : "to install the missing CLI"}.`, "info");
78
60
  return;