@hank-warren/pi-statusline 0.2.4 → 0.4.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.
@@ -0,0 +1,342 @@
1
+ import { homedir } from "node:os";
2
+ import {
3
+ type Component,
4
+ Input,
5
+ type SelectItem,
6
+ SelectList,
7
+ type SelectListTheme,
8
+ type SettingItem,
9
+ type SettingsListTheme,
10
+ truncateToWidth,
11
+ } from "@earendil-works/pi-tui";
12
+ import { type BooleanSettingKey, collapseHome, resolveWorktreeRoot, type StatuslineSettings } from "./settings.ts";
13
+ import { CELEBRATION_STYLE_NAMES, isCelebrationStyleName } from "./celebration-styles.ts";
14
+ import { isThemeName, THEME_NAMES } from "./themes.ts";
15
+
16
+ export const THEME_ID = "theme";
17
+ export const CACHE_CELEBRATION_ID = "showCacheCelebration";
18
+ export const WORKTREE_ROOT_ID = "worktreeRoot";
19
+ export const REPO_ALIASES_ID = "repoAliases";
20
+ export const ADD_ALIAS_VALUE = "\u0000add";
21
+
22
+ export const ON = "on";
23
+ export const OFF = "off";
24
+ const TOGGLE_VALUES = [ON, OFF];
25
+
26
+ export interface BooleanRow {
27
+ id: BooleanSettingKey;
28
+ label: string;
29
+ description: string;
30
+ }
31
+
32
+ /** Toggle rows, in statusline render order. */
33
+ export const BOOLEAN_ROWS: readonly BooleanRow[] = [
34
+ { id: "showModel", label: "Model", description: "Show the active model id." },
35
+ { id: "showDirectory", label: "Directory & git", description: "Show the working directory and its git branch." },
36
+ { id: "showContext", label: "Context", description: "Show context tokens used against the window." },
37
+ { id: "showUsage", label: "Subscription usage", description: "Show Claude/Codex remaining-headroom meters." },
38
+ { id: "showWorktrees", label: "Worktree line", description: "Show touched worktrees and their pull requests." },
39
+ { id: "showSessionId", label: "Session ID line", description: "Show the full Pi session id on its own line." },
40
+ ];
41
+
42
+ /** Label of the celebration row, used to drive the settings-menu preview loop. */
43
+ export const CACHE_CELEBRATION_LABEL = "Cache celebration";
44
+
45
+ /** The celebration row folds "off" into the style list, so it is one row, not two. */
46
+ export function celebrationValue(settings: StatuslineSettings): string {
47
+ return settings.showCacheCelebration ? settings.cacheCelebrationStyle : OFF;
48
+ }
49
+
50
+ export function toggleValue(enabled: boolean): string {
51
+ return enabled ? ON : OFF;
52
+ }
53
+
54
+ export function aliasSummary(settings: StatuslineSettings): string {
55
+ const count = Object.keys(settings.repoAliases).length;
56
+ return `${count} alias${count === 1 ? "" : "es"}`;
57
+ }
58
+
59
+ export interface SettingSubmenus {
60
+ worktreeRoot?: SettingItem["submenu"];
61
+ repoAliases?: SettingItem["submenu"];
62
+ }
63
+
64
+ /** Build the `/statusline` rows for a settings snapshot. */
65
+ export function buildSettingItems(
66
+ settings: StatuslineSettings,
67
+ submenus: SettingSubmenus = {},
68
+ home: string = homedir(),
69
+ ): SettingItem[] {
70
+ const items: SettingItem[] = [
71
+ {
72
+ id: THEME_ID,
73
+ label: "Theme",
74
+ description: "Colour palette for every statusline element.",
75
+ currentValue: settings.theme,
76
+ values: [...THEME_NAMES],
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) => ({
86
+ id: row.id,
87
+ label: row.label,
88
+ description: row.description,
89
+ currentValue: toggleValue(settings[row.id]),
90
+ values: TOGGLE_VALUES,
91
+ })),
92
+ ];
93
+
94
+ items.push({
95
+ id: WORKTREE_ROOT_ID,
96
+ label: "Worktree root",
97
+ description: "Directory whose children are tracked as session worktrees.",
98
+ currentValue: collapseHome(settings.worktreeRoot, home),
99
+ ...(submenus.worktreeRoot ? { submenu: submenus.worktreeRoot } : {}),
100
+ });
101
+ items.push({
102
+ id: REPO_ALIASES_ID,
103
+ label: "Repo aliases",
104
+ description: "Short display names for repositories on the worktree line.",
105
+ currentValue: aliasSummary(settings),
106
+ ...(submenus.repoAliases ? { submenu: submenus.repoAliases } : {}),
107
+ });
108
+
109
+ return items;
110
+ }
111
+
112
+ export type SettingChange =
113
+ | { kind: "settings"; settings: StatuslineSettings }
114
+ | { kind: "error"; message: string }
115
+ | { kind: "ignored" };
116
+
117
+ /** Map one row's new display value onto the settings object. */
118
+ export function applySettingChange(
119
+ settings: StatuslineSettings,
120
+ id: string,
121
+ value: string,
122
+ home: string = homedir(),
123
+ ): SettingChange {
124
+ if (BOOLEAN_ROWS.some((row) => row.id === id)) {
125
+ if (value !== ON && value !== OFF) return { kind: "error", message: `Unknown value for ${id}: ${value}` };
126
+ return { kind: "settings", settings: { ...settings, [id]: value === ON } };
127
+ }
128
+ if (id === CACHE_CELEBRATION_ID) {
129
+ if (value === OFF) {
130
+ return settings.showCacheCelebration
131
+ ? { kind: "settings", settings: { ...settings, showCacheCelebration: false } }
132
+ : { kind: "ignored" };
133
+ }
134
+ if (!isCelebrationStyleName(value)) {
135
+ return { kind: "error", message: `Unknown cache celebration style: ${value}` };
136
+ }
137
+ if (settings.showCacheCelebration && settings.cacheCelebrationStyle === value) return { kind: "ignored" };
138
+ return {
139
+ kind: "settings",
140
+ settings: { ...settings, showCacheCelebration: true, cacheCelebrationStyle: value },
141
+ };
142
+ }
143
+ if (id === THEME_ID) {
144
+ if (!isThemeName(value)) return { kind: "error", message: `Unknown statusline theme: ${value}` };
145
+ if (value === settings.theme) return { kind: "ignored" };
146
+ return { kind: "settings", settings: { ...settings, theme: value } };
147
+ }
148
+ if (id === WORKTREE_ROOT_ID) {
149
+ const resolved = resolveWorktreeRoot(value, home);
150
+ if (!resolved.path) return { kind: "error", message: resolved.error ?? "Invalid worktree root" };
151
+ if (resolved.path === settings.worktreeRoot) return { kind: "ignored" };
152
+ return { kind: "settings", settings: { ...settings, worktreeRoot: resolved.path } };
153
+ }
154
+ // Alias edits are committed by the submenu itself; its done() value is display text.
155
+ return { kind: "ignored" };
156
+ }
157
+
158
+ /** Parse an `repo=alias` (or `repo → alias`) submenu line. */
159
+ export function parseAliasEntry(input: string): { repo: string; alias: string } | undefined {
160
+ const [rawRepo, ...rest] = input.split(/=|→/);
161
+ const repo = rawRepo?.trim() ?? "";
162
+ const alias = rest.join("=").trim();
163
+ if (repo.length === 0 || alias.length === 0) return undefined;
164
+ return { repo, alias };
165
+ }
166
+
167
+ export function aliasItems(settings: StatuslineSettings): SelectItem[] {
168
+ const items: SelectItem[] = Object.entries(settings.repoAliases)
169
+ .sort(([a], [b]) => a.localeCompare(b))
170
+ .map(([repo, alias]) => ({ value: repo, label: `${repo} → ${alias}`, description: "Enter to edit · d to delete" }));
171
+ items.push({ value: ADD_ALIAS_VALUE, label: "Add alias…", description: "Enter a new repo=alias pair" });
172
+ return items;
173
+ }
174
+
175
+ export interface SubmenuHost {
176
+ /** Read the live settings; the menu edits a single shared snapshot. */
177
+ getSettings(): StatuslineSettings;
178
+ /** Commit an edit: persists, applies live, and repaints. */
179
+ commit(settings: StatuslineSettings): void;
180
+ notify(message: string): void;
181
+ requestRender(): void;
182
+ settingsTheme: SettingsListTheme;
183
+ selectTheme: SelectListTheme;
184
+ home?: string;
185
+ }
186
+
187
+ /** Single-line text prompt used by both submenus. */
188
+ class PromptComponent implements Component {
189
+ private readonly input = new Input();
190
+
191
+ constructor(
192
+ private readonly title: string,
193
+ initialValue: string,
194
+ private readonly hint: (text: string) => string,
195
+ onSubmit: (value: string) => void,
196
+ onCancel: () => void,
197
+ ) {
198
+ this.input.setValue(initialValue);
199
+ this.input.focused = true;
200
+ this.input.onSubmit = onSubmit;
201
+ this.input.onEscape = onCancel;
202
+ }
203
+
204
+ invalidate(): void {
205
+ this.input.invalidate();
206
+ }
207
+
208
+ render(width: number): string[] {
209
+ return [
210
+ truncateToWidth(this.hint(` ${this.title}`), width),
211
+ "",
212
+ ...this.input.render(width),
213
+ "",
214
+ truncateToWidth(this.hint(" Enter to save · Esc to cancel"), width),
215
+ ];
216
+ }
217
+
218
+ handleInput(data: string): void {
219
+ this.input.handleInput(data);
220
+ }
221
+ }
222
+
223
+ export function createWorktreeRootSubmenu(host: SubmenuHost): NonNullable<SettingItem["submenu"]> {
224
+ const home = host.home ?? homedir();
225
+ return (currentValue, done) =>
226
+ new PromptComponent(
227
+ "Worktree root directory",
228
+ currentValue,
229
+ host.settingsTheme.hint,
230
+ (value) => {
231
+ const resolved = resolveWorktreeRoot(value, home);
232
+ if (!resolved.path) {
233
+ host.notify(resolved.error ?? "Invalid worktree root");
234
+ done(undefined);
235
+ return;
236
+ }
237
+ host.commit({ ...host.getSettings(), worktreeRoot: resolved.path });
238
+ done(collapseHome(resolved.path, home));
239
+ },
240
+ () => done(undefined),
241
+ );
242
+ }
243
+
244
+ /** Alias list submenu: edit, add, delete, toggle the prefix rule, load the preset. */
245
+ class AliasSubmenu implements Component {
246
+ private list: SelectList;
247
+ private prompt: PromptComponent | undefined;
248
+
249
+ constructor(
250
+ private readonly host: SubmenuHost,
251
+ private readonly done: (value?: string) => void,
252
+ ) {
253
+ this.list = this.buildList();
254
+ }
255
+
256
+ private buildList(selectedIndex = 0): SelectList {
257
+ const list = new SelectList(aliasItems(this.host.getSettings()), 10, this.host.selectTheme);
258
+ list.setSelectedIndex(selectedIndex);
259
+ list.onCancel = () => this.done(aliasSummary(this.host.getSettings()));
260
+ list.onSelect = (item) => this.activate(item);
261
+ return list;
262
+ }
263
+
264
+ private rebuild(selectedIndex: number): void {
265
+ this.list = this.buildList(selectedIndex);
266
+ this.host.requestRender();
267
+ }
268
+
269
+ private activate(item: SelectItem): void {
270
+ const settings = this.host.getSettings();
271
+ const editing = item.value === ADD_ALIAS_VALUE ? "" : `${item.value}=${settings.repoAliases[item.value] ?? ""}`;
272
+ this.prompt = new PromptComponent(
273
+ item.value === ADD_ALIAS_VALUE ? "New alias (repo=alias)" : "Edit alias (repo=alias)",
274
+ editing,
275
+ this.host.settingsTheme.hint,
276
+ (value) => this.submitAlias(item.value, value),
277
+ () => {
278
+ this.prompt = undefined;
279
+ this.host.requestRender();
280
+ },
281
+ );
282
+ this.host.requestRender();
283
+ }
284
+
285
+ private submitAlias(originalRepo: string, value: string): void {
286
+ this.prompt = undefined;
287
+ const parsed = parseAliasEntry(value);
288
+ if (!parsed) {
289
+ this.host.notify("Enter an alias as repo=alias");
290
+ this.host.requestRender();
291
+ return;
292
+ }
293
+ const settings = this.host.getSettings();
294
+ const aliases = { ...settings.repoAliases };
295
+ if (originalRepo !== ADD_ALIAS_VALUE && originalRepo !== parsed.repo) delete aliases[originalRepo];
296
+ aliases[parsed.repo] = parsed.alias;
297
+ this.host.commit({ ...settings, repoAliases: aliases });
298
+ this.rebuild(aliasItems(this.host.getSettings()).findIndex((row) => row.value === parsed.repo));
299
+ }
300
+
301
+ private deleteSelected(): void {
302
+ const selected = this.list.getSelectedItem();
303
+ if (!selected || selected.value.startsWith("\u0000")) return;
304
+ const settings = this.host.getSettings();
305
+ const aliases = { ...settings.repoAliases };
306
+ delete aliases[selected.value];
307
+ this.host.commit({ ...settings, repoAliases: aliases });
308
+ this.rebuild(0);
309
+ }
310
+
311
+ invalidate(): void {
312
+ this.prompt?.invalidate();
313
+ this.list.invalidate();
314
+ }
315
+
316
+ render(width: number): string[] {
317
+ if (this.prompt) return this.prompt.render(width);
318
+ return [
319
+ truncateToWidth(this.host.settingsTheme.hint(" Repo aliases"), width),
320
+ "",
321
+ ...this.list.render(width),
322
+ "",
323
+ truncateToWidth(this.host.settingsTheme.hint(" Enter to edit · d to delete · Esc to go back"), width),
324
+ ];
325
+ }
326
+
327
+ handleInput(data: string): void {
328
+ if (this.prompt) {
329
+ this.prompt.handleInput(data);
330
+ return;
331
+ }
332
+ if (data === "d") {
333
+ this.deleteSelected();
334
+ return;
335
+ }
336
+ this.list.handleInput(data);
337
+ }
338
+ }
339
+
340
+ export function createAliasSubmenu(host: SubmenuHost): NonNullable<SettingItem["submenu"]> {
341
+ return (_currentValue, done) => new AliasSubmenu(host, done);
342
+ }
package/settings.ts ADDED
@@ -0,0 +1,252 @@
1
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, isAbsolute, join } from "node:path";
4
+ import { pid } from "node:process";
5
+ import {
6
+ type CelebrationStyleName,
7
+ DEFAULT_CELEBRATION_STYLE,
8
+ isCelebrationStyleName,
9
+ } from "./celebration-styles.ts";
10
+ import { DEFAULT_THEME, isThemeName, type StatuslineThemeName } from "./themes.ts";
11
+
12
+ /** Toggle keys, in the order the `/statusline` menu lists them. */
13
+ export const BOOLEAN_SETTING_KEYS = [
14
+ "showModel",
15
+ "showDirectory",
16
+ "showContext",
17
+ "showUsage",
18
+ "showWorktrees",
19
+ "showSessionId",
20
+ "showCacheCelebration",
21
+ ] as const;
22
+
23
+ export type BooleanSettingKey = (typeof BOOLEAN_SETTING_KEYS)[number];
24
+
25
+ export interface StatuslineSettings extends Record<BooleanSettingKey, boolean> {
26
+ /** Colour palette name; see themes.ts. */
27
+ theme: StatuslineThemeName;
28
+ /** Cache-hit badge animation; only consulted when showCacheCelebration is on. */
29
+ cacheCelebrationStyle: CelebrationStyleName;
30
+ /** Directory whose immediate children are treated as session worktrees. */
31
+ worktreeRoot: string;
32
+ /** `repository name -> display alias` overrides for the worktree line. */
33
+ repoAliases: Record<string, string>;
34
+ }
35
+
36
+ export function defaultWorktreeRoot(home: string = homedir()): string {
37
+ return join(home || homedir(), "repos", "worktrees");
38
+ }
39
+
40
+ export function defaultSettings(home: string = homedir()): StatuslineSettings {
41
+ return {
42
+ showModel: true,
43
+ showDirectory: true,
44
+ showContext: true,
45
+ showUsage: true,
46
+ showWorktrees: true,
47
+ showSessionId: true,
48
+ showCacheCelebration: true,
49
+ theme: DEFAULT_THEME,
50
+ cacheCelebrationStyle: DEFAULT_CELEBRATION_STYLE,
51
+ worktreeRoot: defaultWorktreeRoot(home),
52
+ repoAliases: {},
53
+ };
54
+ }
55
+
56
+ export function defaultSettingsPath(home: string = homedir()): string {
57
+ return join(home || homedir(), ".pi", "agent", "statusline-settings.json");
58
+ }
59
+
60
+ /** Expand a leading `~` (and `$HOME`) against `home`; other paths are returned as-is. */
61
+ export function expandHome(path: string, home: string = homedir()): string {
62
+ const trimmed = path.trim();
63
+ const base = home || homedir();
64
+ if (trimmed === "~" || trimmed === "$HOME") return base;
65
+ if (trimmed.startsWith("~/")) return join(base, trimmed.slice(2));
66
+ if (trimmed.startsWith("$HOME/")) return join(base, trimmed.slice("$HOME/".length));
67
+ return trimmed;
68
+ }
69
+
70
+ /** Inverse of {@link expandHome}, for compact display in the menu. */
71
+ export function collapseHome(path: string, home: string = homedir()): string {
72
+ const base = home || homedir();
73
+ if (!base) return path;
74
+ if (path === base) return "~";
75
+ return path.startsWith(`${base}/`) ? `~/${path.slice(base.length + 1)}` : path;
76
+ }
77
+
78
+ export interface WorktreeRootResult {
79
+ path?: string;
80
+ error?: string;
81
+ }
82
+
83
+ /** Validate a user-entered worktree root: `~`-expanded, and absolute afterwards. */
84
+ export function resolveWorktreeRoot(input: string, home: string = homedir()): WorktreeRootResult {
85
+ const expanded = expandHome(input, home);
86
+ if (expanded.length === 0) return { error: "Worktree root cannot be empty" };
87
+ if (!isAbsolute(expanded)) return { error: `Worktree root must be an absolute path: ${input.trim()}` };
88
+ return { path: expanded.replace(/\/+$/, "") || "/" };
89
+ }
90
+
91
+ /** Resolve a repository's display alias; with an empty map this is the identity. */
92
+ export function repoAlias(repo: string, aliases: Record<string, string> = {}): string {
93
+ return aliases[repo] ?? repo;
94
+ }
95
+
96
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
97
+ return typeof value === "object" && value !== null && !Array.isArray(value);
98
+ }
99
+
100
+ function normalizeAliases(value: unknown): Record<string, string> | undefined {
101
+ if (!isPlainObject(value)) return undefined;
102
+ const aliases: Record<string, string> = {};
103
+ for (const [repo, alias] of Object.entries(value)) {
104
+ if (repo.length > 0 && typeof alias === "string" && alias.length > 0) aliases[repo] = alias;
105
+ }
106
+ return aliases;
107
+ }
108
+
109
+ export interface NormalizedSettings {
110
+ settings: StatuslineSettings;
111
+ /** Top-level keys this version does not know about, preserved on write. */
112
+ extra: Record<string, unknown>;
113
+ }
114
+
115
+ /**
116
+ * Best-effort normalization: every key falls back to its own default without
117
+ * discarding valid siblings, and unknown keys are carried through untouched.
118
+ */
119
+ export function normalizeSettings(value: unknown, home: string = homedir()): NormalizedSettings {
120
+ const settings = defaultSettings(home);
121
+ const extra: Record<string, unknown> = {};
122
+ if (!isPlainObject(value)) return { settings, extra };
123
+
124
+ const known = new Set<string>([
125
+ ...BOOLEAN_SETTING_KEYS,
126
+ "theme",
127
+ "cacheCelebrationStyle",
128
+ "worktreeRoot",
129
+ "repoAliases",
130
+ ]);
131
+ for (const [key, raw] of Object.entries(value)) {
132
+ if (!known.has(key)) {
133
+ extra[key] = raw;
134
+ continue;
135
+ }
136
+ if (key === "worktreeRoot") {
137
+ if (typeof raw === "string") {
138
+ const resolved = resolveWorktreeRoot(raw, home);
139
+ if (resolved.path) settings.worktreeRoot = resolved.path;
140
+ }
141
+ continue;
142
+ }
143
+ if (key === "repoAliases") {
144
+ const aliases = normalizeAliases(raw);
145
+ if (aliases) settings.repoAliases = aliases;
146
+ continue;
147
+ }
148
+ if (key === "theme") {
149
+ if (isThemeName(raw)) settings.theme = raw;
150
+ continue;
151
+ }
152
+ if (key === "cacheCelebrationStyle") {
153
+ if (isCelebrationStyleName(raw)) settings.cacheCelebrationStyle = raw;
154
+ continue;
155
+ }
156
+ if (typeof raw === "boolean") settings[key as BooleanSettingKey] = raw;
157
+ }
158
+
159
+ return { settings, extra };
160
+ }
161
+
162
+ function sameAliases(a: Record<string, string>, b: Record<string, string>): boolean {
163
+ const aKeys = Object.keys(a);
164
+ if (aKeys.length !== Object.keys(b).length) return false;
165
+ return aKeys.every((key) => a[key] === b[key]);
166
+ }
167
+
168
+ /**
169
+ * Sparse serialization: only values differing from the defaults are written, so
170
+ * a later default change still reaches hosts that never touched that key.
171
+ */
172
+ export function serializeSettings(
173
+ settings: StatuslineSettings,
174
+ extra: Record<string, unknown> = {},
175
+ home: string = homedir(),
176
+ ): Record<string, unknown> {
177
+ const defaults = defaultSettings(home);
178
+ const out: Record<string, unknown> = { ...extra };
179
+ for (const key of BOOLEAN_SETTING_KEYS) {
180
+ if (settings[key] !== defaults[key]) out[key] = settings[key];
181
+ }
182
+ if (settings.theme !== defaults.theme) out.theme = settings.theme;
183
+ if (settings.cacheCelebrationStyle !== defaults.cacheCelebrationStyle) {
184
+ out.cacheCelebrationStyle = settings.cacheCelebrationStyle;
185
+ }
186
+ if (settings.worktreeRoot !== defaults.worktreeRoot) out.worktreeRoot = settings.worktreeRoot;
187
+ if (!sameAliases(settings.repoAliases, defaults.repoAliases)) out.repoAliases = { ...settings.repoAliases };
188
+ return out;
189
+ }
190
+
191
+ export interface SettingsStoreOptions {
192
+ path?: string;
193
+ home?: string;
194
+ }
195
+
196
+ /**
197
+ * Owns the single global settings file. Extensions get no settings API, so this
198
+ * mirrors what usage.ts already does for its shared cache.
199
+ */
200
+ export class SettingsStore {
201
+ private readonly home: string;
202
+ private readonly path: string;
203
+ private extra: Record<string, unknown> = {};
204
+ private current: StatuslineSettings;
205
+
206
+ constructor(options: SettingsStoreOptions = {}) {
207
+ this.home = options.home ?? homedir();
208
+ this.path = options.path ?? defaultSettingsPath(this.home);
209
+ this.current = defaultSettings(this.home);
210
+ }
211
+
212
+ getPath(): string {
213
+ return this.path;
214
+ }
215
+
216
+ get(): StatuslineSettings {
217
+ return this.current;
218
+ }
219
+
220
+ set(settings: StatuslineSettings): void {
221
+ this.current = settings;
222
+ }
223
+
224
+ /** Never throws: a missing, unreadable, or malformed file yields defaults. */
225
+ async load(): Promise<StatuslineSettings> {
226
+ let parsed: unknown;
227
+ try {
228
+ parsed = JSON.parse(await readFile(this.path, "utf8"));
229
+ } catch {
230
+ parsed = undefined;
231
+ }
232
+ const { settings, extra } = normalizeSettings(parsed, this.home);
233
+ this.extra = extra;
234
+ this.current = settings;
235
+ return settings;
236
+ }
237
+
238
+ /** Atomic write via temp file + rename. Rejects so the caller can notify. */
239
+ async save(settings: StatuslineSettings = this.current): Promise<void> {
240
+ this.current = settings;
241
+ const payload = `${JSON.stringify(serializeSettings(settings, this.extra, this.home), null, "\t")}\n`;
242
+ const temporary = `${this.path}.${pid}.tmp`;
243
+ await mkdir(dirname(this.path), { recursive: true });
244
+ try {
245
+ await writeFile(temporary, payload, { mode: 0o600 });
246
+ await rename(temporary, this.path);
247
+ } catch (error) {
248
+ await unlink(temporary).catch(() => {});
249
+ throw error;
250
+ }
251
+ }
252
+ }