pi-note 0.11.0 → 0.12.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
@@ -7,7 +7,7 @@ The extension and Python app share a repository but install separately.
7
7
 
8
8
  ## Install
9
9
 
10
- Requires Node.js 22.19+ and **pinote 0.3.0+** (`note` on PATH). Add and tag listing need **pinote 0.4.0+**.
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
12
  `/reload` in interactive Pi. Use `/pi-note-setup` if the CLI is missing, or the
13
13
  suggested `/pi-note-upgrade` if it is older than the bundled CLI. The planned npm package name is
@@ -63,14 +63,46 @@ they are not copied into the draft. The default prompt asks for a summary, not i
63
63
  Existing drafts are preserved; nothing is submitted automatically.
64
64
  With a selected task it offers **Continue**, **Done**, **Switch task**, and **Settings**.
65
65
  Press **Tab** from the task menu or picker to open global settings; Tab
66
- returns to tasks without saving. Settings also lists fields found on the current
66
+ returns to tasks, keeping saved changes. Settings also lists fields found on the current
67
67
  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 `📌 [tag] Task title` without replacing other footers.
72
- The footer omits the task ID and state and shows only the first line, truncated to the configured width (default 60 terminal columns including the pin and tag). Overflow ends with `...`.
73
- The pin glyph is bundled in `icons/note.txt`; it uses the terminal's emoji font, not a Nerd Font or icon theme.
71
+ The normal Pi status area shows `👁 📌 [tag] Task title` without replacing other footers.
72
+ The footer omits the task ID and state and shows only the first line, truncated to the configured width (default 60 terminal columns including the eye, pin and tag). Overflow ends with `...`.
73
+ The glyphs are bundled in `icons/eye.txt` and `icons/note.txt`; they use terminal fonts, not a Nerd Font or icon theme.
74
+
75
+ ### Display-only preview
76
+
77
+ Run `/pi-note-preview` to print the selected task's full Markdown text and saved
78
+ agent fields in the transcript. The preview is labeled **display only · not sent
79
+ to model**: it is a local custom session entry, excluded from subsequent model
80
+ requests, compaction and branch summaries. It survives session resume but does not
81
+ modify the note, editor draft or task selection, and never starts a model call.
82
+ The agent can still read the task separately with `pinote_get_current`.
83
+ Previewing requires Pi to be idle with no other Pinote operation open.
84
+
85
+ On Linux, the eye is a clickable OSC 8 link to a private per-session Unix socket.
86
+ The package ships [a Kitty configuration example](kitty/open-actions.conf).
87
+ Append its block to `~/.config/kitty/open-actions.conf`, preserving existing
88
+ actions. Replace `/absolute/path/to/pi-note` with the installed package directory
89
+ containing `preview-click.cjs`; for this checkout, that directory is `pi/`.
90
+ Keep `${URL}` literal: Kitty substitutes the clicked eye's link.
91
+
92
+ ```conf
93
+ protocol pi-note-preview
94
+ action launch --type=background node /absolute/path/to/pi-note/preview-click.cjs ${URL}
95
+ ```
96
+
97
+ Do not replace or symlink your entire Kitty configuration to the example: it
98
+ contains only Pinote's handler. Package installation does not edit Kitty files.
99
+
100
+ Reload Kitty with **Ctrl+Shift+F5**. In Pi's regular mode, click the eye; in
101
+ fullscreen mode, use **Ctrl+Shift+click** so Kitty handles the link instead of
102
+ Pi's system URL opener. The helper sends only an authenticated selection
103
+ identifier, never note content or a model prompt. Stale links after task switching, tree navigation, reload or session exit
104
+ are rejected. Other terminals can use `/pi-note-preview`; if the socket cannot
105
+ start, the eye is omitted and that command remains available.
74
106
 
75
107
  Each Pi session remembers its own task, including several sessions in one folder.
76
108
  Resume restores that session's task; a new session starts unselected and does not import
@@ -110,10 +142,11 @@ values or unreadable/malformed configuration suppress offers; interactive startu
110
142
  warns until you fix the file and reload. No project-local configuration is read.
111
143
 
112
144
  In `/pi-note → Settings`, select **Task prompt** to edit the text inserted after
113
- selection or **Continue**. **Shift+Enter** or **Ctrl+J** adds a newline; **Enter** applies it to
114
- the settings draft, **Ctrl+C** clears it, and **Esc** cancels that edit. Select **Save settings** to
115
- persist it globally; leaving Settings with **Esc** or **Tab** discards the draft.
116
- Saving never changes existing editor input or submits anything.
145
+ selection or **Continue**. **Shift+Enter** or **Ctrl+J** adds a newline; **Enter** saves it
146
+ globally, **Ctrl+C** clears the edit, and **Esc** cancels that unfinished edit.
147
+ Settings and fields save automatically after each confirmed change; no separate
148
+ Save action is needed. Leaving Settings with **Esc** or **Tab** keeps saved changes
149
+ and discards only unfinished input. Saving never changes existing editor input or submits anything.
117
150
 
118
151
  Task selection and **Continue** reread `handoffPrompt` each time; changing that
119
152
  field needs no `/reload`. Its value is literal text, not a prompt template; use
@@ -197,7 +230,7 @@ Add `footer` to `~/.pi/agent/pi-note.json` (or `pi-note.json` under
197
230
  }
198
231
  ```
199
232
 
200
- Widths are terminal columns, including icons/tags or field labels, and must be
233
+ Widths are terminal columns, including the eye, pin, tags or field labels, and must be
201
234
  integers from 3 to 1000. Defaults: `titleWidth: 60`, `fieldWidth: 60`,
202
235
  `maxFields: 4` (allowed 0–64). Each field can override `fieldWidth` with `width`.
203
236
  `fields` selects task field names in order, regardless of a task's `Bar`.
@@ -209,9 +242,12 @@ accepted. Missing/blank values do not display and do not consume the field limit
209
242
  In `/pi-note → Settings`, **Add field** adds a global display definition, not a
210
243
  task value. Select any field to edit **Link**, **Label**, **Format**, and **Width**.
211
244
  Label starts as the field name; clear it for no prefix. Link off displays plain
212
- text without a clickable link. **Save** persists the draft; cancellation leaves
213
- configuration untouched. Saves include the task prompt and preserve unrelated
214
- settings (such as `taskOfferPolicy`) and reject configuration changes made while the dialog was open.
245
+ text without a clickable link. Toggles, field additions/removals, and edits confirmed
246
+ with **Enter** save immediately. **Esc** returns from a field to settings without
247
+ undoing saved changes. Invalid edits and failed saves stay open with an error;
248
+ failed saves leave saved values unchanged and can be retried. Saves include the task
249
+ prompt, preserve unrelated settings (such as `taskOfferPolicy`), and reject external
250
+ configuration changes made since opening settings or the last successful save.
215
251
  Concurrent saves use `pi-note.json.lock`; remove stale locks only when no save is
216
252
  running. Symlinked configuration must be edited manually; saves never replace the link.
217
253
 
package/footer-config.ts CHANGED
@@ -90,7 +90,7 @@ export function loadFooterConfig(warn: (message: string) => void): FooterConfig
90
90
 
91
91
  // Synchronous compare-and-replace: reject edits made while the settings dialog was open,
92
92
  // preserve unrelated keys, and never leave partial JSON behind.
93
- export function saveFooterConfig(config: FooterConfig, expectedRaw: string | null, handoffPrompt?: string): FooterConfig {
93
+ export function saveFooterConfig(config: FooterConfig, expectedRaw: string | null, handoffPrompt?: string) {
94
94
  const checked = parseFooterConfig({ footer: config });
95
95
  const prompt = handoffPrompt === undefined ? {} : { handoffPrompt: parseHandoffPrompt(handoffPrompt) };
96
96
  const directory = getAgentDir();
@@ -101,15 +101,17 @@ export function saveFooterConfig(config: FooterConfig, expectedRaw: string | nul
101
101
  try { descriptor = openSync(lock, "wx", 0o600); }
102
102
  catch { throw new Error("Pinote settings are locked. Retry after another save finishes; remove a stale pi-note.json.lock only when no save is running."); }
103
103
  const temp = join(directory, `.pi-note-${randomUUID()}.tmp`);
104
+ let raw: string;
104
105
  try {
105
106
  if (readRaw() !== expectedRaw) throw new Error("Pi-note configuration changed while settings were open. Reopen settings before saving.");
106
107
  if (lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error("pi-note.json is a symlink. Edit its target manually rather than replacing the link.");
107
108
  const root = expectedRaw === null ? {} : JSON.parse(expectedRaw);
108
109
  if (!object(root)) throw new Error("Invalid pi-note configuration; expected an object.");
109
- writeFileSync(temp, `${JSON.stringify({ ...root, ...prompt, footer: checked }, null, 2)}\n`, { flag: "wx", mode: 0o600 });
110
+ raw = `${JSON.stringify({ ...root, ...prompt, footer: checked }, null, 2)}\n`;
111
+ writeFileSync(temp, raw, { flag: "wx", mode: 0o600 });
110
112
  renameSync(temp, path);
111
113
  } finally { rmSync(temp, { force: true }); closeSync(descriptor); rmSync(lock, { force: true }); }
112
- return checked;
114
+ return { config: checked, raw };
113
115
  }
114
116
 
115
117
  export function effectiveFooterFields(config: FooterConfig, notes: Record<string, string> = {}): FooterField[] {
@@ -44,7 +44,8 @@ export class FooterSettings {
44
44
  private knownFields: string[];
45
45
  private theme: Theme;
46
46
  private matches: Matches;
47
- private done: (result: PinoteSettings | "tasks" | undefined) => void;
47
+ private done: (result: "tasks" | undefined) => void;
48
+ private save: (settings: PinoteSettings) => void;
48
49
  private requestRender: () => void;
49
50
  private index = 0;
50
51
  private field?: FooterField;
@@ -53,10 +54,12 @@ export class FooterSettings {
53
54
  private hasFocus = false;
54
55
 
55
56
  constructor(config: PinoteSettings, knownFields: string[], theme: Theme, matches: Matches,
56
- done: (result: PinoteSettings | "tasks" | undefined) => void, requestRender: () => void, createEditor: () => Editor) {
57
+ done: (result: "tasks" | undefined) => void, requestRender: () => void, createEditor: () => Editor,
58
+ save: (settings: PinoteSettings) => void) {
57
59
  this.draft = structuredClone(config.footer);
58
60
  this.prompt = config.handoffPrompt;
59
61
  this.createEditor = createEditor;
62
+ this.save = save;
60
63
  this.draft.fields ??= [];
61
64
  this.knownFields = [...new Set(knownFields)].filter((name) => name !== "Bar");
62
65
  this.theme = theme; this.matches = matches; this.done = done; this.requestRender = requestRender;
@@ -76,6 +79,20 @@ export class FooterSettings {
76
79
  private validate(field: FooterField): FooterField {
77
80
  return parseFooterConfig({ footer: { fields: [field] } }).fields![0];
78
81
  }
82
+ private persist(footer = this.draft, handoffPrompt = this.prompt) {
83
+ const settings = { footer: parseFooterConfig({ footer }), handoffPrompt: parseHandoffPrompt(handoffPrompt) };
84
+ this.save(structuredClone(settings));
85
+ this.draft = settings.footer;
86
+ this.prompt = settings.handoffPrompt;
87
+ }
88
+ private persistField(field: FooterField) {
89
+ const candidate = this.validate(field);
90
+ const fields = this.draft.fields!.filter((entry) => entry.name !== candidate.name);
91
+ const index = this.draft.fields!.findIndex((entry) => entry.name === candidate.name);
92
+ fields.splice(index < 0 ? fields.length : index, 0, candidate);
93
+ this.persist({ ...this.draft, fields });
94
+ this.field = candidate;
95
+ }
79
96
  private edit(name: string) {
80
97
  this.field = structuredClone(this.draft.fields!.find((field) => field.name === name) ?? defaultFooterField(name));
81
98
  this.index = 0; this.error = "";
@@ -86,7 +103,7 @@ export class FooterSettings {
86
103
  action: () => this.ask(key, String(this.draft[key]), (value) => {
87
104
  const candidate = { ...this.draft, [key]: Number(value) };
88
105
  if (!value.trim()) throw new Error("Enter a number.");
89
- this.draft = parseFooterConfig({ footer: candidate });
106
+ this.persist(candidate);
90
107
  }),
91
108
  }));
92
109
  rows.push({ label: `Task prompt: ${display(this.prompt)}`, action: () => {
@@ -101,40 +118,36 @@ export class FooterSettings {
101
118
  }
102
119
  rows.push({ label: "+ Add field", action: () => this.ask("Field name", "", (value) => {
103
120
  const field = this.validate(defaultFooterField(value));
104
- this.edit(field.name);
105
- }) }, { label: "Save settings", action: () => this.done({ footer: parseFooterConfig({ footer: this.draft }), handoffPrompt: parseHandoffPrompt(this.prompt) }) },
121
+ if (this.draft.fields!.some((entry) => entry.name === field.name)) this.edit(field.name);
122
+ else {
123
+ this.persistField(field);
124
+ this.index = 0;
125
+ }
126
+ }) },
106
127
  { label: "Return to tasks", action: () => this.done("tasks") });
107
128
  return rows;
108
129
  }
109
130
  private fieldRows(): Row[] {
110
131
  const field = this.field!;
111
132
  const rows: Row[] = [
112
- { label: `Link: ${field.link ? "on" : "off (plain text)"}`, action: () => { field.link = !field.link; } },
133
+ { label: `Link: ${field.link ? "on" : "off (plain text)"}`, action: () => this.persistField({ ...field, link: !field.link }) },
113
134
  { label: `Label: ${display(field.label) || "(none)"}`, action: () => this.ask("Label (empty = no prefix)", field.label, (value) => {
114
- this.field = this.validate({ ...field, label: value });
135
+ this.persistField({ ...field, label: value });
115
136
  }) },
116
137
  { label: `Format: ${display(field.format) || "(empty)"}`, action: () => this.ask("Format: <value>, <url>, <number>", field.format, (value) => {
117
- this.field = this.validate({ ...field, format: value });
138
+ this.persistField({ ...field, format: value });
118
139
  }) },
119
140
  { label: `Width: ${field.width ?? `default (${this.draft.fieldWidth})`}`, action: () => this.ask("Width (empty = default)", field.width === undefined ? "" : String(field.width), (value) => {
120
141
  const candidate = { ...field };
121
142
  if (value.trim()) candidate.width = Number(value); else delete candidate.width;
122
- this.field = this.validate(candidate);
143
+ this.persistField(candidate);
123
144
  }) },
124
- { label: "Save field", action: () => {
125
- const candidate = this.validate(field);
126
- const fields = this.draft.fields!.filter((entry) => entry.name !== candidate.name);
127
- const index = this.draft.fields!.findIndex((entry) => entry.name === candidate.name);
128
- fields.splice(index < 0 ? fields.length : index, 0, candidate);
129
- this.draft = parseFooterConfig({ footer: { ...this.draft, fields } });
130
- this.field = undefined; this.index = 0;
131
- } },
132
145
  ];
133
146
  if (this.draft.fields!.some((entry) => entry.name === field.name)) rows.push({ label: "Remove field", action: () => {
134
- this.draft.fields = this.draft.fields!.filter((entry) => entry.name !== field.name);
147
+ this.persist({ ...this.draft, fields: this.draft.fields!.filter((entry) => entry.name !== field.name) });
135
148
  this.field = undefined; this.index = 0;
136
149
  } });
137
- rows.push({ label: "Cancel field", action: () => { this.field = undefined; this.index = 0; } });
150
+ rows.push({ label: "Back to settings", action: () => { this.field = undefined; this.index = 0; } });
138
151
  return rows;
139
152
  }
140
153
  handleInput(data: string) {
@@ -146,7 +159,7 @@ export class FooterSettings {
146
159
  else if (this.matches(data, "tui.select.cancel")) this.editor = undefined;
147
160
  else if (this.matches(data, "tui.input.newLine")) this.editor.handleInput(data);
148
161
  else if (this.matches(data, "tui.select.confirm")) {
149
- this.prompt = parseHandoffPrompt(this.editor.getExpandedText());
162
+ this.persist(this.draft, this.editor.getExpandedText());
150
163
  this.editor = undefined;
151
164
  } else this.editor.handleInput(data);
152
165
  } else if (this.input) {
@@ -172,10 +185,10 @@ export class FooterSettings {
172
185
  const lines = [this.theme.fg("accent", "Pinote — Global Settings"), this.theme.fg("dim", "Tasks (Tab) · Settings · Fields")];
173
186
  if (this.editor) {
174
187
  lines.push(this.theme.fg("accent", "Task prompt"), ...this.editor.render(width),
175
- this.theme.fg("dim", "Enter apply · Shift+Enter/Ctrl+J newline · Ctrl+C clear · Esc cancel"));
188
+ this.theme.fg("dim", "Enter save · Shift+Enter/Ctrl+J newline · Ctrl+C clear · Esc cancel edit"));
176
189
  } else if (this.input) {
177
190
  lines.push(this.theme.fg("accent", this.input.heading), ...this.input.widget.render(width),
178
- this.theme.fg("dim", "Enter apply to draft · Esc cancel"));
191
+ this.theme.fg("dim", "Enter save · Esc cancel edit"));
179
192
  } else {
180
193
  if (this.field) lines.push(this.theme.fg("accent", `Field: ${display(this.field.name)}`));
181
194
  const rows = this.field ? this.fieldRows() : this.rootRows();
@@ -184,10 +197,10 @@ export class FooterSettings {
184
197
  lines.push(...rows.slice(start, start + 8).map((row, offset) => offset + start === this.index
185
198
  ? this.theme.fg("accent", `> ${row.label}`) : ` ${row.label}`));
186
199
  if (rows.length > 8) lines.push(this.theme.fg("dim", `${this.index + 1}/${rows.length}`));
187
- lines.push(this.theme.fg("dim", "↑↓ navigate · Enter edit · Esc cancel"));
200
+ lines.push(this.theme.fg("dim", "↑↓ navigate · Enter edit · Esc back"));
188
201
  }
189
202
  if (this.error) lines.push(this.theme.fg("error", this.error));
190
- lines.push(this.theme.fg("dim", "Tab: tasks, discard draft · Save settings: persist globally"));
203
+ lines.push(this.theme.fg("dim", "Changes save automatically · Tab: tasks"));
191
204
  return lines.map((line) => truncateToWidth(line, width));
192
205
  }
193
206
  invalidate() { this.input?.widget.invalidate(); this.editor?.invalidate(); }
package/icons/eye.txt ADDED
@@ -0,0 +1 @@
1
+ 👁
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { Editor, truncateToWidth, type Keybinding } from "@earendil-works/pi-tui";
1
+ import { getAgentDir, getMarkdownTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Container, Editor, Markdown, Text, truncateToWidth, visibleWidth, type Keybinding } from "@earendil-works/pi-tui";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { stripVTControlCharacters } from "node:util";
@@ -9,6 +9,7 @@ import { defaultFooterConfig, defaultHandoffPrompt, effectiveFooterFields, loadF
9
9
  import { FooterSettings, TaskMenu, withSettingsTab } from "./footer-settings.ts";
10
10
  import { TaskPicker, taskState, taskTag } from "./task-picker.ts";
11
11
  import { bundledCLIVersion, cliMenu, detectCLI, setupCLI, setupHint, versionAtLeast, type CLIAction } from "./setup.ts";
12
+ import { createPreviewBridge } from "./preview-bridge.ts";
12
13
 
13
14
  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.";
14
15
  const offerGuidance = {
@@ -30,6 +31,8 @@ type Task = {
30
31
  type Summary = Pick<Task, "id" | "text" | "state" | "tag">;
31
32
  // The standard Pi footer accepts text, so ship a portable terminal glyph, not a theme icon.
32
33
  const noteIcon = readFileSync(new URL("./icons/note.txt", import.meta.url), "utf8").trim();
34
+ const eyeIcon = readFileSync(new URL("./icons/eye.txt", import.meta.url), "utf8").trim();
35
+ const previewType = "pinote-preview";
33
36
  const compatible = "Incompatible note CLI response. Install pinote 0.3.0+ and check note on PATH.";
34
37
  const validId = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) > 0;
35
38
  const record = (value: unknown): value is Record<string, unknown> =>
@@ -115,6 +118,14 @@ function toolResult(value: Task | null) {
115
118
 
116
119
  export default function (pi: ExtensionAPI) {
117
120
  const { guidelines: createGuidance, error: offerConfigError } = creationGuidance();
121
+ pi.registerEntryRenderer<{ id: number; markdown: string }>(previewType, (entry, _options, theme) => {
122
+ if (!record(entry.data) || !validId(entry.data.id) || typeof entry.data.markdown !== "string") return;
123
+ const view = new Container();
124
+ view.addChild(new Text(theme.fg("muted", `Pinote #${entry.data.id} · display only · not sent to model`), 0, 0));
125
+ view.addChild(new Markdown(clean(entry.data.markdown), 0, 0, getMarkdownTheme()));
126
+ return view;
127
+ });
128
+ let previewBridge: ReturnType<typeof createPreviewBridge> | undefined;
118
129
  let alive = true;
119
130
  let epoch = 0;
120
131
  let refreshSerial = 0;
@@ -184,6 +195,7 @@ export default function (pi: ExtensionAPI) {
184
195
  let branchEpoch = 0;
185
196
  const remember = (id: number | null, branch = branchEpoch) => {
186
197
  if (!alive || branch !== branchEpoch || selectedId === id) return;
198
+ previewBridge?.invalidate();
187
199
  selectedId = id;
188
200
  pi.appendEntry(selectionType, { id });
189
201
  };
@@ -238,11 +250,20 @@ export default function (pi: ExtensionAPI) {
238
250
  try {
239
251
  const current = await selected(ctx);
240
252
  if (alive && generation === epoch && serial === refreshSerial) {
241
- ctx.ui.setStatus("pinote", current ? truncateToWidth(`${noteIcon} ${taskTag(current)} ${firstLine(current)}`, footerConfig.titleWidth, "...") : undefined);
253
+ const previewId = current?.id ?? null;
254
+ const previewBranch = branchEpoch;
255
+ previewBridge?.setTask(previewId, (signal) => alive && generation === epoch && previewBranch === branchEpoch &&
256
+ selectedId === previewId ? preview(ctx, signal) : Promise.resolve(false));
257
+ const url = previewBridge?.url();
258
+ // Keep the eye outside title truncation, including the minimum title width.
259
+ const eye = url ? `\x1b]8;;${url}\x07${eyeIcon}\x1b]8;;\x07 ` : "";
260
+ const width = Math.max(0, footerConfig.titleWidth - (eye ? visibleWidth(eyeIcon) + 1 : 0));
261
+ ctx.ui.setStatus("pinote", current ? eye + truncateToWidth(`${noteIcon} ${taskTag(current)} ${firstLine(current)}`, width, "...") : undefined);
242
262
  watcher.update(ctx, current);
243
263
  }
244
264
  } catch {
245
265
  if (alive && generation === epoch && serial === refreshSerial) {
266
+ previewBridge?.invalidate();
246
267
  ctx.ui.setStatus("pinote", `${noteIcon} Pinote unavailable · ${cliState === "upgrade" ? "/pi-note-upgrade" : cliState === "setup" ? "/pi-note-setup" : "/pi-note"}`);
247
268
  }
248
269
  }
@@ -265,14 +286,57 @@ export default function (pi: ExtensionAPI) {
265
286
  },
266
287
  refresh,
267
288
  });
289
+ const preview = async (ctx: ExtensionContext, requestSignal?: AbortSignal): Promise<boolean> => {
290
+ if (!ctx.hasUI || ctx.mode !== "tui" || !ctx.isIdle() || pending || !validId(selectedId)) return false;
291
+ const generation = epoch;
292
+ const branch = branchEpoch;
293
+ const id = selectedId;
294
+ const operation = Symbol();
295
+ pending = operation;
296
+ // Complete before the bridge's 5s and helper's 6s deadlines; disconnection cancels reads too.
297
+ const controller = new AbortController();
298
+ const deadline = Date.now() + 4000;
299
+ const timer = setTimeout(() => controller.abort(), 4000);
300
+ const signal = requestSignal ? AbortSignal.any([requestSignal, controller.signal]) : controller.signal;
301
+ const canUse = () => alive && generation === epoch && branch === branchEpoch && selectedId === id && ctx.isIdle() &&
302
+ !signal.aborted && Date.now() < deadline;
303
+ try {
304
+ const current = await currentTask(signal, canUse);
305
+ if (!current || !canUse()) return false;
306
+ // Custom entries are rendered locally, never conversation messages or compaction input.
307
+ pi.appendEntry(previewType, { id: current.id, markdown: current.markdown });
308
+ return true;
309
+ } catch (error) {
310
+ if (canUse()) ctx.ui.notify(`Pinote preview: ${clean(String(error))}`, "error");
311
+ return false;
312
+ } finally {
313
+ clearTimeout(timer);
314
+ if (pending === operation) pending = undefined;
315
+ }
316
+ };
317
+ pi.registerCommand("pi-note-preview", {
318
+ description: "Display the selected task locally without sending it to the model",
319
+ handler: async (args, ctx) => {
320
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
321
+ if (args.trim()) { ctx.ui.notify("Usage: /pi-note-preview", "warning"); return; }
322
+ if (!await preview(ctx)) ctx.ui.notify("Select a task with /pi-note and wait until Pi is idle before previewing.", "warning");
323
+ },
324
+ });
268
325
  pi.on("session_start", async (_event, ctx) => {
269
326
  setupAbort?.abort();
270
- alive = true;
271
- activeContext = ctx;
327
+ // Retire old polling before bridge teardown/startup yields to its scheduled callbacks.
328
+ watcher.stop();
329
+ alive = false;
272
330
  epoch++;
273
331
  branchEpoch++;
274
- selectedId = readSelection(ctx);
275
332
  const generation = epoch;
333
+ const previousBridge = previewBridge;
334
+ previewBridge = undefined;
335
+ if (previousBridge) await previousBridge.stop();
336
+ if (generation !== epoch) return;
337
+ alive = true;
338
+ activeContext = ctx;
339
+ selectedId = readSelection(ctx);
276
340
  footerConfig = loadFooterConfig((message) => {
277
341
  if (ctx.hasUI) ctx.ui.notify(message, "warning");
278
342
  });
@@ -281,6 +345,18 @@ export default function (pi: ExtensionAPI) {
281
345
  if (offerConfigError) ctx.ui.notify(`Pinote task offers disabled: ${clean(offerConfigError)} Fix the configuration and /reload.`, "warning");
282
346
  ctx.ui.addAutocompleteProvider((current) => cliMenu(current, () => setupAbort ? undefined : cliState));
283
347
  }
348
+ if (ctx.hasUI && ctx.mode === "tui") {
349
+ const bridge = createPreviewBridge();
350
+ previewBridge = bridge;
351
+ try {
352
+ await bridge.start();
353
+ } catch {
354
+ await bridge.stop();
355
+ if (previewBridge === bridge) previewBridge = undefined;
356
+ ctx.ui.notify("Pinote eye link unavailable; use /pi-note-preview.", "warning");
357
+ }
358
+ }
359
+ if (!alive || generation !== epoch) return;
284
360
  // Keep the setup lock until its aborted subprocess has actually settled.
285
361
  if (!setupAbort) pending = undefined;
286
362
  await checkCLI(ctx, true);
@@ -291,6 +367,7 @@ export default function (pi: ExtensionAPI) {
291
367
  pi.on("session_tree", async (_event, ctx) => {
292
368
  if (!alive) return;
293
369
  branchEpoch++;
370
+ previewBridge?.invalidate();
294
371
  const generation = branchEpoch;
295
372
  const id = readSelection(ctx);
296
373
  if (!alive || generation !== branchEpoch) return;
@@ -300,7 +377,7 @@ export default function (pi: ExtensionAPI) {
300
377
  });
301
378
  pi.on("before_agent_start", async (_event, ctx) => { await refresh(ctx); });
302
379
  pi.on("agent_end", async (_event, ctx) => { await refresh(ctx); });
303
- pi.on("session_shutdown", (_event, ctx) => {
380
+ pi.on("session_shutdown", async (_event, ctx) => {
304
381
  setupAbort?.abort();
305
382
  alive = false;
306
383
  activeContext = undefined;
@@ -310,6 +387,9 @@ export default function (pi: ExtensionAPI) {
310
387
  refreshSerial++;
311
388
  watcher.stop();
312
389
  if (ctx.hasUI && ctx.mode === "tui") ctx.ui.setStatus("pinote", undefined);
390
+ const bridge = previewBridge;
391
+ previewBridge = undefined;
392
+ if (bridge) await bridge.stop();
313
393
  });
314
394
 
315
395
  const registerInstallCommand = (name: "pi-note-setup" | "pi-note-upgrade") => pi.registerCommand(name, {
@@ -380,7 +460,8 @@ export default function (pi: ExtensionAPI) {
380
460
  footer: { ...document.config, fields: effectiveFooterFields(document.config, current?.agent_notes) },
381
461
  handoffPrompt: "handoffPrompt" in root ? parseHandoffPrompt(root.handoffPrompt) : defaultHandoffPrompt,
382
462
  };
383
- const edited = await ctx.ui.custom<PinoteSettings | "tasks" | undefined>((tui, theme, kb, done) =>
463
+ let expectedRaw = document.raw;
464
+ const edited = await ctx.ui.custom<"tasks" | undefined>((tui, theme, kb, done) =>
384
465
  new FooterSettings(config, Object.keys(current?.agent_notes ?? {}).filter((name) => name !== "Bar"),
385
466
  theme, (data, action) => kb.matches(data, action as Keybinding), done, () => tui.requestRender(),
386
467
  () => new Editor(tui, {
@@ -388,12 +469,16 @@ export default function (pi: ExtensionAPI) {
388
469
  selectList: { selectedPrefix: (line) => theme.fg("accent", line), selectedText: (line) => theme.fg("accent", line),
389
470
  description: (line) => theme.fg("muted", line), scrollInfo: (line) => theme.fg("dim", line),
390
471
  noMatch: (line) => theme.fg("warning", line) },
391
- })));
472
+ }), (updated) => {
473
+ if (!canAct()) throw new Error("This session changed. Reopen settings before editing.");
474
+ const saved = saveFooterConfig(updated.footer, expectedRaw, updated.handoffPrompt);
475
+ expectedRaw = saved.raw;
476
+ footerConfig = saved.config;
477
+ void refresh(ctx);
478
+ }));
392
479
  if (!canAct() || edited === undefined) return;
393
- if (edited === "tasks") { settings = false; continue; }
394
- footerConfig = saveFooterConfig(edited.footer, document.raw, edited.handoffPrompt);
395
- ctx.ui.notify("Global Pinote settings saved.", "info");
396
- return;
480
+ settings = false;
481
+ continue;
397
482
  }
398
483
  const action = current
399
484
  ? await ctx.ui.custom<string | undefined>((tui, theme, kb, done) =>
@@ -0,0 +1,5 @@
1
+ # Append this block to ~/.config/kitty/open-actions.conf; preserve existing actions.
2
+ # Replace /absolute/path/to/pi-note with the directory containing preview-click.cjs.
3
+ # Keep ${URL} literal: Kitty substitutes the clicked eye's link.
4
+ protocol pi-note-preview
5
+ action launch --type=background node /absolute/path/to/pi-note/preview-click.cjs ${URL}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,7 +14,7 @@
14
14
  "homepage": "https://github.com/kvidzibo/pinote/tree/main/pi#readme",
15
15
  "bugs": { "url": "https://github.com/kvidzibo/pinote/issues" },
16
16
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
17
- "files": ["index.ts", "pr-watch.ts", "footer-links.ts", "footer-config.ts", "footer-settings.ts", "task-picker.ts", "setup.ts", "icons", "README.md", "LICENSE"],
17
+ "files": ["index.ts", "pr-watch.ts", "footer-links.ts", "footer-config.ts", "footer-settings.ts", "task-picker.ts", "setup.ts", "preview-bridge.ts", "preview-click.cjs", "icons", "kitty", "README.md", "LICENSE"],
18
18
  "pi": { "extensions": ["./index.ts"] },
19
19
  "scripts": {
20
20
  "check": "tsc --noEmit",
@@ -0,0 +1,113 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import { createServer, type Server, type Socket } from "node:net";
4
+ import { basename, join } from "node:path";
5
+
6
+ // No note data travels through the socket or hyperlink; both identify a local selection.
7
+ export function createPreviewBridge() {
8
+ const uid = process.getuid?.();
9
+ const capability = randomBytes(16).toString("hex");
10
+ let server: Server | undefined;
11
+ let socketPath: string | undefined;
12
+ let starting: Promise<void> | undefined;
13
+ let stopping: Promise<void> | undefined;
14
+ let closed = false;
15
+ let ready = false;
16
+ let taskId: number | null = null;
17
+ let callback: ((signal: AbortSignal) => Promise<boolean>) | undefined;
18
+ let nonce: string | undefined;
19
+ const clients = new Set<Socket>();
20
+ const invalidate = () => { taskId = null; callback = undefined; nonce = undefined; };
21
+ const cleanup = async () => {
22
+ ready = false;
23
+ for (const client of clients) client.destroy();
24
+ const instance = server;
25
+ server = undefined;
26
+ if (instance) await new Promise<void>((resolve) => instance.close(() => resolve()));
27
+ const file = socketPath;
28
+ socketPath = undefined;
29
+ if (file) {
30
+ try {
31
+ const st = await fs.lstat(file);
32
+ if (st.isSocket() && st.uid === uid) await fs.unlink(file);
33
+ } catch (error) {
34
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
35
+ }
36
+ }
37
+ };
38
+ const api = {
39
+ async start(): Promise<void> {
40
+ if (closed || uid === undefined) throw new Error("Preview bridge unavailable");
41
+ if (starting) return starting;
42
+ starting = (async () => {
43
+ const dir = `/tmp/pi-note-preview-${uid}`;
44
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
45
+ const st = await fs.lstat(dir);
46
+ if (!st.isDirectory() || st.uid !== uid || (st.mode & 0o777) !== 0o700 || closed) {
47
+ throw new Error("Preview bridge unavailable");
48
+ }
49
+ socketPath = join(dir, `${process.pid}-${randomBytes(4).toString("hex")}.sock`);
50
+ const instance = createServer((client) => {
51
+ clients.add(client);
52
+ const request = new AbortController();
53
+ client.on("error", () => client.destroy());
54
+ client.on("close", () => { request.abort(); clients.delete(client); });
55
+ client.setTimeout(5000, () => { request.abort(); client.destroy(); });
56
+ let data = Buffer.alloc(0);
57
+ let dispatched = false;
58
+ client.on("data", (chunk) => {
59
+ if (dispatched || closed) return;
60
+ data = Buffer.concat([data, chunk]);
61
+ if (data.length > 128) { client.destroy(); return; }
62
+ if (!data.includes(10)) return;
63
+ dispatched = true;
64
+ const input = data.toString("utf8");
65
+ const match = /^preview ([0-9a-f]{32}) ([1-9][0-9]*) ([0-9a-f]{32})\n$/u.exec(input);
66
+ const id = match ? Number(match[2]) : NaN;
67
+ if (!match || match[0] !== input || match[1] !== capability || !Number.isSafeInteger(id) ||
68
+ id !== taskId || match[3] !== nonce || !callback) { client.end("rejected\n"); return; }
69
+ const invoke = callback;
70
+ Promise.resolve().then(() => !closed && !request.signal.aborted && taskId === id && nonce === match[3] ? invoke(request.signal) : false)
71
+ .then((accepted) => { if (!client.destroyed) client.end(accepted === true ? "accepted\n" : "rejected\n"); },
72
+ () => { if (!client.destroyed) client.end("rejected\n"); });
73
+ });
74
+ });
75
+ server = instance;
76
+ try {
77
+ await new Promise<void>((resolve, reject) => {
78
+ instance.once("error", reject);
79
+ instance.listen(socketPath!, resolve);
80
+ });
81
+ await fs.chmod(socketPath, 0o600);
82
+ if (closed) throw new Error("Preview bridge unavailable");
83
+ instance.on("error", () => { void api.stop().catch(() => {}); });
84
+ instance.unref();
85
+ ready = true;
86
+ } catch {
87
+ await cleanup();
88
+ throw new Error("Preview bridge unavailable");
89
+ }
90
+ })();
91
+ return starting;
92
+ },
93
+ setTask(id: number | null, preview: (signal: AbortSignal) => Promise<boolean>): void {
94
+ if (closed) return;
95
+ if (id !== null && (!Number.isSafeInteger(id) || id <= 0)) throw new Error("Invalid preview task");
96
+ if (id !== taskId) nonce = id === null ? undefined : randomBytes(16).toString("hex");
97
+ taskId = id;
98
+ callback = id === null ? undefined : preview;
99
+ },
100
+ invalidate,
101
+ url(): string | undefined {
102
+ if (!ready || closed || !socketPath || taskId === null || !nonce) return;
103
+ return `pi-note-preview://${basename(socketPath, ".sock")}/${capability}/${taskId}/${nonce}`;
104
+ },
105
+ async stop(): Promise<void> {
106
+ closed = true;
107
+ invalidate();
108
+ if (!stopping) stopping = (async () => { await starting?.catch(() => {}); await cleanup(); })();
109
+ return stopping;
110
+ },
111
+ };
112
+ return api;
113
+ }
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const net = require('node:net');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ const urlPattern = /^pi-note-preview:\/\/([1-9][0-9]*)-([0-9a-f]{8})\/([0-9a-f]{32})\/([1-9][0-9]*)\/([0-9a-f]{32})$/;
8
+
9
+ function parse(value) {
10
+ if (typeof value !== 'string' || value.length > 160) throw new Error('Preview unavailable');
11
+ const m = urlPattern.exec(value);
12
+ if (!m || m[0] !== value) throw new Error('Preview unavailable');
13
+ const pid = Number(m[1]), id = Number(m[4]);
14
+ if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(id) || pid <= 0 || id <= 0) throw new Error('Preview unavailable');
15
+ return { pid, tag: m[2], capability: m[3], id, nonce: m[5] };
16
+ }
17
+ function socketPathFor(value) {
18
+ const p = parse(value);
19
+ return path.join(`/tmp/pi-note-preview-${typeof process.getuid === 'function' ? process.getuid() : -1}`, `${p.pid}-${p.tag}.sock`);
20
+ }
21
+ function verify(file) {
22
+ const uid = typeof process.getuid === 'function' ? process.getuid() : -1;
23
+ if (uid < 0) throw new Error('Preview unavailable');
24
+ const dir = `/tmp/pi-note-preview-${uid}`;
25
+ const ds = fs.lstatSync(dir);
26
+ if (!ds.isDirectory() || ds.isSymbolicLink() || ds.uid !== uid || (ds.mode & 0o777) !== 0o700) throw new Error('Preview unavailable');
27
+ const ss = fs.lstatSync(file);
28
+ if (!ss.isSocket() || ss.isSymbolicLink() || ss.uid !== uid || (ss.mode & 0o777) !== 0o600) throw new Error('Preview unavailable');
29
+ }
30
+ function preview(value) {
31
+ let p, file;
32
+ try { p = parse(value); file = socketPathFor(value); verify(file); }
33
+ catch { return Promise.reject(new Error('Preview unavailable')); }
34
+ return new Promise((resolve, reject) => {
35
+ const client = net.createConnection(file);
36
+ let response = Buffer.alloc(0), done = false;
37
+ const finish = (err, result) => {
38
+ if (done) return;
39
+ done = true; clearTimeout(timer); client.destroy();
40
+ err ? reject(new Error('Preview unavailable')) : resolve(result);
41
+ };
42
+ const timer = setTimeout(() => finish(new Error()), 6000);
43
+ client.on('connect', () => client.write(`preview ${p.capability} ${p.id} ${p.nonce}\n`));
44
+ client.on('data', chunk => {
45
+ response = Buffer.concat([response, chunk]);
46
+ if (response.length > 64) return finish(new Error());
47
+ const text = response.toString('utf8');
48
+ if (text.includes('\n')) finish(text === 'accepted\n' ? null : new Error(), text === 'accepted\n');
49
+ });
50
+ client.on('error', () => finish(new Error()));
51
+ client.on('end', () => { if (!done) finish(new Error()); });
52
+ });
53
+ }
54
+ if (require.main === module) {
55
+ if (process.argv.length !== 3) {
56
+ console.error('Usage: preview-click.cjs <preview URL>');
57
+ process.exitCode = 2;
58
+ } else {
59
+ preview(process.argv[2]).catch(() => {
60
+ console.error('Pinote preview unavailable; select a task and wait until Pi is idle.');
61
+ process.exitCode = 1;
62
+ });
63
+ }
64
+ }
65
+ module.exports = { preview, socketPathFor };