@underactive/pi-topping-statusline 0.2.7 → 0.3.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.
@@ -4,11 +4,18 @@
4
4
  * menu values, and the command registration.
5
5
  */
6
6
  import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
7
+ import {
8
+ showMenu,
9
+ type MenuItem,
10
+ type MenuSection,
11
+ type MenuValue,
12
+ type PreviewSlot,
13
+ } from "@underactive/pi-topping-primitives/menu";
7
14
  import { makeBoxPainters, renderBoxRow } from "./box.js";
15
+ import { CACHE_HIT_MAX_CELLS, CACHE_HIT_MIN_CELLS, clampCacheHitCells, stripCacheHitStat } from "./cache-hit.js";
8
16
  import { compactionPreviewView } from "./compaction-embed.js";
9
17
  import type { SegmentContextBuilder } from "./context.js";
10
18
  import { buildStatusLine } from "./layout.js";
11
- import { showMenu, type MenuSection, type MenuValue, type PreviewResult } from "./menu.js";
12
19
  import { NvidiaGreenBorder, isSwitchyardProvider } from "./nvidia-green.js";
13
20
  import { RAINBOW_CYCLE_MS, RAINBOW_FRAME_MS, RainbowBorder } from "./rainbow.js";
14
21
  import {
@@ -42,6 +49,12 @@ const SYMBOL_FROM_LABEL: Record<string, SymbolPreset> = Object.fromEntries(
42
49
  (Object.entries(SYMBOL_LABELS) as [SymbolPreset, string][]).map(([preset, label]) => [label, preset]),
43
50
  );
44
51
 
52
+ /** Menu values for the CH bar length: the menu cycles strings, so each size is its decimal text. */
53
+ const CACHE_HIT_CELL_VALUES: readonly string[] = Array.from(
54
+ { length: CACHE_HIT_MAX_CELLS - CACHE_HIT_MIN_CELLS + 1 },
55
+ (_, i) => String(CACHE_HIT_MIN_CELLS + i),
56
+ );
57
+
45
58
  const SECTION_TITLES = [
46
59
  "Top Left Segment Group",
47
60
  "Top Right Segment Group",
@@ -66,6 +79,7 @@ const SEGMENT_ROWS: readonly {
66
79
  { id: "feeds", label: "Feeds", section: "Bottom Right Segment Group" },
67
80
  { id: "tokenRate", label: "Token rate", section: "Bottom Right Segment Group" },
68
81
  { id: "piStats", label: "Pi stats", section: "Bottom Right Segment Group" },
82
+ { id: "cacheHitBar", label: "Cache hit bar", section: "Bottom Right Segment Group" },
69
83
  { id: "contextBar", label: "Context bar", section: "Bottom Right Segment Group" },
70
84
  { id: "contextStats", label: "Context stats", section: "Bottom Right Segment Group" },
71
85
  { id: "scrollHint", label: "Scroll hint", section: "Bottom Left Segment Group" },
@@ -73,8 +87,8 @@ const SEGMENT_ROWS: readonly {
73
87
  { id: "tokenRateBottomLeft", label: "Token rate", section: "Bottom Left Segment Group" },
74
88
  ];
75
89
 
76
- // Feed rows carry a stable key (independent of on-screen position) so
77
- // MenuComponent.rebuild's id-keyed value carry-over survives a removal.
90
+ // Feed rows carry a stable key (independent of on-screen position) so the
91
+ // menu's id-keyed value carry-over on a rebuild survives a removal.
78
92
  const FEED_PREFIX = "feed";
79
93
  const feedKey = (key: number, part: keyof StatusLineFeed) => `${FEED_PREFIX}.${key}.${part}`;
80
94
  const ADD_FEED_ID = `${FEED_PREFIX}.add`;
@@ -90,18 +104,18 @@ interface FeedPair {
90
104
  const pairsFromFeeds = (feeds: readonly StatusLineFeed[]): FeedPair[] => feeds.map((feed, key) => ({ key, feed }));
91
105
 
92
106
  function feedSection(pairs: readonly FeedPair[]): MenuSection {
93
- const items: MenuSection["items"] = [];
107
+ const items: MenuItem[] = [];
94
108
  pairs.forEach(({ key, feed }, index) => {
95
109
  const n = index + 1;
96
110
  items.push(
97
- { id: feedKey(key, "customType"), label: `${n}. type`, value: feed.customType, text: true, placeholder: "ext/custom-type" },
98
- { id: feedKey(key, "field"), label: `${n}. field`, value: feed.field, text: true, placeholder: "fieldName" },
99
- { id: feedKey(key, "prefix"), label: `${n}. prefix`, value: feed.prefix, text: true, placeholder: "(none)" },
100
- { id: feedKey(key, "format"), label: `${n}. format`, value: feed.format, cycleValues: FEED_FORMATS },
101
- { id: removeFeedId(key), label: `${n}. remove this feed`, value: false, action: true },
111
+ { kind: "text", id: feedKey(key, "customType"), label: `${n}. type`, value: feed.customType, placeholder: "ext/custom-type" },
112
+ { kind: "text", id: feedKey(key, "field"), label: `${n}. field`, value: feed.field, placeholder: "fieldName" },
113
+ { kind: "text", id: feedKey(key, "prefix"), label: `${n}. prefix`, value: feed.prefix, placeholder: "(none)" },
114
+ { kind: "cycle", id: feedKey(key, "format"), label: `${n}. format`, value: feed.format, values: FEED_FORMATS },
115
+ { kind: "action", id: removeFeedId(key), label: `${n}. remove this feed` },
102
116
  );
103
117
  });
104
- items.push({ id: ADD_FEED_ID, label: "+ add feed", value: false, action: true });
118
+ items.push({ kind: "action", id: ADD_FEED_ID, label: "+ add feed" });
105
119
  return { title: "Feeds", items };
106
120
  }
107
121
 
@@ -133,51 +147,59 @@ function feedsFromValues(values: Record<string, MenuValue>): StatusLineFeed[] {
133
147
  return feedPairsFromValues(values).map(({ feed }) => feed);
134
148
  }
135
149
 
136
- function buildSections(settings: StatusLineSettings): MenuSection[] {
150
+ export function buildSections(settings: StatusLineSettings): MenuSection[] {
137
151
  const seg = { ...DEFAULT_SEGMENTS, ...settings.segments };
138
152
  const global: MenuSection = {
139
153
  title: "Global",
140
154
  items: [
141
- { id: "transparent", label: "Transparent Segments", value: settings.transparent ?? true },
155
+ { kind: "toggle", id: "transparent", label: "Transparent Segments", value: settings.transparent ?? true },
142
156
  {
157
+ kind: "cycle",
143
158
  id: "separator",
144
159
  label: "Separator",
145
160
  value: settings.separator ?? "powerline-thin",
146
- cycleValues: SEPARATORS,
161
+ values: SEPARATORS,
147
162
  },
148
- { id: "symbols", label: "Symbols", value: SYMBOL_LABELS[settings.symbols ?? "nerd"], cycleValues: SYMBOL_VALUES },
163
+ { kind: "cycle", id: "symbols", label: "Symbols", value: SYMBOL_LABELS[settings.symbols ?? "nerd"], values: SYMBOL_VALUES },
149
164
  {
165
+ kind: "cycle",
150
166
  id: "borderStyle",
151
167
  label: "Border style",
152
168
  value: settings.borderStyle ?? "rounded",
153
- cycleValues: BORDER_STYLES,
169
+ values: BORDER_STYLES,
154
170
  },
155
171
  {
172
+ kind: "toggle",
156
173
  id: "rainbowBorder",
157
174
  label: "Rainbow border on max thinking",
158
175
  value: settings.rainbowBorder ?? true,
159
176
  },
160
177
  {
178
+ kind: "toggle",
161
179
  id: "rainbowAnimation",
162
180
  label: "Animate rainbow border",
163
181
  value: settings.rainbowAnimation ?? true,
164
182
  },
165
183
  {
184
+ kind: "toggle",
166
185
  id: "nvidiaGreenBorder",
167
186
  label: "NVIDIA-green when using Switchyard",
168
187
  value: settings.nvidiaGreenBorder ?? true,
169
188
  },
170
189
  {
190
+ kind: "toggle",
171
191
  id: "nvidiaGreenAnimation",
172
192
  label: "Animate Switchyard green border",
173
193
  value: settings.nvidiaGreenAnimation ?? true,
174
194
  },
175
195
  {
196
+ kind: "toggle",
176
197
  id: "embedWorkingStatus",
177
198
  label: "Embed status spinners",
178
199
  value: settings.embedWorkingStatus ?? false,
179
200
  },
180
201
  {
202
+ kind: "toggle",
181
203
  id: "embedCompactionProgress",
182
204
  label: "Embed compaction progress",
183
205
  value: settings.embedCompactionProgress ?? true,
@@ -188,17 +210,27 @@ function buildSections(settings: StatusLineSettings): MenuSection[] {
188
210
  global,
189
211
  ...SECTION_TITLES.map(title => ({
190
212
  title,
191
- items: SEGMENT_ROWS.filter(row => row.section === title).map(row => ({
192
- id: row.id,
193
- label: row.label,
194
- value: seg[row.id],
195
- })),
213
+ items: SEGMENT_ROWS.filter(row => row.section === title).flatMap((row): MenuItem[] => {
214
+ const item: MenuItem = { kind: "toggle", id: row.id, label: row.label, value: seg[row.id] };
215
+ // The bar's length sits right under its on/off toggle.
216
+ if (row.id !== "cacheHitBar") return [item];
217
+ return [
218
+ item,
219
+ {
220
+ kind: "cycle",
221
+ id: "cacheHitBarSegments",
222
+ label: "CH bar segments",
223
+ value: String(clampCacheHitCells(settings.cacheHitBarSegments)),
224
+ values: CACHE_HIT_CELL_VALUES,
225
+ },
226
+ ];
227
+ }),
196
228
  })),
197
229
  feedSection(pairsFromFeeds(settings.feeds ? sanitizeFeeds(settings.feeds) : DEFAULT_FEEDS)),
198
230
  ];
199
231
  }
200
232
 
201
- function valuesToSettings(values: Record<string, MenuValue>): StatusLineSettings {
233
+ export function valuesToSettings(values: Record<string, MenuValue>): StatusLineSettings {
202
234
  const segments: StatusLineSegmentToggles = {};
203
235
  for (const row of SEGMENT_ROWS) segments[row.id] = values[row.id] === true;
204
236
  const separator = values.separator as StatusLineSeparatorStyle;
@@ -216,6 +248,7 @@ function valuesToSettings(values: Record<string, MenuValue>): StatusLineSettings
216
248
  nvidiaGreenAnimation: values.nvidiaGreenAnimation === true,
217
249
  embedWorkingStatus: values.embedWorkingStatus === true,
218
250
  embedCompactionProgress: values.embedCompactionProgress === true,
251
+ cacheHitBarSegments: clampCacheHitCells(Number(values.cacheHitBarSegments)),
219
252
  };
220
253
  }
221
254
 
@@ -226,7 +259,8 @@ const CANNED_TOKEN_RATE = { rate: 87, phase: "active", fadeShade: 0 } as const;
226
259
  /** Stand-in payloads so configured feeds still preview without a live publisher. */
227
260
  const CANNED_FEED_NUMBER = 1.23;
228
261
  const CANNED_HINT = "↑ 3 more";
229
- const CANNED_STATS = "↑ 12.4K ↓ 3.1K R 148K W 12K 92.3% $0.42";
262
+ const CANNED_CACHE_HIT = 92.3;
263
+ const CANNED_STATS = `↑ 12.4K ↓ 3.1K R 148K W 12K CH${CANNED_CACHE_HIT}% $0.42`;
230
264
  const CANNED_GIT = { branch: "main", status: { staged: 1, unstaged: 2, untracked: 3 } };
231
265
  const CANNED_PERCENT = 42;
232
266
  const CANNED_WINDOW = 200_000;
@@ -250,7 +284,8 @@ function cannedFeedData(
250
284
  return data;
251
285
  }
252
286
 
253
- class StatusLinePreview {
287
+ /** The menu's preview slot: three rows of the real box, sampled synchronously on every render. */
288
+ class StatusLinePreview implements PreviewSlot<Record<string, MenuValue>> {
254
289
  readonly #builder: SegmentContextBuilder;
255
290
  readonly #uiTheme: Theme;
256
291
 
@@ -261,14 +296,12 @@ class StatusLinePreview {
261
296
 
262
297
  render(
263
298
  values: Record<string, MenuValue>,
264
- elapsedMs: number,
265
- activeItemId: string | undefined,
266
- innerWidth: number,
267
- ): PreviewResult {
299
+ { elapsedMs, activeItemId, width }: { elapsedMs: number; activeItemId: string | undefined; width: number },
300
+ ): { lines: string[]; nextRefreshInMs?: number } {
268
301
  const effective = resolveEffectiveSettings(valuesToSettings(values));
269
- // Preview lines are indented one cell by the menu; mirror renderBoxed's
270
- // 6-cell chrome budget for the bars themselves.
271
- const boxWidth = Math.max(24, innerWidth - 1);
302
+ // The menu hands over its full inner width and adds no margin, so the one-cell
303
+ // indent is added here; mirror renderBoxed's 6-cell chrome budget for the bars.
304
+ const boxWidth = Math.max(24, width - 1);
272
305
  const barWidth = boxWidth - 6;
273
306
 
274
307
  // The theme singleton drives glyph lookups everywhere, so swap the
@@ -281,6 +314,7 @@ class StatusLinePreview {
281
314
  pr: true,
282
315
  piStats: true,
283
316
  tokenRate: true,
317
+ cacheHit: effective.includes.cacheHit,
284
318
  feeds: effective.segmentOptions.feeds.map(f => f.customType),
285
319
  };
286
320
  const base = this.#builder.build(barWidth, effective.segmentOptions, include, CANNED_HINT);
@@ -302,7 +336,9 @@ class StatusLinePreview {
302
336
  contextPercent: base.contextPercent ?? CANNED_PERCENT,
303
337
  contextTokens: base.contextPercent == null ? Math.round((contextWindow * CANNED_PERCENT) / 100) : base.contextTokens,
304
338
  contextWindow,
305
- piStats: base.piStats ?? CANNED_STATS,
339
+ cacheHitPercent: base.cacheHitPercent ?? CANNED_CACHE_HIT,
340
+ // The canned line stands in for pi's, so it moves CH beside the graph exactly as the builder does.
341
+ piStats: base.piStats ?? (effective.includes.cacheHit ? stripCacheHitStat(CANNED_STATS) : CANNED_STATS),
306
342
  tokenRate: base.tokenRate ?? CANNED_TOKEN_RATE,
307
343
  feedData: cannedFeedData(effective.segmentOptions.feeds, base.feedData),
308
344
  };
@@ -356,7 +392,7 @@ class StatusLinePreview {
356
392
  " ".repeat(Math.max(0, boxWidth - 2)) +
357
393
  painters.paint(1, boxWidth - 1, box.vertical),
358
394
  renderBoxRow(painters, bottom, bottomIdx, boxWidth, box.bottomLeft, box.bottomRight),
359
- ],
395
+ ].map(line => ` ${line}`),
360
396
  // A static colorizer stays at phase zero and needs no repaint timer.
361
397
  nextRefreshInMs: animationOn ? RAINBOW_FRAME_MS : undefined,
362
398
  };
@@ -379,12 +415,11 @@ export function registerSettingsCommand(
379
415
  ctx.ui.notify("/topping-statusline-settings requires TUI mode", "error");
380
416
  return;
381
417
  }
382
- const preview = new StatusLinePreview(builder, ctx.ui.theme);
383
418
  const result = await showMenu<Record<string, MenuValue>>(ctx, {
384
419
  title: "Pi Topping Statusline: Settings",
385
420
  sections: buildSections(state.settings),
386
421
  hints: ["↑↓ move", "←→ cycle", "␣ toggle", "⏎ apply/edit", "esc cancel"],
387
- preview: preview.render.bind(preview),
422
+ preview: new StatusLinePreview(builder, ctx.ui.theme),
388
423
  onAction: (id, values) => {
389
424
  const pairs = feedPairsFromValues(values);
390
425
  let nextPairs: FeedPair[];
@@ -393,7 +428,7 @@ export function registerSettingsCommand(
393
428
  nextPairs = [...pairs, { key, feed: { customType: "", field: "", prefix: "", format: "currency" } }];
394
429
  } else {
395
430
  const match = REMOVE_FEED_RE.exec(id);
396
- if (!match) return undefined;
431
+ if (!match) return;
397
432
  const removedKey = Number(match[1]);
398
433
  nextPairs = pairs.filter(p => p.key !== removedKey);
399
434
  }
@@ -402,16 +437,13 @@ export function registerSettingsCommand(
402
437
  return [...buildSections(valuesToSettings(values)).slice(0, -1), feedSection(nextPairs)];
403
438
  },
404
439
  });
440
+ // Escape hands back the un-applied draft, so the values are only read on apply.
405
441
  if (!result.applied) return;
406
442
  const next = valuesToSettings(result.values);
407
443
  applySettings(state, next);
408
444
  onChange();
409
- try {
410
- saveSettings(next);
411
- } catch (err) {
412
- const message = err instanceof Error ? err.message : String(err);
413
- ctx.ui.notify(`Failed to save statusline settings: ${message}`, "error");
414
- }
445
+ const saved = saveSettings(next);
446
+ if (!saved.ok) ctx.ui.notify(`Failed to save statusline settings: ${saved.message}`, "error");
415
447
  },
416
448
  });
417
449
  }
package/src/settings.ts CHANGED
@@ -3,9 +3,8 @@
3
3
  * statusline settings live in a small JSON at
4
4
  * ~/.pi/agent/pi-topping-statusline/settings.json.
5
5
  */
6
- import * as fs from "node:fs";
7
- import * as path from "node:path";
8
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+ import { createSettingsStore, sanitize, toppingSettingsPath, type SettingsWriteResult } from "@underactive/pi-topping-primitives/settings";
7
+ import { clampCacheHitCells } from "./cache-hit.js";
9
8
  import { theme, type BorderStyle, type SymbolPreset } from "./theme.js";
10
9
  import type {
11
10
  EffectiveStatusLineSettings,
@@ -18,7 +17,8 @@ import type {
18
17
  StatusLineSettings,
19
18
  } from "./types.js";
20
19
 
21
- const SETTINGS_PATH = path.join(getAgentDir(), "pi-topping-statusline", "settings.json");
20
+ /** Resolved per call: PI_CODING_AGENT_DIR may change after import. */
21
+ export const settingsPath = toppingSettingsPath("pi-topping-statusline");
22
22
 
23
23
  export const SEPARATORS: readonly StatusLineSeparatorStyle[] = ["powerline", "powerline-thin", "slash", "pipe", "ascii"];
24
24
  const SYMBOL_PRESETS: SymbolPreset[] = ["nerd", "unicode", "ascii"];
@@ -27,12 +27,10 @@ export const BORDER_STYLES: readonly BorderStyle[] = ["rounded", "heavy", "doubl
27
27
  export const FEED_FORMATS: readonly FeedFormat[] = ["currency", "number", "text"];
28
28
 
29
29
  /**
30
- * Seeded with pi-prompt-cache's published savings feed, so the segment works
31
- * as soon as it is switched on and doubles as a worked example of the shape.
30
+ * Nothing is subscribed by default: the list comes from the user's settings. The feeds
31
+ * segment renders nothing while the list is empty, so switching it on costs nothing.
32
32
  */
33
- export const DEFAULT_FEEDS: readonly StatusLineFeed[] = [
34
- { customType: "pi-prompt-cache/savings", field: "savedUsd", prefix: "CS", format: "currency" },
35
- ];
33
+ export const DEFAULT_FEEDS: readonly StatusLineFeed[] = [];
36
34
 
37
35
  export const DEFAULT_SEGMENTS: Required<StatusLineSegmentToggles> = {
38
36
  pi: true,
@@ -49,6 +47,7 @@ export const DEFAULT_SEGMENTS: Required<StatusLineSegmentToggles> = {
49
47
  feeds: false,
50
48
  feedsBottomLeft: false,
51
49
  tokenRateBottomLeft: false,
50
+ cacheHitBar: true,
52
51
  contextBar: true,
53
52
  contextStats: true,
54
53
  scrollHint: true,
@@ -75,41 +74,48 @@ export function sanitizeFeeds(raw: unknown): StatusLineFeed[] {
75
74
  return feeds;
76
75
  }
77
76
 
78
- function loadSettings(): StatusLineSettings {
79
- try {
80
- const raw = JSON.parse(fs.readFileSync(SETTINGS_PATH, "utf8")) as StatusLineSettings;
81
- const settings: StatusLineSettings = {};
82
- if (typeof raw.transparent === "boolean") settings.transparent = raw.transparent;
83
- if (raw.separator && SEPARATORS.includes(raw.separator)) settings.separator = raw.separator;
84
- if (raw.symbols && SYMBOL_PRESETS.includes(raw.symbols)) settings.symbols = raw.symbols;
85
- if (raw.borderStyle && BORDER_STYLES.includes(raw.borderStyle)) settings.borderStyle = raw.borderStyle;
86
- if (raw.segments && typeof raw.segments === "object") {
87
- const segments: StatusLineSegmentToggles = {};
88
- for (const key of SEGMENT_KEYS) {
89
- if (typeof raw.segments[key] === "boolean") segments[key] = raw.segments[key];
90
- }
91
- settings.segments = segments;
77
+ function sanitizeSettings(parsed: unknown): StatusLineSettings {
78
+ if (!sanitize.isRecord(parsed)) return {};
79
+ const raw = parsed as StatusLineSettings;
80
+ const settings: StatusLineSettings = {};
81
+ if (typeof raw.transparent === "boolean") settings.transparent = raw.transparent;
82
+ if (raw.separator && SEPARATORS.includes(raw.separator)) settings.separator = raw.separator;
83
+ if (raw.symbols && SYMBOL_PRESETS.includes(raw.symbols)) settings.symbols = raw.symbols;
84
+ if (raw.borderStyle && BORDER_STYLES.includes(raw.borderStyle)) settings.borderStyle = raw.borderStyle;
85
+ if (raw.segments && typeof raw.segments === "object") {
86
+ const segments: StatusLineSegmentToggles = {};
87
+ for (const key of SEGMENT_KEYS) {
88
+ if (typeof raw.segments[key] === "boolean") segments[key] = raw.segments[key];
92
89
  }
93
- if (raw.feeds !== undefined) settings.feeds = sanitizeFeeds(raw.feeds);
94
- if (typeof raw.rainbowBorder === "boolean") settings.rainbowBorder = raw.rainbowBorder;
95
- if (typeof raw.rainbowAnimation === "boolean") settings.rainbowAnimation = raw.rainbowAnimation;
96
- if (typeof raw.nvidiaGreenBorder === "boolean") settings.nvidiaGreenBorder = raw.nvidiaGreenBorder;
97
- if (typeof raw.nvidiaGreenAnimation === "boolean") settings.nvidiaGreenAnimation = raw.nvidiaGreenAnimation;
98
- if (typeof raw.embedWorkingStatus === "boolean") settings.embedWorkingStatus = raw.embedWorkingStatus;
99
- if (typeof raw.embedCompactionProgress === "boolean") settings.embedCompactionProgress = raw.embedCompactionProgress;
100
- return settings;
101
- } catch {
102
- // Missing or corrupt settings.json falls back to defaults.
103
- return {};
90
+ settings.segments = segments;
104
91
  }
92
+ if (raw.feeds !== undefined) settings.feeds = sanitizeFeeds(raw.feeds);
93
+ if (typeof raw.rainbowBorder === "boolean") settings.rainbowBorder = raw.rainbowBorder;
94
+ if (typeof raw.rainbowAnimation === "boolean") settings.rainbowAnimation = raw.rainbowAnimation;
95
+ if (typeof raw.nvidiaGreenBorder === "boolean") settings.nvidiaGreenBorder = raw.nvidiaGreenBorder;
96
+ if (typeof raw.nvidiaGreenAnimation === "boolean") settings.nvidiaGreenAnimation = raw.nvidiaGreenAnimation;
97
+ if (typeof raw.embedWorkingStatus === "boolean") settings.embedWorkingStatus = raw.embedWorkingStatus;
98
+ if (typeof raw.embedCompactionProgress === "boolean") settings.embedCompactionProgress = raw.embedCompactionProgress;
99
+ if (typeof raw.cacheHitBarSegments === "number") settings.cacheHitBarSegments = clampCacheHitCells(raw.cacheHitBarSegments);
100
+ return settings;
105
101
  }
106
102
 
107
- /** Atomic write; throws so callers can surface the failure. */
108
- export function saveSettings(settings: StatusLineSettings): void {
109
- fs.mkdirSync(path.dirname(SETTINGS_PATH), { recursive: true });
110
- fs.writeFileSync(`${SETTINGS_PATH}.tmp`, `${JSON.stringify(settings, null, "\t")}\n`, { mode: 0o600 });
111
- fs.renameSync(`${SETTINGS_PATH}.tmp`, SETTINGS_PATH);
112
- fs.chmodSync(SETTINGS_PATH, 0o600);
103
+ const settingsStore = createSettingsStore<StatusLineSettings>({
104
+ path: settingsPath,
105
+ codec: {
106
+ sanitize: sanitizeSettings,
107
+ serialize: (settings) => `${JSON.stringify(settings, null, "\t")}\n`,
108
+ },
109
+ });
110
+
111
+ /** Missing or corrupt settings.json falls back to defaults. */
112
+ export function loadSettings(): StatusLineSettings {
113
+ return settingsStore.read();
114
+ }
115
+
116
+ /** Atomic write; the result says whether it landed so callers can surface a failure. */
117
+ export function saveSettings(settings: StatusLineSettings): SettingsWriteResult<StatusLineSettings> {
118
+ return settingsStore.write(settings);
113
119
  }
114
120
 
115
121
  export function resolveEffectiveSettings(settings: StatusLineSettings): EffectiveStatusLineSettings {
@@ -126,6 +132,7 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
126
132
  if (seg.feeds) bottomRightSegments.push("feeds");
127
133
  if (seg.tokenRate) bottomRightSegments.push("token_rate");
128
134
  if (seg.piStats) bottomRightSegments.push("pi_stats");
135
+ if (seg.cacheHitBar) bottomRightSegments.push("cache_hit_graph");
129
136
  if (seg.contextBar || seg.contextStats) bottomRightSegments.push("context_graph");
130
137
 
131
138
  const rightSegments: StatusLineSegmentId[] = [];
@@ -145,6 +152,7 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
145
152
  pr: allSegments.includes("pr"),
146
153
  piStats: allSegments.includes("pi_stats"),
147
154
  tokenRate: allSegments.includes("token_rate"),
155
+ cacheHit: allSegments.includes("cache_hit_graph"),
148
156
  feeds: allSegments.includes("feeds") ? feeds.map(feed => feed.customType) : [],
149
157
  };
150
158
 
@@ -168,6 +176,7 @@ export function resolveEffectiveSettings(settings: StatusLineSettings): Effectiv
168
176
  model: { showModel: seg.model, showProvider: seg.provider, showThinking: seg.thinking },
169
177
  path: { abbreviate: true, maxLength: 40, stripWorkPrefix: true },
170
178
  context: { showBar: seg.contextBar, showStats: seg.contextStats },
179
+ cacheHit: { cells: clampCacheHitCells(settings.cacheHitBarSegments) },
171
180
  feeds,
172
181
  },
173
182
  };