@xzzpig/pi-starline 0.1.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.
Files changed (45) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +223 -0
  3. package/extensions/starline/config.ts +1317 -0
  4. package/extensions/starline/editor-cursor.ts +55 -0
  5. package/extensions/starline/editor-factory-marker.ts +49 -0
  6. package/extensions/starline/editor-metadata-format.ts +259 -0
  7. package/extensions/starline/extension-status.ts +88 -0
  8. package/extensions/starline/footer-format.ts +279 -0
  9. package/extensions/starline/footer.ts +744 -0
  10. package/extensions/starline/format.ts +473 -0
  11. package/extensions/starline/git.ts +467 -0
  12. package/extensions/starline/icons.ts +294 -0
  13. package/extensions/starline/index.ts +651 -0
  14. package/extensions/starline/live-context.ts +75 -0
  15. package/extensions/starline/mouse/capabilities.ts +207 -0
  16. package/extensions/starline/mouse/component-tree.ts +230 -0
  17. package/extensions/starline/mouse/editor-caret.ts +498 -0
  18. package/extensions/starline/mouse/editor-hit-test.ts +133 -0
  19. package/extensions/starline/mouse/editor-mouse.ts +142 -0
  20. package/extensions/starline/mouse/editor-scroll.ts +119 -0
  21. package/extensions/starline/mouse/editor-text-cursor.ts +209 -0
  22. package/extensions/starline/mouse/editor-text-edit.ts +90 -0
  23. package/extensions/starline/mouse/hint.ts +14 -0
  24. package/extensions/starline/mouse/hit-test.ts +144 -0
  25. package/extensions/starline/mouse/index.ts +1119 -0
  26. package/extensions/starline/mouse/selection-state.ts +68 -0
  27. package/extensions/starline/mouse/tool-box.ts +290 -0
  28. package/extensions/starline/mouse/transcript-copy.ts +166 -0
  29. package/extensions/starline/mouse/word-select.ts +145 -0
  30. package/extensions/starline/package-version.ts +650 -0
  31. package/extensions/starline/paste-collapse.ts +312 -0
  32. package/extensions/starline/pill-config.ts +122 -0
  33. package/extensions/starline/pill.ts +295 -0
  34. package/extensions/starline/project-refresh.ts +104 -0
  35. package/extensions/starline/project-state.ts +66 -0
  36. package/extensions/starline/prototype-patch-registry.ts +136 -0
  37. package/extensions/starline/runtime.ts +841 -0
  38. package/extensions/starline/selector-border.ts +77 -0
  39. package/extensions/starline/session-lifecycle.ts +60 -0
  40. package/extensions/starline/settings-command.ts +925 -0
  41. package/extensions/starline/state.ts +65 -0
  42. package/extensions/starline/style.ts +528 -0
  43. package/extensions/starline/ui.ts +611 -0
  44. package/extensions/starline/user-message.ts +267 -0
  45. package/package.json +60 -0
@@ -0,0 +1,1317 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ fchmodSync,
6
+ fsyncSync,
7
+ lstatSync,
8
+ openSync,
9
+ readFileSync,
10
+ realpathSync,
11
+ renameSync,
12
+ statSync,
13
+ unlinkSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import { basename, dirname, join } from "node:path";
17
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
18
+ import { type EditorCursorStyle, parseEditorCursorStyle } from "./editor-cursor";
19
+ import {
20
+ ICON_GLYPH_KEYS,
21
+ type IconGlyphs,
22
+ type IconMode,
23
+ NERD_DEFAULT_ICONS,
24
+ normalizeIconMode,
25
+ type ResolvedIcons,
26
+ resolveConfiguredIcons,
27
+ } from "./icons";
28
+ import {
29
+ DEFAULT_PILL_CONFIG,
30
+ type FooterStyle,
31
+ normalizePillConfig,
32
+ type PillConfig,
33
+ parseFooterStyle,
34
+ } from "./pill-config";
35
+ import { isSupportedColorSpec } from "./style";
36
+
37
+ export type ColorSpec = string;
38
+ export type ColorSource = "theme" | "terminal";
39
+ export type { IconMode } from "./icons";
40
+
41
+ export type ContextStyle = "text" | "gauge" | "text+gauge";
42
+ /** How much the context segment spells out. Orthogonal to ContextStyle. */
43
+ export type ContextFormat = "full" | "percent";
44
+ /** What the tokens segment shows for cache activity. */
45
+ export type TokensCacheFormat = "percent" | "tokens" | "off";
46
+
47
+ export type SegmentOptionsConfig = {
48
+ context: { format: ContextFormat };
49
+ tokens: { cache: TokensCacheFormat };
50
+ };
51
+ export type SeparatorStyle = "pipe" | "dot" | "chevron" | "none";
52
+ export type ModelLabelSource = "id" | "name";
53
+
54
+ export type ContextThresholds = {
55
+ warning: number;
56
+ error: number;
57
+ };
58
+
59
+ export type PathDisplayMode = "basename" | "full";
60
+
61
+ export type PathDisplayConfig = {
62
+ mode: PathDisplayMode;
63
+ /** Trailing directories to show in full mode. 0 = unlimited; clamped to 0..5. */
64
+ depth: number;
65
+ };
66
+
67
+ export type GitBranchMaxLength = "full" | number;
68
+
69
+ export type GitBranchConfig = {
70
+ maxLength: GitBranchMaxLength;
71
+ };
72
+
73
+ export type ColorSourcesConfig = {
74
+ starship: ColorSource;
75
+ editor: ColorSource;
76
+ userMessages: ColorSource;
77
+ };
78
+
79
+ export type UiFeaturesConfig = {
80
+ editor: boolean;
81
+ statusLine: boolean;
82
+ copyFriendly: boolean;
83
+ };
84
+
85
+ export type FooterSegmentsConfig = {
86
+ model: boolean;
87
+ thinking: boolean;
88
+ cwd: boolean;
89
+ sessionName: boolean;
90
+ gitBranch: boolean;
91
+ gitStatus: boolean;
92
+ gitCounts: boolean;
93
+ gitCommit: boolean;
94
+ gitMetrics: boolean;
95
+ runtime: boolean;
96
+ context: boolean;
97
+ tokens: boolean;
98
+ cacheHit: boolean;
99
+ cost: boolean;
100
+ sessionDuration: boolean;
101
+ username: boolean;
102
+ time: boolean;
103
+ os: boolean;
104
+ packageVersion: boolean;
105
+ };
106
+
107
+ export type MouseConfig = {
108
+ enabled: boolean;
109
+ wheelRouting: boolean;
110
+ /** Show a "copied to clipboard" notice for a copy Starline itself performs. */
111
+ copyNotice: boolean;
112
+ /** Copy on mouse release. When false the highlight waits for ctrl+c. */
113
+ copyOnSelect: boolean;
114
+ /** Clicking a tool box's `ctrl+o to expand` hint expands that one box. */
115
+ clickToExpandTools: boolean;
116
+ /** Double/triple-click word selection stops at path separators. */
117
+ pathAwareWords: boolean;
118
+ /** Copy transcript selections without the painted rails, rules and frames. */
119
+ transcriptCleanCopy: boolean;
120
+ };
121
+
122
+ const FIXED_EDITOR_KEY_MAP: Record<string, keyof MouseConfig> = {
123
+ enabled: "enabled",
124
+ mouseScroll: "wheelRouting",
125
+ copyNotice: "copyNotice",
126
+ copyOnSelect: "copyOnSelect",
127
+ clickToExpandTools: "clickToExpandTools",
128
+ };
129
+
130
+ /**
131
+ * Carry a 0.2.x `fixedEditor` block over to `mouse`.
132
+ *
133
+ * The old name described a compositor that no longer exists. Anything already
134
+ * written under `mouse` was set deliberately and outranks what is being
135
+ * migrated.
136
+ */
137
+ export function migrateFixedEditorKeys(raw: Record<string, unknown>): {
138
+ config: Record<string, unknown>;
139
+ migrated: boolean;
140
+ } {
141
+ const legacy = raw.fixedEditor;
142
+ if (!legacy || typeof legacy !== "object") return { config: raw, migrated: false };
143
+
144
+ const existing = (typeof raw.mouse === "object" && raw.mouse !== null ? raw.mouse : {}) as Record<
145
+ string,
146
+ unknown
147
+ >;
148
+ const mouse: Record<string, unknown> = { ...existing };
149
+ for (const [oldKey, newKey] of Object.entries(FIXED_EDITOR_KEY_MAP)) {
150
+ const value = (legacy as Record<string, unknown>)[oldKey];
151
+ if (value !== undefined && !(newKey in mouse)) mouse[newKey] = value;
152
+ }
153
+
154
+ const { fixedEditor: _dropped, ...rest } = raw;
155
+ return { config: { ...rest, mouse }, migrated: true };
156
+ }
157
+
158
+ export type ExtensionStatusPlacement = "off" | "left" | "middle" | "right";
159
+ export type ExtensionStatusColorMode = "themed" | "original";
160
+
161
+ /**
162
+ * Starship `git_commit`-style options.
163
+ * See https://starship.rs/config/#git-commit
164
+ */
165
+ export type GitCommitConfig = {
166
+ hashLength: number;
167
+ onlyDetached: boolean;
168
+ showTag: boolean;
169
+ };
170
+
171
+ /**
172
+ * Starship `git_metrics`-style options.
173
+ * See https://starship.rs/config/#git-metrics
174
+ */
175
+ export type GitMetricsConfig = {
176
+ onlyNonzero: boolean;
177
+ ignoreSubmodules: boolean;
178
+ };
179
+
180
+ const DEFAULT_EXTENSION_STATUS_COLOR_MODE: ExtensionStatusColorMode = "themed";
181
+
182
+ export type ExtensionStatusesConfig = {
183
+ defaultPlacement: ExtensionStatusPlacement;
184
+ placements: Record<string, ExtensionStatusPlacement>;
185
+ colorModes: Record<string, ExtensionStatusColorMode>;
186
+ /** Per-status colour, keyed by status key. Falls back to colors.extensionStatus. */
187
+ colors: Record<string, ColorSpec>;
188
+ /** Per-status icon, keyed by status key. Prefixed to the status text. */
189
+ icons: Record<string, string>;
190
+ };
191
+
192
+ const DEFAULT_PROJECT_REFRESH_INTERVAL_MS = 30_000;
193
+ const MIN_PROJECT_REFRESH_INTERVAL_MS = 5_000;
194
+ export const DEFAULT_EDITOR_METADATA_FORMAT = "$model $provider( $thinking)";
195
+
196
+ export type PolishedTuiConfig = {
197
+ projectRefreshIntervalMs: number;
198
+ /** "text" renders footerFormat as before; "pill" renders pill.segments instead. */
199
+ footerStyle: FooterStyle;
200
+ pill: PillConfig;
201
+ footerFormat: string;
202
+ editorMetadataFormat: string;
203
+ separator: SeparatorStyle;
204
+ contextStyle: ContextStyle;
205
+ segmentOptions: SegmentOptionsConfig;
206
+ editorModelLabel: ModelLabelSource;
207
+ editorCursor: EditorCursorStyle;
208
+ /** Clicking in the editor text moves the caret there. Needs `mouse.enabled`. */
209
+ editorClickCursor: boolean;
210
+ /** Collapse pastes at this many lines. 11 (default) leaves Pi's threshold alone. */
211
+ pasteCollapseLines: number;
212
+ /** Blank rows inside the editor box, above the input and above the metadata row. */
213
+ editorPaddingY: number;
214
+ /** Blank rows inside the previous-message box, above and below the body. */
215
+ userMessagePaddingY: number;
216
+ contextThresholds: ContextThresholds;
217
+ pathDisplay: PathDisplayConfig;
218
+ gitBranch: GitBranchConfig;
219
+ /** Replace the branch icon with the origin remote's forge logo. */
220
+ gitHostIcon: boolean;
221
+ icons: ResolvedIcons;
222
+ colors: {
223
+ model: ColorSpec;
224
+ /** Unset derives the colour from the level via the theme's thinking* keys. */
225
+ thinking?: ColorSpec;
226
+ cwd: ColorSpec;
227
+ sessionName: ColorSpec;
228
+ gitBranch: ColorSpec;
229
+ gitStatus: ColorSpec;
230
+ contextNormal: ColorSpec;
231
+ contextWarning: ColorSpec;
232
+ contextError: ColorSpec;
233
+ tokens: ColorSpec;
234
+ cacheHit: ColorSpec;
235
+ cost: ColorSpec;
236
+ separator: ColorSpec;
237
+ runtimePrefix: ColorSpec;
238
+ extensionStatus: ColorSpec;
239
+ sessionDuration: ColorSpec;
240
+ packageVersion: ColorSpec;
241
+ gitCommit: ColorSpec;
242
+ gitMetricsAdded: ColorSpec;
243
+ gitMetricsDeleted: ColorSpec;
244
+ username: ColorSpec;
245
+ time: ColorSpec;
246
+ os: ColorSpec;
247
+ editorAccent?: ColorSpec;
248
+ editorPrompt?: ColorSpec;
249
+ editorBorder?: ColorSpec;
250
+ /** Unset falls back to editorBorder, which is upstream's behaviour. */
251
+ userMessageBorder?: ColorSpec;
252
+ /** Unset follows the theme's userMessageText colour. */
253
+ userMessageText?: ColorSpec;
254
+ editorModel?: ColorSpec;
255
+ editorProvider?: ColorSpec;
256
+ editorThinking?: ColorSpec;
257
+ editorThinkingMinimal?: ColorSpec;
258
+ editorThinkingLow?: ColorSpec;
259
+ editorThinkingMedium?: ColorSpec;
260
+ editorThinkingHigh?: ColorSpec;
261
+ editorThinkingXhigh?: ColorSpec;
262
+ };
263
+ colorSources: ColorSourcesConfig;
264
+ features: UiFeaturesConfig;
265
+ footerSegments: FooterSegmentsConfig;
266
+ gitCommit: GitCommitConfig;
267
+ gitMetrics: GitMetricsConfig;
268
+ extensionStatuses: ExtensionStatusesConfig;
269
+ mouse: MouseConfig;
270
+ };
271
+
272
+ /**
273
+ * Canonical footer format variable names. In a `footerFormat` string these
274
+ * are written as `$name` or `${name}`.
275
+ */
276
+ export const FOOTER_FORMAT_VARIABLES = [
277
+ "model",
278
+ "thinking",
279
+ "cwd",
280
+ "session_name",
281
+ "git_branch",
282
+ "git_status",
283
+ "git_state",
284
+ "runtime",
285
+ "session_duration",
286
+ "username",
287
+ "os",
288
+ "time",
289
+ "context",
290
+ "tokens",
291
+ "cache_hit",
292
+ "cost",
293
+ "package",
294
+ "package_version",
295
+ "git_commit",
296
+ "git_tag",
297
+ "git_metrics",
298
+ "git_added",
299
+ "git_deleted",
300
+ "sep",
301
+ ] as const;
302
+
303
+ /**
304
+ * Alias → canonical variable name mapping for `footerFormat`.
305
+ * `$fill` is special (not a variable) and handled by the parser.
306
+ */
307
+ export const FOOTER_FORMAT_ALIASES: Record<string, string> = {
308
+ directory: "cwd",
309
+ branch: "git_branch",
310
+ status: "git_status",
311
+ state: "git_state",
312
+ commit: "git_commit",
313
+ tag: "git_tag",
314
+ duration: "session_duration",
315
+ separator: "sep",
316
+ };
317
+
318
+ export const configPath = join(getAgentDir(), "starline.json");
319
+
320
+ export const defaultConfig: PolishedTuiConfig = {
321
+ projectRefreshIntervalMs: DEFAULT_PROJECT_REFRESH_INTERVAL_MS,
322
+ footerStyle: "text",
323
+ pill: DEFAULT_PILL_CONFIG,
324
+ footerFormat: "",
325
+ editorMetadataFormat: DEFAULT_EDITOR_METADATA_FORMAT,
326
+ separator: "pipe",
327
+ contextStyle: "text",
328
+ segmentOptions: { context: { format: "full" }, tokens: { cache: "percent" } },
329
+ editorModelLabel: "id",
330
+ editorCursor: "block",
331
+ editorClickCursor: true,
332
+ pasteCollapseLines: 11,
333
+ editorPaddingY: 1,
334
+ userMessagePaddingY: 1,
335
+ contextThresholds: { warning: 70, error: 90 },
336
+ pathDisplay: { mode: "basename", depth: 0 },
337
+ gitBranch: { maxLength: "full" },
338
+ gitHostIcon: false,
339
+ icons: {
340
+ mode: "auto",
341
+ ...NERD_DEFAULT_ICONS,
342
+ },
343
+ colors: {
344
+ model: "bold blue",
345
+ cwd: "bold cyan",
346
+ sessionName: "bold green",
347
+ gitBranch: "bold purple",
348
+ gitStatus: "bold red",
349
+ contextNormal: "bright-black",
350
+ contextWarning: "bold yellow",
351
+ contextError: "bold red",
352
+ tokens: "bright-black",
353
+ cacheHit: "bright-black",
354
+ cost: "bold green",
355
+ separator: "bright-black",
356
+ runtimePrefix: "",
357
+ extensionStatus: "bright-black",
358
+ sessionDuration: "yellow",
359
+ packageVersion: "208",
360
+ gitCommit: "bold green",
361
+ gitMetricsAdded: "bold green",
362
+ gitMetricsDeleted: "bold red",
363
+ username: "bold yellow",
364
+ time: "bold yellow",
365
+ os: "bold white",
366
+ },
367
+ colorSources: {
368
+ starship: "theme",
369
+ editor: "theme",
370
+ userMessages: "theme",
371
+ },
372
+ features: {
373
+ editor: true,
374
+ statusLine: true,
375
+ copyFriendly: false,
376
+ },
377
+ footerSegments: {
378
+ model: false,
379
+ thinking: false,
380
+ cwd: true,
381
+ sessionName: true,
382
+ gitBranch: true,
383
+ gitStatus: true,
384
+ gitCounts: false,
385
+ runtime: true,
386
+ context: true,
387
+ tokens: true,
388
+ cacheHit: false,
389
+ cost: true,
390
+ sessionDuration: false,
391
+ username: false,
392
+ time: false,
393
+ os: false,
394
+ packageVersion: false,
395
+ gitCommit: false,
396
+ gitMetrics: false,
397
+ },
398
+ gitCommit: {
399
+ hashLength: 7,
400
+ onlyDetached: true,
401
+ showTag: true,
402
+ },
403
+ gitMetrics: {
404
+ onlyNonzero: true,
405
+ ignoreSubmodules: false,
406
+ },
407
+ extensionStatuses: {
408
+ defaultPlacement: "right",
409
+ colors: {},
410
+ icons: {},
411
+ placements: {},
412
+ colorModes: {},
413
+ },
414
+ mouse: {
415
+ enabled: true,
416
+ wheelRouting: true,
417
+ copyNotice: true,
418
+ copyOnSelect: true,
419
+ clickToExpandTools: true,
420
+ pathAwareWords: true,
421
+ transcriptCleanCopy: true,
422
+ },
423
+ };
424
+
425
+ type ConfigRecord = Record<string, unknown>;
426
+
427
+ function isRecord(value: unknown): value is ConfigRecord {
428
+ return value !== null && typeof value === "object" && !Array.isArray(value);
429
+ }
430
+
431
+ function parseProjectRefreshIntervalMs(value: unknown): number {
432
+ if (value === 0) return 0;
433
+ if (typeof value !== "number" || !Number.isFinite(value)) {
434
+ return DEFAULT_PROJECT_REFRESH_INTERVAL_MS;
435
+ }
436
+
437
+ const interval = Math.round(value);
438
+ if (interval <= 0) return 0;
439
+ return Math.max(MIN_PROJECT_REFRESH_INTERVAL_MS, interval);
440
+ }
441
+
442
+ function clampPercent(value: number): number {
443
+ return Math.max(0, Math.min(100, value));
444
+ }
445
+
446
+ function parseContextStyle(value: unknown): ContextStyle {
447
+ if (value === "text" || value === "gauge" || value === "text+gauge") return value;
448
+ return defaultConfig.contextStyle;
449
+ }
450
+
451
+ function parseEditorModelLabel(value: unknown): ModelLabelSource {
452
+ if (value === "id" || value === "name") return value;
453
+ return defaultConfig.editorModelLabel;
454
+ }
455
+
456
+ export function isSeparatorStyle(value: unknown): value is SeparatorStyle {
457
+ return value === "pipe" || value === "dot" || value === "chevron" || value === "none";
458
+ }
459
+
460
+ function parseSeparatorStyle(value: unknown): SeparatorStyle {
461
+ return isSeparatorStyle(value) ? value : defaultConfig.separator;
462
+ }
463
+
464
+ function parseContextThresholds(value: unknown): ContextThresholds {
465
+ const defaults = defaultConfig.contextThresholds;
466
+ if (!isRecord(value)) return { ...defaults };
467
+
468
+ const warningRaw = value.warning;
469
+ const errorRaw = value.error;
470
+ let warning =
471
+ typeof warningRaw === "number" && Number.isFinite(warningRaw)
472
+ ? clampPercent(Math.round(warningRaw))
473
+ : defaults.warning;
474
+ let error =
475
+ typeof errorRaw === "number" && Number.isFinite(errorRaw)
476
+ ? clampPercent(Math.round(errorRaw))
477
+ : defaults.error;
478
+ if (error < warning) {
479
+ const swapped = warning;
480
+ warning = error;
481
+ error = swapped;
482
+ }
483
+ return { warning, error };
484
+ }
485
+
486
+ function parsePathDisplay(value: unknown): PathDisplayConfig {
487
+ const defaults = defaultConfig.pathDisplay;
488
+ if (!isRecord(value)) return { ...defaults };
489
+ const mode = value.mode === "full" || value.mode === "basename" ? value.mode : defaults.mode;
490
+ const rawDepth = value.depth;
491
+ const depth =
492
+ typeof rawDepth === "number" && Number.isFinite(rawDepth) && rawDepth >= 0
493
+ ? Math.min(5, Math.floor(rawDepth))
494
+ : defaults.depth;
495
+ return { mode, depth };
496
+ }
497
+
498
+ function normalizeGitBranchMaxLength(value: unknown): GitBranchMaxLength {
499
+ if (value === "full") return value;
500
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
501
+ return defaultConfig.gitBranch.maxLength;
502
+ }
503
+
504
+ function parseGitBranchConfig(value: unknown): GitBranchConfig {
505
+ const defaults = defaultConfig.gitBranch;
506
+ if (!isRecord(value)) return { ...defaults };
507
+ return {
508
+ maxLength: normalizeGitBranchMaxLength(value.maxLength),
509
+ };
510
+ }
511
+
512
+ function stringValue(record: Record<string, unknown>, key: string): string | undefined {
513
+ const value = record[key];
514
+ return typeof value === "string" ? value : undefined;
515
+ }
516
+
517
+ function colorValue(record: Record<string, unknown>, key: string): string | undefined {
518
+ const value = stringValue(record, key);
519
+ return value !== undefined && isSupportedColorSpec(value) ? value : undefined;
520
+ }
521
+
522
+ function colorSourceValue(
523
+ record: Record<string, unknown>,
524
+ key: keyof ColorSourcesConfig,
525
+ ): ColorSource {
526
+ const value = record[key];
527
+ return value === "terminal" || value === "theme" ? value : defaultConfig.colorSources[key];
528
+ }
529
+
530
+ function booleanValue(record: Record<string, unknown>, key: keyof UiFeaturesConfig): boolean {
531
+ const value = record[key];
532
+ return typeof value === "boolean" ? value : defaultConfig.features[key];
533
+ }
534
+
535
+ function footerSegmentValue(
536
+ record: Record<string, unknown>,
537
+ key: keyof FooterSegmentsConfig,
538
+ ): boolean {
539
+ const value = record[key];
540
+ return typeof value === "boolean" ? value : defaultConfig.footerSegments[key];
541
+ }
542
+
543
+ function definedColors(
544
+ colors: Partial<Record<keyof PolishedTuiConfig["colors"], string | undefined>>,
545
+ ): Partial<PolishedTuiConfig["colors"]> {
546
+ return Object.fromEntries(
547
+ Object.entries(colors).filter(
548
+ (entry): entry is [keyof PolishedTuiConfig["colors"], string] => typeof entry[1] === "string",
549
+ ),
550
+ ) as Partial<PolishedTuiConfig["colors"]>;
551
+ }
552
+
553
+ function normalizeIconOverrides(record: Record<string, unknown>): Partial<IconGlyphs> {
554
+ return Object.fromEntries(
555
+ ICON_GLYPH_KEYS.flatMap((key) => {
556
+ const value = stringValue(record, key);
557
+ return value === undefined ? [] : [[key, value]];
558
+ }),
559
+ ) as Partial<IconGlyphs>;
560
+ }
561
+
562
+ /**
563
+ * A named colour palette, so a whole scheme can be restated in one place
564
+ * instead of repeating hex values across every segment.
565
+ *
566
+ * Expansion is scoped to colour records only — `footerFormat` and
567
+ * `editorMetadataFormat` use `$name` for their own format variables and must
568
+ * never be touched by it.
569
+ */
570
+ export type Palette = Record<string, string>;
571
+
572
+ const PALETTE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*$/;
573
+ const PALETTE_REF_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_-]*)\}|\$([A-Za-z_][A-Za-z0-9_-]*)/g;
574
+ const PALETTE_MAX_DEPTH = 8;
575
+
576
+ export function normalizePalette(value: unknown): Palette {
577
+ if (!isRecord(value)) return {};
578
+ const palette: Palette = {};
579
+ for (const [name, entry] of Object.entries(value)) {
580
+ if (typeof entry === "string" && PALETTE_NAME_PATTERN.test(name)) palette[name] = entry;
581
+ }
582
+ return palette;
583
+ }
584
+
585
+ function expandWithGuard(
586
+ spec: string,
587
+ palette: Palette,
588
+ visiting: Set<string>,
589
+ depth: number,
590
+ ): string {
591
+ if (depth > PALETTE_MAX_DEPTH || !spec.includes("$")) return spec;
592
+ return spec.replace(PALETTE_REF_PATTERN, (match, braced?: string, bare?: string) => {
593
+ const name = braced ?? bare;
594
+ if (name === undefined) return match;
595
+ const value = palette[name];
596
+ // An unknown name, or one already being expanded further up the chain,
597
+ // stays literal: it then fails isSupportedColorSpec and renders unstyled,
598
+ // which surfaces the typo instead of silently resolving to nothing.
599
+ if (value === undefined || visiting.has(name)) return match;
600
+ visiting.add(name);
601
+ const expanded = expandWithGuard(value, palette, visiting, depth + 1);
602
+ visiting.delete(name);
603
+ return expanded;
604
+ });
605
+ }
606
+
607
+ /** Expand `$name` and `${name}` in a ColorSpec against the palette. */
608
+ export function expandPaletteRefs(spec: string, palette: Palette): string {
609
+ return expandWithGuard(spec, palette, new Set(), 0);
610
+ }
611
+
612
+ /** Expand palette references in every string value of a colour record. */
613
+ export function expandPaletteInRecord(
614
+ record: Record<string, unknown>,
615
+ palette: Palette,
616
+ ): Record<string, unknown> {
617
+ if (Object.keys(palette).length === 0) return record;
618
+ const expanded: Record<string, unknown> = { ...record };
619
+ for (const [key, value] of Object.entries(record)) {
620
+ if (typeof value === "string") expanded[key] = expandPaletteRefs(value, palette);
621
+ }
622
+ return expanded;
623
+ }
624
+
625
+ function normalizeColors(record: Record<string, unknown>): Partial<PolishedTuiConfig["colors"]> {
626
+ return definedColors({
627
+ model: colorValue(record, "model"),
628
+ thinking: colorValue(record, "thinking"),
629
+ cwd: colorValue(record, "cwd") ?? colorValue(record, "cwdText"),
630
+ sessionName: colorValue(record, "sessionName"),
631
+ gitBranch: colorValue(record, "gitBranch") ?? colorValue(record, "git"),
632
+ gitStatus: colorValue(record, "gitStatus"),
633
+ contextNormal: colorValue(record, "contextNormal"),
634
+ contextWarning: colorValue(record, "contextWarning"),
635
+ contextError: colorValue(record, "contextError"),
636
+ tokens: colorValue(record, "tokens"),
637
+ cacheHit: colorValue(record, "cacheHit"),
638
+ cost: colorValue(record, "cost"),
639
+ separator: colorValue(record, "separator"),
640
+ runtimePrefix: colorValue(record, "runtimePrefix"),
641
+ extensionStatus: colorValue(record, "extensionStatus"),
642
+ sessionDuration: colorValue(record, "sessionDuration"),
643
+ packageVersion: colorValue(record, "packageVersion"),
644
+ gitCommit: colorValue(record, "gitCommit"),
645
+ gitMetricsAdded: colorValue(record, "gitMetricsAdded"),
646
+ gitMetricsDeleted: colorValue(record, "gitMetricsDeleted"),
647
+ username: colorValue(record, "username"),
648
+ time: colorValue(record, "time"),
649
+ os: colorValue(record, "os"),
650
+ userMessageBorder: colorValue(record, "userMessageBorder"),
651
+ userMessageText: colorValue(record, "userMessageText"),
652
+ editorAccent: colorValue(record, "editorAccent"),
653
+ editorPrompt: colorValue(record, "editorPrompt"),
654
+ editorBorder: colorValue(record, "editorBorder"),
655
+ editorModel: colorValue(record, "editorModel"),
656
+ editorProvider: colorValue(record, "editorProvider"),
657
+ editorThinking: colorValue(record, "editorThinking"),
658
+ editorThinkingMinimal: colorValue(record, "editorThinkingMinimal"),
659
+ editorThinkingLow: colorValue(record, "editorThinkingLow"),
660
+ editorThinkingMedium: colorValue(record, "editorThinkingMedium"),
661
+ editorThinkingHigh: colorValue(record, "editorThinkingHigh"),
662
+ editorThinkingXhigh: colorValue(record, "editorThinkingXhigh"),
663
+ });
664
+ }
665
+
666
+ function normalizeColorSources(record: Record<string, unknown>): ColorSourcesConfig {
667
+ return {
668
+ starship: colorSourceValue(record, "starship"),
669
+ editor: colorSourceValue(record, "editor"),
670
+ userMessages: colorSourceValue(record, "userMessages"),
671
+ };
672
+ }
673
+
674
+ function normalizeUiFeatures(record: Record<string, unknown>): UiFeaturesConfig {
675
+ return {
676
+ editor: booleanValue(record, "editor"),
677
+ statusLine: booleanValue(record, "statusLine"),
678
+ copyFriendly: booleanValue(record, "copyFriendly"),
679
+ };
680
+ }
681
+
682
+ /**
683
+ * Only 2..10 lowers the threshold; anything else — including Pi's own 11 — means
684
+ * "leave Pi's behaviour alone".
685
+ */
686
+ function parsePasteCollapseLines(value: unknown): number {
687
+ if (typeof value !== "number" || !Number.isInteger(value)) return 11;
688
+ return value >= 2 && value <= 10 ? value : 11;
689
+ }
690
+
691
+ /** Box padding is 0 or 1 rows; anything else falls back to the default. */
692
+ function parseBoxPadding(value: unknown, fallback: number): number {
693
+ return value === 0 || value === 1 ? value : fallback;
694
+ }
695
+
696
+ function normalizeSegmentOptions(value: unknown): SegmentOptionsConfig {
697
+ const record = isRecord(value) ? value : {};
698
+ const context = isRecord(record.context) ? record.context : {};
699
+ const tokens = isRecord(record.tokens) ? record.tokens : {};
700
+ const cache = tokens.cache;
701
+ return {
702
+ context: { format: context.format === "percent" ? "percent" : "full" },
703
+ tokens: {
704
+ cache: cache === "tokens" || cache === "off" || cache === "percent" ? cache : "percent",
705
+ },
706
+ };
707
+ }
708
+
709
+ function normalizeFooterSegments(record: Record<string, unknown>): FooterSegmentsConfig {
710
+ return {
711
+ model: footerSegmentValue(record, "model"),
712
+ thinking: footerSegmentValue(record, "thinking"),
713
+ cwd: footerSegmentValue(record, "cwd"),
714
+ sessionName: footerSegmentValue(record, "sessionName"),
715
+ gitBranch: footerSegmentValue(record, "gitBranch"),
716
+ gitStatus: footerSegmentValue(record, "gitStatus"),
717
+ gitCounts: footerSegmentValue(record, "gitCounts"),
718
+ runtime: footerSegmentValue(record, "runtime"),
719
+ context: footerSegmentValue(record, "context"),
720
+ tokens: footerSegmentValue(record, "tokens"),
721
+ cacheHit: footerSegmentValue(record, "cacheHit"),
722
+ cost: footerSegmentValue(record, "cost"),
723
+ sessionDuration: footerSegmentValue(record, "sessionDuration"),
724
+ username: footerSegmentValue(record, "username"),
725
+ time: footerSegmentValue(record, "time"),
726
+ os: footerSegmentValue(record, "os"),
727
+ packageVersion: footerSegmentValue(record, "packageVersion"),
728
+ gitCommit: footerSegmentValue(record, "gitCommit"),
729
+ gitMetrics: footerSegmentValue(record, "gitMetrics"),
730
+ };
731
+ }
732
+
733
+ /** Clamp hashLength to Git's valid abbreviation range [4, 40]. */
734
+ function normalizeGitHashLength(value: unknown): number {
735
+ const parsed = typeof value === "number" ? value : Number(value);
736
+ if (!Number.isFinite(parsed)) return defaultConfig.gitCommit.hashLength;
737
+ const rounded = Math.round(parsed);
738
+ return Math.min(40, Math.max(4, rounded));
739
+ }
740
+
741
+ function normalizeGitCommitConfig(record: Record<string, unknown>): GitCommitConfig {
742
+ return {
743
+ hashLength: normalizeGitHashLength(record.hashLength),
744
+ onlyDetached:
745
+ typeof record.onlyDetached === "boolean"
746
+ ? record.onlyDetached
747
+ : defaultConfig.gitCommit.onlyDetached,
748
+ showTag: typeof record.showTag === "boolean" ? record.showTag : defaultConfig.gitCommit.showTag,
749
+ };
750
+ }
751
+
752
+ function normalizeGitMetricsConfig(record: Record<string, unknown>): GitMetricsConfig {
753
+ return {
754
+ onlyNonzero:
755
+ typeof record.onlyNonzero === "boolean"
756
+ ? record.onlyNonzero
757
+ : defaultConfig.gitMetrics.onlyNonzero,
758
+ ignoreSubmodules:
759
+ typeof record.ignoreSubmodules === "boolean"
760
+ ? record.ignoreSubmodules
761
+ : defaultConfig.gitMetrics.ignoreSubmodules,
762
+ };
763
+ }
764
+
765
+ export function isExtensionStatusPlacement(value: unknown): value is ExtensionStatusPlacement {
766
+ return value === "off" || value === "left" || value === "middle" || value === "right";
767
+ }
768
+
769
+ export function isExtensionStatusColorMode(value: unknown): value is ExtensionStatusColorMode {
770
+ return value === "themed" || value === "original";
771
+ }
772
+
773
+ function normalizeExtensionStatuses(
774
+ record: Record<string, unknown>,
775
+ palette: Palette = {},
776
+ ): ExtensionStatusesConfig {
777
+ const defaultPlacement = isExtensionStatusPlacement(record.defaultPlacement)
778
+ ? record.defaultPlacement
779
+ : defaultConfig.extensionStatuses.defaultPlacement;
780
+ const placements = isRecord(record.placements)
781
+ ? Object.fromEntries(
782
+ Object.entries(record.placements).filter(
783
+ (entry): entry is [string, ExtensionStatusPlacement] =>
784
+ isExtensionStatusPlacement(entry[1]),
785
+ ),
786
+ )
787
+ : {};
788
+ const colorModes = isRecord(record.colorModes)
789
+ ? Object.fromEntries(
790
+ Object.entries(record.colorModes).filter(
791
+ (entry): entry is [string, ExtensionStatusColorMode] =>
792
+ isExtensionStatusColorMode(entry[1]),
793
+ ),
794
+ )
795
+ : {};
796
+
797
+ // Palette references have to be expanded before validation, or a $ref would
798
+ // be rejected as an unsupported spec and silently drop the colour.
799
+ const colors = isRecord(record.colors)
800
+ ? Object.fromEntries(
801
+ Object.entries(expandPaletteInRecord(record.colors, palette)).filter(
802
+ (entry): entry is [string, ColorSpec] =>
803
+ typeof entry[1] === "string" && isSupportedColorSpec(entry[1]),
804
+ ),
805
+ )
806
+ : {};
807
+
808
+ const icons = isRecord(record.icons)
809
+ ? Object.fromEntries(
810
+ Object.entries(record.icons).filter(
811
+ (entry): entry is [string, string] => typeof entry[1] === "string",
812
+ ),
813
+ )
814
+ : {};
815
+
816
+ return {
817
+ defaultPlacement,
818
+ placements,
819
+ colorModes,
820
+ colors,
821
+ icons,
822
+ };
823
+ }
824
+
825
+ function normalizeMouseConfig(record: Record<string, unknown>): MouseConfig {
826
+ return {
827
+ enabled: typeof record.enabled === "boolean" ? record.enabled : defaultConfig.mouse.enabled,
828
+ wheelRouting:
829
+ typeof record.wheelRouting === "boolean"
830
+ ? record.wheelRouting
831
+ : defaultConfig.mouse.wheelRouting,
832
+ copyNotice:
833
+ typeof record.copyNotice === "boolean" ? record.copyNotice : defaultConfig.mouse.copyNotice,
834
+ copyOnSelect:
835
+ typeof record.copyOnSelect === "boolean"
836
+ ? record.copyOnSelect
837
+ : defaultConfig.mouse.copyOnSelect,
838
+ clickToExpandTools:
839
+ typeof record.clickToExpandTools === "boolean"
840
+ ? record.clickToExpandTools
841
+ : defaultConfig.mouse.clickToExpandTools,
842
+ pathAwareWords:
843
+ typeof record.pathAwareWords === "boolean"
844
+ ? record.pathAwareWords
845
+ : defaultConfig.mouse.pathAwareWords,
846
+ transcriptCleanCopy:
847
+ typeof record.transcriptCleanCopy === "boolean"
848
+ ? record.transcriptCleanCopy
849
+ : defaultConfig.mouse.transcriptCleanCopy,
850
+ };
851
+ }
852
+
853
+ function isColorSourceKey(value: string): value is keyof ColorSourcesConfig {
854
+ return value === "starship" || value === "editor" || value === "userMessages";
855
+ }
856
+
857
+ function isUiFeatureKey(value: string): value is keyof UiFeaturesConfig {
858
+ return value === "editor" || value === "statusLine" || value === "copyFriendly";
859
+ }
860
+
861
+ function isFooterSegmentKey(value: string): value is keyof FooterSegmentsConfig {
862
+ return (
863
+ value === "cwd" ||
864
+ value === "sessionName" ||
865
+ value === "gitBranch" ||
866
+ value === "gitStatus" ||
867
+ value === "gitCounts" ||
868
+ value === "runtime" ||
869
+ value === "context" ||
870
+ value === "tokens" ||
871
+ value === "cost" ||
872
+ value === "sessionDuration" ||
873
+ value === "username" ||
874
+ value === "time" ||
875
+ value === "os" ||
876
+ value === "packageVersion" ||
877
+ value === "gitCommit" ||
878
+ value === "gitMetrics"
879
+ );
880
+ }
881
+
882
+ function validColorSourceEntries(record: Record<string, unknown>): Partial<ColorSourcesConfig> {
883
+ return Object.fromEntries(
884
+ Object.entries(record).filter((entry): entry is [keyof ColorSourcesConfig, ColorSource] => {
885
+ const [key, value] = entry;
886
+ return isColorSourceKey(key) && (value === "theme" || value === "terminal");
887
+ }),
888
+ ) as Partial<ColorSourcesConfig>;
889
+ }
890
+
891
+ function validUiFeatureEntries(record: Record<string, unknown>): Partial<UiFeaturesConfig> {
892
+ return Object.fromEntries(
893
+ Object.entries(record).filter((entry): entry is [keyof UiFeaturesConfig, boolean] => {
894
+ const [key, value] = entry;
895
+ return isUiFeatureKey(key) && typeof value === "boolean";
896
+ }),
897
+ ) as Partial<UiFeaturesConfig>;
898
+ }
899
+
900
+ function validFooterSegmentEntries(record: Record<string, unknown>): Partial<FooterSegmentsConfig> {
901
+ return Object.fromEntries(
902
+ Object.entries(record).filter((entry): entry is [keyof FooterSegmentsConfig, boolean] => {
903
+ const [key, value] = entry;
904
+ return isFooterSegmentKey(key) && typeof value === "boolean";
905
+ }),
906
+ ) as Partial<FooterSegmentsConfig>;
907
+ }
908
+
909
+ type ConfigFileState =
910
+ | { kind: "missing"; record: ConfigRecord; writePath: string }
911
+ | { kind: "valid"; record: ConfigRecord; writePath: string; mode: number }
912
+ | { kind: "corrupt"; error: unknown };
913
+
914
+ function errorCode(error: unknown): string | undefined {
915
+ return typeof error === "object" && error !== null && "code" in error
916
+ ? String(error.code)
917
+ : undefined;
918
+ }
919
+
920
+ function readConfigFileState(path: string): ConfigFileState {
921
+ let writePath = path;
922
+ try {
923
+ const pathStat = lstatSync(path);
924
+ if (pathStat.isSymbolicLink()) writePath = realpathSync(path);
925
+ const targetStat = statSync(writePath);
926
+ const parsed = JSON.parse(readFileSync(writePath, "utf8"));
927
+ return isRecord(parsed)
928
+ ? { kind: "valid", record: parsed, writePath, mode: targetStat.mode & 0o7777 }
929
+ : { kind: "corrupt", error: new Error("top-level value must be a JSON object") };
930
+ } catch (error) {
931
+ if (errorCode(error) === "ENOENT") {
932
+ try {
933
+ lstatSync(path);
934
+ } catch (pathError) {
935
+ if (errorCode(pathError) === "ENOENT")
936
+ return { kind: "missing", record: {}, writePath: path };
937
+ }
938
+ }
939
+ return { kind: "corrupt", error };
940
+ }
941
+ }
942
+
943
+ function writeConfigAtomically(path: string, record: ConfigRecord, mode?: number): void {
944
+ const tempPath = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
945
+ let file: number | undefined;
946
+ try {
947
+ file = openSync(tempPath, "wx", mode ?? 0o666);
948
+ if (mode !== undefined) fchmodSync(file, mode);
949
+ writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`, "utf8");
950
+ fsyncSync(file);
951
+ closeSync(file);
952
+ file = undefined;
953
+ renameSync(tempPath, path);
954
+ } catch (error) {
955
+ if (file !== undefined) {
956
+ try {
957
+ closeSync(file);
958
+ } catch {}
959
+ }
960
+ try {
961
+ unlinkSync(tempPath);
962
+ } catch (cleanupError) {
963
+ if (errorCode(cleanupError) !== "ENOENT") {
964
+ // Preserve the persistence failure; the best-effort cleanup error is secondary.
965
+ }
966
+ }
967
+ throw error;
968
+ }
969
+ }
970
+
971
+ function mutateConfig(path: string, mutate: (record: ConfigRecord) => void): PolishedTuiConfig {
972
+ const state = readConfigFileState(path);
973
+ if (state.kind === "corrupt") {
974
+ const detail = state.error instanceof Error ? ` (${state.error.message})` : "";
975
+ throw new Error(
976
+ `Refusing to save Starline config because ${path} is corrupt or unreadable; fix or remove it first.${detail}`,
977
+ );
978
+ }
979
+ mutate(state.record);
980
+ writeConfigAtomically(
981
+ state.writePath,
982
+ state.record,
983
+ state.kind === "valid" ? state.mode : undefined,
984
+ );
985
+ return mergeConfig(state.record);
986
+ }
987
+
988
+ export function ensureConfigExists(): void {
989
+ // Intentionally left as a no-op. Starline config is user-owned and
990
+ // compatibility-sensitive: runtime defaults come from `mergeConfig({})`, and
991
+ // the extension should not persist opinionated defaults unless the user
992
+ // explicitly changes a setting.
993
+ }
994
+
995
+ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
996
+ const config = isRecord(parsed) ? parsed : {};
997
+ const iconsRecord = isRecord(config.icons) ? (config.icons as Record<string, unknown>) : {};
998
+ const iconMode = normalizeIconMode(iconsRecord.mode);
999
+ const iconOverrides = normalizeIconOverrides(iconsRecord);
1000
+ const palette = normalizePalette(config.palette);
1001
+ const colors = isRecord(config.colors)
1002
+ ? normalizeColors(expandPaletteInRecord(config.colors as Record<string, unknown>, palette))
1003
+ : {};
1004
+ const colorSources = isRecord(config.colorSources)
1005
+ ? normalizeColorSources(config.colorSources as Record<string, unknown>)
1006
+ : defaultConfig.colorSources;
1007
+ const features = isRecord(config.features)
1008
+ ? normalizeUiFeatures(config.features as Record<string, unknown>)
1009
+ : defaultConfig.features;
1010
+ const footerSegments = isRecord(config.footerSegments)
1011
+ ? normalizeFooterSegments(config.footerSegments as Record<string, unknown>)
1012
+ : defaultConfig.footerSegments;
1013
+ const extensionStatuses = isRecord(config.extensionStatuses)
1014
+ ? normalizeExtensionStatuses(config.extensionStatuses as Record<string, unknown>, palette)
1015
+ : defaultConfig.extensionStatuses;
1016
+ const gitCommit = isRecord(config.gitCommit)
1017
+ ? normalizeGitCommitConfig(config.gitCommit as Record<string, unknown>)
1018
+ : defaultConfig.gitCommit;
1019
+ const gitMetrics = isRecord(config.gitMetrics)
1020
+ ? normalizeGitMetricsConfig(config.gitMetrics as Record<string, unknown>)
1021
+ : defaultConfig.gitMetrics;
1022
+ const gitBranch = parseGitBranchConfig(config.gitBranch);
1023
+ const mouse = isRecord(config.mouse)
1024
+ ? normalizeMouseConfig(config.mouse as Record<string, unknown>)
1025
+ : defaultConfig.mouse;
1026
+ const editorMetadataFormat = stringValue(config, "editorMetadataFormat");
1027
+ return {
1028
+ projectRefreshIntervalMs: parseProjectRefreshIntervalMs(config.projectRefreshIntervalMs),
1029
+ footerStyle: parseFooterStyle(config.footerStyle),
1030
+ pill: normalizePillConfig(config.pill),
1031
+ footerFormat: stringValue(config, "footerFormat") ?? "",
1032
+ // An explicit "" means "show nothing", which is how the model/thinking
1033
+ // segments get moved down to the footer without leaving an empty row.
1034
+ // Only a missing or non-string value falls back to the default.
1035
+ editorMetadataFormat: editorMetadataFormat ?? DEFAULT_EDITOR_METADATA_FORMAT,
1036
+ separator: parseSeparatorStyle(config.separator),
1037
+ contextStyle: parseContextStyle(config.contextStyle),
1038
+ segmentOptions: normalizeSegmentOptions(config.segmentOptions),
1039
+ editorModelLabel: parseEditorModelLabel(config.editorModelLabel),
1040
+ editorCursor: parseEditorCursorStyle(config.editorCursor),
1041
+ editorClickCursor: config.editorClickCursor !== false,
1042
+ pasteCollapseLines: parsePasteCollapseLines(config.pasteCollapseLines),
1043
+ editorPaddingY: parseBoxPadding(config.editorPaddingY, defaultConfig.editorPaddingY),
1044
+ userMessagePaddingY: parseBoxPadding(
1045
+ config.userMessagePaddingY,
1046
+ defaultConfig.userMessagePaddingY,
1047
+ ),
1048
+ contextThresholds: parseContextThresholds(config.contextThresholds),
1049
+ pathDisplay: parsePathDisplay(config.pathDisplay),
1050
+ gitBranch,
1051
+ gitHostIcon: config.gitHostIcon === true,
1052
+ icons: resolveConfiguredIcons(iconMode, iconOverrides),
1053
+ colors: {
1054
+ ...defaultConfig.colors,
1055
+ ...colors,
1056
+ },
1057
+ colorSources: { ...colorSources },
1058
+ features: { ...features },
1059
+ footerSegments: { ...footerSegments },
1060
+ gitCommit,
1061
+ gitMetrics,
1062
+ extensionStatuses: {
1063
+ defaultPlacement: extensionStatuses.defaultPlacement,
1064
+ placements: { ...extensionStatuses.placements },
1065
+ colorModes: { ...extensionStatuses.colorModes },
1066
+ colors: { ...extensionStatuses.colors },
1067
+ icons: { ...extensionStatuses.icons },
1068
+ },
1069
+ mouse,
1070
+ };
1071
+ }
1072
+
1073
+ export function getExtensionStatusPlacement(
1074
+ config: PolishedTuiConfig,
1075
+ key: string,
1076
+ ): ExtensionStatusPlacement {
1077
+ return config.extensionStatuses.placements[key] ?? config.extensionStatuses.defaultPlacement;
1078
+ }
1079
+
1080
+ /** Colour for one status, or undefined to use the shared extensionStatus colour. */
1081
+ export function getExtensionStatusColor(
1082
+ config: PolishedTuiConfig,
1083
+ key: string,
1084
+ ): ColorSpec | undefined {
1085
+ return config.extensionStatuses.colors[key];
1086
+ }
1087
+
1088
+ /** Icon for one status, or "" when none is configured. */
1089
+ export function getExtensionStatusIcon(config: PolishedTuiConfig, key: string): string {
1090
+ // Icons are a Nerd Font affordance; ascii mode drops them, as elsewhere.
1091
+ if (config.icons.mode === "ascii") return "";
1092
+ return config.extensionStatuses.icons[key] ?? "";
1093
+ }
1094
+
1095
+ export function getExtensionStatusColorMode(
1096
+ config: PolishedTuiConfig,
1097
+ key: string,
1098
+ ): ExtensionStatusColorMode {
1099
+ return config.extensionStatuses.colorModes[key] ?? DEFAULT_EXTENSION_STATUS_COLOR_MODE;
1100
+ }
1101
+
1102
+ export function loadConfig(): PolishedTuiConfig {
1103
+ try {
1104
+ if (!existsSync(configPath)) return mergeConfig({});
1105
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
1106
+ if (!isRecord(parsed)) return mergeConfig(parsed);
1107
+
1108
+ const { config, migrated } = migrateFixedEditorKeys(parsed);
1109
+ if (migrated) {
1110
+ try {
1111
+ writeConfigAtomically(configPath, config);
1112
+ } catch {
1113
+ // Best effort: still use the migrated config for this run even if
1114
+ // the write-back fails (e.g. read-only filesystem).
1115
+ }
1116
+ console.warn(
1117
+ "[starline] Renamed your `fixedEditor` settings to `mouse` — the fixed editor is gone and Pi's own fullscreen mode replaces it. Your choices were kept.",
1118
+ );
1119
+ }
1120
+ return mergeConfig(config);
1121
+ } catch {
1122
+ return mergeConfig({});
1123
+ }
1124
+ }
1125
+
1126
+ export function saveColorSourcesPatch(
1127
+ patch: Partial<ColorSourcesConfig>,
1128
+ path = configPath,
1129
+ ): PolishedTuiConfig {
1130
+ return mutateConfig(path, (record) => {
1131
+ const existing = isRecord(record.colorSources)
1132
+ ? { ...(record.colorSources as Record<string, unknown>) }
1133
+ : {};
1134
+ record.colorSources = {
1135
+ ...existing,
1136
+ ...validColorSourceEntries(patch),
1137
+ };
1138
+ });
1139
+ }
1140
+
1141
+ export function saveUiFeaturesPatch(
1142
+ patch: Partial<UiFeaturesConfig>,
1143
+ path = configPath,
1144
+ ): PolishedTuiConfig {
1145
+ return mutateConfig(path, (record) => {
1146
+ const existing = isRecord(record.features)
1147
+ ? { ...(record.features as Record<string, unknown>) }
1148
+ : {};
1149
+ record.features = {
1150
+ ...existing,
1151
+ ...validUiFeatureEntries(patch),
1152
+ };
1153
+ });
1154
+ }
1155
+
1156
+ export function saveFooterSegmentsPatch(
1157
+ patch: Partial<FooterSegmentsConfig>,
1158
+ path = configPath,
1159
+ ): PolishedTuiConfig {
1160
+ return mutateConfig(path, (record) => {
1161
+ const existing = isRecord(record.footerSegments)
1162
+ ? { ...(record.footerSegments as Record<string, unknown>) }
1163
+ : {};
1164
+ record.footerSegments = {
1165
+ ...existing,
1166
+ ...validFooterSegmentEntries(patch),
1167
+ };
1168
+ });
1169
+ }
1170
+
1171
+ export function saveFooterFormatPatch(value: string, path = configPath): PolishedTuiConfig {
1172
+ return mutateConfig(path, (record) => {
1173
+ record.footerFormat = typeof value === "string" ? value : "";
1174
+ });
1175
+ }
1176
+
1177
+ export function saveIconsModePatch(mode: IconMode, path = configPath): PolishedTuiConfig {
1178
+ return mutateConfig(path, (record) => {
1179
+ const existing = isRecord(record.icons) ? { ...(record.icons as Record<string, unknown>) } : {};
1180
+ record.icons = {
1181
+ ...existing,
1182
+ mode: normalizeIconMode(mode),
1183
+ };
1184
+ });
1185
+ }
1186
+
1187
+ export function saveContextStylePatch(style: ContextStyle, path = configPath): PolishedTuiConfig {
1188
+ return mutateConfig(path, (record) => {
1189
+ record.contextStyle = parseContextStyle(style);
1190
+ });
1191
+ }
1192
+
1193
+ export function saveSeparatorPatch(
1194
+ separator: SeparatorStyle,
1195
+ path = configPath,
1196
+ ): PolishedTuiConfig {
1197
+ return mutateConfig(path, (record) => {
1198
+ record.separator = parseSeparatorStyle(separator);
1199
+ });
1200
+ }
1201
+
1202
+ export function saveContextThresholdsPatch(
1203
+ thresholds: Partial<ContextThresholds>,
1204
+ path = configPath,
1205
+ ): PolishedTuiConfig {
1206
+ return mutateConfig(path, (record) => {
1207
+ const existing = isRecord(record.contextThresholds)
1208
+ ? { ...(record.contextThresholds as Record<string, unknown>) }
1209
+ : {};
1210
+ record.contextThresholds = {
1211
+ ...existing,
1212
+ ...thresholds,
1213
+ };
1214
+ });
1215
+ }
1216
+
1217
+ export function savePathDisplayPatch(
1218
+ patch: Partial<PathDisplayConfig>,
1219
+ path = configPath,
1220
+ ): PolishedTuiConfig {
1221
+ return mutateConfig(path, (record) => {
1222
+ const existing = isRecord(record.pathDisplay)
1223
+ ? { ...(record.pathDisplay as Record<string, unknown>) }
1224
+ : {};
1225
+ if (patch.mode !== undefined) existing.mode = patch.mode;
1226
+ if (patch.depth !== undefined) existing.depth = patch.depth;
1227
+ record.pathDisplay = existing;
1228
+ });
1229
+ }
1230
+
1231
+ export function saveGitBranchPatch(
1232
+ patch: Partial<GitBranchConfig>,
1233
+ path = configPath,
1234
+ ): PolishedTuiConfig {
1235
+ return mutateConfig(path, (record) => {
1236
+ const existing = isRecord(record.gitBranch)
1237
+ ? { ...(record.gitBranch as Record<string, unknown>) }
1238
+ : {};
1239
+ if (patch.maxLength !== undefined)
1240
+ existing.maxLength = normalizeGitBranchMaxLength(patch.maxLength);
1241
+ record.gitBranch = existing;
1242
+ });
1243
+ }
1244
+
1245
+ export function saveExtensionStatusPlacement(
1246
+ key: string,
1247
+ placement: ExtensionStatusPlacement,
1248
+ path = configPath,
1249
+ ): PolishedTuiConfig {
1250
+ return mutateConfig(path, (record) => {
1251
+ const existingExtensionStatuses = isRecord(record.extensionStatuses)
1252
+ ? { ...(record.extensionStatuses as Record<string, unknown>) }
1253
+ : {};
1254
+ const existingPlacements = isRecord(existingExtensionStatuses.placements)
1255
+ ? { ...(existingExtensionStatuses.placements as Record<string, unknown>) }
1256
+ : {};
1257
+
1258
+ Object.defineProperty(existingPlacements, key, {
1259
+ value: placement,
1260
+ enumerable: true,
1261
+ configurable: true,
1262
+ writable: true,
1263
+ });
1264
+
1265
+ record.extensionStatuses = {
1266
+ ...existingExtensionStatuses,
1267
+ placements: existingPlacements,
1268
+ };
1269
+ });
1270
+ }
1271
+
1272
+ export function saveExtensionStatusColorMode(
1273
+ key: string,
1274
+ colorMode: ExtensionStatusColorMode,
1275
+ path = configPath,
1276
+ ): PolishedTuiConfig {
1277
+ return mutateConfig(path, (record) => {
1278
+ const existingExtensionStatuses = isRecord(record.extensionStatuses)
1279
+ ? { ...(record.extensionStatuses as Record<string, unknown>) }
1280
+ : {};
1281
+ const existingColorModes = isRecord(existingExtensionStatuses.colorModes)
1282
+ ? { ...(existingExtensionStatuses.colorModes as Record<string, unknown>) }
1283
+ : {};
1284
+
1285
+ Object.defineProperty(existingColorModes, key, {
1286
+ value: colorMode,
1287
+ enumerable: true,
1288
+ configurable: true,
1289
+ writable: true,
1290
+ });
1291
+
1292
+ record.extensionStatuses = {
1293
+ ...existingExtensionStatuses,
1294
+ colorModes: existingColorModes,
1295
+ };
1296
+ });
1297
+ }
1298
+
1299
+ export function saveMousePatch(patch: Partial<MouseConfig>, path = configPath): PolishedTuiConfig {
1300
+ return mutateConfig(path, (record) => {
1301
+ const existing = isRecord(record.mouse) ? { ...(record.mouse as Record<string, unknown>) } : {};
1302
+ record.mouse = {
1303
+ ...existing,
1304
+ ...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}),
1305
+ ...(patch.wheelRouting !== undefined ? { wheelRouting: patch.wheelRouting } : {}),
1306
+ ...(patch.copyNotice !== undefined ? { copyNotice: patch.copyNotice } : {}),
1307
+ ...(patch.copyOnSelect !== undefined ? { copyOnSelect: patch.copyOnSelect } : {}),
1308
+ ...(patch.clickToExpandTools !== undefined
1309
+ ? { clickToExpandTools: patch.clickToExpandTools }
1310
+ : {}),
1311
+ ...(patch.pathAwareWords !== undefined ? { pathAwareWords: patch.pathAwareWords } : {}),
1312
+ ...(patch.transcriptCleanCopy !== undefined
1313
+ ? { transcriptCleanCopy: patch.transcriptCleanCopy }
1314
+ : {}),
1315
+ };
1316
+ });
1317
+ }