@hank-warren/pi-statusline 0.4.0 → 0.4.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @hank-warren/pi-statusline
2
2
 
3
+ ## 0.4.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 1e25d6b: Guard against a setting being added without being registered for persistence. `SETTING_KEYS` feeds the per-key diff behind every save, so a key missing from it applied live and then silently failed to persist. Omitting one is now a `tsc` error naming the key, plus a test asserting the list matches `defaultSettings()`. Adds an "Adding a setting" checklist and the cross-version compatibility rules to the README.
8
+
9
+ ## 0.4.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 2dbfc7a: Stop settings saves from clobbering the file. Sessions load settings once at startup and used to persist their entire in-memory snapshot, so changing any row in a session that started before an edit silently reverted that edit — losing hand-written config and other sessions' changes, and collapsing the file to `{}` because serialization is sparse. Saves now write only the keys a change actually touched, merged over the current file contents, and opening `/statusline` re-reads the file first.
14
+
15
+ Also moves the **Cache celebration** row after **Subscription usage** so the menu follows the order elements render in.
16
+
3
17
  ## 0.4.0
4
18
 
5
19
  ### Minor Changes
package/README.md CHANGED
@@ -26,7 +26,29 @@ Colors come from a selectable [theme](#themes), with context warning thresholds.
26
26
  - **Worktree root** — the directory whose immediate children are tracked as session worktrees (default `~/repos/worktrees`). `~` and `$HOME` are expanded; a relative path is rejected and the previous value kept.
27
27
  - **Repo aliases** — short display names for repositories on the worktree line. Enter edits the selected `repo → alias` pair, `d` deletes it, and `Add alias…` creates one from a `repo=alias` line.
28
28
 
29
- Settings live in a single global file, `~/.pi/agent/statusline-settings.json`, written atomically. Only values differing from the defaults are stored, unknown keys from a newer version are preserved, and a missing or malformed file simply yields defaults. The file is read once at session start and is not watched, so hand edits apply to the next session. Concurrent pi sessions are last-writer-wins.
29
+ Settings live in a single global file, `~/.pi/agent/statusline-settings.json`, written atomically. Only values differing from the defaults are stored, unknown keys from a newer version are preserved, and a missing or malformed file simply yields defaults.
30
+
31
+ Saves are per-key rather than whole-file: a change writes only the fields it actually touched over whatever is on disk at that moment. Sessions load settings once at startup, so a whole-file write would let a session that started hours ago revert edits it never saw — including hand edits and changes made in another session. Opening `/statusline` also re-reads the file first, so the menu always edits current state. Two sessions changing the *same* field are still last-writer-wins; everything else merges.
32
+
33
+ ### Adding a setting
34
+
35
+ A setting is persisted, validated, diffed, and rendered in separate places, so add it to all of them:
36
+
37
+ 1. `StatuslineSettings` in `settings.ts` — plus `BOOLEAN_SETTING_KEYS` if it is a toggle.
38
+ 2. `defaultSettings()`.
39
+ 3. `normalizeSettings()` — the `known` key set, and a parse branch that falls back to the default for an invalid value rather than discarding the whole file.
40
+ 4. `serializeSettings()` — write it only when it differs from its default, keeping the file sparse.
41
+ 5. `SETTING_KEYS`.
42
+ 6. A row in `buildSettingItems()` and a branch in `applySettingChange()` in `settings-menu.ts`, placed in the order the element renders.
43
+ 7. Live-apply handling in `applySettings()` in `index.ts`, if the change needs more than a repaint (disposing a poller, forcing a full redraw on a row-count change).
44
+
45
+ Steps 1 and 5 are enforced: omitting the key from `SETTING_KEYS` fails `npm run typecheck` by name, and a test asserts it matches the keys of `defaultSettings()`. Nothing enforces steps 3, 4, 6 or 7 — a setting missing from `serializeSettings` applies live and never persists.
46
+
47
+ Compatibility rules, because old and new versions share one file:
48
+
49
+ - **Never change a key's type or meaning — add a sibling key.** This is why `showCacheCelebration` stayed a boolean when it gained animation styles: an older version reading a repurposed key falls back to its default and can write that fallback back.
50
+ - **Unknown keys survive an older version; unknown *values* do not.** A theme name a reader does not recognise falls back to `default`, and a whole-file write from that reader drops the choice. Extending a cosmetic enum is fine; encoding behaviour in one is riskier than adding a key.
51
+ - **Keep settings independent.** Per-key saves mean two fields can be written by different sessions at different times, so resolve any relationship between settings at render time, not on disk.
30
52
 
31
53
  ## Themes
32
54
 
package/index.ts CHANGED
@@ -22,7 +22,13 @@ import {
22
22
  createAliasSubmenu,
23
23
  createWorktreeRootSubmenu,
24
24
  } from "./settings-menu.ts";
25
- import { defaultSettings, repoAlias, SettingsStore, type StatuslineSettings } from "./settings.ts";
25
+ import {
26
+ changedSettingKeys,
27
+ defaultSettings,
28
+ repoAlias,
29
+ SettingsStore,
30
+ type StatuslineSettings,
31
+ } from "./settings.ts";
26
32
  import { resolvePalette, type StatuslinePalette, STATUSLINE_THEMES } from "./themes.ts";
27
33
  import { type UsageSnapshot, usageBand, UsageTracker } from "./usage.ts";
28
34
  import {
@@ -293,7 +299,9 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
293
299
  requestRender?.();
294
300
 
295
301
  if (!persist) return;
296
- settingsStore.save(next).catch((error: unknown) => {
302
+ const changed = changedSettingKeys(previous, next);
303
+ if (changed.length === 0) return;
304
+ settingsStore.save(next, changed).catch((error: unknown) => {
297
305
  ctx.ui.notify(
298
306
  `Could not save statusline settings: ${error instanceof Error ? error.message : String(error)}`,
299
307
  "warning",
@@ -309,6 +317,11 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
309
317
  return;
310
318
  }
311
319
 
320
+ // Settings load once per session; refresh before editing so the menu
321
+ // starts from what is on disk rather than a snapshot that may be hours
322
+ // old and missing another session's changes.
323
+ applySettings(ctx, await settingsStore.load(), false);
324
+
312
325
  await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
313
326
  const tracked = trackSelectedLabel(getSettingsListTheme());
314
327
  const settingsTheme = tracked.theme;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-statusline",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Compact Pi footer statusline with Git/worktree context, token usage, and neon celebrations for exceptional prompt-cache hits.",
5
5
  "type": "module",
6
6
  "keywords": [
package/settings-menu.ts CHANGED
@@ -75,21 +75,28 @@ export function buildSettingItems(
75
75
  currentValue: settings.theme,
76
76
  values: [...THEME_NAMES],
77
77
  },
78
- {
79
- id: CACHE_CELEBRATION_ID,
80
- label: CACHE_CELEBRATION_LABEL,
81
- description: "Badge animation after an exceptional prompt-cache hit; previews in the statusline below.",
82
- currentValue: celebrationValue(settings),
83
- values: [OFF, ...CELEBRATION_STYLE_NAMES],
84
- },
85
- ...BOOLEAN_ROWS.map((row) => ({
78
+ ];
79
+
80
+ // Rows follow the order their elements render in, so the celebration sits
81
+ // after the usage meters it is appended to on line 1.
82
+ for (const row of BOOLEAN_ROWS) {
83
+ items.push({
86
84
  id: row.id,
87
85
  label: row.label,
88
86
  description: row.description,
89
87
  currentValue: toggleValue(settings[row.id]),
90
88
  values: TOGGLE_VALUES,
91
- })),
92
- ];
89
+ });
90
+ if (row.id === "showUsage") {
91
+ items.push({
92
+ id: CACHE_CELEBRATION_ID,
93
+ label: CACHE_CELEBRATION_LABEL,
94
+ description: "Badge animation after an exceptional prompt-cache hit; previews in the statusline below.",
95
+ currentValue: celebrationValue(settings),
96
+ values: [OFF, ...CELEBRATION_STYLE_NAMES],
97
+ });
98
+ }
99
+ }
93
100
 
94
101
  items.push({
95
102
  id: WORKTREE_ROOT_ID,
package/settings.ts CHANGED
@@ -165,6 +165,40 @@ function sameAliases(a: Record<string, string>, b: Record<string, string>): bool
165
165
  return aKeys.every((key) => a[key] === b[key]);
166
166
  }
167
167
 
168
+ /** Every persisted top-level key, so a diff can enumerate them exhaustively. */
169
+ export const SETTING_KEYS = [
170
+ ...BOOLEAN_SETTING_KEYS,
171
+ "theme",
172
+ "cacheCelebrationStyle",
173
+ "worktreeRoot",
174
+ "repoAliases",
175
+ ] as const satisfies readonly (keyof StatuslineSettings)[];
176
+
177
+ export type SettingKey = (typeof SETTING_KEYS)[number];
178
+
179
+ /**
180
+ * Compile-time completeness guard.
181
+ *
182
+ * `satisfies readonly (keyof StatuslineSettings)[]` above only proves the listed
183
+ * keys are real; it does not prove every key is listed. That gap is silent and
184
+ * expensive: SETTING_KEYS feeds changedSettingKeys, which feeds every save, so a
185
+ * setting missing from it works all session and then vanishes on restart.
186
+ */
187
+ type Unlisted<Key extends never> = Key;
188
+ type _EverySettingKeyIsListed = Unlisted<Exclude<keyof StatuslineSettings, SettingKey>>;
189
+
190
+ /** The keys one edit actually touched; the unit of a merging save. */
191
+ export function changedSettingKeys(
192
+ previous: StatuslineSettings,
193
+ next: StatuslineSettings,
194
+ ): SettingKey[] {
195
+ return SETTING_KEYS.filter((key) =>
196
+ key === "repoAliases"
197
+ ? !sameAliases(previous.repoAliases, next.repoAliases)
198
+ : previous[key] !== next[key],
199
+ );
200
+ }
201
+
168
202
  /**
169
203
  * Sparse serialization: only values differing from the defaults are written, so
170
204
  * a later default change still reaches hosts that never touched that key.
@@ -235,10 +269,30 @@ export class SettingsStore {
235
269
  return settings;
236
270
  }
237
271
 
238
- /** Atomic write via temp file + rename. Rejects so the caller can notify. */
239
- async save(settings: StatuslineSettings = this.current): Promise<void> {
272
+ /** Re-read the file as a plain object; anything unusable reads as empty. */
273
+ private async readRaw(): Promise<Record<string, unknown>> {
274
+ try {
275
+ const parsed: unknown = JSON.parse(await readFile(this.path, "utf8"));
276
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
277
+ return { ...(parsed as Record<string, unknown>) };
278
+ } catch {
279
+ return {};
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Atomic write via temp file + rename. Rejects so the caller can notify.
285
+ *
286
+ * With `changed`, only those keys are written over whatever is on disk right
287
+ * now. Settings load once per session, so a full-snapshot write would let a
288
+ * session started before an edit silently revert it — including edits made by
289
+ * hand or by another session. Passing the keys one change actually touched
290
+ * keeps a writer from claiming fields it was never asked about.
291
+ */
292
+ async save(settings: StatuslineSettings = this.current, changed?: readonly SettingKey[]): Promise<void> {
240
293
  this.current = settings;
241
- const payload = `${JSON.stringify(serializeSettings(settings, this.extra, this.home), null, "\t")}\n`;
294
+ const object = changed ? await this.merge(settings, changed) : serializeSettings(settings, this.extra, this.home);
295
+ const payload = `${JSON.stringify(object, null, "\t")}\n`;
242
296
  const temporary = `${this.path}.${pid}.tmp`;
243
297
  await mkdir(dirname(this.path), { recursive: true });
244
298
  try {
@@ -249,4 +303,19 @@ export class SettingsStore {
249
303
  throw error;
250
304
  }
251
305
  }
306
+
307
+ private async merge(
308
+ settings: StatuslineSettings,
309
+ changed: readonly SettingKey[],
310
+ ): Promise<Record<string, unknown>> {
311
+ const merged = await this.readRaw();
312
+ // Serialization is sparse, so a key absent here is back at its default and
313
+ // must be removed rather than written.
314
+ const desired = serializeSettings(settings, {}, this.home);
315
+ for (const key of changed) {
316
+ if (Object.hasOwn(desired, key)) merged[key] = desired[key];
317
+ else delete merged[key];
318
+ }
319
+ return merged;
320
+ }
252
321
  }