pi-note 0.10.0 → 0.11.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
@@ -62,7 +62,7 @@ The agent fetches current task text and saved handoff fields when you submit;
62
62
  they are not copied into the draft. The default prompt asks for a summary, not implementation.
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
- Press **Tab** from the task menu or picker to open global footer settings; Tab
65
+ Press **Tab** from the task menu or picker to open global settings; Tab
66
66
  returns to tasks without saving. 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).
@@ -109,6 +109,12 @@ and selected tasks. Run `/reload` after changing this setting. Invalid policy
109
109
  values or unreadable/malformed configuration suppress offers; interactive startup
110
110
  warns until you fix the file and reload. No project-local configuration is read.
111
111
 
112
+ 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.
117
+
112
118
  Task selection and **Continue** reread `handoffPrompt` each time; changing that
113
119
  field needs no `/reload`. Its value is literal text, not a prompt template; use
114
120
  `\n` in JSON strings for multiple lines. It must be a nonblank string without control
@@ -204,8 +210,8 @@ In `/pi-note → Settings`, **Add field** adds a global display definition, not
204
210
  task value. Select any field to edit **Link**, **Label**, **Format**, and **Width**.
205
211
  Label starts as the field name; clear it for no prefix. Link off displays plain
206
212
  text without a clickable link. **Save** persists the draft; cancellation leaves
207
- configuration untouched. Saves preserve unrelated settings (`handoffPrompt`,
208
- `taskOfferPolicy`) and reject configuration changes made while the dialog was open.
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.
209
215
  Concurrent saves use `pi-note.json.lock`; remove stale locks only when no save is
210
216
  running. Symlinked configuration must be edited manually; saves never replace the link.
211
217
 
@@ -221,8 +227,8 @@ the first Settings save turns this legacy list into explicit global definitions.
221
227
  Settings saves apply immediately in this Pi session. Run `/reload` after manual
222
228
  footer edits or in other running sessions; settings are also reread on session start.
223
229
  Invalid footer configuration warns and uses footer defaults; missing files silently
224
- use defaults. This does not validate or change `handoffPrompt`, which is read
225
- separately on selection/Continue as described above.
230
+ use defaults. The task prompt is also read separately on selection/Continue as
231
+ described above; prompt changes need no reload in any running session.
226
232
  This is user-level configuration, not task data; agents should not change it
227
233
  without approval. Task values still come from `pinote_update_current`.
228
234
 
package/footer-config.ts CHANGED
@@ -3,6 +3,15 @@ import { closeSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rm
3
3
  import { join } from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
5
 
6
+ export const defaultHandoffPrompt = "Read the current Pinote task. Summarize your understanding, but don’t start work yet.";
7
+ export function parseHandoffPrompt(value: unknown): string {
8
+ if (typeof value !== "string" || !value.trim() || /[\x00-\x08\x0b-\x1f\x7f-\x9f]/u.test(value)) {
9
+ throw new Error("handoffPrompt in pi-note.json must be a nonblank string without control characters (except tabs/newlines).");
10
+ }
11
+ return value;
12
+ }
13
+ export type PinoteSettings = { footer: FooterConfig; handoffPrompt: string };
14
+
6
15
  export type FooterField = { name: string; label: string; link: boolean; format: string; width?: number };
7
16
  export type FooterConfig = { titleWidth: number; fieldWidth: number; maxFields: number; fields: FooterField[] | null };
8
17
  export const defaultFooterConfig: FooterConfig = { titleWidth: 60, fieldWidth: 60, maxFields: 4, fields: null };
@@ -80,9 +89,10 @@ export function loadFooterConfig(warn: (message: string) => void): FooterConfig
80
89
  }
81
90
 
82
91
  // Synchronous compare-and-replace: reject edits made while the settings dialog was open,
83
- // preserve unrelated keys (including handoffPrompt), and never leave partial JSON behind.
84
- export function saveFooterConfig(config: FooterConfig, expectedRaw: string | null): FooterConfig {
92
+ // preserve unrelated keys, and never leave partial JSON behind.
93
+ export function saveFooterConfig(config: FooterConfig, expectedRaw: string | null, handoffPrompt?: string): FooterConfig {
85
94
  const checked = parseFooterConfig({ footer: config });
95
+ const prompt = handoffPrompt === undefined ? {} : { handoffPrompt: parseHandoffPrompt(handoffPrompt) };
86
96
  const directory = getAgentDir();
87
97
  mkdirSync(directory, { recursive: true });
88
98
  const path = join(directory, "pi-note.json");
@@ -96,7 +106,7 @@ export function saveFooterConfig(config: FooterConfig, expectedRaw: string | nul
96
106
  if (lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error("pi-note.json is a symlink. Edit its target manually rather than replacing the link.");
97
107
  const root = expectedRaw === null ? {} : JSON.parse(expectedRaw);
98
108
  if (!object(root)) throw new Error("Invalid pi-note configuration; expected an object.");
99
- writeFileSync(temp, `${JSON.stringify({ ...root, footer: checked }, null, 2)}\n`, { flag: "wx", mode: 0o600 });
109
+ writeFileSync(temp, `${JSON.stringify({ ...root, ...prompt, footer: checked }, null, 2)}\n`, { flag: "wx", mode: 0o600 });
100
110
  renameSync(temp, path);
101
111
  } finally { rmSync(temp, { force: true }); closeSync(descriptor); rmSync(lock, { force: true }); }
102
112
  return checked;
@@ -1,6 +1,6 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { Input, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
- import { defaultFooterField, parseFooterConfig, type FooterConfig, type FooterField } from "./footer-config.ts";
2
+ import { Editor, Input, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { defaultFooterField, parseFooterConfig, parseHandoffPrompt, type PinoteSettings, type FooterConfig, type FooterField } from "./footer-config.ts";
4
4
 
5
5
  type Matches = (data: string, action: string) => boolean;
6
6
  type Row = { label: string; action: () => void };
@@ -38,10 +38,13 @@ export class TaskMenu {
38
38
 
39
39
  export class FooterSettings {
40
40
  private draft: FooterConfig;
41
+ private prompt: string;
42
+ private editor?: Editor;
43
+ private createEditor: () => Editor;
41
44
  private knownFields: string[];
42
45
  private theme: Theme;
43
46
  private matches: Matches;
44
- private done: (result: FooterConfig | "tasks" | undefined) => void;
47
+ private done: (result: PinoteSettings | "tasks" | undefined) => void;
45
48
  private requestRender: () => void;
46
49
  private index = 0;
47
50
  private field?: FooterField;
@@ -49,15 +52,21 @@ export class FooterSettings {
49
52
  private error = "";
50
53
  private hasFocus = false;
51
54
 
52
- constructor(config: FooterConfig, knownFields: string[], theme: Theme, matches: Matches,
53
- done: (result: FooterConfig | "tasks" | undefined) => void, requestRender: () => void) {
54
- this.draft = structuredClone(config);
55
+ constructor(config: PinoteSettings, knownFields: string[], theme: Theme, matches: Matches,
56
+ done: (result: PinoteSettings | "tasks" | undefined) => void, requestRender: () => void, createEditor: () => Editor) {
57
+ this.draft = structuredClone(config.footer);
58
+ this.prompt = config.handoffPrompt;
59
+ this.createEditor = createEditor;
55
60
  this.draft.fields ??= [];
56
61
  this.knownFields = [...new Set(knownFields)].filter((name) => name !== "Bar");
57
62
  this.theme = theme; this.matches = matches; this.done = done; this.requestRender = requestRender;
58
63
  }
59
64
  get focused() { return this.hasFocus; }
60
- set focused(value: boolean) { this.hasFocus = value; if (this.input) this.input.widget.focused = value; }
65
+ set focused(value: boolean) {
66
+ this.hasFocus = value;
67
+ if (this.input) this.input.widget.focused = value;
68
+ if (this.editor) this.editor.focused = value;
69
+ }
61
70
 
62
71
  private ask(heading: string, value: string, apply: (value: string) => void) {
63
72
  const widget = new Input();
@@ -80,6 +89,12 @@ export class FooterSettings {
80
89
  this.draft = parseFooterConfig({ footer: candidate });
81
90
  }),
82
91
  }));
92
+ rows.push({ label: `Task prompt: ${display(this.prompt)}`, action: () => {
93
+ this.editor = this.createEditor();
94
+ this.editor.disableSubmit = true;
95
+ this.editor.setText(this.prompt);
96
+ this.editor.focused = this.hasFocus;
97
+ } });
83
98
  const configured = this.draft.fields!.map((field) => field.name);
84
99
  for (const name of [...configured, ...this.knownFields.filter((name) => !configured.includes(name))]) {
85
100
  rows.push({ label: `Field: ${display(name)}${configured.includes(name) ? "" : " (not configured)"}`, action: () => this.edit(name) });
@@ -87,7 +102,7 @@ export class FooterSettings {
87
102
  rows.push({ label: "+ Add field", action: () => this.ask("Field name", "", (value) => {
88
103
  const field = this.validate(defaultFooterField(value));
89
104
  this.edit(field.name);
90
- }) }, { label: "Save settings", action: () => this.done(parseFooterConfig({ footer: this.draft })) },
105
+ }) }, { label: "Save settings", action: () => this.done({ footer: parseFooterConfig({ footer: this.draft }), handoffPrompt: parseHandoffPrompt(this.prompt) }) },
91
106
  { label: "Return to tasks", action: () => this.done("tasks") });
92
107
  return rows;
93
108
  }
@@ -126,7 +141,15 @@ export class FooterSettings {
126
141
  this.error = "";
127
142
  try {
128
143
  if (isTab(data)) this.done("tasks");
129
- else if (this.input) {
144
+ else if (this.editor) {
145
+ if (matchesKey(data, "ctrl+c")) this.editor.setText("");
146
+ else if (this.matches(data, "tui.select.cancel")) this.editor = undefined;
147
+ else if (this.matches(data, "tui.input.newLine")) this.editor.handleInput(data);
148
+ else if (this.matches(data, "tui.select.confirm")) {
149
+ this.prompt = parseHandoffPrompt(this.editor.getExpandedText());
150
+ this.editor = undefined;
151
+ } else this.editor.handleInput(data);
152
+ } else if (this.input) {
130
153
  if (this.matches(data, "tui.select.cancel")) this.input = undefined;
131
154
  else if (this.matches(data, "tui.select.confirm")) {
132
155
  const input = this.input;
@@ -147,7 +170,10 @@ export class FooterSettings {
147
170
  }
148
171
  render(width: number) {
149
172
  const lines = [this.theme.fg("accent", "Pinote — Global Settings"), this.theme.fg("dim", "Tasks (Tab) · Settings · Fields")];
150
- if (this.input) {
173
+ if (this.editor) {
174
+ 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"));
176
+ } else if (this.input) {
151
177
  lines.push(this.theme.fg("accent", this.input.heading), ...this.input.widget.render(width),
152
178
  this.theme.fg("dim", "Enter apply to draft · Esc cancel"));
153
179
  } else {
@@ -164,7 +190,7 @@ export class FooterSettings {
164
190
  lines.push(this.theme.fg("dim", "Tab: tasks, discard draft · Save settings: persist globally"));
165
191
  return lines.map((line) => truncateToWidth(line, width));
166
192
  }
167
- invalidate() { this.input?.widget.invalidate(); }
193
+ invalidate() { this.input?.widget.invalidate(); this.editor?.invalidate(); }
168
194
  }
169
195
 
170
196
  export function withSettingsTab<T extends { handleInput?(data: string): void; render(width: number): string[]; invalidate(): void; focused?: boolean }>(
package/index.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { truncateToWidth, type Keybinding } from "@earendil-works/pi-tui";
2
+ import { Editor, truncateToWidth, 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";
6
6
  import { Type } from "typebox";
7
7
  import { createPRWatcher } from "./pr-watch.ts";
8
- import { defaultFooterConfig, effectiveFooterFields, loadFooterConfig, readFooterDocument, saveFooterConfig, type FooterConfig } from "./footer-config.ts";
8
+ import { defaultFooterConfig, defaultHandoffPrompt, effectiveFooterFields, loadFooterConfig, parseHandoffPrompt, readFooterDocument, saveFooterConfig, type FooterConfig, type PinoteSettings } from "./footer-config.ts";
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";
@@ -40,7 +40,6 @@ const clean = (value: string) => stripVTControlCharacters(value).replace(/[\x00-
40
40
  const firstLine = (task: Summary) => clean(task.text.split("\n", 1)[0]).replace(/\s+/gu, " ").trim();
41
41
  const title = (task: Summary) => truncateToWidth(`#${task.id} ${firstLine(task)}`, 60);
42
42
 
43
- const defaultHandoffPrompt = "Read the current Pinote task. Summarize your understanding, but don’t start work yet.";
44
43
  function readConfig(): Record<string, unknown> {
45
44
  const path = join(getAgentDir(), "pi-note.json");
46
45
  let raw: string;
@@ -77,12 +76,7 @@ function creationGuidance(): { guidelines: string[]; error?: string } {
77
76
 
78
77
  function handoffPrompt(): string {
79
78
  const config = readConfig();
80
- const path = join(getAgentDir(), "pi-note.json");
81
- if (!("handoffPrompt" in config)) return defaultHandoffPrompt;
82
- if (!text(config.handoffPrompt) || !config.handoffPrompt.trim()) {
83
- throw new Error(`handoffPrompt in ${path} must be a nonblank string without control characters (except tabs/newlines).`);
84
- }
85
- return config.handoffPrompt;
79
+ return "handoffPrompt" in config ? parseHandoffPrompt(config.handoffPrompt) : defaultHandoffPrompt;
86
80
  }
87
81
 
88
82
  function parse(raw: string): unknown {
@@ -360,7 +354,7 @@ export default function (pi: ExtensionAPI) {
360
354
  registerInstallCommand("pi-note-upgrade");
361
355
 
362
356
  pi.registerCommand("pi-note", {
363
- description: "Continue, complete, switch tasks, or edit global footer settings (Tab)",
357
+ description: "Continue, complete, switch tasks, or edit global settings (Tab)",
364
358
  handler: async (args, ctx) => {
365
359
  if (!ctx.hasUI || ctx.mode !== "tui") return;
366
360
  if (args.trim()) { ctx.ui.notify("Usage: /pi-note", "warning"); return; }
@@ -381,14 +375,24 @@ export default function (pi: ExtensionAPI) {
381
375
  while (canAct()) {
382
376
  if (settings) {
383
377
  const document = readFooterDocument();
384
- const config = { ...document.config, fields: effectiveFooterFields(document.config, current?.agent_notes) };
385
- const edited = await ctx.ui.custom<FooterConfig | "tasks" | undefined>((tui, theme, kb, done) =>
378
+ const root = document.raw === null ? {} : JSON.parse(document.raw);
379
+ const config: PinoteSettings = {
380
+ footer: { ...document.config, fields: effectiveFooterFields(document.config, current?.agent_notes) },
381
+ handoffPrompt: "handoffPrompt" in root ? parseHandoffPrompt(root.handoffPrompt) : defaultHandoffPrompt,
382
+ };
383
+ const edited = await ctx.ui.custom<PinoteSettings | "tasks" | undefined>((tui, theme, kb, done) =>
386
384
  new FooterSettings(config, Object.keys(current?.agent_notes ?? {}).filter((name) => name !== "Bar"),
387
- theme, (data, action) => kb.matches(data, action as Keybinding), done, () => tui.requestRender()));
385
+ theme, (data, action) => kb.matches(data, action as Keybinding), done, () => tui.requestRender(),
386
+ () => new Editor(tui, {
387
+ borderColor: (line) => theme.fg("accent", line),
388
+ selectList: { selectedPrefix: (line) => theme.fg("accent", line), selectedText: (line) => theme.fg("accent", line),
389
+ description: (line) => theme.fg("muted", line), scrollInfo: (line) => theme.fg("dim", line),
390
+ noMatch: (line) => theme.fg("warning", line) },
391
+ })));
388
392
  if (!canAct() || edited === undefined) return;
389
393
  if (edited === "tasks") { settings = false; continue; }
390
- footerConfig = saveFooterConfig(edited, document.raw);
391
- ctx.ui.notify("Global Pinote footer settings saved.", "info");
394
+ footerConfig = saveFooterConfig(edited.footer, document.raw, edited.handoffPrompt);
395
+ ctx.ui.notify("Global Pinote settings saved.", "info");
392
396
  return;
393
397
  }
394
398
  const action = current
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",