pi-note 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -63,7 +63,7 @@ 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
@@ -110,10 +110,11 @@ values or unreadable/malformed configuration suppress offers; interactive startu
110
110
  warns until you fix the file and reload. No project-local configuration is read.
111
111
 
112
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.
113
+ selection or **Continue**. **Shift+Enter** or **Ctrl+J** adds a newline; **Enter** saves it
114
+ globally, **Ctrl+C** clears the edit, and **Esc** cancels that unfinished edit.
115
+ Settings and fields save automatically after each confirmed change; no separate
116
+ Save action is needed. Leaving Settings with **Esc** or **Tab** keeps saved changes
117
+ and discards only unfinished input. Saving never changes existing editor input or submits anything.
117
118
 
118
119
  Task selection and **Continue** reread `handoffPrompt` each time; changing that
119
120
  field needs no `/reload`. Its value is literal text, not a prompt template; use
@@ -209,9 +210,12 @@ accepted. Missing/blank values do not display and do not consume the field limit
209
210
  In `/pi-note → Settings`, **Add field** adds a global display definition, not a
210
211
  task value. Select any field to edit **Link**, **Label**, **Format**, and **Width**.
211
212
  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.
213
+ text without a clickable link. Toggles, field additions/removals, and edits confirmed
214
+ with **Enter** save immediately. **Esc** returns from a field to settings without
215
+ undoing saved changes. Invalid edits and failed saves stay open with an error;
216
+ failed saves leave saved values unchanged and can be retried. Saves include the task
217
+ prompt, preserve unrelated settings (such as `taskOfferPolicy`), and reject external
218
+ configuration changes made since opening settings or the last successful save.
215
219
  Concurrent saves use `pi-note.json.lock`; remove stale locks only when no save is
216
220
  running. Symlinked configuration must be edited manually; saves never replace the link.
217
221
 
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/index.ts CHANGED
@@ -380,7 +380,8 @@ export default function (pi: ExtensionAPI) {
380
380
  footer: { ...document.config, fields: effectiveFooterFields(document.config, current?.agent_notes) },
381
381
  handoffPrompt: "handoffPrompt" in root ? parseHandoffPrompt(root.handoffPrompt) : defaultHandoffPrompt,
382
382
  };
383
- const edited = await ctx.ui.custom<PinoteSettings | "tasks" | undefined>((tui, theme, kb, done) =>
383
+ let expectedRaw = document.raw;
384
+ const edited = await ctx.ui.custom<"tasks" | undefined>((tui, theme, kb, done) =>
384
385
  new FooterSettings(config, Object.keys(current?.agent_notes ?? {}).filter((name) => name !== "Bar"),
385
386
  theme, (data, action) => kb.matches(data, action as Keybinding), done, () => tui.requestRender(),
386
387
  () => new Editor(tui, {
@@ -388,12 +389,16 @@ export default function (pi: ExtensionAPI) {
388
389
  selectList: { selectedPrefix: (line) => theme.fg("accent", line), selectedText: (line) => theme.fg("accent", line),
389
390
  description: (line) => theme.fg("muted", line), scrollInfo: (line) => theme.fg("dim", line),
390
391
  noMatch: (line) => theme.fg("warning", line) },
391
- })));
392
+ }), (updated) => {
393
+ if (!canAct()) throw new Error("This session changed. Reopen settings before editing.");
394
+ const saved = saveFooterConfig(updated.footer, expectedRaw, updated.handoffPrompt);
395
+ expectedRaw = saved.raw;
396
+ footerConfig = saved.config;
397
+ void refresh(ctx);
398
+ }));
392
399
  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;
400
+ settings = false;
401
+ continue;
397
402
  }
398
403
  const action = current
399
404
  ? await ctx.ui.custom<string | undefined>((tui, theme, kb, done) =>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",