pi-note 0.15.0 → 0.16.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 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,24 +54,29 @@ 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).
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
+ 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
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
@@ -152,8 +155,11 @@ field needs no `/reload`. Its value is literal text, not a prompt template; use
152
155
  `\n` in JSON strings for multiple lines. It must be a nonblank string without control
153
156
  characters other than tabs and newlines. A missing file or key uses the default.
154
157
  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.
158
+ task or changing the editor. Menu **Done**, footer completion, and agent tools are
159
+ unaffected. If configuration is invalid or unreadable, Settings shows only task,
160
+ suggestion, and CLI actions; repair `pi-note.json` before editing preferences.
161
+ Invalid configuration is never replaced with defaults. This is user-level
162
+ configuration only; project-local files are not read.
157
163
 
158
164
  ### Agent tools
159
165
 
@@ -185,7 +191,7 @@ asking in chat. The pin footer shows a pending suggestion, distinct from a selec
185
191
 
186
192
  ```text
187
193
  📌 ✕ + · [tag] Task title...
188
- 📌 ✓ · [tag] Task title...
194
+ 📌 ✓ · [tag] Task title...
189
195
  ```
190
196
 
191
197
  ![Suggested task controls](../docs/images/pi-task-suggestion.png)
@@ -193,13 +199,13 @@ asking in chat. The pin footer shows a pending suggestion, distinct from a selec
193
199
 
194
200
  - Muted **✕** dismisses without creating a note; further offers are suppressed until a new session.
195
201
  - 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.
202
+ - 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
203
 
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.
204
+ 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
205
 
200
206
  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
207
 
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.
208
+ 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
209
 
204
210
  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
211
 
@@ -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,9 +7,9 @@ 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
 
@@ -179,7 +179,7 @@ export default function (pi: ExtensionAPI) {
179
179
  if (!alive || generation !== epoch || setupAbort) return;
180
180
  cliState = action;
181
181
  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");
182
+ ctx.ui.notify(`Pinote CLI ${bundledCLIVersion} is bundled with this extension. Open /pi-note → Settings → Upgrade CLI to update your older CLI.`, "info");
183
183
  }
184
184
  };
185
185
 
@@ -306,7 +306,7 @@ export default function (pi: ExtensionAPI) {
306
306
  if (alive && generation === epoch && serial === refreshSerial) {
307
307
  previewBridge?.invalidate();
308
308
  clearTaskFooter(ctx);
309
- ctx.ui.setStatus("pinote", suggestionStatus(ctx) ?? `${noteIcon} Pinote unavailable · ${cliState === "upgrade" ? "/pi-note-upgrade" : cliState === "setup" ? "/pi-note-setup" : "/pi-note"}`);
309
+ ctx.ui.setStatus("pinote", suggestionStatus(ctx) ?? `${ctx.ui.theme.bold(noteIcon)} Pinote unavailable · /pi-note → Settings`);
310
310
  }
311
311
  }
312
312
  };
@@ -349,10 +349,10 @@ export default function (pi: ExtensionAPI) {
349
349
  };
350
350
  const timer = setTimeout(() => {
351
351
  finish(false);
352
- if (alive && activeContext === ctx) ctx.ui.notify("Pinote completion could not start. Use /pi-note-done.", "warning");
352
+ if (alive && activeContext === ctx) ctx.ui.notify("Pinote completion could not start. Open /pi-note → Settings → Complete task.", "warning");
353
353
  }, 4000);
354
354
  completionDispatch = { token, selection, task: displayed, started: () => clearTimeout(timer), finish };
355
- try { pi.sendUserMessage(`/pi-note-done ${token}`, { expandPromptTemplates: true }); }
355
+ try { pi.sendUserMessage(`/pi-note ${token}`, { expandPromptTemplates: true }); }
356
356
  catch { finish(false); }
357
357
  });
358
358
  };
@@ -404,18 +404,6 @@ export default function (pi: ExtensionAPI) {
404
404
  if (alive && generation === epoch) await refresh(ctx);
405
405
  }
406
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
- });
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
407
  pi.on("session_start", async (_event, ctx) => {
420
408
  setupAbort?.abort();
421
409
  cancelCompletionDispatch();
@@ -445,7 +433,6 @@ export default function (pi: ExtensionAPI) {
445
433
  cliState = undefined;
446
434
  if (ctx.hasUI && ctx.mode === "tui") {
447
435
  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
436
  }
450
437
  if (ctx.hasUI && ctx.mode === "tui") {
451
438
  const bridge = createPreviewBridge();
@@ -455,7 +442,7 @@ export default function (pi: ExtensionAPI) {
455
442
  } catch {
456
443
  await bridge.stop();
457
444
  if (previewBridge === bridge) previewBridge = undefined;
458
- ctx.ui.notify("Pinote task link unavailable; use /pi-note-preview.", "warning");
445
+ ctx.ui.notify("Pinote task link unavailable; open /pi-note → Settings → Preview task.", "warning");
459
446
  }
460
447
  }
461
448
  if (!alive || generation !== epoch) return;
@@ -500,51 +487,49 @@ export default function (pi: ExtensionAPI) {
500
487
  if (bridge) await bridge.stop();
501
488
  });
502
489
 
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
- }
490
+ const installCLI = async (upgrade: boolean, ctx: ExtensionCommandContext) => {
491
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
492
+ if (pending || !ctx.isIdle()) {
493
+ ctx.ui.notify("Wait until Pi is idle and the pinote operation has finished.", "warning");
494
+ return;
495
+ }
496
+ const operation = Symbol();
497
+ pending = operation;
498
+ const generation = epoch;
499
+ const controller = new AbortController();
500
+ setupAbort = controller;
501
+ clearTaskFooter(ctx);
502
+ const currentSession = () => alive && generation === epoch;
503
+ try {
504
+ await setupCLI(pi, ctx, upgrade,
505
+ () => currentSession() && ctx.isIdle(), controller.signal);
506
+ } catch (error) {
507
+ if (currentSession()) ctx.ui.notify(`Pinote setup: ${clean(String(error))}`, "error");
508
+ } finally {
509
+ if (pending === operation) pending = undefined;
510
+ if (setupAbort === controller) setupAbort = undefined;
511
+ const latestContext = activeContext ?? (currentSession() ? ctx : undefined);
512
+ if (alive && latestContext) {
513
+ const generation = epoch;
514
+ await checkCLI(latestContext, !currentSession());
515
+ if (!alive || generation !== epoch || setupAbort) return;
516
+ await refresh(latestContext);
537
517
  }
538
- },
539
- });
540
- registerInstallCommand("pi-note-setup");
541
- registerInstallCommand("pi-note-upgrade");
518
+ }
519
+ };
542
520
 
543
521
  pi.registerCommand("pi-note", {
544
522
  description: "Continue, complete, switch tasks, or edit global settings (Tab)",
545
523
  handler: async (args, ctx) => {
546
524
  if (!ctx.hasUI || ctx.mode !== "tui") return;
547
- if (args.trim()) { ctx.ui.notify("Usage: /pi-note", "warning"); return; }
525
+ // Footer completion needs a command context, but no extra slash command.
526
+ // Only the current one-use capability may dispatch; stale tokens remain inert.
527
+ const token = args.trim();
528
+ if (/^[a-f0-9]{32}:\d+:\d+:\d+:[a-f0-9]{16}$/u.test(token)) {
529
+ await completeSelected(token, ctx);
530
+ return;
531
+ }
532
+ if (token) { ctx.ui.notify("Usage: /pi-note", "warning"); return; }
548
533
  if (pending || !ctx.isIdle()) {
549
534
  ctx.ui.notify("Wait until Pi is idle and the pinote operation has finished.", "warning");
550
535
  return;
@@ -556,19 +541,46 @@ export default function (pi: ExtensionAPI) {
556
541
  const currentSession = () => alive && generation === epoch;
557
542
  const canAct = () => currentSession() && ctx.isIdle() && branch === branchEpoch;
558
543
  try {
559
- const current = await selected(ctx);
544
+ await checkCLI(ctx);
560
545
  if (!canAct()) return;
561
- let settings = false;
546
+ const current = cliState === "ready" ? await selected(ctx) : null;
547
+ if (!canAct()) return;
548
+ let settings = cliState !== "ready";
562
549
  while (canAct()) {
550
+ if (!settings && cliState !== "ready") {
551
+ ctx.ui.notify(setupHint, "warning");
552
+ return;
553
+ }
563
554
  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) =>
555
+ let config: PinoteSettings = { footer: { ...defaultFooterConfig, fields: [] }, handoffPrompt: defaultHandoffPrompt };
556
+ let expectedRaw: string | null = null;
557
+ let configurationError: string | undefined;
558
+ try {
559
+ const document = readFooterDocument();
560
+ const root = document.raw === null ? {} : JSON.parse(document.raw);
561
+ config = {
562
+ footer: { ...document.config, fields: effectiveFooterFields(document.config, current?.agent_notes) },
563
+ handoffPrompt: "handoffPrompt" in root ? parseHandoffPrompt(root.handoffPrompt) : defaultHandoffPrompt,
564
+ };
565
+ expectedRaw = document.raw;
566
+ } catch (error) {
567
+ // Broken preferences must not strand task actions or CLI recovery.
568
+ // Offer actions only; never save defaults over invalid user data.
569
+ configurationError = clean(error instanceof Error ? error.message : String(error));
570
+ }
571
+ const options: SettingsOption[] = [
572
+ ...(current ? [
573
+ { label: "Preview task", result: "preview" as const },
574
+ { label: "Complete task (new session)", result: "done" as const },
575
+ ] : []),
576
+ ...(suggestion ? [
577
+ { label: "Accept suggestion", result: "yes" as const },
578
+ { label: "Dismiss suggestion", result: "no" as const },
579
+ ] : []),
580
+ ...(cliState === "setup" ? [{ label: "Install CLI", result: "setup" as const }]
581
+ : cliState === "upgrade" ? [{ label: "Upgrade CLI", result: "upgrade" as const }] : []),
582
+ ];
583
+ const edited = await ctx.ui.custom<SettingsResult>((tui, theme, kb, done) =>
572
584
  new FooterSettings(config, Object.keys(current?.agent_notes ?? {}).filter((name) => name !== "Bar"),
573
585
  theme, (data, action) => kb.matches(data, action as Keybinding), done, () => tui.requestRender(),
574
586
  () => new Editor(tui, {
@@ -578,14 +590,22 @@ export default function (pi: ExtensionAPI) {
578
590
  noMatch: (line) => theme.fg("warning", line) },
579
591
  }), (updated) => {
580
592
  if (!canAct()) throw new Error("This session changed. Reopen settings before editing.");
593
+ if (configurationError) throw new Error(configurationError);
581
594
  const saved = saveFooterConfig(updated.footer, expectedRaw, updated.handoffPrompt);
582
595
  expectedRaw = saved.raw;
583
596
  footerConfig = saved.config;
584
597
  void refresh(ctx);
585
- }));
598
+ }, options, configurationError));
586
599
  if (!canAct() || edited === undefined) return;
587
- settings = false;
588
- continue;
600
+ if (edited === "tasks") { settings = false; continue; }
601
+ if (!options.some((option) => option.result === edited)) return;
602
+ if (pending === operation) pending = undefined;
603
+ if (edited === "preview") {
604
+ if (!await preview(ctx)) ctx.ui.notify("The task is no longer available for preview.", "warning");
605
+ } else if (edited === "done") await completeSelected("", ctx, current ?? undefined);
606
+ else if (edited === "yes" || edited === "no") await respondToSuggestion(edited, ctx);
607
+ else await installCLI(edited === "upgrade", ctx);
608
+ return;
589
609
  }
590
610
  const action = current
591
611
  ? await ctx.ui.custom<string | undefined>((tui, theme, kb, done) =>
@@ -733,20 +753,6 @@ export default function (pi: ExtensionAPI) {
733
753
  if (alive && generation === epoch) await refresh(ctx);
734
754
  }
735
755
  };
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
756
  pi.registerTool({
751
757
  name: "pinote_propose",
752
758
  label: "Pinote suggest task",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
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;
@@ -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
  }