pi-one-ui 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/extensions/app/config/renderer.ts +452 -0
  4. package/extensions/app/config/shell.ts +2484 -0
  5. package/extensions/app/ownership.ts +18 -0
  6. package/extensions/app/panel.ts +533 -0
  7. package/extensions/app/presets.ts +36 -0
  8. package/extensions/features/agent-summary/core.ts +194 -0
  9. package/extensions/features/agent-summary/index.ts +37 -0
  10. package/extensions/features/compact-thinking.ts +1262 -0
  11. package/extensions/features/context.ts +857 -0
  12. package/extensions/features/reference/index.ts +643 -0
  13. package/extensions/features/reference/session.ts +278 -0
  14. package/extensions/features/reference/subagent.ts +218 -0
  15. package/extensions/features/shell/aliases.ts +28 -0
  16. package/extensions/features/shell/flush-docked-bash.ts +39 -0
  17. package/extensions/features/shell/startup-header.ts +357 -0
  18. package/extensions/features/shell/working-message.ts +226 -0
  19. package/extensions/index.ts +52 -0
  20. package/extensions/shell/accent-rail-editor.ts +112 -0
  21. package/extensions/shell/accent-rail-layout-patch.ts +530 -0
  22. package/extensions/shell/completion-menu.ts +186 -0
  23. package/extensions/shell/editor-metadata-format.ts +287 -0
  24. package/extensions/shell/editor-transfer.ts +73 -0
  25. package/extensions/shell/extension-status.ts +102 -0
  26. package/extensions/shell/footer-format.ts +388 -0
  27. package/extensions/shell/footer-layout.ts +144 -0
  28. package/extensions/shell/footer.ts +1035 -0
  29. package/extensions/shell/format.ts +577 -0
  30. package/extensions/shell/git.ts +434 -0
  31. package/extensions/shell/icons.ts +261 -0
  32. package/extensions/shell/index.ts +1659 -0
  33. package/extensions/shell/interaction-summary.ts +1194 -0
  34. package/extensions/shell/live-context.ts +82 -0
  35. package/extensions/shell/minimalist-editor.ts +548 -0
  36. package/extensions/shell/package-version.ts +675 -0
  37. package/extensions/shell/project-refresh.ts +130 -0
  38. package/extensions/shell/project-state.ts +60 -0
  39. package/extensions/shell/prototype-patch-registry.ts +183 -0
  40. package/extensions/shell/runtime.ts +915 -0
  41. package/extensions/shell/selector-border.ts +109 -0
  42. package/extensions/shell/session-lifecycle.ts +60 -0
  43. package/extensions/shell/settings-command.ts +2144 -0
  44. package/extensions/shell/settings-previews.ts +137 -0
  45. package/extensions/shell/state.ts +83 -0
  46. package/extensions/shell/style.ts +480 -0
  47. package/extensions/shell/telemetry.ts +90 -0
  48. package/extensions/shell/ui.ts +1326 -0
  49. package/extensions/shell/user-message-osc.ts +279 -0
  50. package/extensions/shell/user-message-styles.ts +266 -0
  51. package/extensions/shell/user-message.ts +226 -0
  52. package/extensions/shell/working-line-messages.ts +24 -0
  53. package/extensions/shell/working-line-spinners.ts +54 -0
  54. package/extensions/shell/working-line.ts +1603 -0
  55. package/extensions/tools/ansi-color.ts +44 -0
  56. package/extensions/tools/ansi-text.ts +52 -0
  57. package/extensions/tools/component-tree.ts +37 -0
  58. package/extensions/tools/format.ts +28 -0
  59. package/extensions/tools/fullscreen-detect.ts +11 -0
  60. package/extensions/tools/patch-keys.ts +158 -0
  61. package/extensions/tools/sgr-mouse.ts +59 -0
  62. package/extensions/tools/tool-loading-icon.ts +20 -0
  63. package/extensions/tools/tool-result-sanitize.ts +27 -0
  64. package/extensions/transcript/index.ts +65 -0
  65. package/extensions/transcript/renderer/compact-mode.ts +1443 -0
  66. package/extensions/transcript/renderer/default-mode.ts +735 -0
  67. package/extensions/transcript/renderer/index.ts +286 -0
  68. package/extensions/transcript/renderer/markdown-enhance.ts +325 -0
  69. package/extensions/transcript/renderer/mouse/hover.ts +175 -0
  70. package/extensions/transcript/renderer/mouse/interaction.ts +1016 -0
  71. package/extensions/transcript/renderer/mouse/layout.ts +207 -0
  72. package/extensions/transcript/renderer/mouse/packets.ts +96 -0
  73. package/extensions/transcript/renderer/mouse/scroll.ts +211 -0
  74. package/extensions/transcript/renderer/tool/diff/ATTRIBUTION.md +28 -0
  75. package/extensions/transcript/renderer/tool/diff/ansi-utils.ts +128 -0
  76. package/extensions/transcript/renderer/tool/diff/diff-component.ts +112 -0
  77. package/extensions/transcript/renderer/tool/diff/diff-edit-render.ts +255 -0
  78. package/extensions/transcript/renderer/tool/diff/diff-header.ts +184 -0
  79. package/extensions/transcript/renderer/tool/diff/diff-highlight.ts +131 -0
  80. package/extensions/transcript/renderer/tool/diff/diff-inline.ts +350 -0
  81. package/extensions/transcript/renderer/tool/diff/diff-layout.ts +870 -0
  82. package/extensions/transcript/renderer/tool/diff/diff-limits.ts +189 -0
  83. package/extensions/transcript/renderer/tool/diff/diff-palette.ts +436 -0
  84. package/extensions/transcript/renderer/tool/diff/diff-parse.ts +536 -0
  85. package/extensions/transcript/renderer/tool/diff/diff-presentation.ts +79 -0
  86. package/extensions/transcript/renderer/tool/diff/diff-renderer.ts +29 -0
  87. package/extensions/transcript/renderer/tool/diff/diff-text.ts +57 -0
  88. package/extensions/transcript/renderer/tool/diff/diff-write-render.ts +629 -0
  89. package/extensions/transcript/renderer/tool/diff/index.ts +147 -0
  90. package/extensions/transcript/renderer/tool/diff/line-width-safety.ts +100 -0
  91. package/extensions/transcript/renderer/tool/diff/shiki-highlight.ts +104 -0
  92. package/extensions/transcript/renderer/tool/diff/write-execution.ts +124 -0
  93. package/extensions/transcript/renderer/tool/grouping.ts +619 -0
  94. package/extensions/transcript/renderer/tool/message-display.ts +183 -0
  95. package/extensions/transcript/renderer/tool/names.ts +195 -0
  96. package/extensions/transcript/renderer/tool/result.ts +842 -0
  97. package/extensions/transcript/renderer/tool/show-more-hint.ts +42 -0
  98. package/extensions/transcript/renderer/transcript-refresh.ts +79 -0
  99. package/package.json +58 -0
  100. package/themes/cc-dark.json +91 -0
  101. package/themes/cc-light.json +91 -0
@@ -0,0 +1,2484 @@
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 {
19
+ ICON_GLYPH_KEYS,
20
+ type IconGlyphs,
21
+ type IconMode,
22
+ NERD_DEFAULT_ICONS,
23
+ normalizeIconMode,
24
+ type ResolvedIcons,
25
+ resolveConfiguredIcons,
26
+ } from "../../shell/icons.ts";
27
+ import { isSupportedColorSpec } from "../../shell/style.ts";
28
+ import { normalizeWorkingLineMessages } from "../../shell/working-line.ts";
29
+ import { PI_WORKING_LINE_MESSAGES } from "../../shell/working-line-messages.ts";
30
+
31
+ export type ColorSpec = string;
32
+ export type ColorSource = "theme" | "terminal";
33
+ export type { IconMode } from "../../shell/icons.ts";
34
+
35
+ export type ContextStyle = "text" | "gauge" | "text+gauge";
36
+ export type SeparatorStyle = "pipe" | "dot" | "chevron" | "none";
37
+ export type ModelLabelSource = "id" | "name";
38
+ export type EditorStyle =
39
+ | "opencode"
40
+ | "opencode-copy-friendly"
41
+ | "accent-rail"
42
+ | "minimalist";
43
+ export type UserMessageStyle =
44
+ | "framed"
45
+ | "framed-copy-friendly"
46
+ | "compact"
47
+ | "labeled";
48
+ export type SelectorBorderStyle = "zentui";
49
+ export type FooterStyle = "native" | "starship" | "hidden";
50
+ export type WorkingLineSpinner =
51
+ | "braille"
52
+ | "star-bloom"
53
+ | "pinwheel"
54
+ | "claude-inspired"
55
+ | "pulse";
56
+ export type WorkingLineTextAnimation = "classic" | "kitt" | "disabled";
57
+ export type ComponentStyleOwner =
58
+ | "editor"
59
+ | "userMessages"
60
+ | "selectorBorders"
61
+ | "footer";
62
+ export type MinimalistPathDisplayMode = "compact" | "project" | "full";
63
+ export type MinimalistContextFormat = "percent" | "percent-total";
64
+ export type EditorBorderColorMode = "static" | "adaptive";
65
+ export type CompletionMenuStyle = "native" | "palette";
66
+ export type CompactFooterMaxLines = 1 | 2 | 3 | "unlimited";
67
+
68
+ export const DEFAULT_COMPACT_FOOTER_FORMAT =
69
+ "$cwd$wrap(in $session_name)$wrap(on $git_branch) $git_status$wrap$context$wrap_sep$tokens";
70
+
71
+ export type ContextThresholds = {
72
+ warning: number;
73
+ error: number;
74
+ };
75
+
76
+ export type PathDisplayMode = "basename" | "full";
77
+
78
+ export type PathDisplayConfig = {
79
+ mode: PathDisplayMode;
80
+ /** Trailing directories to show in full mode. 0 = unlimited; clamped to 0..5. */
81
+ depth: number;
82
+ };
83
+
84
+ export type GitBranchMaxLength = "full" | number;
85
+
86
+ export type GitBranchConfig = {
87
+ maxLength: GitBranchMaxLength;
88
+ };
89
+
90
+ export type ColorSourcesConfig = {
91
+ starship: ColorSource;
92
+ editor: ColorSource;
93
+ userMessages: ColorSource;
94
+ };
95
+
96
+ export type UiFeaturesConfig = {
97
+ editor: boolean;
98
+ statusLine: boolean;
99
+ viewportIndicators: boolean;
100
+ };
101
+
102
+ export type FooterSegmentsConfig = {
103
+ cwd: boolean;
104
+ sessionName: boolean;
105
+ gitBranch: boolean;
106
+ gitStatus: boolean;
107
+ gitCounts: boolean;
108
+ gitCommit: boolean;
109
+ gitMetrics: boolean;
110
+ runtime: boolean;
111
+ modelInfo: boolean;
112
+ context: boolean;
113
+ tokens: boolean;
114
+ cost: boolean;
115
+ sessionDuration: boolean;
116
+ username: boolean;
117
+ time: boolean;
118
+ os: boolean;
119
+ packageVersion: boolean;
120
+ };
121
+
122
+ export type PolishedEditorStyleConfig = {
123
+ metadataFormat: string;
124
+ completionMenu: CompletionMenuStyle;
125
+ };
126
+
127
+ export type PolishedCopyFriendlyEditorStyleConfig = {
128
+ metadataFormat: string;
129
+ completionMenu: CompletionMenuStyle;
130
+ };
131
+
132
+ export type AccentRailEditorStyleConfig = {
133
+ rail: string;
134
+ asciiRail: string;
135
+ transparent: boolean;
136
+ };
137
+
138
+ export type MinimalistEditorStyleConfig = {
139
+ pathDisplay: MinimalistPathDisplayMode;
140
+ contextFormat: MinimalistContextFormat;
141
+ contextGauge: boolean;
142
+ showSessionName: boolean;
143
+ showTimer: boolean;
144
+ showCost: boolean;
145
+ showGit: boolean;
146
+ contextThresholds: ContextThresholds;
147
+ };
148
+
149
+ /** Temporary name retained for existing settings consumers. */
150
+ export type MinimalistConfig = MinimalistEditorStyleConfig;
151
+
152
+ export type EditorStylesConfig = {
153
+ minimalist: MinimalistEditorStyleConfig;
154
+ };
155
+
156
+ export type EditorComponentConfig = {
157
+ enabled: boolean;
158
+ style: EditorStyle;
159
+ colorSource: ColorSource;
160
+ borderColorMode: EditorBorderColorMode;
161
+ modelLabel: ModelLabelSource;
162
+ viewportIndicators: boolean;
163
+ styles: {
164
+ opencode: PolishedEditorStyleConfig;
165
+ "opencode-copy-friendly": PolishedCopyFriendlyEditorStyleConfig;
166
+ "accent-rail": AccentRailEditorStyleConfig;
167
+ minimalist: MinimalistEditorStyleConfig;
168
+ };
169
+ };
170
+
171
+ export type FramedUserMessageStyleConfig = Record<string, never>;
172
+ export type FramedCopyFriendlyUserMessageStyleConfig = Record<string, never>;
173
+ export type CompactUserMessageStyleConfig = Record<string, never>;
174
+ export type LabeledUserMessageStyleConfig = Record<string, never>;
175
+
176
+ export type UserMessagesComponentConfig = {
177
+ enabled: boolean;
178
+ style: UserMessageStyle;
179
+ colorSource: ColorSource;
180
+ styles: {
181
+ framed: FramedUserMessageStyleConfig;
182
+ "framed-copy-friendly": FramedCopyFriendlyUserMessageStyleConfig;
183
+ compact: CompactUserMessageStyleConfig;
184
+ labeled: LabeledUserMessageStyleConfig;
185
+ };
186
+ };
187
+
188
+ export type SelectorBordersComponentConfig = {
189
+ enabled: boolean;
190
+ style: SelectorBorderStyle;
191
+ colorSource: ColorSource;
192
+ };
193
+
194
+ export type StarshipFooterStyleConfig = {
195
+ format: string;
196
+ responsive: boolean;
197
+ compactFormat: string;
198
+ compactMaxLines: CompactFooterMaxLines;
199
+ separator: SeparatorStyle;
200
+ contextStyle: ContextStyle;
201
+ contextThresholds: ContextThresholds;
202
+ pathDisplay: PathDisplayConfig;
203
+ segments: FooterSegmentsConfig;
204
+ gitBranch: GitBranchConfig;
205
+ gitCommit: GitCommitConfig;
206
+ gitMetrics: GitMetricsConfig;
207
+ extensionStatuses: ExtensionStatusesConfig;
208
+ };
209
+
210
+ export type FooterComponentConfig = {
211
+ style: FooterStyle;
212
+ colorSource: ColorSource;
213
+ modelLabel: ModelLabelSource;
214
+ styles: {
215
+ starship: StarshipFooterStyleConfig;
216
+ };
217
+ };
218
+
219
+ export type WorkingLineMessagesConfig = {
220
+ custom: boolean;
221
+ values: string[];
222
+ };
223
+
224
+ export type WorkingLineSegmentsConfig = {
225
+ tool: boolean;
226
+ elapsed: boolean;
227
+ thought: boolean;
228
+ tokens: boolean;
229
+ };
230
+
231
+ export const DEFAULT_WORKING_LINE_SPINNER_INTERVAL_MS = 100;
232
+ export const DEFAULT_WORKING_LINE_TEXT_INTERVAL_MS = 60;
233
+ export const MIN_WORKING_LINE_INTERVAL_MS = 30;
234
+ export const MAX_WORKING_LINE_INTERVAL_MS = 1000;
235
+
236
+ export function isValidWorkingLineIntervalMs(value: unknown): value is number {
237
+ return (
238
+ typeof value === "number" &&
239
+ Number.isSafeInteger(value) &&
240
+ value >= MIN_WORKING_LINE_INTERVAL_MS &&
241
+ value <= MAX_WORKING_LINE_INTERVAL_MS
242
+ );
243
+ }
244
+
245
+ export type WorkingLineComponentConfig = {
246
+ enabled: boolean;
247
+ turnSummary: boolean;
248
+ spinner: WorkingLineSpinner;
249
+ spinnerIntervalMs: number;
250
+ animateSpinnerColor: boolean;
251
+ textIntervalMs: number;
252
+ textAnimation: WorkingLineTextAnimation;
253
+ colorSource: ColorSource;
254
+ messages: WorkingLineMessagesConfig;
255
+ segments: WorkingLineSegmentsConfig;
256
+ };
257
+
258
+ export type WorkingLineComponentPatch = Partial<
259
+ Omit<WorkingLineComponentConfig, "messages" | "segments">
260
+ > & {
261
+ messages?: Partial<WorkingLineMessagesConfig>;
262
+ segments?: Partial<WorkingLineSegmentsConfig>;
263
+ };
264
+
265
+ export type ComponentsConfig = {
266
+ editor: EditorComponentConfig;
267
+ userMessages: UserMessagesComponentConfig;
268
+ workingLine: WorkingLineComponentConfig;
269
+ selectorBorders: SelectorBordersComponentConfig;
270
+ footer: FooterComponentConfig;
271
+ };
272
+
273
+ export type ExtensionStatusPlacement = "off" | "left" | "middle" | "right";
274
+ export type ExtensionStatusColorMode = "zentui" | "original";
275
+
276
+ /**
277
+ * Starship `git_commit`-style options.
278
+ * See https://starship.rs/config/#git-commit
279
+ */
280
+ export type GitCommitConfig = {
281
+ hashLength: number;
282
+ onlyDetached: boolean;
283
+ showTag: boolean;
284
+ };
285
+
286
+ /**
287
+ * Starship `git_metrics`-style options.
288
+ * See https://starship.rs/config/#git-metrics
289
+ */
290
+ export type GitMetricsConfig = {
291
+ onlyNonzero: boolean;
292
+ ignoreSubmodules: boolean;
293
+ };
294
+
295
+ const DEFAULT_EXTENSION_STATUS_PLACEMENT: ExtensionStatusPlacement = "right";
296
+ const DEFAULT_EXTENSION_STATUS_COLOR_MODE: ExtensionStatusColorMode = "zentui";
297
+
298
+ export type ExtensionStatusesConfig = {
299
+ defaultPlacement: ExtensionStatusPlacement;
300
+ placements: Record<string, ExtensionStatusPlacement>;
301
+ colorModes: Record<string, ExtensionStatusColorMode>;
302
+ };
303
+
304
+ const DEFAULT_PROJECT_REFRESH_INTERVAL_MS = 30_000;
305
+ const MIN_PROJECT_REFRESH_INTERVAL_MS = 5_000;
306
+ export const DEFAULT_EDITOR_METADATA_FORMAT = "$model $provider( $thinking)";
307
+
308
+ export type ZentuiConfig = {
309
+ projectRefreshIntervalMs: number;
310
+ icons: ResolvedIcons;
311
+ colors: PolishedTuiColors;
312
+ components: ComponentsConfig;
313
+ };
314
+
315
+ export type PolishedTuiColors = {
316
+ cwd: ColorSpec;
317
+ sessionName: ColorSpec;
318
+ gitBranch: ColorSpec;
319
+ gitStatus: ColorSpec;
320
+ contextNormal: ColorSpec;
321
+ contextWarning: ColorSpec;
322
+ contextError: ColorSpec;
323
+ tokens: ColorSpec;
324
+ cost: ColorSpec;
325
+ separator: ColorSpec;
326
+ runtimePrefix: ColorSpec;
327
+ extensionStatus: ColorSpec;
328
+ sessionDuration: ColorSpec;
329
+ packageVersion: ColorSpec;
330
+ gitCommit: ColorSpec;
331
+ gitMetricsAdded: ColorSpec;
332
+ gitMetricsDeleted: ColorSpec;
333
+ username: ColorSpec;
334
+ time: ColorSpec;
335
+ os: ColorSpec;
336
+ editorAccent?: ColorSpec;
337
+ editorRail?: ColorSpec;
338
+ editorPrompt?: ColorSpec;
339
+ editorBorder?: ColorSpec;
340
+ editorGitBranch?: ColorSpec;
341
+ editorModel?: ColorSpec;
342
+ editorProvider?: ColorSpec;
343
+ editorThinking?: ColorSpec;
344
+ editorThinkingMinimal?: ColorSpec;
345
+ editorThinkingLow?: ColorSpec;
346
+ editorThinkingMedium?: ColorSpec;
347
+ editorThinkingHigh?: ColorSpec;
348
+ editorThinkingXhigh?: ColorSpec;
349
+ editorThinkingMax?: ColorSpec;
350
+ workingLineLow?: ColorSpec;
351
+ workingLineMid?: ColorSpec;
352
+ workingLineHigh?: ColorSpec;
353
+ };
354
+
355
+ /**
356
+ * Canonical configuration plus a temporary flat compatibility projection used
357
+ * by production consumers while they migrate to `components`.
358
+ */
359
+ export type PolishedTuiConfig = ZentuiConfig & {
360
+ footerFormat: string;
361
+ responsiveFooter: boolean;
362
+ compactFooterFormat: string;
363
+ compactFooterMaxLines: CompactFooterMaxLines;
364
+ editorMetadataFormat: string;
365
+ separator: SeparatorStyle;
366
+ contextStyle: ContextStyle;
367
+ editorModelLabel: ModelLabelSource;
368
+ editorStyle: EditorStyle;
369
+ editorStyles: EditorStylesConfig;
370
+ editorBorderColorMode: EditorBorderColorMode;
371
+ contextThresholds: ContextThresholds;
372
+ pathDisplay: PathDisplayConfig;
373
+ gitBranch: GitBranchConfig;
374
+ colorSources: ColorSourcesConfig;
375
+ features: UiFeaturesConfig;
376
+ footerSegments: FooterSegmentsConfig;
377
+ gitCommit: GitCommitConfig;
378
+ gitMetrics: GitMetricsConfig;
379
+ extensionStatuses: ExtensionStatusesConfig;
380
+ };
381
+
382
+ /**
383
+ * Canonical footer format variable names. In a `footerFormat` string these
384
+ * are written as `$name` or `${name}`.
385
+ */
386
+ export const FOOTER_FORMAT_VARIABLES = [
387
+ "cwd",
388
+ "session_name",
389
+ "git_branch",
390
+ "git_status",
391
+ "git_state",
392
+ "runtime",
393
+ "model",
394
+ "provider",
395
+ "session_duration",
396
+ "username",
397
+ "os",
398
+ "time",
399
+ "context",
400
+ "tokens",
401
+ "cache_read",
402
+ "cache_write",
403
+ "cost",
404
+ "subscription",
405
+ "auto_compaction",
406
+ "package",
407
+ "package_version",
408
+ "git_commit",
409
+ "git_tag",
410
+ "git_metrics",
411
+ "git_added",
412
+ "git_deleted",
413
+ "sep",
414
+ ] as const;
415
+
416
+ /**
417
+ * Alias → canonical variable name mapping for `footerFormat`.
418
+ * `$fill` is special (not a variable) and handled by the parser.
419
+ */
420
+ export const FOOTER_FORMAT_ALIASES: Record<string, string> = {
421
+ directory: "cwd",
422
+ branch: "git_branch",
423
+ status: "git_status",
424
+ state: "git_state",
425
+ commit: "git_commit",
426
+ tag: "git_tag",
427
+ duration: "session_duration",
428
+ separator: "sep",
429
+ };
430
+
431
+ /** Shared configuration file used by the pi-one-ui package. */
432
+ export const configPath = join(getAgentDir(), "pi-one-ui.json");
433
+ const legacyConfigPath = join(getAgentDir(), "pi-mine-ui.json");
434
+ const legacyZentuiConfigPath = join(getAgentDir(), "zentui.json");
435
+
436
+ const defaultFooterSegments: FooterSegmentsConfig = {
437
+ cwd: true,
438
+ sessionName: true,
439
+ gitBranch: true,
440
+ gitStatus: true,
441
+ gitCounts: false,
442
+ gitCommit: false,
443
+ gitMetrics: false,
444
+ runtime: true,
445
+ modelInfo: false,
446
+ context: true,
447
+ tokens: true,
448
+ cost: true,
449
+ sessionDuration: false,
450
+ username: false,
451
+ time: false,
452
+ os: false,
453
+ packageVersion: false,
454
+ };
455
+
456
+ const DEFAULT_COMPLETION_MENU: CompletionMenuStyle = "palette";
457
+
458
+ const defaultAccentRailStyle: AccentRailEditorStyleConfig = {
459
+ rail: "▎",
460
+ asciiRail: "|",
461
+ transparent: false,
462
+ };
463
+
464
+ const defaultMinimalistStyle: MinimalistEditorStyleConfig = {
465
+ pathDisplay: "compact",
466
+ contextFormat: "percent",
467
+ contextGauge: false,
468
+ showSessionName: true,
469
+ showTimer: true,
470
+ showCost: true,
471
+ showGit: true,
472
+ contextThresholds: { warning: 70, error: 90 },
473
+ };
474
+
475
+ const defaultStarshipStyle: StarshipFooterStyleConfig = {
476
+ format: "",
477
+ responsive: true,
478
+ compactFormat: DEFAULT_COMPACT_FOOTER_FORMAT,
479
+ compactMaxLines: 2,
480
+ separator: "pipe",
481
+ contextStyle: "text",
482
+ contextThresholds: { warning: 70, error: 90 },
483
+ pathDisplay: { mode: "basename", depth: 0 },
484
+ segments: defaultFooterSegments,
485
+ gitBranch: { maxLength: "full" },
486
+ gitCommit: { hashLength: 7, onlyDetached: true, showTag: true },
487
+ gitMetrics: { onlyNonzero: true, ignoreSubmodules: false },
488
+ extensionStatuses: {
489
+ defaultPlacement: "right",
490
+ placements: {},
491
+ colorModes: {},
492
+ },
493
+ };
494
+
495
+ const defaultComponents: ComponentsConfig = {
496
+ editor: {
497
+ enabled: true,
498
+ style: "opencode",
499
+ colorSource: "theme",
500
+ borderColorMode: "static",
501
+ modelLabel: "id",
502
+ viewportIndicators: true,
503
+ styles: {
504
+ opencode: {
505
+ metadataFormat: DEFAULT_EDITOR_METADATA_FORMAT,
506
+ completionMenu: DEFAULT_COMPLETION_MENU,
507
+ },
508
+ "opencode-copy-friendly": {
509
+ metadataFormat: DEFAULT_EDITOR_METADATA_FORMAT,
510
+ completionMenu: DEFAULT_COMPLETION_MENU,
511
+ },
512
+ "accent-rail": defaultAccentRailStyle,
513
+ minimalist: defaultMinimalistStyle,
514
+ },
515
+ },
516
+ userMessages: {
517
+ enabled: true,
518
+ style: "framed",
519
+ colorSource: "theme",
520
+ styles: {
521
+ framed: {},
522
+ "framed-copy-friendly": {},
523
+ compact: {},
524
+ labeled: {},
525
+ },
526
+ },
527
+ workingLine: {
528
+ enabled: false,
529
+ turnSummary: true,
530
+ spinner: "star-bloom",
531
+ spinnerIntervalMs: DEFAULT_WORKING_LINE_SPINNER_INTERVAL_MS,
532
+ animateSpinnerColor: false,
533
+ textIntervalMs: DEFAULT_WORKING_LINE_TEXT_INTERVAL_MS,
534
+ textAnimation: "classic",
535
+ colorSource: "theme",
536
+ messages: { custom: true, values: [...PI_WORKING_LINE_MESSAGES] },
537
+ segments: { tool: true, elapsed: true, thought: true, tokens: true },
538
+ },
539
+ selectorBorders: { enabled: true, style: "zentui", colorSource: "theme" },
540
+ footer: {
541
+ style: "starship",
542
+ colorSource: "theme",
543
+ modelLabel: "id",
544
+ styles: { starship: defaultStarshipStyle },
545
+ },
546
+ };
547
+
548
+ export const defaultConfig: PolishedTuiConfig = {
549
+ projectRefreshIntervalMs: DEFAULT_PROJECT_REFRESH_INTERVAL_MS,
550
+ icons: { mode: "auto", ...NERD_DEFAULT_ICONS },
551
+ colors: {
552
+ cwd: "bold cyan",
553
+ sessionName: "bold green",
554
+ gitBranch: "bold purple",
555
+ gitStatus: "bold red",
556
+ contextNormal: "bright-black",
557
+ contextWarning: "bold yellow",
558
+ contextError: "bold red",
559
+ tokens: "bright-black",
560
+ cost: "bold green",
561
+ separator: "bright-black",
562
+ runtimePrefix: "",
563
+ extensionStatus: "bright-black",
564
+ sessionDuration: "yellow",
565
+ packageVersion: "208",
566
+ gitCommit: "bold green",
567
+ gitMetricsAdded: "bold green",
568
+ gitMetricsDeleted: "bold red",
569
+ username: "bold yellow",
570
+ time: "bold yellow",
571
+ os: "bold white",
572
+ },
573
+ components: defaultComponents,
574
+ footerFormat: defaultStarshipStyle.format,
575
+ responsiveFooter: defaultStarshipStyle.responsive,
576
+ compactFooterFormat: defaultStarshipStyle.compactFormat,
577
+ compactFooterMaxLines: defaultStarshipStyle.compactMaxLines,
578
+ editorMetadataFormat: DEFAULT_EDITOR_METADATA_FORMAT,
579
+ separator: defaultStarshipStyle.separator,
580
+ contextStyle: defaultStarshipStyle.contextStyle,
581
+ editorModelLabel: defaultComponents.editor.modelLabel,
582
+ editorStyle: defaultComponents.editor.style,
583
+ editorStyles: { minimalist: defaultMinimalistStyle },
584
+ editorBorderColorMode: defaultComponents.editor.borderColorMode,
585
+ contextThresholds: defaultStarshipStyle.contextThresholds,
586
+ pathDisplay: defaultStarshipStyle.pathDisplay,
587
+ gitBranch: defaultStarshipStyle.gitBranch,
588
+ colorSources: { starship: "theme", editor: "theme", userMessages: "theme" },
589
+ features: {
590
+ editor: true,
591
+ statusLine: true,
592
+ viewportIndicators: true,
593
+ },
594
+ footerSegments: defaultFooterSegments,
595
+ gitCommit: defaultStarshipStyle.gitCommit,
596
+ gitMetrics: defaultStarshipStyle.gitMetrics,
597
+ extensionStatuses: defaultStarshipStyle.extensionStatuses,
598
+ };
599
+
600
+ type ConfigRecord = Record<string, unknown>;
601
+
602
+ function isRecord(value: unknown): value is ConfigRecord {
603
+ return value !== null && typeof value === "object" && !Array.isArray(value);
604
+ }
605
+
606
+ function parseProjectRefreshIntervalMs(value: unknown): number {
607
+ if (value === 0) return 0;
608
+ if (typeof value !== "number" || !Number.isFinite(value)) {
609
+ return DEFAULT_PROJECT_REFRESH_INTERVAL_MS;
610
+ }
611
+
612
+ const interval = Math.round(value);
613
+ if (interval <= 0) return 0;
614
+ return Math.max(MIN_PROJECT_REFRESH_INTERVAL_MS, interval);
615
+ }
616
+
617
+ function clampPercent(value: number): number {
618
+ return Math.max(0, Math.min(100, value));
619
+ }
620
+
621
+ function parseContextStyle(value: unknown): ContextStyle {
622
+ if (value === "text" || value === "gauge" || value === "text+gauge")
623
+ return value;
624
+ return defaultConfig.contextStyle;
625
+ }
626
+
627
+ function parseEditorModelLabel(
628
+ value: unknown,
629
+ fallback: ModelLabelSource = defaultComponents.editor.modelLabel,
630
+ ): ModelLabelSource {
631
+ if (value === "id" || value === "name") return value;
632
+ return fallback;
633
+ }
634
+
635
+ function parseEditorStyle(value: unknown): EditorStyle {
636
+ if (
637
+ value === "opencode" ||
638
+ value === "opencode-copy-friendly" ||
639
+ value === "accent-rail" ||
640
+ value === "minimalist"
641
+ ) {
642
+ return value;
643
+ }
644
+ if (value === "polished") return "opencode";
645
+ if (value === "polished-copy-friendly") return "opencode-copy-friendly";
646
+ return defaultConfig.editorStyle;
647
+ }
648
+
649
+ function parseEditorBorderColorMode(value: unknown): EditorBorderColorMode {
650
+ if (value === "static" || value === "adaptive") return value;
651
+ return defaultConfig.editorBorderColorMode;
652
+ }
653
+
654
+ function parseCompletionMenuStyle(value: unknown): CompletionMenuStyle {
655
+ return value === "native" || value === "palette"
656
+ ? value
657
+ : DEFAULT_COMPLETION_MENU;
658
+ }
659
+
660
+ export function isSeparatorStyle(value: unknown): value is SeparatorStyle {
661
+ return (
662
+ value === "pipe" ||
663
+ value === "dot" ||
664
+ value === "chevron" ||
665
+ value === "none"
666
+ );
667
+ }
668
+
669
+ function parseSeparatorStyle(value: unknown): SeparatorStyle {
670
+ return isSeparatorStyle(value) ? value : defaultConfig.separator;
671
+ }
672
+
673
+ function parseContextThresholds(
674
+ value: unknown,
675
+ defaults: ContextThresholds = defaultStarshipStyle.contextThresholds,
676
+ ): ContextThresholds {
677
+ if (!isRecord(value)) return { ...defaults };
678
+
679
+ const warningRaw = value.warning;
680
+ const errorRaw = value.error;
681
+ let warning =
682
+ typeof warningRaw === "number" && Number.isFinite(warningRaw)
683
+ ? clampPercent(Math.round(warningRaw))
684
+ : defaults.warning;
685
+ let error =
686
+ typeof errorRaw === "number" && Number.isFinite(errorRaw)
687
+ ? clampPercent(Math.round(errorRaw))
688
+ : defaults.error;
689
+ if (error < warning) {
690
+ const swapped = warning;
691
+ warning = error;
692
+ error = swapped;
693
+ }
694
+ return { warning, error };
695
+ }
696
+
697
+ function parsePathDisplay(value: unknown): PathDisplayConfig {
698
+ const defaults = defaultConfig.pathDisplay;
699
+ if (!isRecord(value)) return { ...defaults };
700
+ const mode =
701
+ value.mode === "full" || value.mode === "basename"
702
+ ? value.mode
703
+ : defaults.mode;
704
+ const rawDepth = value.depth;
705
+ const depth =
706
+ typeof rawDepth === "number" && Number.isFinite(rawDepth) && rawDepth >= 0
707
+ ? Math.min(5, Math.floor(rawDepth))
708
+ : defaults.depth;
709
+ return { mode, depth };
710
+ }
711
+
712
+ function normalizeGitBranchMaxLength(value: unknown): GitBranchMaxLength {
713
+ if (value === "full") return value;
714
+ if (typeof value === "number" && Number.isInteger(value) && value > 0)
715
+ return value;
716
+ return defaultConfig.gitBranch.maxLength;
717
+ }
718
+
719
+ function parseGitBranchConfig(value: unknown): GitBranchConfig {
720
+ const defaults = defaultConfig.gitBranch;
721
+ if (!isRecord(value)) return { ...defaults };
722
+ return {
723
+ maxLength: normalizeGitBranchMaxLength(value.maxLength),
724
+ };
725
+ }
726
+
727
+ function stringValue(
728
+ record: Record<string, unknown>,
729
+ key: string,
730
+ ): string | undefined {
731
+ const value = record[key];
732
+ return typeof value === "string" ? value : undefined;
733
+ }
734
+
735
+ function parseCompactFooterMaxLines(value: unknown): CompactFooterMaxLines {
736
+ return value === 1 || value === 2 || value === 3 || value === "unlimited"
737
+ ? value
738
+ : 2;
739
+ }
740
+
741
+ function colorValue(
742
+ record: Record<string, unknown>,
743
+ key: string,
744
+ ): string | undefined {
745
+ const value = stringValue(record, key);
746
+ return value !== undefined && isSupportedColorSpec(value) ? value : undefined;
747
+ }
748
+
749
+ function definedColors(
750
+ colors: Partial<
751
+ Record<keyof PolishedTuiConfig["colors"], string | undefined>
752
+ >,
753
+ ): Partial<PolishedTuiConfig["colors"]> {
754
+ return Object.fromEntries(
755
+ Object.entries(colors).filter(
756
+ (entry): entry is [keyof PolishedTuiConfig["colors"], string] =>
757
+ typeof entry[1] === "string",
758
+ ),
759
+ ) as Partial<PolishedTuiConfig["colors"]>;
760
+ }
761
+
762
+ function normalizeIconOverrides(
763
+ record: Record<string, unknown>,
764
+ ): Partial<IconGlyphs> {
765
+ return Object.fromEntries(
766
+ ICON_GLYPH_KEYS.flatMap((key) => {
767
+ const value = stringValue(record, key);
768
+ return value === undefined ? [] : [[key, value]];
769
+ }),
770
+ ) as Partial<IconGlyphs>;
771
+ }
772
+
773
+ function normalizeColors(
774
+ record: Record<string, unknown>,
775
+ ): Partial<PolishedTuiConfig["colors"]> {
776
+ return definedColors({
777
+ cwd: colorValue(record, "cwd") ?? colorValue(record, "cwdText"),
778
+ sessionName: colorValue(record, "sessionName"),
779
+ gitBranch: colorValue(record, "gitBranch") ?? colorValue(record, "git"),
780
+ gitStatus: colorValue(record, "gitStatus"),
781
+ contextNormal: colorValue(record, "contextNormal"),
782
+ contextWarning: colorValue(record, "contextWarning"),
783
+ contextError: colorValue(record, "contextError"),
784
+ tokens: colorValue(record, "tokens"),
785
+ cost: colorValue(record, "cost"),
786
+ separator: colorValue(record, "separator"),
787
+ runtimePrefix: colorValue(record, "runtimePrefix"),
788
+ extensionStatus: colorValue(record, "extensionStatus"),
789
+ sessionDuration: colorValue(record, "sessionDuration"),
790
+ packageVersion: colorValue(record, "packageVersion"),
791
+ gitCommit: colorValue(record, "gitCommit"),
792
+ gitMetricsAdded: colorValue(record, "gitMetricsAdded"),
793
+ gitMetricsDeleted: colorValue(record, "gitMetricsDeleted"),
794
+ username: colorValue(record, "username"),
795
+ time: colorValue(record, "time"),
796
+ os: colorValue(record, "os"),
797
+ editorAccent: colorValue(record, "editorAccent"),
798
+ editorRail: colorValue(record, "editorRail"),
799
+ editorPrompt: colorValue(record, "editorPrompt"),
800
+ editorBorder: colorValue(record, "editorBorder"),
801
+ editorGitBranch: colorValue(record, "editorGitBranch"),
802
+ editorModel: colorValue(record, "editorModel"),
803
+ editorProvider: colorValue(record, "editorProvider"),
804
+ editorThinking: colorValue(record, "editorThinking"),
805
+ editorThinkingMinimal: colorValue(record, "editorThinkingMinimal"),
806
+ editorThinkingLow: colorValue(record, "editorThinkingLow"),
807
+ editorThinkingMedium: colorValue(record, "editorThinkingMedium"),
808
+ editorThinkingHigh: colorValue(record, "editorThinkingHigh"),
809
+ editorThinkingXhigh: colorValue(record, "editorThinkingXhigh"),
810
+ editorThinkingMax: colorValue(record, "editorThinkingMax"),
811
+ workingLineLow: colorValue(record, "workingLineLow"),
812
+ workingLineMid: colorValue(record, "workingLineMid"),
813
+ workingLineHigh: colorValue(record, "workingLineHigh"),
814
+ });
815
+ }
816
+
817
+ /** Clamp hashLength to Git's valid abbreviation range [4, 40]. */
818
+ function normalizeGitHashLength(value: unknown): number {
819
+ const parsed = typeof value === "number" ? value : Number(value);
820
+ if (!Number.isFinite(parsed)) return defaultConfig.gitCommit.hashLength;
821
+ const rounded = Math.round(parsed);
822
+ return Math.min(40, Math.max(4, rounded));
823
+ }
824
+
825
+ function normalizeGitCommitConfig(
826
+ record: Record<string, unknown>,
827
+ ): GitCommitConfig {
828
+ return {
829
+ hashLength: normalizeGitHashLength(record.hashLength),
830
+ onlyDetached:
831
+ typeof record.onlyDetached === "boolean"
832
+ ? record.onlyDetached
833
+ : defaultConfig.gitCommit.onlyDetached,
834
+ showTag:
835
+ typeof record.showTag === "boolean"
836
+ ? record.showTag
837
+ : defaultConfig.gitCommit.showTag,
838
+ };
839
+ }
840
+
841
+ function normalizeGitMetricsConfig(
842
+ record: Record<string, unknown>,
843
+ ): GitMetricsConfig {
844
+ return {
845
+ onlyNonzero:
846
+ typeof record.onlyNonzero === "boolean"
847
+ ? record.onlyNonzero
848
+ : defaultConfig.gitMetrics.onlyNonzero,
849
+ ignoreSubmodules:
850
+ typeof record.ignoreSubmodules === "boolean"
851
+ ? record.ignoreSubmodules
852
+ : defaultConfig.gitMetrics.ignoreSubmodules,
853
+ };
854
+ }
855
+
856
+ export function isExtensionStatusPlacement(
857
+ value: unknown,
858
+ ): value is ExtensionStatusPlacement {
859
+ return (
860
+ value === "off" ||
861
+ value === "left" ||
862
+ value === "middle" ||
863
+ value === "right"
864
+ );
865
+ }
866
+
867
+ export function isExtensionStatusColorMode(
868
+ value: unknown,
869
+ ): value is ExtensionStatusColorMode {
870
+ return value === "zentui" || value === "original";
871
+ }
872
+
873
+ function normalizeExtensionStatuses(
874
+ record: Record<string, unknown>,
875
+ ): ExtensionStatusesConfig {
876
+ const defaultPlacement = isExtensionStatusPlacement(record.defaultPlacement)
877
+ ? record.defaultPlacement
878
+ : defaultConfig.extensionStatuses.defaultPlacement;
879
+ const placements = isRecord(record.placements)
880
+ ? Object.fromEntries(
881
+ Object.entries(record.placements).filter(
882
+ (entry): entry is [string, ExtensionStatusPlacement] =>
883
+ isExtensionStatusPlacement(entry[1]),
884
+ ),
885
+ )
886
+ : {};
887
+ const colorModes = isRecord(record.colorModes)
888
+ ? Object.fromEntries(
889
+ Object.entries(record.colorModes).filter(
890
+ (entry): entry is [string, ExtensionStatusColorMode] =>
891
+ isExtensionStatusColorMode(entry[1]),
892
+ ),
893
+ )
894
+ : {};
895
+
896
+ return {
897
+ defaultPlacement,
898
+ placements,
899
+ colorModes,
900
+ };
901
+ }
902
+
903
+ function isColorSourceKey(value: string): value is keyof ColorSourcesConfig {
904
+ return value === "starship" || value === "editor" || value === "userMessages";
905
+ }
906
+
907
+ function isUiFeatureKey(value: string): value is keyof UiFeaturesConfig {
908
+ return (
909
+ value === "editor" ||
910
+ value === "statusLine" ||
911
+ value === "viewportIndicators"
912
+ );
913
+ }
914
+
915
+ function isFooterSegmentKey(
916
+ value: string,
917
+ ): value is keyof FooterSegmentsConfig {
918
+ return (
919
+ value === "cwd" ||
920
+ value === "sessionName" ||
921
+ value === "gitBranch" ||
922
+ value === "gitStatus" ||
923
+ value === "gitCounts" ||
924
+ value === "runtime" ||
925
+ value === "modelInfo" ||
926
+ value === "context" ||
927
+ value === "tokens" ||
928
+ value === "cost" ||
929
+ value === "sessionDuration" ||
930
+ value === "username" ||
931
+ value === "time" ||
932
+ value === "os" ||
933
+ value === "packageVersion" ||
934
+ value === "gitCommit" ||
935
+ value === "gitMetrics"
936
+ );
937
+ }
938
+
939
+ function validColorSourceEntries(
940
+ record: Record<string, unknown>,
941
+ ): Partial<ColorSourcesConfig> {
942
+ return Object.fromEntries(
943
+ Object.entries(record).filter(
944
+ (entry): entry is [keyof ColorSourcesConfig, ColorSource] => {
945
+ const [key, value] = entry;
946
+ return (
947
+ isColorSourceKey(key) && (value === "theme" || value === "terminal")
948
+ );
949
+ },
950
+ ),
951
+ ) as Partial<ColorSourcesConfig>;
952
+ }
953
+
954
+ function validUiFeatureEntries(
955
+ record: Record<string, unknown>,
956
+ ): Partial<UiFeaturesConfig> {
957
+ return Object.fromEntries(
958
+ Object.entries(record).filter(
959
+ (entry): entry is [keyof UiFeaturesConfig, boolean] => {
960
+ const [key, value] = entry;
961
+ return isUiFeatureKey(key) && typeof value === "boolean";
962
+ },
963
+ ),
964
+ ) as Partial<UiFeaturesConfig>;
965
+ }
966
+
967
+ function validFooterSegmentEntries(
968
+ record: Record<string, unknown>,
969
+ ): Partial<FooterSegmentsConfig> {
970
+ return Object.fromEntries(
971
+ Object.entries(record).filter(
972
+ (entry): entry is [keyof FooterSegmentsConfig, boolean] => {
973
+ const [key, value] = entry;
974
+ return isFooterSegmentKey(key) && typeof value === "boolean";
975
+ },
976
+ ),
977
+ ) as Partial<FooterSegmentsConfig>;
978
+ }
979
+
980
+ type ConfigFileState =
981
+ | { kind: "missing"; record: ConfigRecord; writePath: string }
982
+ | { kind: "valid"; record: ConfigRecord; writePath: string; mode: number }
983
+ | { kind: "corrupt"; error: unknown };
984
+
985
+ function errorCode(error: unknown): string | undefined {
986
+ return typeof error === "object" && error !== null && "code" in error
987
+ ? String(error.code)
988
+ : undefined;
989
+ }
990
+
991
+ function readConfigFileState(path: string): ConfigFileState {
992
+ let writePath = path;
993
+ try {
994
+ const pathStat = lstatSync(path);
995
+ if (pathStat.isSymbolicLink()) writePath = realpathSync(path);
996
+ const targetStat = statSync(writePath);
997
+ const parsed = JSON.parse(readFileSync(writePath, "utf8"));
998
+ return isRecord(parsed)
999
+ ? {
1000
+ kind: "valid",
1001
+ record: parsed,
1002
+ writePath,
1003
+ mode: targetStat.mode & 0o7777,
1004
+ }
1005
+ : {
1006
+ kind: "corrupt",
1007
+ error: new Error("top-level value must be a JSON object"),
1008
+ };
1009
+ } catch (error) {
1010
+ if (errorCode(error) === "ENOENT") {
1011
+ try {
1012
+ lstatSync(path);
1013
+ } catch (pathError) {
1014
+ if (errorCode(pathError) === "ENOENT")
1015
+ return { kind: "missing", record: {}, writePath: path };
1016
+ }
1017
+ }
1018
+ return { kind: "corrupt", error };
1019
+ }
1020
+ }
1021
+
1022
+ function writeConfigAtomically(
1023
+ path: string,
1024
+ record: ConfigRecord,
1025
+ mode?: number,
1026
+ ): void {
1027
+ const tempPath = join(
1028
+ dirname(path),
1029
+ `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`,
1030
+ );
1031
+ let file: number | undefined;
1032
+ try {
1033
+ file = openSync(tempPath, "wx", mode ?? 0o666);
1034
+ if (mode !== undefined) fchmodSync(file, mode);
1035
+ writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`, "utf8");
1036
+ fsyncSync(file);
1037
+ closeSync(file);
1038
+ file = undefined;
1039
+ renameSync(tempPath, path);
1040
+ } catch (error) {
1041
+ if (file !== undefined) {
1042
+ try {
1043
+ closeSync(file);
1044
+ } catch {}
1045
+ }
1046
+ try {
1047
+ unlinkSync(tempPath);
1048
+ } catch (cleanupError) {
1049
+ if (errorCode(cleanupError) !== "ENOENT") {
1050
+ // Preserve the persistence failure; the best-effort cleanup error is secondary.
1051
+ }
1052
+ }
1053
+ throw error;
1054
+ }
1055
+ }
1056
+
1057
+ function mutateConfig(
1058
+ path: string,
1059
+ mutate: (record: ConfigRecord) => void,
1060
+ ): PolishedTuiConfig {
1061
+ const state = readConfigFileState(path);
1062
+ if (state.kind === "corrupt") {
1063
+ const detail =
1064
+ state.error instanceof Error ? ` (${state.error.message})` : "";
1065
+ throw new Error(
1066
+ `Refusing to save Zentui config because ${path} is corrupt or unreadable; fix or remove it first.${detail}`,
1067
+ );
1068
+ }
1069
+ mutate(state.record);
1070
+ writeConfigAtomically(
1071
+ state.writePath,
1072
+ state.record,
1073
+ state.kind === "valid" ? state.mode : undefined,
1074
+ );
1075
+ return mergeConfig(state.record);
1076
+ }
1077
+
1078
+ export function ensureConfigExists(_path = configPath): void {
1079
+ // Intentionally left as a no-op. Zentui config is user-owned and
1080
+ // compatibility-sensitive: runtime defaults come from `mergeConfig({})`, and
1081
+ // the extension should not persist opinionated defaults unless the user
1082
+ // explicitly changes a setting.
1083
+ }
1084
+
1085
+ function hasOwn(record: ConfigRecord, key: string): boolean {
1086
+ return Object.hasOwn(record, key);
1087
+ }
1088
+
1089
+ function recordValue(value: unknown): ConfigRecord {
1090
+ return isRecord(value) ? value : {};
1091
+ }
1092
+
1093
+ function resolvedValue(
1094
+ canonical: ConfigRecord,
1095
+ canonicalKey: string,
1096
+ legacy?: ConfigRecord,
1097
+ legacyKey = canonicalKey,
1098
+ ): unknown {
1099
+ if (hasOwn(canonical, canonicalKey)) return canonical[canonicalKey];
1100
+ if (legacy && hasOwn(legacy, legacyKey)) return legacy[legacyKey];
1101
+ return undefined;
1102
+ }
1103
+
1104
+ function parseBoolean(value: unknown, fallback: boolean): boolean {
1105
+ return typeof value === "boolean" ? value : fallback;
1106
+ }
1107
+
1108
+ function parseColorSource(value: unknown, fallback: ColorSource): ColorSource {
1109
+ return value === "theme" || value === "terminal" ? value : fallback;
1110
+ }
1111
+
1112
+ function parseSelectorBorderStyle(value: unknown): SelectorBorderStyle {
1113
+ return value === "zentui" ? value : "zentui";
1114
+ }
1115
+
1116
+ function parseFooterStyle(value: unknown): FooterStyle | undefined {
1117
+ return value === "native" || value === "starship" || value === "hidden"
1118
+ ? value
1119
+ : undefined;
1120
+ }
1121
+
1122
+ function parseNonEmptyString(value: unknown, fallback: string): string {
1123
+ return typeof value === "string" && value.length > 0 ? value : fallback;
1124
+ }
1125
+
1126
+ function resolveContextThresholds(
1127
+ canonical: unknown,
1128
+ legacy: unknown,
1129
+ defaults: ContextThresholds,
1130
+ ): ContextThresholds {
1131
+ const canonicalRecord = recordValue(canonical);
1132
+ const legacyRecord = recordValue(legacy);
1133
+ return parseContextThresholds(
1134
+ {
1135
+ warning: resolvedValue(canonicalRecord, "warning", legacyRecord),
1136
+ error: resolvedValue(canonicalRecord, "error", legacyRecord),
1137
+ },
1138
+ defaults,
1139
+ );
1140
+ }
1141
+
1142
+ function resolvePathDisplay(
1143
+ canonical: unknown,
1144
+ legacy: unknown,
1145
+ ): PathDisplayConfig {
1146
+ const canonicalRecord = recordValue(canonical);
1147
+ const legacyRecord = recordValue(legacy);
1148
+ return parsePathDisplay({
1149
+ mode: resolvedValue(canonicalRecord, "mode", legacyRecord),
1150
+ depth: resolvedValue(canonicalRecord, "depth", legacyRecord),
1151
+ });
1152
+ }
1153
+
1154
+ function resolveGitBranch(
1155
+ canonical: unknown,
1156
+ legacy: unknown,
1157
+ ): GitBranchConfig {
1158
+ const canonicalRecord = recordValue(canonical);
1159
+ const legacyRecord = recordValue(legacy);
1160
+ return parseGitBranchConfig({
1161
+ maxLength: resolvedValue(canonicalRecord, "maxLength", legacyRecord),
1162
+ });
1163
+ }
1164
+
1165
+ function resolveGitCommit(
1166
+ canonical: unknown,
1167
+ legacy: unknown,
1168
+ ): GitCommitConfig {
1169
+ const canonicalRecord = recordValue(canonical);
1170
+ const legacyRecord = recordValue(legacy);
1171
+ return normalizeGitCommitConfig({
1172
+ hashLength: resolvedValue(canonicalRecord, "hashLength", legacyRecord),
1173
+ onlyDetached: resolvedValue(canonicalRecord, "onlyDetached", legacyRecord),
1174
+ showTag: resolvedValue(canonicalRecord, "showTag", legacyRecord),
1175
+ });
1176
+ }
1177
+
1178
+ function resolveGitMetrics(
1179
+ canonical: unknown,
1180
+ legacy: unknown,
1181
+ ): GitMetricsConfig {
1182
+ const canonicalRecord = recordValue(canonical);
1183
+ const legacyRecord = recordValue(legacy);
1184
+ return normalizeGitMetricsConfig({
1185
+ onlyNonzero: resolvedValue(canonicalRecord, "onlyNonzero", legacyRecord),
1186
+ ignoreSubmodules: resolvedValue(
1187
+ canonicalRecord,
1188
+ "ignoreSubmodules",
1189
+ legacyRecord,
1190
+ ),
1191
+ });
1192
+ }
1193
+
1194
+ function resolveExtensionStatuses(
1195
+ canonical: unknown,
1196
+ legacy: unknown,
1197
+ ): ExtensionStatusesConfig {
1198
+ const canonicalRecord = recordValue(canonical);
1199
+ const legacyRecord = recordValue(legacy);
1200
+ return normalizeExtensionStatuses({
1201
+ defaultPlacement: resolvedValue(
1202
+ canonicalRecord,
1203
+ "defaultPlacement",
1204
+ legacyRecord,
1205
+ ),
1206
+ placements: resolvedValue(canonicalRecord, "placements", legacyRecord),
1207
+ colorModes: resolvedValue(canonicalRecord, "colorModes", legacyRecord),
1208
+ });
1209
+ }
1210
+
1211
+ const FOOTER_SEGMENT_KEYS = Object.keys(defaultFooterSegments) as Array<
1212
+ keyof FooterSegmentsConfig
1213
+ >;
1214
+
1215
+ function resolveFooterSegments(
1216
+ canonical: unknown,
1217
+ legacy: unknown,
1218
+ ): FooterSegmentsConfig {
1219
+ const canonicalRecord = recordValue(canonical);
1220
+ const legacyRecord = recordValue(legacy);
1221
+ return Object.fromEntries(
1222
+ FOOTER_SEGMENT_KEYS.map((key) => [
1223
+ key,
1224
+ parseBoolean(
1225
+ resolvedValue(canonicalRecord, key, legacyRecord),
1226
+ defaultFooterSegments[key],
1227
+ ),
1228
+ ]),
1229
+ ) as FooterSegmentsConfig;
1230
+ }
1231
+
1232
+ function legacyCopyFriendly(record: ConfigRecord): boolean {
1233
+ return record.copyFriendly === true;
1234
+ }
1235
+
1236
+ function resolveEditorStyle(
1237
+ editor: ConfigRecord,
1238
+ polished: ConfigRecord,
1239
+ features: ConfigRecord,
1240
+ ): EditorStyle {
1241
+ const rawStyle = editor.style;
1242
+ if (
1243
+ rawStyle === "opencode" ||
1244
+ rawStyle === "opencode-copy-friendly" ||
1245
+ rawStyle === "accent-rail" ||
1246
+ rawStyle === "minimalist"
1247
+ ) {
1248
+ return rawStyle;
1249
+ }
1250
+ if (rawStyle === "polished-copy-friendly") return "opencode-copy-friendly";
1251
+ if (rawStyle === "polished") {
1252
+ return hasOwn(polished, "copyFriendly") && legacyCopyFriendly(polished)
1253
+ ? "opencode-copy-friendly"
1254
+ : "opencode";
1255
+ }
1256
+ if (hasOwn(polished, "copyFriendly")) {
1257
+ return legacyCopyFriendly(polished) ? "opencode-copy-friendly" : "opencode";
1258
+ }
1259
+ return legacyCopyFriendly(features) ? "opencode-copy-friendly" : "opencode";
1260
+ }
1261
+
1262
+ function resolveFooterStyle(
1263
+ footer: ConfigRecord,
1264
+ features: ConfigRecord,
1265
+ ): FooterStyle {
1266
+ const explicit = parseFooterStyle(footer.style);
1267
+ if (explicit) return explicit;
1268
+ if (typeof footer.enabled === "boolean")
1269
+ return footer.enabled ? "starship" : "native";
1270
+ if (typeof features.statusLine === "boolean") {
1271
+ return features.statusLine ? "starship" : "native";
1272
+ }
1273
+ return defaultComponents.footer.style;
1274
+ }
1275
+
1276
+ function resolveUserMessagesSelection(
1277
+ userMessages: ConfigRecord,
1278
+ framed: ConfigRecord,
1279
+ features: ConfigRecord,
1280
+ ): Pick<UserMessagesComponentConfig, "enabled" | "style"> {
1281
+ const normalEnabled = parseBoolean(
1282
+ resolvedValue(userMessages, "enabled", features, "editor"),
1283
+ defaultComponents.userMessages.enabled,
1284
+ );
1285
+ const rawStyle = userMessages.style;
1286
+ if (
1287
+ rawStyle === "compact" ||
1288
+ rawStyle === "labeled" ||
1289
+ rawStyle === "framed-copy-friendly"
1290
+ ) {
1291
+ return { style: rawStyle, enabled: normalEnabled };
1292
+ }
1293
+ if (rawStyle === "framed" || hasOwn(framed, "copyFriendly")) {
1294
+ return {
1295
+ style: legacyCopyFriendly(framed) ? "framed-copy-friendly" : "framed",
1296
+ enabled: normalEnabled,
1297
+ };
1298
+ }
1299
+ return {
1300
+ style: legacyCopyFriendly(features) ? "framed-copy-friendly" : "framed",
1301
+ enabled: normalEnabled,
1302
+ };
1303
+ }
1304
+
1305
+ function resolveWorkingLineMessages(
1306
+ messages: ConfigRecord,
1307
+ ): WorkingLineMessagesConfig {
1308
+ const preset = () => [...PI_WORKING_LINE_MESSAGES];
1309
+ const hasCanonicalCustom = hasOwn(messages, "custom");
1310
+ const custom = hasCanonicalCustom
1311
+ ? parseBoolean(messages.custom, true)
1312
+ : messages.mode !== "native";
1313
+ if (hasCanonicalCustom) {
1314
+ return {
1315
+ custom,
1316
+ values: hasOwn(messages, "values")
1317
+ ? normalizeWorkingLineMessages(messages.values)
1318
+ : preset(),
1319
+ };
1320
+ }
1321
+ const legacyValues = normalizeWorkingLineMessages(messages.values);
1322
+ if (messages.mode === "append") {
1323
+ return {
1324
+ custom,
1325
+ values: normalizeWorkingLineMessages([...preset(), ...legacyValues]),
1326
+ };
1327
+ }
1328
+ if (messages.mode === "replace" || messages.mode === "native") {
1329
+ return {
1330
+ custom,
1331
+ values: legacyValues.length > 0 ? legacyValues : preset(),
1332
+ };
1333
+ }
1334
+ return {
1335
+ custom,
1336
+ values: hasOwn(messages, "values") ? legacyValues : preset(),
1337
+ };
1338
+ }
1339
+
1340
+ function resolveComponents(config: ConfigRecord): ComponentsConfig {
1341
+ const components = recordValue(config.components);
1342
+ const editor = recordValue(components.editor);
1343
+ const editorStyles = recordValue(editor.styles);
1344
+ const opencode = recordValue(editorStyles.opencode);
1345
+ const polished = recordValue(editorStyles.polished);
1346
+ const opencodeCopyFriendly = recordValue(
1347
+ editorStyles["opencode-copy-friendly"],
1348
+ );
1349
+ const polishedCopyFriendly = recordValue(
1350
+ editorStyles["polished-copy-friendly"],
1351
+ );
1352
+ const accentRail = recordValue(editorStyles["accent-rail"]);
1353
+ const minimalist = recordValue(editorStyles.minimalist);
1354
+ const userMessages = recordValue(components.userMessages);
1355
+ const userMessageStyles = recordValue(userMessages.styles);
1356
+ const framed = recordValue(userMessageStyles.framed);
1357
+ const workingLine = recordValue(components.workingLine);
1358
+ const workingLineMessages = recordValue(workingLine.messages);
1359
+ const workingLineSegments = recordValue(workingLine.segments);
1360
+ const selectorBorders = recordValue(components.selectorBorders);
1361
+ const footer = recordValue(components.footer);
1362
+ const footerStyles = recordValue(footer.styles);
1363
+ const starship = recordValue(footerStyles.starship);
1364
+ const features = recordValue(config.features);
1365
+ const colorSources = recordValue(config.colorSources);
1366
+
1367
+ const minimalistThresholds = resolveContextThresholds(
1368
+ minimalist.contextThresholds,
1369
+ config.contextThresholds,
1370
+ defaultMinimalistStyle.contextThresholds,
1371
+ );
1372
+ const footerThresholds = resolveContextThresholds(
1373
+ starship.contextThresholds,
1374
+ config.contextThresholds,
1375
+ defaultStarshipStyle.contextThresholds,
1376
+ );
1377
+ const compactFormat = resolvedValue(
1378
+ starship,
1379
+ "compactFormat",
1380
+ config,
1381
+ "compactFooterFormat",
1382
+ );
1383
+ const metadataFormat = hasOwn(opencode, "metadataFormat")
1384
+ ? opencode.metadataFormat
1385
+ : resolvedValue(polished, "metadataFormat", config, "editorMetadataFormat");
1386
+ const lowRailMetadataFormat = hasOwn(opencodeCopyFriendly, "metadataFormat")
1387
+ ? opencodeCopyFriendly.metadataFormat
1388
+ : hasOwn(polishedCopyFriendly, "metadataFormat")
1389
+ ? polishedCopyFriendly.metadataFormat
1390
+ : metadataFormat;
1391
+ const userMessagesSelection = resolveUserMessagesSelection(
1392
+ userMessages,
1393
+ framed,
1394
+ features,
1395
+ );
1396
+
1397
+ return {
1398
+ editor: {
1399
+ enabled: parseBoolean(
1400
+ resolvedValue(editor, "enabled", features, "editor"),
1401
+ defaultComponents.editor.enabled,
1402
+ ),
1403
+ style: resolveEditorStyle(editor, polished, features),
1404
+ colorSource: parseColorSource(
1405
+ resolvedValue(editor, "colorSource", colorSources, "editor"),
1406
+ defaultComponents.editor.colorSource,
1407
+ ),
1408
+ borderColorMode: parseEditorBorderColorMode(
1409
+ resolvedValue(
1410
+ editor,
1411
+ "borderColorMode",
1412
+ config,
1413
+ "editorBorderColorMode",
1414
+ ),
1415
+ ),
1416
+ modelLabel: parseEditorModelLabel(
1417
+ resolvedValue(editor, "modelLabel", config, "editorModelLabel"),
1418
+ defaultComponents.editor.modelLabel,
1419
+ ),
1420
+ viewportIndicators: parseBoolean(
1421
+ resolvedValue(
1422
+ editor,
1423
+ "viewportIndicators",
1424
+ features,
1425
+ "viewportIndicators",
1426
+ ),
1427
+ defaultComponents.editor.viewportIndicators,
1428
+ ),
1429
+ styles: {
1430
+ opencode: {
1431
+ metadataFormat: parseNonEmptyString(
1432
+ metadataFormat,
1433
+ DEFAULT_EDITOR_METADATA_FORMAT,
1434
+ ),
1435
+ completionMenu: parseCompletionMenuStyle(opencode.completionMenu),
1436
+ },
1437
+ "opencode-copy-friendly": {
1438
+ metadataFormat: parseNonEmptyString(
1439
+ lowRailMetadataFormat,
1440
+ DEFAULT_EDITOR_METADATA_FORMAT,
1441
+ ),
1442
+ completionMenu: parseCompletionMenuStyle(
1443
+ opencodeCopyFriendly.completionMenu,
1444
+ ),
1445
+ },
1446
+ "accent-rail": {
1447
+ rail: parseNonEmptyString(
1448
+ accentRail.rail,
1449
+ defaultAccentRailStyle.rail,
1450
+ ),
1451
+ asciiRail: parseNonEmptyString(
1452
+ accentRail.asciiRail,
1453
+ defaultAccentRailStyle.asciiRail,
1454
+ ),
1455
+ transparent: parseBoolean(
1456
+ accentRail.transparent,
1457
+ defaultAccentRailStyle.transparent,
1458
+ ),
1459
+ },
1460
+ minimalist: {
1461
+ pathDisplay:
1462
+ minimalist.pathDisplay === "compact" ||
1463
+ minimalist.pathDisplay === "project" ||
1464
+ minimalist.pathDisplay === "full"
1465
+ ? minimalist.pathDisplay
1466
+ : defaultMinimalistStyle.pathDisplay,
1467
+ contextFormat:
1468
+ minimalist.contextFormat === "percent" ||
1469
+ minimalist.contextFormat === "percent-total"
1470
+ ? minimalist.contextFormat
1471
+ : defaultMinimalistStyle.contextFormat,
1472
+ contextGauge: parseBoolean(
1473
+ minimalist.contextGauge,
1474
+ defaultMinimalistStyle.contextGauge,
1475
+ ),
1476
+ showSessionName: parseBoolean(
1477
+ minimalist.showSessionName,
1478
+ defaultMinimalistStyle.showSessionName,
1479
+ ),
1480
+ showTimer: parseBoolean(
1481
+ minimalist.showTimer,
1482
+ defaultMinimalistStyle.showTimer,
1483
+ ),
1484
+ showCost: parseBoolean(
1485
+ minimalist.showCost,
1486
+ defaultMinimalistStyle.showCost,
1487
+ ),
1488
+ showGit: parseBoolean(
1489
+ minimalist.showGit,
1490
+ defaultMinimalistStyle.showGit,
1491
+ ),
1492
+ contextThresholds: minimalistThresholds,
1493
+ },
1494
+ },
1495
+ },
1496
+ userMessages: {
1497
+ enabled: userMessagesSelection.enabled,
1498
+ style: userMessagesSelection.style,
1499
+ colorSource: parseColorSource(
1500
+ resolvedValue(
1501
+ userMessages,
1502
+ "colorSource",
1503
+ colorSources,
1504
+ "userMessages",
1505
+ ),
1506
+ defaultComponents.userMessages.colorSource,
1507
+ ),
1508
+ styles: {
1509
+ framed: {},
1510
+ "framed-copy-friendly": {},
1511
+ compact: {},
1512
+ labeled: {},
1513
+ },
1514
+ },
1515
+ workingLine: {
1516
+ enabled: parseBoolean(
1517
+ workingLine.enabled,
1518
+ defaultComponents.workingLine.enabled,
1519
+ ),
1520
+ turnSummary: parseBoolean(
1521
+ workingLine.turnSummary,
1522
+ defaultComponents.workingLine.turnSummary,
1523
+ ),
1524
+ spinner:
1525
+ workingLine.spinner === "braille" ||
1526
+ workingLine.spinner === "star-bloom" ||
1527
+ workingLine.spinner === "pinwheel" ||
1528
+ workingLine.spinner === "claude-inspired" ||
1529
+ workingLine.spinner === "pulse"
1530
+ ? workingLine.spinner
1531
+ : defaultComponents.workingLine.spinner,
1532
+ spinnerIntervalMs: isValidWorkingLineIntervalMs(
1533
+ resolvedValue(
1534
+ workingLine,
1535
+ "spinnerIntervalMs",
1536
+ workingLine,
1537
+ "intervalMs",
1538
+ ),
1539
+ )
1540
+ ? (resolvedValue(
1541
+ workingLine,
1542
+ "spinnerIntervalMs",
1543
+ workingLine,
1544
+ "intervalMs",
1545
+ ) as number)
1546
+ : defaultComponents.workingLine.spinnerIntervalMs,
1547
+ animateSpinnerColor: parseBoolean(
1548
+ workingLine.animateSpinnerColor,
1549
+ defaultComponents.workingLine.animateSpinnerColor,
1550
+ ),
1551
+ textIntervalMs: isValidWorkingLineIntervalMs(workingLine.textIntervalMs)
1552
+ ? workingLine.textIntervalMs
1553
+ : defaultComponents.workingLine.textIntervalMs,
1554
+ textAnimation:
1555
+ workingLine.textAnimation === "classic" ||
1556
+ workingLine.textAnimation === "kitt" ||
1557
+ workingLine.textAnimation === "disabled"
1558
+ ? workingLine.textAnimation
1559
+ : defaultComponents.workingLine.textAnimation,
1560
+ colorSource: parseColorSource(
1561
+ workingLine.colorSource,
1562
+ defaultComponents.workingLine.colorSource,
1563
+ ),
1564
+ messages: resolveWorkingLineMessages(workingLineMessages),
1565
+ segments: {
1566
+ tool: parseBoolean(
1567
+ workingLineSegments.tool,
1568
+ defaultComponents.workingLine.segments.tool,
1569
+ ),
1570
+ elapsed: parseBoolean(
1571
+ workingLineSegments.elapsed,
1572
+ defaultComponents.workingLine.segments.elapsed,
1573
+ ),
1574
+ thought: parseBoolean(
1575
+ workingLineSegments.thought,
1576
+ defaultComponents.workingLine.segments.thought,
1577
+ ),
1578
+ tokens: parseBoolean(
1579
+ workingLineSegments.tokens,
1580
+ defaultComponents.workingLine.segments.tokens,
1581
+ ),
1582
+ },
1583
+ },
1584
+ selectorBorders: {
1585
+ enabled: parseBoolean(
1586
+ resolvedValue(selectorBorders, "enabled", features, "editor"),
1587
+ defaultComponents.selectorBorders.enabled,
1588
+ ),
1589
+ style: parseSelectorBorderStyle(resolvedValue(selectorBorders, "style")),
1590
+ colorSource: parseColorSource(
1591
+ resolvedValue(selectorBorders, "colorSource", colorSources, "editor"),
1592
+ defaultComponents.selectorBorders.colorSource,
1593
+ ),
1594
+ },
1595
+ footer: {
1596
+ style: resolveFooterStyle(footer, features),
1597
+ colorSource: parseColorSource(
1598
+ resolvedValue(footer, "colorSource", colorSources, "starship"),
1599
+ defaultComponents.footer.colorSource,
1600
+ ),
1601
+ modelLabel: parseEditorModelLabel(
1602
+ resolvedValue(footer, "modelLabel", config, "editorModelLabel"),
1603
+ defaultComponents.footer.modelLabel,
1604
+ ),
1605
+ styles: {
1606
+ starship: {
1607
+ format:
1608
+ typeof resolvedValue(starship, "format", config, "footerFormat") ===
1609
+ "string"
1610
+ ? (resolvedValue(
1611
+ starship,
1612
+ "format",
1613
+ config,
1614
+ "footerFormat",
1615
+ ) as string)
1616
+ : defaultStarshipStyle.format,
1617
+ responsive: parseBoolean(
1618
+ resolvedValue(starship, "responsive", config, "responsiveFooter"),
1619
+ defaultStarshipStyle.responsive,
1620
+ ),
1621
+ compactFormat: parseNonEmptyString(
1622
+ compactFormat,
1623
+ defaultStarshipStyle.compactFormat,
1624
+ ),
1625
+ compactMaxLines: parseCompactFooterMaxLines(
1626
+ resolvedValue(
1627
+ starship,
1628
+ "compactMaxLines",
1629
+ config,
1630
+ "compactFooterMaxLines",
1631
+ ),
1632
+ ),
1633
+ separator: parseSeparatorStyle(
1634
+ resolvedValue(starship, "separator", config, "separator"),
1635
+ ),
1636
+ contextStyle: parseContextStyle(
1637
+ resolvedValue(starship, "contextStyle", config, "contextStyle"),
1638
+ ),
1639
+ contextThresholds: footerThresholds,
1640
+ pathDisplay: resolvePathDisplay(
1641
+ starship.pathDisplay,
1642
+ config.pathDisplay,
1643
+ ),
1644
+ segments: resolveFooterSegments(
1645
+ starship.segments,
1646
+ config.footerSegments,
1647
+ ),
1648
+ gitBranch: resolveGitBranch(starship.gitBranch, config.gitBranch),
1649
+ gitCommit: resolveGitCommit(starship.gitCommit, config.gitCommit),
1650
+ gitMetrics: resolveGitMetrics(starship.gitMetrics, config.gitMetrics),
1651
+ extensionStatuses: resolveExtensionStatuses(
1652
+ starship.extensionStatuses,
1653
+ config.extensionStatuses,
1654
+ ),
1655
+ },
1656
+ },
1657
+ },
1658
+ };
1659
+ }
1660
+
1661
+ function compatibilityView(config: ZentuiConfig): PolishedTuiConfig {
1662
+ const starship = config.components.footer.styles.starship;
1663
+ const minimalist = config.components.editor.styles.minimalist;
1664
+ return {
1665
+ ...config,
1666
+ footerFormat: starship.format,
1667
+ responsiveFooter: starship.responsive,
1668
+ compactFooterFormat: starship.compactFormat,
1669
+ compactFooterMaxLines: starship.compactMaxLines,
1670
+ separator: starship.separator,
1671
+ contextStyle: starship.contextStyle,
1672
+ contextThresholds: starship.contextThresholds,
1673
+ pathDisplay: starship.pathDisplay,
1674
+ gitBranch: starship.gitBranch,
1675
+ footerSegments: starship.segments,
1676
+ gitCommit: starship.gitCommit,
1677
+ gitMetrics: starship.gitMetrics,
1678
+ extensionStatuses: starship.extensionStatuses,
1679
+ editorStyle: config.components.editor.style,
1680
+ editorStyles: { minimalist },
1681
+ editorMetadataFormat:
1682
+ config.components.editor.styles.opencode.metadataFormat,
1683
+ editorBorderColorMode: config.components.editor.borderColorMode,
1684
+ editorModelLabel: config.components.editor.modelLabel,
1685
+ colorSources: {
1686
+ starship: config.components.footer.colorSource,
1687
+ editor: config.components.editor.colorSource,
1688
+ userMessages: config.components.userMessages.colorSource,
1689
+ },
1690
+ features: {
1691
+ editor: config.components.editor.enabled,
1692
+ statusLine: config.components.footer.style === "starship",
1693
+ viewportIndicators: config.components.editor.viewportIndicators,
1694
+ },
1695
+ };
1696
+ }
1697
+
1698
+ const unsupportedComponentStyles = new WeakMap<
1699
+ ZentuiConfig,
1700
+ ReadonlySet<ComponentStyleOwner>
1701
+ >();
1702
+
1703
+ const knownComponentStyleIds: Record<
1704
+ ComponentStyleOwner,
1705
+ ReadonlySet<string>
1706
+ > = {
1707
+ editor: new Set([
1708
+ "opencode",
1709
+ "opencode-copy-friendly",
1710
+ "accent-rail",
1711
+ "minimalist",
1712
+ "polished",
1713
+ "polished-copy-friendly",
1714
+ ]),
1715
+ userMessages: new Set([
1716
+ "framed",
1717
+ "framed-copy-friendly",
1718
+ "compact",
1719
+ "labeled",
1720
+ ]),
1721
+ selectorBorders: new Set(["zentui"]),
1722
+ footer: new Set(["native", "starship", "hidden"]),
1723
+ };
1724
+
1725
+ function unsupportedSelectedStyleId(
1726
+ record: ConfigRecord,
1727
+ owner: ComponentStyleOwner,
1728
+ ): string | undefined {
1729
+ const component = recordValue(recordValue(record.components)[owner]);
1730
+ if (!hasOwn(component, "style")) return undefined;
1731
+ const style = component.style;
1732
+ return typeof style === "string" &&
1733
+ style.trim() &&
1734
+ !knownComponentStyleIds[owner].has(style)
1735
+ ? style
1736
+ : undefined;
1737
+ }
1738
+
1739
+ export function hasUnsupportedComponentStyle(
1740
+ config: ZentuiConfig,
1741
+ owner: ComponentStyleOwner,
1742
+ ): boolean {
1743
+ return unsupportedComponentStyles.get(config)?.has(owner) ?? false;
1744
+ }
1745
+
1746
+ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
1747
+ const config = isRecord(parsed) ? parsed : {};
1748
+ const iconsRecord = recordValue(config.icons);
1749
+ const colorsRecord = recordValue(config.colors);
1750
+ const colors = normalizeColors(colorsRecord);
1751
+ const canonical: ZentuiConfig = {
1752
+ projectRefreshIntervalMs: parseProjectRefreshIntervalMs(
1753
+ config.projectRefreshIntervalMs,
1754
+ ),
1755
+ icons: resolveConfiguredIcons(
1756
+ normalizeIconMode(iconsRecord.mode),
1757
+ normalizeIconOverrides(iconsRecord),
1758
+ ),
1759
+ colors: {
1760
+ ...defaultConfig.colors,
1761
+ ...colors,
1762
+ ...(colors.editorGitBranch === undefined && colors.gitBranch !== undefined
1763
+ ? { editorGitBranch: colors.gitBranch }
1764
+ : {}),
1765
+ },
1766
+ components: resolveComponents(config),
1767
+ };
1768
+ const view = compatibilityView(canonical);
1769
+ const unsupported = new Set<ComponentStyleOwner>();
1770
+ for (const owner of [
1771
+ "editor",
1772
+ "userMessages",
1773
+ "selectorBorders",
1774
+ "footer",
1775
+ ] as const) {
1776
+ if (unsupportedSelectedStyleId(config, owner) !== undefined)
1777
+ unsupported.add(owner);
1778
+ }
1779
+ if (unsupported.size > 0) unsupportedComponentStyles.set(view, unsupported);
1780
+ return view;
1781
+ }
1782
+
1783
+ export function getExtensionStatusPlacement(
1784
+ config: ZentuiConfig,
1785
+ key: string,
1786
+ ): ExtensionStatusPlacement {
1787
+ const statuses = config.components.footer.styles.starship.extensionStatuses;
1788
+ if (Object.hasOwn(statuses.placements, key)) {
1789
+ const placement = statuses.placements[key];
1790
+ if (isExtensionStatusPlacement(placement)) return placement;
1791
+ }
1792
+ return isExtensionStatusPlacement(statuses.defaultPlacement)
1793
+ ? statuses.defaultPlacement
1794
+ : DEFAULT_EXTENSION_STATUS_PLACEMENT;
1795
+ }
1796
+
1797
+ export function getExtensionStatusColorMode(
1798
+ config: ZentuiConfig,
1799
+ key: string,
1800
+ ): ExtensionStatusColorMode {
1801
+ const colorModes =
1802
+ config.components.footer.styles.starship.extensionStatuses.colorModes;
1803
+ if (Object.hasOwn(colorModes, key)) {
1804
+ const colorMode = colorModes[key];
1805
+ if (isExtensionStatusColorMode(colorMode)) return colorMode;
1806
+ }
1807
+ return DEFAULT_EXTENSION_STATUS_COLOR_MODE;
1808
+ }
1809
+
1810
+ export function loadConfig(): PolishedTuiConfig {
1811
+ try {
1812
+ let raw: string | undefined;
1813
+ if (existsSync(configPath)) {
1814
+ raw = readFileSync(configPath, "utf8");
1815
+ } else if (existsSync(legacyConfigPath)) {
1816
+ raw = readFileSync(legacyConfigPath, "utf8");
1817
+ try {
1818
+ writeFileSync(configPath, raw);
1819
+ } catch {
1820
+ // Keep using the legacy file in memory if migration cannot be written.
1821
+ }
1822
+ } else if (existsSync(legacyZentuiConfigPath)) {
1823
+ raw = readFileSync(legacyZentuiConfigPath, "utf8");
1824
+ try {
1825
+ writeFileSync(configPath, raw);
1826
+ } catch {
1827
+ // Keep using the legacy file in memory if migration cannot be written.
1828
+ }
1829
+ }
1830
+ return mergeConfig(raw ? JSON.parse(raw) : {});
1831
+ } catch {
1832
+ return mergeConfig({});
1833
+ }
1834
+ }
1835
+
1836
+ function overlayKnown(raw: unknown, known: unknown): unknown {
1837
+ if (!isRecord(known)) return known;
1838
+ const output: ConfigRecord = isRecord(raw) ? { ...raw } : {};
1839
+ for (const [key, value] of Object.entries(known)) {
1840
+ Object.defineProperty(output, key, {
1841
+ value: overlayKnown(output[key], value),
1842
+ enumerable: true,
1843
+ configurable: true,
1844
+ writable: true,
1845
+ });
1846
+ }
1847
+ return output;
1848
+ }
1849
+
1850
+ type PreservedStyleIds = Partial<Record<ComponentStyleOwner, string>>;
1851
+
1852
+ function unknownSelectedStyleIds(record: ConfigRecord): PreservedStyleIds {
1853
+ return {
1854
+ editor: unsupportedSelectedStyleId(record, "editor"),
1855
+ userMessages: unsupportedSelectedStyleId(record, "userMessages"),
1856
+ selectorBorders: unsupportedSelectedStyleId(record, "selectorBorders"),
1857
+ footer: unsupportedSelectedStyleId(record, "footer"),
1858
+ };
1859
+ }
1860
+
1861
+ function restoreUnknownSelectedStyleIds(
1862
+ record: ConfigRecord,
1863
+ preserved: PreservedStyleIds,
1864
+ replacedStyle?: ComponentStyleOwner,
1865
+ ): void {
1866
+ const components = recordValue(record.components);
1867
+ for (const [owner, style] of Object.entries(preserved) as [
1868
+ ComponentStyleOwner,
1869
+ string,
1870
+ ][]) {
1871
+ if (style === undefined || owner === replacedStyle) continue;
1872
+ const component = recordValue(components[owner]);
1873
+ component.style = style;
1874
+ components[owner] = component;
1875
+ }
1876
+ record.components = components;
1877
+ }
1878
+
1879
+ function saveComponentsMutation(
1880
+ update: (components: ComponentsConfig) => void,
1881
+ path: string,
1882
+ cleanupRaw?: (record: ConfigRecord) => void,
1883
+ replacedStyle?: ComponentStyleOwner,
1884
+ ): PolishedTuiConfig {
1885
+ return mutateConfig(path, (record) => {
1886
+ const preservedStyles = unknownSelectedStyleIds(record);
1887
+ const components = mergeConfig(record).components;
1888
+ update(components);
1889
+ const normalized = resolveComponents({ components });
1890
+ record.components = overlayKnown(record.components, normalized);
1891
+ restoreUnknownSelectedStyleIds(record, preservedStyles, replacedStyle);
1892
+ cleanupRaw?.(record);
1893
+ });
1894
+ }
1895
+
1896
+ function deleteLegacyEditorCopyFriendly(record: ConfigRecord): void {
1897
+ const components = record.components;
1898
+ if (!isRecord(components)) return;
1899
+ const editor = components.editor;
1900
+ if (!isRecord(editor)) return;
1901
+ const styles = editor.styles;
1902
+ if (!isRecord(styles)) return;
1903
+ const polished = styles.polished;
1904
+ if (isRecord(polished)) delete polished.copyFriendly;
1905
+ }
1906
+
1907
+ function deleteLegacyFooterEnabled(record: ConfigRecord): void {
1908
+ const components = record.components;
1909
+ if (!isRecord(components)) return;
1910
+ const footer = components.footer;
1911
+ if (isRecord(footer)) delete footer.enabled;
1912
+ }
1913
+
1914
+ function deleteLegacyMessageCopyFriendly(record: ConfigRecord): void {
1915
+ const components = record.components;
1916
+ if (!isRecord(components)) return;
1917
+ const userMessages = components.userMessages;
1918
+ if (!isRecord(userMessages)) return;
1919
+ const styles = userMessages.styles;
1920
+ if (!isRecord(styles)) return;
1921
+ const framed = styles.framed;
1922
+ if (isRecord(framed)) delete framed.copyFriendly;
1923
+ }
1924
+
1925
+ function applyEditorComponentPatch(
1926
+ component: EditorComponentConfig,
1927
+ patch: Partial<
1928
+ Pick<
1929
+ EditorComponentConfig,
1930
+ | "enabled"
1931
+ | "style"
1932
+ | "colorSource"
1933
+ | "borderColorMode"
1934
+ | "modelLabel"
1935
+ | "viewportIndicators"
1936
+ >
1937
+ >,
1938
+ ): void {
1939
+ if (patch.enabled !== undefined) component.enabled = patch.enabled;
1940
+ if (patch.style !== undefined) component.style = patch.style;
1941
+ if (patch.colorSource !== undefined)
1942
+ component.colorSource = patch.colorSource;
1943
+ if (patch.borderColorMode !== undefined)
1944
+ component.borderColorMode = patch.borderColorMode;
1945
+ if (patch.modelLabel !== undefined) component.modelLabel = patch.modelLabel;
1946
+ if (patch.viewportIndicators !== undefined) {
1947
+ component.viewportIndicators = patch.viewportIndicators;
1948
+ }
1949
+ }
1950
+
1951
+ export function saveEditorComponentPatch(
1952
+ patch: Partial<
1953
+ Pick<
1954
+ EditorComponentConfig,
1955
+ | "enabled"
1956
+ | "style"
1957
+ | "colorSource"
1958
+ | "borderColorMode"
1959
+ | "modelLabel"
1960
+ | "viewportIndicators"
1961
+ >
1962
+ >,
1963
+ path = configPath,
1964
+ ): PolishedTuiConfig {
1965
+ return saveComponentsMutation(
1966
+ (components) => applyEditorComponentPatch(components.editor, patch),
1967
+ path,
1968
+ patch.style !== undefined ? deleteLegacyEditorCopyFriendly : undefined,
1969
+ patch.style !== undefined ? "editor" : undefined,
1970
+ );
1971
+ }
1972
+
1973
+ export function savePolishedEditorStylePatch(
1974
+ patch: Partial<PolishedEditorStyleConfig>,
1975
+ path = configPath,
1976
+ ): PolishedTuiConfig {
1977
+ return saveComponentsMutation((components) => {
1978
+ const style = components.editor.styles.opencode;
1979
+ if (patch.metadataFormat !== undefined)
1980
+ style.metadataFormat = patch.metadataFormat;
1981
+ if (patch.completionMenu !== undefined)
1982
+ style.completionMenu = patch.completionMenu;
1983
+ }, path);
1984
+ }
1985
+
1986
+ export function savePolishedCopyFriendlyEditorStylePatch(
1987
+ patch: Partial<PolishedCopyFriendlyEditorStyleConfig>,
1988
+ path = configPath,
1989
+ ): PolishedTuiConfig {
1990
+ return saveComponentsMutation((components) => {
1991
+ const style = components.editor.styles["opencode-copy-friendly"];
1992
+ if (patch.metadataFormat !== undefined)
1993
+ style.metadataFormat = patch.metadataFormat;
1994
+ if (patch.completionMenu !== undefined)
1995
+ style.completionMenu = patch.completionMenu;
1996
+ }, path);
1997
+ }
1998
+
1999
+ export function saveAccentRailEditorStylePatch(
2000
+ patch: Partial<AccentRailEditorStyleConfig>,
2001
+ path = configPath,
2002
+ ): PolishedTuiConfig {
2003
+ return saveComponentsMutation((components) => {
2004
+ const style = components.editor.styles["accent-rail"];
2005
+ if (patch.rail !== undefined) style.rail = patch.rail;
2006
+ if (patch.asciiRail !== undefined) style.asciiRail = patch.asciiRail;
2007
+ if (patch.transparent !== undefined) style.transparent = patch.transparent;
2008
+ }, path);
2009
+ }
2010
+
2011
+ function applyMinimalistStylePatch(
2012
+ style: MinimalistEditorStyleConfig,
2013
+ patch: Partial<MinimalistEditorStyleConfig>,
2014
+ ): void {
2015
+ if (patch.pathDisplay !== undefined) style.pathDisplay = patch.pathDisplay;
2016
+ if (patch.contextFormat !== undefined)
2017
+ style.contextFormat = patch.contextFormat;
2018
+ if (patch.contextGauge !== undefined) style.contextGauge = patch.contextGauge;
2019
+ if (patch.showSessionName !== undefined)
2020
+ style.showSessionName = patch.showSessionName;
2021
+ if (patch.showTimer !== undefined) style.showTimer = patch.showTimer;
2022
+ if (patch.showCost !== undefined) style.showCost = patch.showCost;
2023
+ if (patch.showGit !== undefined) style.showGit = patch.showGit;
2024
+ if (patch.contextThresholds !== undefined) {
2025
+ style.contextThresholds = {
2026
+ ...style.contextThresholds,
2027
+ ...patch.contextThresholds,
2028
+ };
2029
+ }
2030
+ }
2031
+
2032
+ export function saveMinimalistEditorStylePatch(
2033
+ patch: Partial<MinimalistEditorStyleConfig>,
2034
+ path = configPath,
2035
+ ): PolishedTuiConfig {
2036
+ return saveComponentsMutation(
2037
+ (components) =>
2038
+ applyMinimalistStylePatch(components.editor.styles.minimalist, patch),
2039
+ path,
2040
+ );
2041
+ }
2042
+
2043
+ export function saveUserMessagesComponentPatch(
2044
+ patch: Partial<
2045
+ Pick<UserMessagesComponentConfig, "enabled" | "style" | "colorSource">
2046
+ >,
2047
+ path = configPath,
2048
+ ): PolishedTuiConfig {
2049
+ return saveComponentsMutation(
2050
+ (components) => {
2051
+ const component = components.userMessages;
2052
+ if (patch.enabled !== undefined) component.enabled = patch.enabled;
2053
+ if (patch.style !== undefined) component.style = patch.style;
2054
+ if (patch.colorSource !== undefined)
2055
+ component.colorSource = patch.colorSource;
2056
+ },
2057
+ path,
2058
+ patch.style !== undefined ? deleteLegacyMessageCopyFriendly : undefined,
2059
+ patch.style !== undefined ? "userMessages" : undefined,
2060
+ );
2061
+ }
2062
+
2063
+ export function saveWorkingLineComponentPatch(
2064
+ patch: WorkingLineComponentPatch,
2065
+ path = configPath,
2066
+ ): PolishedTuiConfig {
2067
+ return saveComponentsMutation(
2068
+ (components) => {
2069
+ const component = components.workingLine;
2070
+ if (patch.enabled !== undefined) component.enabled = patch.enabled;
2071
+ if (patch.turnSummary !== undefined)
2072
+ component.turnSummary = patch.turnSummary;
2073
+ if (patch.spinner !== undefined) component.spinner = patch.spinner;
2074
+ if (patch.spinnerIntervalMs !== undefined)
2075
+ component.spinnerIntervalMs = patch.spinnerIntervalMs;
2076
+ if (patch.animateSpinnerColor !== undefined)
2077
+ component.animateSpinnerColor = patch.animateSpinnerColor;
2078
+ if (patch.textIntervalMs !== undefined)
2079
+ component.textIntervalMs = patch.textIntervalMs;
2080
+ if (patch.textAnimation !== undefined)
2081
+ component.textAnimation = patch.textAnimation;
2082
+ if (patch.colorSource !== undefined)
2083
+ component.colorSource = patch.colorSource;
2084
+ if (patch.messages?.custom !== undefined)
2085
+ component.messages.custom = patch.messages.custom;
2086
+ if (patch.messages?.values !== undefined) {
2087
+ component.messages.values = normalizeWorkingLineMessages([
2088
+ ...patch.messages.values,
2089
+ ]);
2090
+ }
2091
+ if (patch.segments?.tool !== undefined)
2092
+ component.segments.tool = patch.segments.tool;
2093
+ if (patch.segments?.elapsed !== undefined)
2094
+ component.segments.elapsed = patch.segments.elapsed;
2095
+ if (patch.segments?.thought !== undefined)
2096
+ component.segments.thought = patch.segments.thought;
2097
+ if (patch.segments?.tokens !== undefined)
2098
+ component.segments.tokens = patch.segments.tokens;
2099
+ },
2100
+ path,
2101
+ (record) => {
2102
+ const workingLine = recordValue(
2103
+ recordValue(record.components).workingLine,
2104
+ );
2105
+ delete workingLine.intervalMs;
2106
+ const messages = recordValue(workingLine.messages);
2107
+ delete messages.mode;
2108
+ },
2109
+ );
2110
+ }
2111
+
2112
+ export function saveSelectorBordersComponentPatch(
2113
+ patch: Partial<SelectorBordersComponentConfig>,
2114
+ path = configPath,
2115
+ ): PolishedTuiConfig {
2116
+ return saveComponentsMutation(
2117
+ (components) => {
2118
+ const component = components.selectorBorders;
2119
+ if (patch.enabled !== undefined) component.enabled = patch.enabled;
2120
+ if (patch.style !== undefined) component.style = patch.style;
2121
+ if (patch.colorSource !== undefined)
2122
+ component.colorSource = patch.colorSource;
2123
+ },
2124
+ path,
2125
+ undefined,
2126
+ patch.style !== undefined ? "selectorBorders" : undefined,
2127
+ );
2128
+ }
2129
+
2130
+ export function saveFooterComponentPatch(
2131
+ patch: Partial<
2132
+ Pick<FooterComponentConfig, "style" | "colorSource" | "modelLabel">
2133
+ >,
2134
+ path = configPath,
2135
+ ): PolishedTuiConfig {
2136
+ return saveComponentsMutation(
2137
+ (components) => {
2138
+ const component = components.footer;
2139
+ if (patch.style !== undefined) component.style = patch.style;
2140
+ if (patch.colorSource !== undefined)
2141
+ component.colorSource = patch.colorSource;
2142
+ if (patch.modelLabel !== undefined)
2143
+ component.modelLabel = patch.modelLabel;
2144
+ },
2145
+ path,
2146
+ patch.style !== undefined ? deleteLegacyFooterEnabled : undefined,
2147
+ patch.style !== undefined ? "footer" : undefined,
2148
+ );
2149
+ }
2150
+
2151
+ function applyStarshipStylePatch(
2152
+ style: StarshipFooterStyleConfig,
2153
+ patch: Partial<StarshipFooterStyleConfig>,
2154
+ ): void {
2155
+ if (patch.format !== undefined) style.format = patch.format;
2156
+ if (patch.responsive !== undefined) style.responsive = patch.responsive;
2157
+ if (patch.compactFormat !== undefined)
2158
+ style.compactFormat = patch.compactFormat;
2159
+ if (patch.compactMaxLines !== undefined)
2160
+ style.compactMaxLines = patch.compactMaxLines;
2161
+ if (patch.separator !== undefined) style.separator = patch.separator;
2162
+ if (patch.contextStyle !== undefined) style.contextStyle = patch.contextStyle;
2163
+ if (patch.contextThresholds !== undefined) {
2164
+ style.contextThresholds = {
2165
+ ...style.contextThresholds,
2166
+ ...patch.contextThresholds,
2167
+ };
2168
+ }
2169
+ if (patch.pathDisplay !== undefined) {
2170
+ style.pathDisplay = { ...style.pathDisplay, ...patch.pathDisplay };
2171
+ }
2172
+ if (patch.segments !== undefined)
2173
+ style.segments = { ...style.segments, ...patch.segments };
2174
+ if (patch.gitBranch !== undefined)
2175
+ style.gitBranch = { ...style.gitBranch, ...patch.gitBranch };
2176
+ if (patch.gitCommit !== undefined)
2177
+ style.gitCommit = { ...style.gitCommit, ...patch.gitCommit };
2178
+ if (patch.gitMetrics !== undefined)
2179
+ style.gitMetrics = { ...style.gitMetrics, ...patch.gitMetrics };
2180
+ if (patch.extensionStatuses !== undefined) {
2181
+ style.extensionStatuses = {
2182
+ ...style.extensionStatuses,
2183
+ ...patch.extensionStatuses,
2184
+ placements: {
2185
+ ...style.extensionStatuses.placements,
2186
+ ...patch.extensionStatuses.placements,
2187
+ },
2188
+ colorModes: {
2189
+ ...style.extensionStatuses.colorModes,
2190
+ ...patch.extensionStatuses.colorModes,
2191
+ },
2192
+ };
2193
+ }
2194
+ }
2195
+
2196
+ export function saveStarshipFooterStylePatch(
2197
+ patch: Partial<StarshipFooterStyleConfig>,
2198
+ path = configPath,
2199
+ ): PolishedTuiConfig {
2200
+ return saveComponentsMutation(
2201
+ (components) =>
2202
+ applyStarshipStylePatch(components.footer.styles.starship, patch),
2203
+ path,
2204
+ );
2205
+ }
2206
+
2207
+ export function saveColorSourcesPatch(
2208
+ patch: Partial<ColorSourcesConfig>,
2209
+ path = configPath,
2210
+ ): PolishedTuiConfig {
2211
+ const valid = validColorSourceEntries(patch);
2212
+ return saveComponentsMutation((components) => {
2213
+ if (valid.starship !== undefined)
2214
+ components.footer.colorSource = valid.starship;
2215
+ if (valid.editor !== undefined) {
2216
+ components.editor.colorSource = valid.editor;
2217
+ components.selectorBorders.colorSource = valid.editor;
2218
+ }
2219
+ if (valid.userMessages !== undefined) {
2220
+ components.userMessages.colorSource = valid.userMessages;
2221
+ }
2222
+ }, path);
2223
+ }
2224
+
2225
+ export function saveUiFeaturesPatch(
2226
+ patch: Partial<UiFeaturesConfig>,
2227
+ path = configPath,
2228
+ ): PolishedTuiConfig {
2229
+ const valid = validUiFeatureEntries(patch);
2230
+ return saveComponentsMutation(
2231
+ (components) => {
2232
+ if (valid.editor !== undefined) {
2233
+ components.editor.enabled = valid.editor;
2234
+ components.userMessages.enabled = valid.editor;
2235
+ components.selectorBorders.enabled = valid.editor;
2236
+ }
2237
+ if (valid.statusLine !== undefined) {
2238
+ components.footer.style = valid.statusLine ? "starship" : "native";
2239
+ }
2240
+ if (valid.viewportIndicators !== undefined) {
2241
+ components.editor.viewportIndicators = valid.viewportIndicators;
2242
+ }
2243
+ },
2244
+ path,
2245
+ valid.statusLine !== undefined ? deleteLegacyFooterEnabled : undefined,
2246
+ valid.statusLine !== undefined ? "footer" : undefined,
2247
+ );
2248
+ }
2249
+
2250
+ export function saveFooterSegmentsPatch(
2251
+ patch: Partial<FooterSegmentsConfig>,
2252
+ path = configPath,
2253
+ ): PolishedTuiConfig {
2254
+ return saveStarshipFooterStylePatch(
2255
+ { segments: validFooterSegmentEntries(patch) as FooterSegmentsConfig },
2256
+ path,
2257
+ );
2258
+ }
2259
+
2260
+ export function saveFooterFormatPatch(
2261
+ value: string,
2262
+ path = configPath,
2263
+ ): PolishedTuiConfig {
2264
+ return saveStarshipFooterStylePatch(
2265
+ { format: typeof value === "string" ? value : "" },
2266
+ path,
2267
+ );
2268
+ }
2269
+
2270
+ export function saveResponsiveFooterPatch(
2271
+ patch: Partial<
2272
+ Pick<
2273
+ PolishedTuiConfig,
2274
+ "responsiveFooter" | "compactFooterFormat" | "compactFooterMaxLines"
2275
+ >
2276
+ >,
2277
+ path = configPath,
2278
+ ): PolishedTuiConfig {
2279
+ const canonical: Partial<StarshipFooterStyleConfig> = {};
2280
+ if (typeof patch.responsiveFooter === "boolean")
2281
+ canonical.responsive = patch.responsiveFooter;
2282
+ if (typeof patch.compactFooterFormat === "string")
2283
+ canonical.compactFormat = patch.compactFooterFormat;
2284
+ if (patch.compactFooterMaxLines !== undefined) {
2285
+ canonical.compactMaxLines = parseCompactFooterMaxLines(
2286
+ patch.compactFooterMaxLines,
2287
+ );
2288
+ }
2289
+ return saveStarshipFooterStylePatch(canonical, path);
2290
+ }
2291
+
2292
+ export function saveIconsModePatch(
2293
+ mode: IconMode,
2294
+ path = configPath,
2295
+ ): PolishedTuiConfig {
2296
+ return mutateConfig(path, (record) => {
2297
+ const existing = isRecord(record.icons) ? { ...record.icons } : {};
2298
+ record.icons = { ...existing, mode: normalizeIconMode(mode) };
2299
+ });
2300
+ }
2301
+
2302
+ export function saveContextStylePatch(
2303
+ style: ContextStyle,
2304
+ path = configPath,
2305
+ ): PolishedTuiConfig {
2306
+ return saveStarshipFooterStylePatch(
2307
+ { contextStyle: parseContextStyle(style) },
2308
+ path,
2309
+ );
2310
+ }
2311
+
2312
+ export function saveSeparatorPatch(
2313
+ separator: SeparatorStyle,
2314
+ path = configPath,
2315
+ ): PolishedTuiConfig {
2316
+ return saveStarshipFooterStylePatch(
2317
+ { separator: parseSeparatorStyle(separator) },
2318
+ path,
2319
+ );
2320
+ }
2321
+
2322
+ export function saveContextThresholdsPatch(
2323
+ thresholds: Partial<ContextThresholds>,
2324
+ path = configPath,
2325
+ ): PolishedTuiConfig {
2326
+ if (
2327
+ typeof thresholds.warning === "bigint" ||
2328
+ typeof thresholds.error === "bigint"
2329
+ ) {
2330
+ throw new TypeError("Context thresholds must be JSON-serializable numbers");
2331
+ }
2332
+ return saveComponentsMutation((components) => {
2333
+ components.editor.styles.minimalist.contextThresholds = {
2334
+ ...components.editor.styles.minimalist.contextThresholds,
2335
+ ...thresholds,
2336
+ };
2337
+ components.footer.styles.starship.contextThresholds = {
2338
+ ...components.footer.styles.starship.contextThresholds,
2339
+ ...thresholds,
2340
+ };
2341
+ }, path);
2342
+ }
2343
+
2344
+ export function savePathDisplayPatch(
2345
+ patch: Partial<PathDisplayConfig>,
2346
+ path = configPath,
2347
+ ): PolishedTuiConfig {
2348
+ return saveComponentsMutation((components) => {
2349
+ components.footer.styles.starship.pathDisplay = {
2350
+ ...components.footer.styles.starship.pathDisplay,
2351
+ ...patch,
2352
+ };
2353
+ }, path);
2354
+ }
2355
+
2356
+ export function saveGitBranchPatch(
2357
+ patch: Partial<GitBranchConfig>,
2358
+ path = configPath,
2359
+ ): PolishedTuiConfig {
2360
+ return saveComponentsMutation((components) => {
2361
+ components.footer.styles.starship.gitBranch = {
2362
+ ...components.footer.styles.starship.gitBranch,
2363
+ ...patch,
2364
+ };
2365
+ }, path);
2366
+ }
2367
+
2368
+ export function saveEditorModelLabel(
2369
+ value: ModelLabelSource,
2370
+ path = configPath,
2371
+ ): PolishedTuiConfig {
2372
+ return saveComponentsMutation((components) => {
2373
+ const normalized = parseEditorModelLabel(value);
2374
+ components.editor.modelLabel = normalized;
2375
+ components.footer.modelLabel = normalized;
2376
+ }, path);
2377
+ }
2378
+
2379
+ export function saveEditorStyle(
2380
+ value: EditorStyle,
2381
+ path = configPath,
2382
+ ): PolishedTuiConfig {
2383
+ return saveEditorComponentPatch({ style: parseEditorStyle(value) }, path);
2384
+ }
2385
+
2386
+ export function saveMinimalistPatch(
2387
+ patch: Partial<MinimalistConfig>,
2388
+ path = configPath,
2389
+ ): PolishedTuiConfig {
2390
+ return saveMinimalistEditorStylePatch(patch, path);
2391
+ }
2392
+
2393
+ export function saveEditorBorderColorMode(
2394
+ value: EditorBorderColorMode,
2395
+ path = configPath,
2396
+ ): PolishedTuiConfig {
2397
+ return saveEditorComponentPatch(
2398
+ { borderColorMode: parseEditorBorderColorMode(value) },
2399
+ path,
2400
+ );
2401
+ }
2402
+
2403
+ export function saveGitCommitPatch(
2404
+ patch: Partial<Pick<GitCommitConfig, "onlyDetached" | "showTag">>,
2405
+ path = configPath,
2406
+ ): PolishedTuiConfig {
2407
+ const valid: Partial<GitCommitConfig> = {};
2408
+ if (typeof patch.onlyDetached === "boolean")
2409
+ valid.onlyDetached = patch.onlyDetached;
2410
+ if (typeof patch.showTag === "boolean") valid.showTag = patch.showTag;
2411
+ return saveComponentsMutation((components) => {
2412
+ components.footer.styles.starship.gitCommit = {
2413
+ ...components.footer.styles.starship.gitCommit,
2414
+ ...valid,
2415
+ };
2416
+ }, path);
2417
+ }
2418
+
2419
+ export function saveGitMetricsPatch(
2420
+ patch: Partial<GitMetricsConfig>,
2421
+ path = configPath,
2422
+ ): PolishedTuiConfig {
2423
+ const valid: Partial<GitMetricsConfig> = {};
2424
+ if (typeof patch.onlyNonzero === "boolean")
2425
+ valid.onlyNonzero = patch.onlyNonzero;
2426
+ if (typeof patch.ignoreSubmodules === "boolean")
2427
+ valid.ignoreSubmodules = patch.ignoreSubmodules;
2428
+ return saveComponentsMutation((components) => {
2429
+ components.footer.styles.starship.gitMetrics = {
2430
+ ...components.footer.styles.starship.gitMetrics,
2431
+ ...valid,
2432
+ };
2433
+ }, path);
2434
+ }
2435
+
2436
+ export function saveExtensionStatusDefaultPlacement(
2437
+ placement: ExtensionStatusPlacement,
2438
+ path = configPath,
2439
+ ): PolishedTuiConfig {
2440
+ return saveComponentsMutation((components) => {
2441
+ components.footer.styles.starship.extensionStatuses.defaultPlacement =
2442
+ isExtensionStatusPlacement(placement)
2443
+ ? placement
2444
+ : defaultConfig.extensionStatuses.defaultPlacement;
2445
+ }, path);
2446
+ }
2447
+
2448
+ export function saveExtensionStatusPlacement(
2449
+ key: string,
2450
+ placement: ExtensionStatusPlacement,
2451
+ path = configPath,
2452
+ ): PolishedTuiConfig {
2453
+ return saveComponentsMutation((components) => {
2454
+ Object.defineProperty(
2455
+ components.footer.styles.starship.extensionStatuses.placements,
2456
+ key,
2457
+ {
2458
+ value: placement,
2459
+ enumerable: true,
2460
+ configurable: true,
2461
+ writable: true,
2462
+ },
2463
+ );
2464
+ }, path);
2465
+ }
2466
+
2467
+ export function saveExtensionStatusColorMode(
2468
+ key: string,
2469
+ colorMode: ExtensionStatusColorMode,
2470
+ path = configPath,
2471
+ ): PolishedTuiConfig {
2472
+ return saveComponentsMutation((components) => {
2473
+ Object.defineProperty(
2474
+ components.footer.styles.starship.extensionStatuses.colorModes,
2475
+ key,
2476
+ {
2477
+ value: colorMode,
2478
+ enumerable: true,
2479
+ configurable: true,
2480
+ writable: true,
2481
+ },
2482
+ );
2483
+ }, path);
2484
+ }