@selesai/code 0.9.15 → 0.10.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 (269) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +6 -9
  3. package/dist/defaults/models.json +2 -2
  4. package/dist/defaults/settings.json +3 -7
  5. package/dist/extensions/package.json +2 -4
  6. package/dist/extensions/pi-zentui/.editorconfig +12 -0
  7. package/dist/extensions/pi-zentui/.github/CODEOWNERS +1 -0
  8. package/dist/extensions/pi-zentui/.github/ISSUE_TEMPLATE/bug_report.yml +110 -0
  9. package/dist/extensions/pi-zentui/.github/ISSUE_TEMPLATE/config.yml +8 -0
  10. package/dist/extensions/pi-zentui/.github/ISSUE_TEMPLATE/config_question.yml +56 -0
  11. package/dist/extensions/pi-zentui/.github/ISSUE_TEMPLATE/feature_request.yml +63 -0
  12. package/dist/extensions/pi-zentui/.github/ISSUE_TEMPLATE/rendering_issue.yml +106 -0
  13. package/dist/extensions/pi-zentui/.github/ISSUE_TEMPLATE/runtime_detection.yml +103 -0
  14. package/dist/extensions/pi-zentui/.github/dependabot.yml +47 -0
  15. package/dist/extensions/pi-zentui/.github/pull_request_template.md +34 -0
  16. package/dist/extensions/pi-zentui/.github/release.yml +18 -0
  17. package/dist/extensions/pi-zentui/.github/workflows/ci.yml +79 -0
  18. package/dist/extensions/pi-zentui/.github/workflows/publish.yml +63 -0
  19. package/dist/extensions/pi-zentui/AGENTS.md +107 -0
  20. package/dist/extensions/pi-zentui/CONTRIBUTING.md +68 -0
  21. package/dist/extensions/pi-zentui/LICENSE +21 -0
  22. package/dist/extensions/pi-zentui/README.md +188 -0
  23. package/dist/extensions/pi-zentui/SECURITY.md +18 -0
  24. package/dist/extensions/pi-zentui/assets/main-cover.png +0 -0
  25. package/dist/extensions/pi-zentui/assets/screenshots/editor-accent-rail.png +0 -0
  26. package/dist/extensions/pi-zentui/assets/screenshots/editor-minimalist.png +0 -0
  27. package/dist/extensions/pi-zentui/assets/screenshots/editor-opencode-copy-friendly.png +0 -0
  28. package/dist/extensions/pi-zentui/assets/screenshots/editor-opencode.png +0 -0
  29. package/dist/extensions/pi-zentui/assets/screenshots/user-message-compact.png +0 -0
  30. package/dist/extensions/pi-zentui/assets/screenshots/user-message-framed-copy-friendly.png +0 -0
  31. package/dist/extensions/pi-zentui/assets/screenshots/user-message-framed.png +0 -0
  32. package/dist/extensions/pi-zentui/assets/screenshots/user-message-labeled.png +0 -0
  33. package/dist/extensions/pi-zentui/assets/zentui.png +0 -0
  34. package/dist/extensions/pi-zentui/biome.json +31 -0
  35. package/dist/extensions/pi-zentui/docs/configuration.md +509 -0
  36. package/dist/extensions/pi-zentui/docs/footer-format.md +162 -0
  37. package/dist/extensions/pi-zentui/extensions/zentui/accent-rail-editor.ts +99 -0
  38. package/dist/extensions/pi-zentui/extensions/zentui/accent-rail-layout-patch.ts +457 -0
  39. package/dist/extensions/pi-zentui/extensions/zentui/completion-menu.ts +157 -0
  40. package/dist/extensions/pi-zentui/extensions/zentui/config.ts +2100 -0
  41. package/dist/extensions/pi-zentui/extensions/zentui/editor-metadata-format.ts +321 -0
  42. package/dist/extensions/pi-zentui/extensions/zentui/editor-transfer.ts +73 -0
  43. package/dist/extensions/pi-zentui/extensions/zentui/extension-status.ts +89 -0
  44. package/dist/extensions/pi-zentui/extensions/zentui/footer-format.ts +364 -0
  45. package/dist/extensions/pi-zentui/extensions/zentui/footer-layout.ts +122 -0
  46. package/dist/extensions/pi-zentui/extensions/zentui/footer.ts +812 -0
  47. package/dist/extensions/pi-zentui/extensions/zentui/format.ts +568 -0
  48. package/dist/extensions/pi-zentui/extensions/zentui/git.ts +384 -0
  49. package/dist/extensions/pi-zentui/extensions/zentui/icons.ts +252 -0
  50. package/dist/extensions/pi-zentui/extensions/zentui/index.ts +1406 -0
  51. package/dist/extensions/pi-zentui/extensions/zentui/interaction-summary.ts +1049 -0
  52. package/dist/extensions/pi-zentui/extensions/zentui/live-context.ts +75 -0
  53. package/dist/extensions/pi-zentui/extensions/zentui/minimalist-editor.ts +463 -0
  54. package/dist/extensions/pi-zentui/extensions/zentui/package-version.ts +651 -0
  55. package/dist/extensions/pi-zentui/extensions/zentui/project-refresh.ts +120 -0
  56. package/dist/extensions/pi-zentui/extensions/zentui/project-state.ts +59 -0
  57. package/dist/extensions/pi-zentui/extensions/zentui/prototype-patch-registry.ts +166 -0
  58. package/dist/extensions/pi-zentui/extensions/zentui/repository-root.ts +98 -0
  59. package/dist/extensions/pi-zentui/extensions/zentui/runtime.ts +841 -0
  60. package/dist/extensions/pi-zentui/extensions/zentui/selector-border.ts +93 -0
  61. package/dist/extensions/pi-zentui/extensions/zentui/session-lifecycle.ts +60 -0
  62. package/dist/extensions/pi-zentui/extensions/zentui/settings-command.ts +1796 -0
  63. package/dist/extensions/pi-zentui/extensions/zentui/settings-previews.ts +128 -0
  64. package/dist/extensions/pi-zentui/extensions/zentui/state.ts +94 -0
  65. package/dist/extensions/pi-zentui/extensions/zentui/style.ts +424 -0
  66. package/dist/extensions/pi-zentui/extensions/zentui/telemetry.ts +82 -0
  67. package/dist/extensions/pi-zentui/extensions/zentui/ui.ts +1290 -0
  68. package/dist/extensions/pi-zentui/extensions/zentui/user-message-osc.ts +248 -0
  69. package/dist/extensions/pi-zentui/extensions/zentui/user-message-styles.ts +215 -0
  70. package/dist/extensions/pi-zentui/extensions/zentui/user-message.ts +206 -0
  71. package/dist/extensions/pi-zentui/extensions/zentui/working-line-messages.ts +24 -0
  72. package/dist/extensions/pi-zentui/extensions/zentui/working-line-spinners.ts +54 -0
  73. package/dist/extensions/pi-zentui/extensions/zentui/working-line.ts +1433 -0
  74. package/dist/extensions/pi-zentui/package-lock.json +5311 -0
  75. package/dist/extensions/pi-zentui/package.json +60 -0
  76. package/dist/extensions/pi-zentui/test/accent-rail-editor.test.ts +232 -0
  77. package/dist/extensions/pi-zentui/test/accent-rail-layout-patch.test.ts +669 -0
  78. package/dist/extensions/pi-zentui/test/completion-menu.test.ts +265 -0
  79. package/dist/extensions/pi-zentui/test/config.test.ts +3119 -0
  80. package/dist/extensions/pi-zentui/test/editor-metadata-format.test.ts +405 -0
  81. package/dist/extensions/pi-zentui/test/editor-transfer.test.ts +260 -0
  82. package/dist/extensions/pi-zentui/test/editor-viewport-indicators.test.ts +1286 -0
  83. package/dist/extensions/pi-zentui/test/extension-compliance.test.ts +7196 -0
  84. package/dist/extensions/pi-zentui/test/extension-status.test.ts +182 -0
  85. package/dist/extensions/pi-zentui/test/fixtures/starship-nerd-font-symbols.toml +172 -0
  86. package/dist/extensions/pi-zentui/test/footer-format.test.ts +431 -0
  87. package/dist/extensions/pi-zentui/test/footer-layout.test.ts +113 -0
  88. package/dist/extensions/pi-zentui/test/footer-repository-path.test.ts +123 -0
  89. package/dist/extensions/pi-zentui/test/format.test.ts +865 -0
  90. package/dist/extensions/pi-zentui/test/git.test.ts +212 -0
  91. package/dist/extensions/pi-zentui/test/icons.test.ts +69 -0
  92. package/dist/extensions/pi-zentui/test/interaction-summary.test.ts +1402 -0
  93. package/dist/extensions/pi-zentui/test/live-context-integration.test.ts +378 -0
  94. package/dist/extensions/pi-zentui/test/live-context.test.ts +139 -0
  95. package/dist/extensions/pi-zentui/test/minimalist-editor.test.ts +811 -0
  96. package/dist/extensions/pi-zentui/test/package-version.test.ts +321 -0
  97. package/dist/extensions/pi-zentui/test/project-refresh.test.ts +250 -0
  98. package/dist/extensions/pi-zentui/test/project-state.test.ts +159 -0
  99. package/dist/extensions/pi-zentui/test/repository-root.test.ts +108 -0
  100. package/dist/extensions/pi-zentui/test/responsive-dependencies.test.ts +825 -0
  101. package/dist/extensions/pi-zentui/test/runtime.test.ts +183 -0
  102. package/dist/extensions/pi-zentui/test/session-lifecycle.test.ts +40 -0
  103. package/dist/extensions/pi-zentui/test/settings-command.test.ts +1381 -0
  104. package/dist/extensions/pi-zentui/test/settings-previews.test.ts +347 -0
  105. package/dist/extensions/pi-zentui/test/state.test.ts +84 -0
  106. package/dist/extensions/pi-zentui/test/telemetry-lifecycle-integration.test.ts +276 -0
  107. package/dist/extensions/pi-zentui/test/telemetry.test.ts +105 -0
  108. package/dist/extensions/pi-zentui/test/turn-summary-lifecycle.integration.test.ts +327 -0
  109. package/dist/extensions/pi-zentui/test/user-message-osc.test.ts +164 -0
  110. package/dist/extensions/pi-zentui/test/user-message-styles.test.ts +292 -0
  111. package/dist/extensions/pi-zentui/test/working-line-lifecycle.integration.test.ts +730 -0
  112. package/dist/extensions/pi-zentui/test/working-line-messages.test.ts +34 -0
  113. package/dist/extensions/pi-zentui/test/working-line.test.ts +1990 -0
  114. package/dist/extensions/pi-zentui/tsconfig.json +16 -0
  115. package/dist/extensions/tps.test.ts +38 -0
  116. package/dist/extensions/{pi-powerline-footer/tps.ts → tps.ts} +4 -0
  117. package/dist/modes/interactive/components/settings-selector.d.ts +1 -1
  118. package/dist/modes/interactive/interactive-mode.js +1 -1
  119. package/dist/skills/design-references/SKILL.md +31 -0
  120. package/dist/skills/design-references/references/clickhouse/DESIGN.md +544 -0
  121. package/dist/themes/adventure.json +84 -0
  122. package/dist/themes/adwaita-dark.json +84 -0
  123. package/dist/themes/arcoiris.json +84 -0
  124. package/dist/themes/arthur.json +84 -0
  125. package/dist/themes/atom.json +84 -0
  126. package/dist/themes/aura.json +84 -0
  127. package/dist/themes/black-metal-bathory.json +84 -0
  128. package/dist/themes/black-metal-burzum.json +84 -0
  129. package/dist/themes/black-metal-khold.json +84 -0
  130. package/dist/themes/box.json +84 -0
  131. package/dist/themes/brogrammer.json +84 -0
  132. package/dist/themes/carbonfox.json +84 -0
  133. package/dist/themes/catppuccin-mocha.json +84 -0
  134. package/dist/themes/citruszest.json +84 -0
  135. package/dist/themes/cursor-dark.json +84 -0
  136. package/dist/themes/cutie-pro.json +84 -0
  137. package/dist/themes/dark-modern.json +84 -0
  138. package/dist/themes/dark-pastel.json +84 -0
  139. package/dist/themes/dimmed-monokai.json +84 -0
  140. package/dist/themes/doom-peacock.json +84 -0
  141. package/dist/themes/dracula-plus.json +84 -0
  142. package/dist/themes/dracula.json +80 -0
  143. package/dist/themes/earthsong.json +84 -0
  144. package/dist/themes/everforest-dark-hard.json +84 -0
  145. package/dist/themes/everforest-dark.json +80 -0
  146. package/dist/themes/fahrenheit.json +84 -0
  147. package/dist/themes/flatland.json +84 -0
  148. package/dist/themes/flexoki-dark.json +84 -0
  149. package/dist/themes/front-end-delight.json +84 -0
  150. package/dist/themes/fun-forrest.json +84 -0
  151. package/dist/themes/galizur.json +84 -0
  152. package/dist/themes/github-dark-colorblind.json +84 -0
  153. package/dist/themes/github-dark-high-contrast.json +84 -0
  154. package/dist/themes/glacier.json +84 -0
  155. package/dist/themes/gruber-darker.json +84 -0
  156. package/dist/themes/gruvbox-dark-hard.json +84 -0
  157. package/dist/themes/gruvbox-dark.json +84 -0
  158. package/dist/themes/gruvbox-material.json +84 -0
  159. package/dist/themes/guezwhoz.json +84 -0
  160. package/dist/themes/hacktober.json +84 -0
  161. package/dist/themes/hardcore.json +84 -0
  162. package/dist/themes/havn-skumring.json +84 -0
  163. package/dist/themes/ic-orange-ppl.json +84 -0
  164. package/dist/themes/iterm2-smoooooth.json +84 -0
  165. package/dist/themes/iterm2-tango-dark.json +84 -0
  166. package/dist/themes/japanesque.json +84 -0
  167. package/dist/themes/jellybeans.json +84 -0
  168. package/dist/themes/kanagawa-wave.json +84 -0
  169. package/dist/themes/kurokula.json +84 -0
  170. package/dist/themes/later-this-evening.json +84 -0
  171. package/dist/themes/lovelace.json +84 -0
  172. package/dist/themes/material-darker.json +84 -0
  173. package/dist/themes/matte-black.json +84 -0
  174. package/dist/themes/mellow.json +84 -0
  175. package/dist/themes/miasma.json +84 -0
  176. package/dist/themes/nord.json +80 -0
  177. package/dist/themes/nvim-dark.json +84 -0
  178. package/dist/themes/one-dark.json +80 -0
  179. package/dist/themes/popping-and-locking.json +84 -0
  180. package/dist/themes/rose-pine.json +80 -0
  181. package/dist/themes/sea-shells.json +84 -0
  182. package/dist/themes/sleepy-hollow.json +84 -0
  183. package/dist/themes/smyck.json +84 -0
  184. package/dist/themes/solarized-dark.json +80 -0
  185. package/dist/themes/tokyo-night.json +80 -0
  186. package/dist/themes/tomorrow-night-bright.json +84 -0
  187. package/dist/themes/tomorrow-night-burns.json +84 -0
  188. package/dist/themes/tomorrow-night.json +84 -0
  189. package/dist/themes/twilight.json +84 -0
  190. package/dist/themes/vague.json +84 -0
  191. package/dist/themes/vesper.json +84 -0
  192. package/dist/themes/xcode-dark-hc.json +84 -0
  193. package/docs/custom-provider.md +0 -1
  194. package/package.json +3 -3
  195. package/dist/extensions/auto-model/auto-model.test.ts +0 -18
  196. package/dist/extensions/auto-model/classifier.ts +0 -23
  197. package/dist/extensions/auto-model/config.ts +0 -51
  198. package/dist/extensions/auto-model/index.ts +0 -22
  199. package/dist/extensions/auto-model/lifecycle.test.ts +0 -24
  200. package/dist/extensions/pi-powerline-footer/.github/workflows/test.yml +0 -27
  201. package/dist/extensions/pi-powerline-footer/CHANGELOG.md +0 -687
  202. package/dist/extensions/pi-powerline-footer/CONTRIBUTING.md +0 -16
  203. package/dist/extensions/pi-powerline-footer/README.md +0 -513
  204. package/dist/extensions/pi-powerline-footer/banner.png +0 -0
  205. package/dist/extensions/pi-powerline-footer/bash-mode/completion.ts +0 -586
  206. package/dist/extensions/pi-powerline-footer/bash-mode/editor.ts +0 -583
  207. package/dist/extensions/pi-powerline-footer/bash-mode/history.ts +0 -204
  208. package/dist/extensions/pi-powerline-footer/bash-mode/shell-session.ts +0 -286
  209. package/dist/extensions/pi-powerline-footer/bash-mode/transcript.ts +0 -108
  210. package/dist/extensions/pi-powerline-footer/bash-mode/types.ts +0 -60
  211. package/dist/extensions/pi-powerline-footer/cd-command.ts +0 -190
  212. package/dist/extensions/pi-powerline-footer/colors.ts +0 -70
  213. package/dist/extensions/pi-powerline-footer/context-usage.ts +0 -156
  214. package/dist/extensions/pi-powerline-footer/currency-rates.ts +0 -142
  215. package/dist/extensions/pi-powerline-footer/editor-composition.ts +0 -32
  216. package/dist/extensions/pi-powerline-footer/editor-performance.ts +0 -99
  217. package/dist/extensions/pi-powerline-footer/git-status.ts +0 -329
  218. package/dist/extensions/pi-powerline-footer/guide.ts +0 -220
  219. package/dist/extensions/pi-powerline-footer/icons.ts +0 -190
  220. package/dist/extensions/pi-powerline-footer/index.ts +0 -3428
  221. package/dist/extensions/pi-powerline-footer/lifecycle.ts +0 -10
  222. package/dist/extensions/pi-powerline-footer/package.json +0 -49
  223. package/dist/extensions/pi-powerline-footer/paths.ts +0 -22
  224. package/dist/extensions/pi-powerline-footer/powerline-config.ts +0 -446
  225. package/dist/extensions/pi-powerline-footer/presets.ts +0 -102
  226. package/dist/extensions/pi-powerline-footer/queue/store.ts +0 -313
  227. package/dist/extensions/pi-powerline-footer/queue/types.ts +0 -53
  228. package/dist/extensions/pi-powerline-footer/render-scheduler.ts +0 -29
  229. package/dist/extensions/pi-powerline-footer/segments.ts +0 -570
  230. package/dist/extensions/pi-powerline-footer/separators.ts +0 -57
  231. package/dist/extensions/pi-powerline-footer/session-usage.ts +0 -44
  232. package/dist/extensions/pi-powerline-footer/shortcuts.ts +0 -61
  233. package/dist/extensions/pi-powerline-footer/tests/bash-mode.test.ts +0 -1905
  234. package/dist/extensions/pi-powerline-footer/tests/cd-command.test.ts +0 -144
  235. package/dist/extensions/pi-powerline-footer/tests/context-usage.test.ts +0 -156
  236. package/dist/extensions/pi-powerline-footer/tests/custom-items.test.ts +0 -344
  237. package/dist/extensions/pi-powerline-footer/tests/editor-composition.test.ts +0 -48
  238. package/dist/extensions/pi-powerline-footer/tests/editor-performance.test.ts +0 -43
  239. package/dist/extensions/pi-powerline-footer/tests/editor-responsiveness.test.ts +0 -136
  240. package/dist/extensions/pi-powerline-footer/tests/fixed-editor-border.test.ts +0 -39
  241. package/dist/extensions/pi-powerline-footer/tests/git-optional-locks.test.ts +0 -64
  242. package/dist/extensions/pi-powerline-footer/tests/git-status.test.ts +0 -92
  243. package/dist/extensions/pi-powerline-footer/tests/guide.test.ts +0 -129
  244. package/dist/extensions/pi-powerline-footer/tests/jump-shortcuts.test.ts +0 -116
  245. package/dist/extensions/pi-powerline-footer/tests/mode-aware-trigger.test.ts +0 -79
  246. package/dist/extensions/pi-powerline-footer/tests/paths.test.ts +0 -63
  247. package/dist/extensions/pi-powerline-footer/tests/queue-store.test.ts +0 -65
  248. package/dist/extensions/pi-powerline-footer/tests/quit-cursor.test.ts +0 -14
  249. package/dist/extensions/pi-powerline-footer/tests/remaining-regressions.test.ts +0 -173
  250. package/dist/extensions/pi-powerline-footer/tests/session-usage.test.ts +0 -47
  251. package/dist/extensions/pi-powerline-footer/tests/stash-shortcut.test.ts +0 -37
  252. package/dist/extensions/pi-powerline-footer/tests/thinking-segment.test.ts +0 -76
  253. package/dist/extensions/pi-powerline-footer/tests/token-stats.test.ts +0 -249
  254. package/dist/extensions/pi-powerline-footer/tests/tps.test.ts +0 -39
  255. package/dist/extensions/pi-powerline-footer/tests/usage-display.test.ts +0 -237
  256. package/dist/extensions/pi-powerline-footer/tests/welcome.test.ts +0 -199
  257. package/dist/extensions/pi-powerline-footer/tests/working-vibes.test.ts +0 -317
  258. package/dist/extensions/pi-powerline-footer/theme.example.json +0 -24
  259. package/dist/extensions/pi-powerline-footer/theme.json +0 -12
  260. package/dist/extensions/pi-powerline-footer/theme.ts +0 -241
  261. package/dist/extensions/pi-powerline-footer/token-stats.ts +0 -304
  262. package/dist/extensions/pi-powerline-footer/tsconfig.json +0 -13
  263. package/dist/extensions/pi-powerline-footer/types.ts +0 -235
  264. package/dist/extensions/pi-powerline-footer/welcome-dismiss.ts +0 -34
  265. package/dist/extensions/pi-powerline-footer/welcome.ts +0 -709
  266. package/dist/extensions/pi-powerline-footer/working-vibes.ts +0 -721
  267. package/dist/extensions/preview-tools-disabled.test.ts +0 -37
  268. package/dist/extensions/preview-tools-disabled.ts +0 -11
  269. package/dist/themes/powerline-footer/theme.json +0 -33
@@ -0,0 +1,3119 @@
1
+ import {
2
+ chmodSync,
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ readlinkSync,
10
+ rmSync,
11
+ statSync,
12
+ symlinkSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { visibleWidth } from "@earendil-works/pi-tui";
18
+ import { describe, expect, it } from "vitest";
19
+ import {
20
+ DEFAULT_COMPACT_FOOTER_FORMAT,
21
+ DEFAULT_EDITOR_METADATA_FORMAT,
22
+ defaultConfig,
23
+ ensureConfigExists,
24
+ FOOTER_FORMAT_VARIABLES,
25
+ getExtensionStatusColorMode,
26
+ getExtensionStatusPlacement,
27
+ hasUnsupportedComponentStyle,
28
+ mergeConfig,
29
+ saveAccentRailEditorStylePatch,
30
+ saveColorSourcesPatch,
31
+ saveContextStylePatch,
32
+ saveContextThresholdsPatch,
33
+ saveEditorBorderColorMode,
34
+ saveEditorComponentPatch,
35
+ saveEditorModelLabel,
36
+ saveEditorStyle,
37
+ saveExtensionStatusColorMode,
38
+ saveExtensionStatusDefaultPlacement,
39
+ saveExtensionStatusPlacement,
40
+ saveFooterComponentPatch,
41
+ saveFooterFormatPatch,
42
+ saveFooterSegmentsPatch,
43
+ saveGitBranchPatch,
44
+ saveGitCommitPatch,
45
+ saveGitMetricsPatch,
46
+ saveMinimalistEditorStylePatch,
47
+ saveMinimalistPatch,
48
+ savePathDisplayPatch,
49
+ savePolishedCopyFriendlyEditorStylePatch,
50
+ savePolishedEditorStylePatch,
51
+ saveResponsiveFooterPatch,
52
+ saveSelectorBordersComponentPatch,
53
+ saveSeparatorPatch,
54
+ saveStarshipFooterStylePatch,
55
+ saveUiFeaturesPatch,
56
+ saveUserMessagesComponentPatch,
57
+ saveWorkingLineComponentPatch,
58
+ } from "../extensions/zentui/config";
59
+ import {
60
+ colorize,
61
+ renderChromeBorder,
62
+ renderStyle,
63
+ renderStyleForSource,
64
+ renderTerminalStyle,
65
+ } from "../extensions/zentui/style";
66
+
67
+ function configTempFiles(dir: string, filename = "zentui.json"): string[] {
68
+ return readdirSync(dir).filter(
69
+ (name) => name.startsWith(`.${filename}.`) && name.endsWith(".tmp"),
70
+ );
71
+ }
72
+
73
+ function withConfig(
74
+ initial: Record<string, unknown> | undefined,
75
+ assertions: (path: string, dir: string) => void,
76
+ ): void {
77
+ const dir = mkdtempSync(join(tmpdir(), "zentui-config-"));
78
+ const path = join(dir, "zentui.json");
79
+ try {
80
+ if (initial !== undefined) writeFileSync(path, `${JSON.stringify(initial, null, 2)}\n`);
81
+ assertions(path, dir);
82
+ } finally {
83
+ rmSync(dir, { recursive: true, force: true });
84
+ }
85
+ }
86
+
87
+ // biome-ignore lint/suspicious/noExplicitAny: Tests intentionally inspect arbitrary JSON fixture shapes.
88
+ function readRaw(path: string): Record<string, any> {
89
+ return JSON.parse(readFileSync(path, "utf8"));
90
+ }
91
+
92
+ describe("canonical config resolution", () => {
93
+ it("provides complete canonical defaults and the established palette defaults", () => {
94
+ const config = mergeConfig({});
95
+ expect(config.components).toEqual({
96
+ editor: {
97
+ enabled: false,
98
+ style: "opencode",
99
+ colorSource: "theme",
100
+ borderColorMode: "adaptive",
101
+ modelLabel: "name",
102
+ viewportIndicators: false,
103
+ styles: {
104
+ opencode: {
105
+ metadataFormat: DEFAULT_EDITOR_METADATA_FORMAT,
106
+ completionMenu: "native",
107
+ },
108
+ "opencode-copy-friendly": {
109
+ metadataFormat: DEFAULT_EDITOR_METADATA_FORMAT,
110
+ completionMenu: "native",
111
+ },
112
+ "accent-rail": {
113
+ rail: "▎",
114
+ asciiRail: "|",
115
+ transparent: false,
116
+ },
117
+ minimalist: {
118
+ pathDisplay: "compact",
119
+ contextFormat: "percent",
120
+ contextGauge: true,
121
+ showSessionName: true,
122
+ showTimer: true,
123
+ showCost: true,
124
+ showGit: true,
125
+ contextThresholds: { warning: 70, error: 90 },
126
+ },
127
+ },
128
+ },
129
+ userMessages: {
130
+ enabled: true,
131
+ style: "labeled",
132
+ colorSource: "theme",
133
+ styles: { framed: {}, "framed-copy-friendly": {}, compact: {}, labeled: {} },
134
+ },
135
+ workingLine: {
136
+ enabled: true,
137
+ turnSummary: true,
138
+ spinner: "star-bloom",
139
+ spinnerIntervalMs: 100,
140
+ animateSpinnerColor: false,
141
+ textIntervalMs: 60,
142
+ textAnimation: "classic",
143
+ colorSource: "theme",
144
+ messages: {
145
+ custom: true,
146
+ values: [...defaultConfig.components.workingLine.messages.values],
147
+ },
148
+ segments: { tool: true, elapsed: true, thought: true, tokens: true },
149
+ },
150
+ selectorBorders: { enabled: true, style: "zentui", colorSource: "theme" },
151
+ footer: {
152
+ style: "native",
153
+ colorSource: "theme",
154
+ modelLabel: "name",
155
+ styles: {
156
+ starship: {
157
+ format: "",
158
+ responsive: true,
159
+ compactFormat: DEFAULT_COMPACT_FOOTER_FORMAT,
160
+ compactMaxLines: "unlimited",
161
+ separator: "none",
162
+ contextStyle: "text",
163
+ contextThresholds: { warning: 70, error: 90 },
164
+ pathDisplay: { mode: "basename", depth: 2 },
165
+ segments: defaultConfig.footerSegments,
166
+ gitBranch: { maxLength: "full" },
167
+ gitCommit: { hashLength: 7, onlyDetached: true, showTag: true },
168
+ gitMetrics: { onlyNonzero: true, ignoreSubmodules: false },
169
+ extensionStatuses: {
170
+ defaultPlacement: "right",
171
+ placements: {},
172
+ colorModes: {},
173
+ },
174
+ },
175
+ },
176
+ },
177
+ });
178
+ expect(config.projectRefreshIntervalMs).toBe(30_000);
179
+ expect(config.icons.cacheHit).toBe("c");
180
+ expect(config.colors).toEqual(defaultConfig.colors);
181
+ expect(defaultConfig.components).toEqual(config.components);
182
+ });
183
+
184
+ it("normalizes Opencode completion menus independently", () => {
185
+ const config = mergeConfig({
186
+ components: {
187
+ editor: {
188
+ styles: {
189
+ opencode: { completionMenu: "native" },
190
+ "opencode-copy-friendly": { completionMenu: "invalid" },
191
+ },
192
+ },
193
+ },
194
+ });
195
+ expect(config.components.editor.styles.opencode.completionMenu).toBe("native");
196
+ expect(config.components.editor.styles["opencode-copy-friendly"].completionMenu).toBe(
197
+ "native",
198
+ );
199
+ });
200
+
201
+ it("normalizes the canonical accent-rail style and its editor-owned roles", () => {
202
+ const config = mergeConfig({
203
+ colors: { editorRail: "bright-green", editorAccent: "error" },
204
+ components: {
205
+ editor: {
206
+ style: "accent-rail",
207
+ styles: { "accent-rail": { rail: "!", asciiRail: ":", transparent: true } },
208
+ },
209
+ },
210
+ });
211
+ expect(config.components.editor.style).toBe("accent-rail");
212
+ expect(config.components.editor.styles["accent-rail"]).toEqual({
213
+ rail: "!",
214
+ asciiRail: ":",
215
+ transparent: true,
216
+ });
217
+ expect(config.colors.editorRail).toBe("bright-green");
218
+ expect(config.colors.editorAccent).toBe("error");
219
+
220
+ const invalid = mergeConfig({
221
+ components: {
222
+ editor: {
223
+ styles: { "accent-rail": { rail: "", asciiRail: 1, transparent: "yes" } },
224
+ },
225
+ },
226
+ });
227
+ expect(invalid.components.editor.styles["accent-rail"]).toEqual({
228
+ rail: "▎",
229
+ asciiRail: "|",
230
+ transparent: false,
231
+ });
232
+ });
233
+
234
+ it("bridges explicit legacy branch colors into the independent Editor branch role", () => {
235
+ expect(mergeConfig({}).colors.editorGitBranch).toBeUndefined();
236
+
237
+ const legacyDefault = mergeConfig({ colors: { gitBranch: "bold purple" } }).colors;
238
+ expect(legacyDefault.gitBranch).toBe("bold purple");
239
+ expect(legacyDefault.editorGitBranch).toBe("bold purple");
240
+
241
+ const legacyCustom = mergeConfig({ colors: { gitBranch: "success" } }).colors;
242
+ expect(legacyCustom.gitBranch).toBe("success");
243
+ expect(legacyCustom.editorGitBranch).toBe("success");
244
+
245
+ const independent = mergeConfig({
246
+ colors: { gitBranch: "success", editorGitBranch: "accent" },
247
+ }).colors;
248
+ expect(independent.gitBranch).toBe("success");
249
+ expect(independent.editorGitBranch).toBe("accent");
250
+ });
251
+
252
+ it("ignores stale fixed-editor config forms without exposing runtime fields", () => {
253
+ const config = mergeConfig({
254
+ fixedEditor: { enabled: true, mouseScroll: false },
255
+ layout: { fixedEditor: { enabled: "yes", copyNotice: false }, futureLayout: true },
256
+ });
257
+ expect(config).not.toHaveProperty("fixedEditor");
258
+ expect(config).not.toHaveProperty("layout");
259
+ });
260
+
261
+ it("migrates every released legacy field leaf-by-leaf", () => {
262
+ const config = mergeConfig({
263
+ features: {
264
+ editor: false,
265
+ statusLine: false,
266
+ viewportIndicators: false,
267
+ copyFriendly: true,
268
+ },
269
+ colorSources: { editor: "terminal", userMessages: "terminal", starship: "terminal" },
270
+ editorBorderColorMode: "adaptive",
271
+ editorModelLabel: "name",
272
+ editorMetadataFormat: "$model",
273
+ contextThresholds: { warning: 55, error: 88 },
274
+ footerFormat: "$cwd",
275
+ responsiveFooter: false,
276
+ compactFooterFormat: "$context",
277
+ compactFooterMaxLines: 3,
278
+ separator: "dot",
279
+ contextStyle: "gauge",
280
+ pathDisplay: { mode: "full", depth: 2 },
281
+ footerSegments: { cwd: false, modelInfo: true },
282
+ gitBranch: { maxLength: 18 },
283
+ gitCommit: { hashLength: 12, onlyDetached: false, showTag: false },
284
+ gitMetrics: { onlyNonzero: false, ignoreSubmodules: true },
285
+ extensionStatuses: {
286
+ defaultPlacement: "middle",
287
+ placements: { alpha: "left" },
288
+ colorModes: { alpha: "original" },
289
+ },
290
+ });
291
+ const { editor, userMessages, selectorBorders, footer } = config.components;
292
+ const starship = footer.styles.starship;
293
+ expect([editor.enabled, userMessages.enabled, selectorBorders.enabled]).toEqual([
294
+ false,
295
+ false,
296
+ false,
297
+ ]);
298
+ expect(editor.viewportIndicators).toBe(false);
299
+ expect(editor.style).toBe("opencode-copy-friendly");
300
+ expect(userMessages).toMatchObject({ enabled: false, style: "framed-copy-friendly" });
301
+ expect([editor.colorSource, selectorBorders.colorSource]).toEqual(["terminal", "terminal"]);
302
+ expect(userMessages.colorSource).toBe("terminal");
303
+ expect(footer.colorSource).toBe("terminal");
304
+ expect(editor.borderColorMode).toBe("adaptive");
305
+ expect([editor.modelLabel, footer.modelLabel]).toEqual(["name", "name"]);
306
+ expect(editor.styles.opencode.metadataFormat).toBe("$model");
307
+ expect(editor.styles.minimalist.contextThresholds).toEqual({ warning: 55, error: 88 });
308
+ expect(starship).toMatchObject({
309
+ format: "$cwd",
310
+ responsive: false,
311
+ compactFormat: "$context",
312
+ compactMaxLines: 3,
313
+ separator: "dot",
314
+ contextStyle: "gauge",
315
+ contextThresholds: { warning: 55, error: 88 },
316
+ pathDisplay: { mode: "full", depth: 2 },
317
+ gitBranch: { maxLength: 18 },
318
+ gitCommit: { hashLength: 12, onlyDetached: false, showTag: false },
319
+ gitMetrics: { onlyNonzero: false, ignoreSubmodules: true },
320
+ extensionStatuses: {
321
+ defaultPlacement: "middle",
322
+ placements: { alpha: "left" },
323
+ colorModes: { alpha: "original" },
324
+ },
325
+ });
326
+ expect(starship.segments).toMatchObject({ cwd: false, modelInfo: true });
327
+ });
328
+
329
+ it("gives canonical leaves precedence, including false and invalid-but-present values", () => {
330
+ const config = mergeConfig({
331
+ features: { editor: true, copyFriendly: true, viewportIndicators: false },
332
+ colorSources: { editor: "terminal" },
333
+ editorBorderColorMode: "adaptive",
334
+ editorMetadataFormat: "$legacy",
335
+ components: {
336
+ editor: {
337
+ enabled: false,
338
+ style: "unknown",
339
+ colorSource: "unknown",
340
+ styles: {
341
+ polished: { copyFriendly: "yes" },
342
+ },
343
+ },
344
+ },
345
+ });
346
+ expect(config.components.editor.enabled).toBe(false);
347
+ expect(config.components.editor.style).toBe("opencode");
348
+ expect(config.components.editor.colorSource).toBe("theme");
349
+ // Missing canonical siblings still migrate independently.
350
+ expect(config.components.editor.viewportIndicators).toBe(false);
351
+ expect(config.components.editor.borderColorMode).toBe("adaptive");
352
+ expect(config.components.editor.styles.opencode.metadataFormat).toBe("$legacy");
353
+ });
354
+
355
+ it("normalizes every canonical minimalist leaf without reviving branch-only inputs", () => {
356
+ for (const pathDisplay of ["compact", "project", "full"]) {
357
+ expect(
358
+ mergeConfig({
359
+ components: { editor: { styles: { minimalist: { pathDisplay } } } },
360
+ }).components.editor.styles.minimalist.pathDisplay,
361
+ ).toBe(pathDisplay);
362
+ }
363
+ for (const pathDisplay of ["basename", "", 1, null, true]) {
364
+ expect(
365
+ mergeConfig({
366
+ editorStyles: { minimalist: { pathDisplay: "full" } },
367
+ components: { editor: { styles: { minimalist: { pathDisplay } } } },
368
+ }).components.editor.styles.minimalist.pathDisplay,
369
+ ).toBe("compact");
370
+ }
371
+
372
+ for (const contextFormat of ["percent", "percent-total"]) {
373
+ expect(
374
+ mergeConfig({
375
+ components: { editor: { styles: { minimalist: { contextFormat } } } },
376
+ }).components.editor.styles.minimalist.contextFormat,
377
+ ).toBe(contextFormat);
378
+ }
379
+ for (const contextFormat of ["tokens", "", 1, null, true]) {
380
+ expect(
381
+ mergeConfig({
382
+ editorStyles: { minimalist: { contextFormat: "percent-total" } },
383
+ components: { editor: { styles: { minimalist: { contextFormat } } } },
384
+ }).components.editor.styles.minimalist.contextFormat,
385
+ ).toBe("percent");
386
+ }
387
+
388
+ const valid = mergeConfig({
389
+ components: {
390
+ editor: {
391
+ styles: {
392
+ minimalist: {
393
+ contextGauge: true,
394
+ showSessionName: false,
395
+ showTimer: false,
396
+ showCost: false,
397
+ showGit: false,
398
+ },
399
+ },
400
+ },
401
+ },
402
+ }).components.editor.styles.minimalist;
403
+ expect(valid).toMatchObject({
404
+ contextGauge: true,
405
+ showSessionName: false,
406
+ showTimer: false,
407
+ showCost: false,
408
+ showGit: false,
409
+ });
410
+
411
+ const invalid = mergeConfig({
412
+ editorStyles: {
413
+ minimalist: {
414
+ contextGauge: true,
415
+ showSessionName: false,
416
+ showTimer: false,
417
+ showCost: false,
418
+ showGit: false,
419
+ },
420
+ },
421
+ components: {
422
+ editor: {
423
+ styles: {
424
+ minimalist: {
425
+ contextGauge: "yes",
426
+ showSessionName: "yes",
427
+ showTimer: null,
428
+ showCost: 0,
429
+ showGit: "no",
430
+ },
431
+ },
432
+ },
433
+ },
434
+ }).components.editor.styles.minimalist;
435
+ expect(invalid).toEqual(defaultConfig.components.editor.styles.minimalist);
436
+ });
437
+
438
+ it("ignores branch-only editorStyle/editorStyles and safely defaults closed style IDs", () => {
439
+ const config = mergeConfig({
440
+ editorStyle: "minimalist",
441
+ editorStyles: { minimalist: { showGit: false } },
442
+ components: {
443
+ editor: { style: "future" },
444
+ userMessages: { style: "future" },
445
+ selectorBorders: { style: "future" },
446
+ footer: { style: "future" },
447
+ },
448
+ });
449
+ expect(config.components.editor.style).toBe("opencode");
450
+ expect(config.components.editor.styles.minimalist.showGit).toBe(true);
451
+ expect(config.components.userMessages.style).toBe("labeled");
452
+ expect(config.components.selectorBorders.style).toBe("zentui");
453
+ expect(config.components.footer.style).toBe("native");
454
+ });
455
+
456
+ it("does not treat native as a User-message style", () => {
457
+ expect(
458
+ mergeConfig({ components: { userMessages: { style: "native" } } }).components.userMessages
459
+ .style,
460
+ ).toBe("labeled");
461
+ });
462
+
463
+ it("normalizes nested canonical leaves without falling through to contradictory legacy leaves", () => {
464
+ const config = mergeConfig({
465
+ contextThresholds: { warning: 20, error: 80 },
466
+ pathDisplay: { mode: "full", depth: 4 },
467
+ components: {
468
+ editor: {
469
+ styles: { minimalist: { contextThresholds: { warning: "bad" } } },
470
+ },
471
+ footer: {
472
+ styles: {
473
+ starship: {
474
+ contextThresholds: { warning: "bad" },
475
+ pathDisplay: { mode: "bad" },
476
+ },
477
+ },
478
+ },
479
+ },
480
+ });
481
+ expect(config.components.editor.styles.minimalist.contextThresholds).toEqual({
482
+ warning: 70,
483
+ error: 80,
484
+ });
485
+ expect(config.components.footer.styles.starship.contextThresholds).toEqual({
486
+ warning: 70,
487
+ error: 80,
488
+ });
489
+ expect(config.components.footer.styles.starship.pathDisplay).toEqual({
490
+ mode: "basename",
491
+ depth: 4,
492
+ });
493
+ });
494
+
495
+ it("projects the intentionally lossy flat compatibility view from canonical sources", () => {
496
+ const config = mergeConfig({
497
+ components: {
498
+ editor: {
499
+ enabled: false,
500
+ style: "minimalist",
501
+ colorSource: "terminal",
502
+ modelLabel: "name",
503
+ styles: { opencode: { copyFriendly: true, metadataFormat: "$model" } },
504
+ },
505
+ userMessages: {
506
+ enabled: true,
507
+ colorSource: "theme",
508
+ styles: { framed: { copyFriendly: false } },
509
+ },
510
+ selectorBorders: { enabled: true, colorSource: "theme" },
511
+ footer: {
512
+ modelLabel: "id",
513
+ colorSource: "terminal",
514
+ styles: { starship: { contextThresholds: { warning: 40, error: 60 } } },
515
+ },
516
+ },
517
+ });
518
+ const starship = config.components.footer.styles.starship;
519
+ expect(config.footerFormat).toBe(starship.format);
520
+ expect(config.responsiveFooter).toBe(starship.responsive);
521
+ expect(config.compactFooterFormat).toBe(starship.compactFormat);
522
+ expect(config.compactFooterMaxLines).toBe(starship.compactMaxLines);
523
+ expect(config.separator).toBe(starship.separator);
524
+ expect(config.contextStyle).toBe(starship.contextStyle);
525
+ expect(config.contextThresholds).toBe(starship.contextThresholds);
526
+ expect(config.pathDisplay).toBe(starship.pathDisplay);
527
+ expect(config.footerSegments).toBe(starship.segments);
528
+ expect(config.gitBranch).toBe(starship.gitBranch);
529
+ expect(config.gitCommit).toBe(starship.gitCommit);
530
+ expect(config.gitMetrics).toBe(starship.gitMetrics);
531
+ expect(config.extensionStatuses).toBe(starship.extensionStatuses);
532
+ expect(config.features).toEqual({
533
+ editor: false,
534
+ statusLine: false,
535
+ viewportIndicators: false,
536
+ });
537
+ expect(config.colorSources).toEqual({
538
+ starship: "terminal",
539
+ editor: "terminal",
540
+ userMessages: "theme",
541
+ });
542
+ expect(config.editorModelLabel).toBe("name");
543
+ expect(config.contextThresholds).toEqual({ warning: 40, error: 60 });
544
+ });
545
+
546
+ it("does not mutate the parsed record", () => {
547
+ const parsed = {
548
+ features: { editor: false },
549
+ components: { editor: { styles: { opencode: { copyFriendly: true } } } },
550
+ };
551
+ const before = structuredClone(parsed);
552
+ mergeConfig(parsed);
553
+ expect(parsed).toEqual(before);
554
+ });
555
+
556
+ it("retains root parsing behavior for intervals, icons, colors, and telemetry variables", () => {
557
+ const config = mergeConfig({
558
+ projectRefreshIntervalMs: 100,
559
+ icons: { mode: "ascii", cwd: "DIR" },
560
+ colors: { gitBranch: "syntaxKeyword", editorAccent: "accent" },
561
+ });
562
+ expect(config.projectRefreshIntervalMs).toBe(5_000);
563
+ expect(config.icons.cwd).toBe("DIR");
564
+ expect(config.colors.gitBranch).toBe("syntaxKeyword");
565
+ expect(config.colors.editorAccent).toBe("accent");
566
+ expect(FOOTER_FORMAT_VARIABLES).toEqual(
567
+ expect.arrayContaining(["cache_read", "cache_write", "subscription", "auto_compaction"]),
568
+ );
569
+ });
570
+ });
571
+
572
+ describe("working-line config", () => {
573
+ it("defaults to cloned custom presets and migrates legacy modes without losing values", () => {
574
+ const first = mergeConfig({}).components.workingLine.messages;
575
+ const second = mergeConfig({}).components.workingLine.messages;
576
+ expect(first).toEqual({
577
+ custom: true,
578
+ values: defaultConfig.components.workingLine.messages.values,
579
+ });
580
+ expect(first.values).not.toBe(second.values);
581
+ expect(first.values).not.toBe(defaultConfig.components.workingLine.messages.values);
582
+ expect(
583
+ mergeConfig({ components: { workingLine: { messages: { mode: "native", values: [] } } } })
584
+ .components.workingLine.messages,
585
+ ).toEqual({ custom: false, values: defaultConfig.components.workingLine.messages.values });
586
+ expect(
587
+ mergeConfig({ components: { workingLine: { messages: { mode: "replace", values: [] } } } })
588
+ .components.workingLine.messages,
589
+ ).toEqual({ custom: true, values: defaultConfig.components.workingLine.messages.values });
590
+ expect(
591
+ mergeConfig({
592
+ components: { workingLine: { messages: { mode: "replace", values: [" Custom "] } } },
593
+ }).components.workingLine.messages,
594
+ ).toEqual({ custom: true, values: ["Custom"] });
595
+ const appendValues = Array.from({ length: 32 }, (_, index) => `legacy-${index}`);
596
+ const append = mergeConfig({
597
+ components: { workingLine: { messages: { mode: "append", values: appendValues } } },
598
+ }).components.workingLine.messages;
599
+ expect(append.custom).toBe(true);
600
+ expect(append.values).toEqual([
601
+ ...defaultConfig.components.workingLine.messages.values,
602
+ ...appendValues,
603
+ ]);
604
+ expect(append.values).toHaveLength(48);
605
+ });
606
+
607
+ it("gives canonical custom presence precedence and preserves explicit empty values", () => {
608
+ expect(
609
+ mergeConfig({
610
+ components: {
611
+ workingLine: { messages: { custom: false, mode: "append", values: [] } },
612
+ },
613
+ }).components.workingLine.messages,
614
+ ).toEqual({ custom: false, values: [] });
615
+ expect(
616
+ mergeConfig({
617
+ components: { workingLine: { messages: { custom: "bad", mode: "native" } } },
618
+ }).components.workingLine.messages,
619
+ ).toEqual({ custom: true, values: defaultConfig.components.workingLine.messages.values });
620
+ expect(
621
+ mergeConfig({ components: { workingLine: { messages: { values: [] } } } }).components
622
+ .workingLine.messages,
623
+ ).toEqual({ custom: true, values: [] });
624
+ });
625
+
626
+ it.each([
627
+ [30, 30],
628
+ [1000, 1000],
629
+ [29, 100],
630
+ [1001, 100],
631
+ [60.5, 100],
632
+ ["60", 100],
633
+ [Number.NaN, 100],
634
+ [Number.MAX_SAFE_INTEGER, 100],
635
+ ] as const)(
636
+ "migrates legacy Working-line interval %s to spinner speed %s ms",
637
+ (intervalMs, expected) => {
638
+ expect(
639
+ mergeConfig({ components: { workingLine: { intervalMs } } }).components.workingLine
640
+ .spinnerIntervalMs,
641
+ ).toBe(expected);
642
+ expect(
643
+ mergeConfig({ components: { workingLine: { intervalMs } } }).components.workingLine
644
+ .textIntervalMs,
645
+ ).toBe(60);
646
+ },
647
+ );
648
+
649
+ it.each([
650
+ [30, 30],
651
+ [1000, 1000],
652
+ [29, 60],
653
+ [1001, 60],
654
+ [60.5, 60],
655
+ ["60", 60],
656
+ ] as const)("normalizes canonical text speed %s to %s ms", (textIntervalMs, expected) => {
657
+ expect(
658
+ mergeConfig({ components: { workingLine: { textIntervalMs } } }).components.workingLine
659
+ .textIntervalMs,
660
+ ).toBe(expected);
661
+ });
662
+
663
+ it("gives canonical spinner speed presence precedence over legacy input", () => {
664
+ expect(
665
+ mergeConfig({
666
+ components: { workingLine: { intervalMs: 180, spinnerIntervalMs: 160 } },
667
+ }).components.workingLine.spinnerIntervalMs,
668
+ ).toBe(160);
669
+ expect(
670
+ mergeConfig({
671
+ components: { workingLine: { intervalMs: 180, spinnerIntervalMs: "bad" } },
672
+ }).components.workingLine.spinnerIntervalMs,
673
+ ).toBe(100);
674
+ });
675
+
676
+ it("defaults malformed or missing Turn summary to true and preserves explicit false", () => {
677
+ expect(mergeConfig({}).components.workingLine.turnSummary).toBe(true);
678
+ expect(
679
+ mergeConfig({ components: { workingLine: { turnSummary: "bad" } } }).components.workingLine
680
+ .turnSummary,
681
+ ).toBe(true);
682
+ expect(
683
+ mergeConfig({ components: { workingLine: { turnSummary: false } } }).components.workingLine
684
+ .turnSummary,
685
+ ).toBe(false);
686
+ });
687
+
688
+ it("saves Turn summary while preserving unknown config", () => {
689
+ withConfig(
690
+ { components: { workingLine: { unknown: { keep: true } } }, topLevel: "keep" },
691
+ (path) => {
692
+ const config = saveWorkingLineComponentPatch({ turnSummary: false }, path);
693
+ const raw = JSON.parse(readFileSync(path, "utf8"));
694
+ expect(config.components.workingLine.turnSummary).toBe(false);
695
+ expect(raw.components.workingLine.turnSummary).toBe(false);
696
+ expect(raw.components.workingLine.unknown).toEqual({ keep: true });
697
+ expect(raw.topLevel).toBe("keep");
698
+ },
699
+ );
700
+ });
701
+
702
+ it("parses Pulse without changing established Working-line defaults", () => {
703
+ expect(mergeConfig({}).components.workingLine.spinner).toBe("star-bloom");
704
+ expect(
705
+ mergeConfig({
706
+ components: { workingLine: { spinner: "pulse", spinnerIntervalMs: 180 } },
707
+ }).components.workingLine,
708
+ ).toMatchObject({ spinner: "pulse", spinnerIntervalMs: 180, textIntervalMs: 60 });
709
+ });
710
+
711
+ it("normalizes each independent field, messages, and palette override", () => {
712
+ const config = mergeConfig({
713
+ components: {
714
+ workingLine: {
715
+ enabled: true,
716
+ spinner: "pinwheel",
717
+ spinnerIntervalMs: 160,
718
+ animateSpinnerColor: true,
719
+ textIntervalMs: 40,
720
+ textAnimation: "kitt",
721
+ colorSource: "terminal",
722
+ messages: {
723
+ mode: "replace",
724
+ values: [" One ", "One", "\x1b[31mTwo\x1b[0m", "\n"],
725
+ },
726
+ segments: { tool: false, elapsed: true, thought: true, tokens: false },
727
+ },
728
+ },
729
+ colors: {
730
+ workingLineLow: "fg:240",
731
+ workingLineMid: "cyan",
732
+ workingLineHigh: "bold cyan",
733
+ editorAccent: "red",
734
+ },
735
+ });
736
+ expect(config.components.workingLine).toEqual({
737
+ enabled: true,
738
+ turnSummary: true,
739
+ spinner: "pinwheel",
740
+ spinnerIntervalMs: 160,
741
+ animateSpinnerColor: true,
742
+ textIntervalMs: 40,
743
+ textAnimation: "kitt",
744
+ colorSource: "terminal",
745
+ messages: { custom: true, values: ["One", "Two"] },
746
+ segments: { tool: false, elapsed: true, thought: true, tokens: false },
747
+ });
748
+ expect(config.colors).toMatchObject({
749
+ workingLineLow: "fg:240",
750
+ workingLineMid: "cyan",
751
+ workingLineHigh: "bold cyan",
752
+ editorAccent: "red",
753
+ });
754
+ });
755
+
756
+ it("falls back malformed leaves independently and enforces message caps", () => {
757
+ const values = Array.from({ length: 40 }, (_, index) => `${index}:${"界".repeat(30)}`);
758
+ const component = mergeConfig({
759
+ components: {
760
+ workingLine: {
761
+ enabled: "yes",
762
+ spinner: "future",
763
+ intervalMs: 29,
764
+ animateSpinnerColor: "yes",
765
+ textAnimation: "pulse",
766
+ colorSource: "editor",
767
+ messages: { mode: "rotate", values },
768
+ },
769
+ },
770
+ colors: { workingLineLow: "not-a-color" },
771
+ }).components.workingLine;
772
+ expect(component).toMatchObject({
773
+ enabled: true,
774
+ spinner: "star-bloom",
775
+ spinnerIntervalMs: 100,
776
+ animateSpinnerColor: false,
777
+ textIntervalMs: 60,
778
+ textAnimation: "classic",
779
+ colorSource: "theme",
780
+ messages: { custom: true },
781
+ segments: { tool: true, elapsed: true, thought: true, tokens: true },
782
+ });
783
+ expect(component.messages.values).toHaveLength(40);
784
+ expect(component.messages.values.every((value) => value.length > 0)).toBe(true);
785
+ expect(component.messages.values.every((value) => visibleWidth(value) <= 43)).toBe(true);
786
+ });
787
+
788
+ it("saves nested patches, copies arrays, and preserves unknown fields", () => {
789
+ withConfig(
790
+ {
791
+ unknownTop: true,
792
+ components: {
793
+ workingLine: {
794
+ future: { keep: true },
795
+ intervalMs: 180,
796
+ messages: { mode: "append", futureMessages: true, values: ["Old"] },
797
+ },
798
+ },
799
+ },
800
+ (path) => {
801
+ const values = [" New ", "New", "Other"];
802
+ const saved = saveWorkingLineComponentPatch(
803
+ {
804
+ spinner: "braille",
805
+ spinnerIntervalMs: 60,
806
+ animateSpinnerColor: true,
807
+ messages: { custom: true, values },
808
+ segments: { elapsed: false },
809
+ },
810
+ path,
811
+ );
812
+ values[0] = "mutated";
813
+ const raw = readRaw(path);
814
+ expect(saved.components.workingLine).toMatchObject({
815
+ spinner: "braille",
816
+ spinnerIntervalMs: 60,
817
+ animateSpinnerColor: true,
818
+ textIntervalMs: 60,
819
+ messages: { custom: true, values: ["New", "Other"] },
820
+ segments: { tool: true, elapsed: false, thought: true, tokens: true },
821
+ });
822
+ expect(raw.unknownTop).toBe(true);
823
+ expect(raw.components.workingLine.future).toEqual({ keep: true });
824
+ expect(raw.components.workingLine.messages.futureMessages).toBe(true);
825
+ expect(raw.components.workingLine.messages.values).toEqual(["New", "Other"]);
826
+ expect(raw.components.workingLine.messages.custom).toBe(true);
827
+ expect(raw.components.workingLine.messages).not.toHaveProperty("mode");
828
+ expect(raw.components.workingLine).not.toHaveProperty("intervalMs");
829
+ },
830
+ );
831
+ });
832
+ });
833
+
834
+ describe("Phase 4 style migration", () => {
835
+ it.each([
836
+ [true, "opencode-copy-friendly"],
837
+ [false, "opencode"],
838
+ ["invalid", "opencode"],
839
+ ] as const)("maps a present nested Editor flag %s deterministically", (copyFriendly, style) => {
840
+ const config = mergeConfig({
841
+ features: { copyFriendly: true },
842
+ components: {
843
+ editor: { style: "polished", styles: { polished: { copyFriendly } } },
844
+ },
845
+ });
846
+ expect(config.components.editor.style).toBe(style);
847
+ });
848
+
849
+ it.each([
850
+ [true, "framed-copy-friendly"],
851
+ [false, "framed"],
852
+ ["invalid", "framed"],
853
+ ] as const)("maps a present nested message flag %s deterministically", (copyFriendly, style) => {
854
+ const config = mergeConfig({
855
+ features: { editor: true, copyFriendly: true },
856
+ components: {
857
+ userMessages: {
858
+ enabled: true,
859
+ style: "framed",
860
+ styles: { framed: { copyFriendly } },
861
+ },
862
+ },
863
+ });
864
+ expect(config.components.userMessages).toMatchObject({ style, enabled: true });
865
+ });
866
+
867
+ it("maps the released feature flag to copy-friendly Editor and messages", () => {
868
+ const migrated = mergeConfig({ features: { copyFriendly: true } });
869
+ expect(migrated.components.editor.style).toBe("opencode-copy-friendly");
870
+ expect(migrated.components.userMessages).toMatchObject({
871
+ style: "framed-copy-friendly",
872
+ enabled: true,
873
+ });
874
+
875
+ const regular = mergeConfig({ features: { copyFriendly: false } });
876
+ expect(regular.components.editor.style).toBe("opencode");
877
+ expect(regular.components.userMessages).toMatchObject({ style: "labeled", enabled: true });
878
+ expect(migrated.features).not.toHaveProperty("copyFriendly");
879
+ });
880
+
881
+ it.each([
882
+ [undefined, true, "opencode-copy-friendly"],
883
+ [undefined, false, "opencode"],
884
+ [undefined, "invalid", "opencode"],
885
+ ["future", true, "opencode-copy-friendly"],
886
+ ["future", false, "opencode"],
887
+ ["future", "invalid", "opencode"],
888
+ ] as const)(
889
+ "resolves absent or invalid Editor style %s from nested flag %s",
890
+ (rawStyle, copyFriendly, expected) => {
891
+ const config = mergeConfig({
892
+ features: { copyFriendly: copyFriendly !== true },
893
+ components: {
894
+ editor: {
895
+ ...(rawStyle === undefined ? {} : { style: rawStyle }),
896
+ styles: { polished: { copyFriendly } },
897
+ },
898
+ },
899
+ });
900
+ expect(config.components.editor.style).toBe(expected);
901
+ },
902
+ );
903
+
904
+ it.each([
905
+ [undefined, true, "framed-copy-friendly"],
906
+ [undefined, false, "framed"],
907
+ [undefined, "invalid", "framed"],
908
+ ["future", true, "framed-copy-friendly"],
909
+ ["future", false, "framed"],
910
+ ["future", "invalid", "framed"],
911
+ ] as const)(
912
+ "resolves absent or invalid message style %s from nested flag %s",
913
+ (rawStyle, copyFriendly, expectedStyle) => {
914
+ const config = mergeConfig({
915
+ features: { editor: true, copyFriendly: copyFriendly !== true },
916
+ components: {
917
+ userMessages: {
918
+ enabled: true,
919
+ ...(rawStyle === undefined ? {} : { style: rawStyle }),
920
+ styles: { framed: { copyFriendly } },
921
+ },
922
+ },
923
+ });
924
+ expect(config.components.userMessages).toMatchObject({
925
+ style: expectedStyle,
926
+ enabled: true,
927
+ });
928
+ },
929
+ );
930
+
931
+ it("treats ambiguous explicit styles without nested flags as authoritative", () => {
932
+ const config = mergeConfig({
933
+ features: { editor: true, copyFriendly: true },
934
+ components: {
935
+ editor: { style: "opencode" },
936
+ userMessages: { enabled: true, style: "framed" },
937
+ },
938
+ });
939
+ expect(config.components.editor.style).toBe("opencode");
940
+ expect(config.components.userMessages).toMatchObject({ style: "framed", enabled: true });
941
+ });
942
+
943
+ it("uses released flags for absent or invalid styles without nested flags", () => {
944
+ for (const style of [undefined, "future"] as const) {
945
+ const config = mergeConfig({
946
+ features: { editor: true, copyFriendly: true },
947
+ components: {
948
+ editor: style === undefined ? {} : { style },
949
+ userMessages: style === undefined ? { enabled: true } : { enabled: true, style },
950
+ },
951
+ });
952
+ expect(config.components.editor.style).toBe("opencode-copy-friendly");
953
+ expect(config.components.userMessages).toMatchObject({
954
+ style: "framed-copy-friendly",
955
+ enabled: true,
956
+ });
957
+ }
958
+ });
959
+
960
+ it.each([
961
+ ["minimalist", "compact"],
962
+ ["opencode-copy-friendly", "labeled"],
963
+ ["opencode", "framed-copy-friendly"],
964
+ ] as const)("preserves unambiguous explicit styles %s and %s", (editorStyle, messageStyle) => {
965
+ const config = mergeConfig({
966
+ features: { copyFriendly: true },
967
+ components: {
968
+ editor: {
969
+ style: editorStyle,
970
+ styles: { opencode: { copyFriendly: true } },
971
+ },
972
+ userMessages: {
973
+ enabled: true,
974
+ style: messageStyle,
975
+ styles: { framed: { copyFriendly: true } },
976
+ },
977
+ },
978
+ });
979
+ expect(config.components.editor.style).toBe(editorStyle);
980
+ expect(config.components.userMessages).toMatchObject({ style: messageStyle, enabled: true });
981
+ });
982
+
983
+ it("seeds and parses polished metadata independently", () => {
984
+ const seeded = mergeConfig({ editorMetadataFormat: "$legacy" });
985
+ expect(seeded.components.editor.styles.opencode.metadataFormat).toBe("$legacy");
986
+ expect(seeded.components.editor.styles["opencode-copy-friendly"].metadataFormat).toBe(
987
+ "$legacy",
988
+ );
989
+ const independent = mergeConfig({
990
+ editorMetadataFormat: "$flat",
991
+ components: {
992
+ editor: {
993
+ styles: {
994
+ polished: { metadataFormat: "$regular" },
995
+ "opencode-copy-friendly": { metadataFormat: "$low" },
996
+ },
997
+ },
998
+ },
999
+ });
1000
+ expect(independent.components.editor.styles.opencode.metadataFormat).toBe("$regular");
1001
+ expect(independent.components.editor.styles["opencode-copy-friendly"].metadataFormat).toBe(
1002
+ "$low",
1003
+ );
1004
+ });
1005
+
1006
+ it("deletes only obsolete owning leaves on explicit style saves", () => {
1007
+ withConfig(
1008
+ {
1009
+ features: { copyFriendly: true },
1010
+ components: {
1011
+ editor: {
1012
+ styles: {
1013
+ polished: { copyFriendly: true, sibling: "keep" },
1014
+ future: { keep: true },
1015
+ },
1016
+ },
1017
+ userMessages: {
1018
+ styles: { framed: { copyFriendly: true, sibling: "keep" }, future: { keep: true } },
1019
+ },
1020
+ },
1021
+ },
1022
+ (path) => {
1023
+ saveEditorComponentPatch({ style: "opencode" }, path);
1024
+ const afterEditor = readRaw(path);
1025
+ expect(afterEditor.components.editor.styles.polished).toMatchObject({ sibling: "keep" });
1026
+ expect(afterEditor.components.editor.styles.polished).not.toHaveProperty("copyFriendly");
1027
+ expect(afterEditor.components.editor.styles.opencode).toHaveProperty("metadataFormat");
1028
+ expect(afterEditor.components.userMessages.styles.framed).toMatchObject({
1029
+ copyFriendly: true,
1030
+ sibling: "keep",
1031
+ });
1032
+
1033
+ saveUserMessagesComponentPatch({ style: "framed" }, path);
1034
+ const raw = readRaw(path);
1035
+ expect(raw.components.editor.styles.polished).toMatchObject({ sibling: "keep" });
1036
+ expect(raw.components.editor.styles.polished).not.toHaveProperty("copyFriendly");
1037
+ expect(raw.components.userMessages.styles.framed).toMatchObject({ sibling: "keep" });
1038
+ expect(raw.components.userMessages.styles.framed).not.toHaveProperty("copyFriendly");
1039
+ expect(raw.components.editor.styles.future).toEqual({ keep: true });
1040
+ expect(raw.components.userMessages.styles.future).toEqual({ keep: true });
1041
+ expect(raw.features.copyFriendly).toBe(true);
1042
+ const reloaded = mergeConfig(raw);
1043
+ expect(reloaded.components.editor.style).toBe("opencode");
1044
+ expect(reloaded.components.userMessages).toMatchObject({ style: "framed", enabled: true });
1045
+ },
1046
+ );
1047
+ });
1048
+
1049
+ it.each(["framed", "framed-copy-friendly"] as const)(
1050
+ "cleans only the obsolete nested flag when explicitly saving %s",
1051
+ (style) => {
1052
+ withConfig(
1053
+ {
1054
+ features: { copyFriendly: true },
1055
+ components: {
1056
+ userMessages: {
1057
+ styles: {
1058
+ framed: { copyFriendly: true, sibling: "keep" },
1059
+ future: { keep: true },
1060
+ },
1061
+ futureComponent: "keep",
1062
+ },
1063
+ },
1064
+ },
1065
+ (path) => {
1066
+ saveUserMessagesComponentPatch({ style }, path);
1067
+ const raw = readRaw(path);
1068
+ expect(raw.components.userMessages.style).toBe(style);
1069
+ expect(raw.components.userMessages.styles.framed).toEqual({ sibling: "keep" });
1070
+ expect(raw.components.userMessages.styles.future).toEqual({ keep: true });
1071
+ expect(raw.components.userMessages.futureComponent).toBe("keep");
1072
+ expect(raw.features.copyFriendly).toBe(true);
1073
+ expect(mergeConfig(raw).components.userMessages.style).toBe(style);
1074
+ },
1075
+ );
1076
+ },
1077
+ );
1078
+
1079
+ it.each([
1080
+ [
1081
+ "Editor",
1082
+ "editor",
1083
+ (path: string) => saveEditorComponentPatch({ enabled: false }, path),
1084
+ (path: string) => saveEditorComponentPatch({ style: "opencode" }, path),
1085
+ "opencode",
1086
+ ],
1087
+ [
1088
+ "User messages",
1089
+ "userMessages",
1090
+ (path: string) => saveUserMessagesComponentPatch({ enabled: false }, path),
1091
+ (path: string) => saveUserMessagesComponentPatch({ style: "framed" }, path),
1092
+ "framed",
1093
+ ],
1094
+ [
1095
+ "Selector borders",
1096
+ "selectorBorders",
1097
+ (path: string) => saveSelectorBordersComponentPatch({ enabled: false }, path),
1098
+ (path: string) => saveSelectorBordersComponentPatch({ style: "zentui" }, path),
1099
+ "zentui",
1100
+ ],
1101
+ [
1102
+ "Footer",
1103
+ "footer",
1104
+ (path: string) => saveFooterComponentPatch({ colorSource: "terminal" }, path),
1105
+ (path: string) => saveFooterComponentPatch({ style: "starship" }, path),
1106
+ "starship",
1107
+ ],
1108
+ ] as const)(
1109
+ "preserves an unknown raw %s style until its owning style is explicitly selected",
1110
+ (_label, owner, saveUnrelated, saveStyle, expectedStyle) => {
1111
+ withConfig(
1112
+ {
1113
+ components: {
1114
+ [owner]: {
1115
+ style: `future-${owner}`,
1116
+ futureOption: "keep",
1117
+ styles: { future: { keep: true } },
1118
+ },
1119
+ },
1120
+ },
1121
+ (path) => {
1122
+ const unrelatedConfig = saveUnrelated(path);
1123
+ expect(hasUnsupportedComponentStyle(unrelatedConfig, owner)).toBe(true);
1124
+ const preserved = readRaw(path);
1125
+ expect(preserved.components[owner].style).toBe(`future-${owner}`);
1126
+ expect(preserved.components[owner].futureOption).toBe("keep");
1127
+ expect(preserved.components[owner].styles.future).toEqual({ keep: true });
1128
+
1129
+ const replacedConfig = saveStyle(path);
1130
+ expect(hasUnsupportedComponentStyle(replacedConfig, owner)).toBe(false);
1131
+ const replaced = readRaw(path);
1132
+ expect(replaced.components[owner].style).toBe(expectedStyle);
1133
+ expect(replaced.components[owner].futureOption).toBe("keep");
1134
+ expect(replaced.components[owner].styles.future).toEqual({ keep: true });
1135
+ },
1136
+ );
1137
+ },
1138
+ );
1139
+ });
1140
+
1141
+ describe("canonical snapshot persistence", () => {
1142
+ it("materializes every component from legacy inputs and preserves unknown data", () => {
1143
+ withConfig(
1144
+ {
1145
+ unknownTop: { keep: true },
1146
+ features: { editor: false, copyFriendly: true },
1147
+ colorSources: { editor: "terminal" },
1148
+ components: {
1149
+ futureDomain: { keep: true },
1150
+ editor: {
1151
+ futureComponent: true,
1152
+ styles: {
1153
+ futureStyle: { keep: true },
1154
+ polished: { futurePolished: true },
1155
+ },
1156
+ },
1157
+ footer: {
1158
+ styles: {
1159
+ starship: {
1160
+ futureStarship: true,
1161
+ pathDisplay: { futurePath: true },
1162
+ extensionStatuses: { futureStatuses: true },
1163
+ },
1164
+ },
1165
+ },
1166
+ },
1167
+ },
1168
+ (path) => {
1169
+ const config = saveEditorComponentPatch({ enabled: true }, path);
1170
+ const raw = readRaw(path);
1171
+ expect(Object.keys(raw.components)).toEqual(
1172
+ expect.arrayContaining([
1173
+ "editor",
1174
+ "userMessages",
1175
+ "workingLine",
1176
+ "selectorBorders",
1177
+ "footer",
1178
+ "futureDomain",
1179
+ ]),
1180
+ );
1181
+ expect(raw.components.editor.enabled).toBe(true);
1182
+ expect(raw.components.userMessages.enabled).toBe(false);
1183
+ expect(raw.components.selectorBorders.enabled).toBe(false);
1184
+ expect(raw.components.editor.style).toBe("opencode-copy-friendly");
1185
+ expect(raw.components.userMessages.enabled).toBe(false);
1186
+ expect(raw.components.editor.colorSource).toBe("terminal");
1187
+ expect(raw.components.selectorBorders.colorSource).toBe("terminal");
1188
+ expect(raw.features).toEqual({ editor: false, copyFriendly: true });
1189
+ expect(raw.unknownTop).toEqual({ keep: true });
1190
+ expect(raw.components.futureDomain).toEqual({ keep: true });
1191
+ expect(raw.components.editor.futureComponent).toBe(true);
1192
+ expect(raw.components.editor.styles.futureStyle).toEqual({ keep: true });
1193
+ expect(raw.components.editor.styles.polished.futurePolished).toBe(true);
1194
+ expect(raw.components.footer.styles.starship.futureStarship).toBe(true);
1195
+ expect(raw.components.footer.styles.starship.pathDisplay.futurePath).toBe(true);
1196
+ expect(raw.components.footer.styles.starship.extensionStatuses.futureStatuses).toBe(true);
1197
+ expect(config).toEqual(mergeConfig(raw));
1198
+ },
1199
+ );
1200
+ });
1201
+
1202
+ it("prevents legacy edits from recoupling surfaces after the first save", () => {
1203
+ withConfig({ features: { editor: false, copyFriendly: true } }, (path) => {
1204
+ saveEditorComponentPatch({ enabled: true }, path);
1205
+ const raw = readRaw(path);
1206
+ expect(raw.components.editor.enabled).toBe(true);
1207
+ expect(raw.components.userMessages.enabled).toBe(false);
1208
+ expect(raw.components.selectorBorders.enabled).toBe(false);
1209
+ expect(raw.components.editor.style).toBe("opencode-copy-friendly");
1210
+ expect(raw.components.userMessages.enabled).toBe(false);
1211
+ raw.features.editor = true;
1212
+ raw.features.copyFriendly = false;
1213
+ writeFileSync(path, `${JSON.stringify(raw, null, 2)}\n`);
1214
+ const reloaded = mergeConfig(readRaw(path));
1215
+ expect(reloaded.components.editor.enabled).toBe(true);
1216
+ expect(reloaded.components.userMessages.enabled).toBe(false);
1217
+ expect(reloaded.components.selectorBorders.enabled).toBe(false);
1218
+ expect(reloaded.components.editor.style).toBe("opencode-copy-friendly");
1219
+ expect(reloaded.components.userMessages.enabled).toBe(false);
1220
+ });
1221
+ });
1222
+
1223
+ it("supports every typed component saver without discarding inactive styles", () => {
1224
+ withConfig(undefined, (path) => {
1225
+ savePolishedEditorStylePatch({ metadataFormat: "$provider", completionMenu: "native" }, path);
1226
+ savePolishedCopyFriendlyEditorStylePatch(
1227
+ { metadataFormat: "$model", completionMenu: "palette" },
1228
+ path,
1229
+ );
1230
+ saveAccentRailEditorStylePatch({ transparent: true }, path);
1231
+ saveMinimalistEditorStylePatch({ showGit: false, contextGauge: true }, path);
1232
+ saveUserMessagesComponentPatch({ enabled: false, colorSource: "terminal" }, path);
1233
+ saveSelectorBordersComponentPatch({ enabled: false, colorSource: "terminal" }, path);
1234
+ saveFooterComponentPatch({ style: "native", modelLabel: "name" }, path);
1235
+ saveStarshipFooterStylePatch(
1236
+ { separator: "chevron", pathDisplay: { mode: "full", depth: 2 } },
1237
+ path,
1238
+ );
1239
+ const config = mergeConfig(readRaw(path));
1240
+ expect(config.components.editor.styles.opencode).toEqual({
1241
+ metadataFormat: "$provider",
1242
+ completionMenu: "native",
1243
+ });
1244
+ expect(config.components.editor.styles["opencode-copy-friendly"]).toEqual({
1245
+ metadataFormat: "$model",
1246
+ completionMenu: "palette",
1247
+ });
1248
+ expect(config.components.editor.styles["accent-rail"]).toMatchObject({
1249
+ transparent: true,
1250
+ });
1251
+ expect(config.components.editor.styles.minimalist).toMatchObject({
1252
+ showGit: false,
1253
+ contextGauge: true,
1254
+ });
1255
+ expect(config.components.userMessages).toMatchObject({
1256
+ enabled: false,
1257
+ colorSource: "terminal",
1258
+ });
1259
+ expect(config.components.selectorBorders).toMatchObject({
1260
+ enabled: false,
1261
+ colorSource: "terminal",
1262
+ });
1263
+ expect(config.components.footer).toMatchObject({ style: "native", modelLabel: "name" });
1264
+ expect(config.components.footer.styles.starship).toMatchObject({
1265
+ separator: "chevron",
1266
+ pathDisplay: { mode: "full", depth: 2 },
1267
+ });
1268
+ });
1269
+ });
1270
+
1271
+ it("preserves stale fixed-editor keys during an unrelated explicit save", () => {
1272
+ const stale = {
1273
+ fixedEditor: { enabled: true, legacyUnknown: true },
1274
+ layout: { fixedEditor: { mouseScroll: false }, futureLayout: true },
1275
+ };
1276
+ withConfig(stale, (path) => {
1277
+ saveFooterComponentPatch({ style: "hidden" }, path);
1278
+ const raw = readRaw(path);
1279
+ expect(raw.fixedEditor).toEqual(stale.fixedEditor);
1280
+ expect(raw.layout).toEqual(stale.layout);
1281
+ expect(raw.components.footer.style).toBe("hidden");
1282
+ });
1283
+ });
1284
+ });
1285
+
1286
+ describe("compatibility saver recipes", () => {
1287
+ it("writes canonical paths only and updates all historically coupled destinations", () => {
1288
+ withConfig(
1289
+ {
1290
+ features: { editor: true, statusLine: true, copyFriendly: false },
1291
+ colorSources: { editor: "theme", starship: "theme", userMessages: "theme" },
1292
+ editorModelLabel: "id",
1293
+ footerFormat: "$legacy",
1294
+ unknown: true,
1295
+ },
1296
+ (path) => {
1297
+ const legacyBefore = structuredClone(readRaw(path));
1298
+ saveColorSourcesPatch(
1299
+ { editor: "terminal", starship: "terminal", userMessages: "terminal" },
1300
+ path,
1301
+ );
1302
+ saveUiFeaturesPatch({ editor: false, statusLine: false, viewportIndicators: false }, path);
1303
+ saveEditorModelLabel("name", path);
1304
+ saveEditorStyle("minimalist", path);
1305
+ saveMinimalistPatch({ showTimer: false, showGit: false }, path);
1306
+ saveEditorBorderColorMode("adaptive", path);
1307
+ saveFooterFormatPatch("$cwd", path);
1308
+ saveResponsiveFooterPatch(
1309
+ { responsiveFooter: false, compactFooterFormat: "$context", compactFooterMaxLines: 3 },
1310
+ path,
1311
+ );
1312
+ saveSeparatorPatch("dot", path);
1313
+ saveContextStylePatch("text+gauge", path);
1314
+ saveContextThresholdsPatch({ warning: 45, error: 75 }, path);
1315
+ savePathDisplayPatch({ mode: "full", depth: 3 }, path);
1316
+ saveFooterSegmentsPatch({ modelInfo: true, tokens: false }, path);
1317
+ saveGitBranchPatch({ maxLength: 24 }, path);
1318
+ saveGitCommitPatch({ onlyDetached: false, showTag: false }, path);
1319
+ saveGitMetricsPatch({ onlyNonzero: false, ignoreSubmodules: true }, path);
1320
+ saveExtensionStatusDefaultPlacement("middle", path);
1321
+ saveExtensionStatusPlacement("alpha", "left", path);
1322
+ saveExtensionStatusColorMode("alpha", "original", path);
1323
+ const raw = readRaw(path);
1324
+ const config = mergeConfig(raw);
1325
+ expect(raw.features).toEqual(legacyBefore.features);
1326
+ expect(raw.colorSources).toEqual(legacyBefore.colorSources);
1327
+ expect(raw.editorModelLabel).toBe("id");
1328
+ expect(raw.footerFormat).toBe("$legacy");
1329
+ expect(raw.unknown).toBe(true);
1330
+ expect(raw).not.toHaveProperty("editorStyle");
1331
+ expect(raw).not.toHaveProperty("editorStyles");
1332
+ expect(config.components.editor).toMatchObject({
1333
+ enabled: false,
1334
+ style: "minimalist",
1335
+ colorSource: "terminal",
1336
+ borderColorMode: "adaptive",
1337
+ modelLabel: "name",
1338
+ viewportIndicators: false,
1339
+ });
1340
+ expect(config.components.userMessages).toMatchObject({
1341
+ enabled: false,
1342
+ colorSource: "terminal",
1343
+ });
1344
+ expect(config.components.selectorBorders).toMatchObject({
1345
+ enabled: false,
1346
+ colorSource: "terminal",
1347
+ });
1348
+ expect(config.components.editor.styles.minimalist).toMatchObject({
1349
+ showTimer: false,
1350
+ showGit: false,
1351
+ contextThresholds: { warning: 45, error: 75 },
1352
+ });
1353
+ expect(config.components.footer).toMatchObject({
1354
+ style: "native",
1355
+ colorSource: "terminal",
1356
+ modelLabel: "name",
1357
+ });
1358
+ expect(config.components.footer.styles.starship).toMatchObject({
1359
+ format: "$cwd",
1360
+ responsive: false,
1361
+ compactFormat: "$context",
1362
+ compactMaxLines: 3,
1363
+ separator: "dot",
1364
+ contextStyle: "text+gauge",
1365
+ contextThresholds: { warning: 45, error: 75 },
1366
+ pathDisplay: { mode: "full", depth: 3 },
1367
+ segments: { modelInfo: true, tokens: false },
1368
+ gitBranch: { maxLength: 24 },
1369
+ gitCommit: { onlyDetached: false, showTag: false },
1370
+ gitMetrics: { onlyNonzero: false, ignoreSubmodules: true },
1371
+ extensionStatuses: {
1372
+ defaultPlacement: "middle",
1373
+ placements: { alpha: "left" },
1374
+ colorModes: { alpha: "original" },
1375
+ },
1376
+ });
1377
+ },
1378
+ );
1379
+ });
1380
+
1381
+ it("materializes a complete snapshot when a compatibility saver creates the file", () => {
1382
+ withConfig(undefined, (path) => {
1383
+ saveUiFeaturesPatch({ editor: false }, path);
1384
+ const raw = readRaw(path);
1385
+ expect(Object.keys(raw)).toEqual(["components"]);
1386
+ expect(Object.keys(raw.components).sort()).toEqual([
1387
+ "editor",
1388
+ "footer",
1389
+ "selectorBorders",
1390
+ "userMessages",
1391
+ "workingLine",
1392
+ ]);
1393
+ expect(raw.components.editor.enabled).toBe(false);
1394
+ expect(raw.components.userMessages.enabled).toBe(false);
1395
+ expect(raw.components.selectorBorders.enabled).toBe(false);
1396
+ expect(raw).not.toHaveProperty("features");
1397
+ });
1398
+ });
1399
+ });
1400
+
1401
+ describe("mergeConfig", () => {
1402
+ it("defaults project refresh polling to 30 seconds and Starship styles", () => {
1403
+ const config = mergeConfig({});
1404
+ expect(config.projectRefreshIntervalMs).toBe(30_000);
1405
+ expect(config.icons.cacheHit).toBe("c");
1406
+ expect(config.icons.editorPrompt).toBe("");
1407
+ expect(config.colors.gitBranch).toBe("bold purple");
1408
+ expect(config.colors.packageVersion).toBe("208");
1409
+ expect(config.colors.gitCommit).toBe("bold green");
1410
+ expect(config.colors.gitMetricsAdded).toBe("bold green");
1411
+ expect(config.colors.gitMetricsDeleted).toBe("bold red");
1412
+ expect(config.colors.sessionName).toBe("bold green");
1413
+ expect(config.colors.contextNormal).toBe("bright-black");
1414
+ expect(config.colors.tokens).toBe("bright-black");
1415
+ expect(config.colors.extensionStatus).toBe("bright-black");
1416
+ expect(config.colors.editorAccent).toBeUndefined();
1417
+ expect(config.colors.editorRail).toBeUndefined();
1418
+ expect(config.colors.editorPrompt).toBeUndefined();
1419
+ expect(config.colors.editorBorder).toBeUndefined();
1420
+ expect(config.colorSources).toEqual({
1421
+ starship: "theme",
1422
+ editor: "theme",
1423
+ userMessages: "theme",
1424
+ });
1425
+ expect(config.features).toEqual({
1426
+ editor: false,
1427
+ statusLine: false,
1428
+ viewportIndicators: false,
1429
+ });
1430
+ expect(config.footerSegments).toEqual({
1431
+ cwd: true,
1432
+ sessionName: true,
1433
+ gitBranch: true,
1434
+ gitStatus: true,
1435
+ runtime: true,
1436
+ modelInfo: false,
1437
+ context: true,
1438
+ gitCounts: false,
1439
+ sessionDuration: false,
1440
+ username: false,
1441
+ time: false,
1442
+ os: false,
1443
+ packageVersion: false,
1444
+ gitCommit: false,
1445
+ gitMetrics: true,
1446
+ tokens: true,
1447
+ cost: true,
1448
+ });
1449
+ expect(config.extensionStatuses).toEqual({
1450
+ defaultPlacement: "right",
1451
+ placements: {},
1452
+ colorModes: {},
1453
+ });
1454
+ });
1455
+
1456
+ it("registers the canonical telemetry variables without aliases", () => {
1457
+ expect(FOOTER_FORMAT_VARIABLES).toEqual(
1458
+ expect.arrayContaining(["cache_read", "cache_write", "subscription", "auto_compaction"]),
1459
+ );
1460
+ expect(FOOTER_FORMAT_VARIABLES).not.toContain("experimental");
1461
+ });
1462
+
1463
+ it("defaults footerFormat to empty string", () => {
1464
+ expect(mergeConfig({}).footerFormat).toBe("");
1465
+ expect(defaultConfig.footerFormat).toBe("");
1466
+ });
1467
+
1468
+ it("accepts a custom footerFormat string", () => {
1469
+ expect(mergeConfig({ footerFormat: "$cwd on $git_branch $fill $cost" }).footerFormat).toBe(
1470
+ "$cwd on $git_branch $fill $cost",
1471
+ );
1472
+ });
1473
+
1474
+ it("defaults and normalizes responsive footer settings", () => {
1475
+ expect(DEFAULT_COMPACT_FOOTER_FORMAT).toBe(
1476
+ "$cwd$wrap(in $session_name)$wrap(on $git_branch) $git_status$wrap$context$wrap_sep$tokens",
1477
+ );
1478
+ expect(mergeConfig({})).toMatchObject({
1479
+ responsiveFooter: true,
1480
+ compactFooterFormat: DEFAULT_COMPACT_FOOTER_FORMAT,
1481
+ compactFooterMaxLines: "unlimited",
1482
+ });
1483
+ expect(
1484
+ mergeConfig({
1485
+ responsiveFooter: false,
1486
+ compactFooterFormat: "$cwd$wrap$context",
1487
+ compactFooterMaxLines: "unlimited",
1488
+ }),
1489
+ ).toMatchObject({
1490
+ responsiveFooter: false,
1491
+ compactFooterFormat: "$cwd$wrap$context",
1492
+ compactFooterMaxLines: "unlimited",
1493
+ });
1494
+ for (const compactFooterMaxLines of [1, 2, 3, "unlimited"] as const) {
1495
+ expect(mergeConfig({ compactFooterMaxLines }).compactFooterMaxLines).toBe(
1496
+ compactFooterMaxLines,
1497
+ );
1498
+ }
1499
+ for (const value of [0, 4, "2", null, false]) {
1500
+ expect(mergeConfig({ compactFooterMaxLines: value }).compactFooterMaxLines).toBe("unlimited");
1501
+ }
1502
+ expect(mergeConfig({ responsiveFooter: "false" }).responsiveFooter).toBe(true);
1503
+ expect(mergeConfig({ compactFooterFormat: "" }).compactFooterFormat).toBe(
1504
+ DEFAULT_COMPACT_FOOTER_FORMAT,
1505
+ );
1506
+ });
1507
+
1508
+ it("persists responsive footer patches without replacing unrelated keys", () => {
1509
+ const dir = mkdtempSync(join(tmpdir(), "zentui-responsive-config-"));
1510
+ const path = join(dir, "zentui.json");
1511
+ try {
1512
+ writeFileSync(path, JSON.stringify({ unknown: { keep: true }, footerFormat: "$cwd" }));
1513
+ const config = saveResponsiveFooterPatch(
1514
+ { responsiveFooter: false, compactFooterMaxLines: 3 },
1515
+ path,
1516
+ );
1517
+ const raw = JSON.parse(readFileSync(path, "utf8"));
1518
+ expect(raw.unknown).toEqual({ keep: true });
1519
+ expect(raw.footerFormat).toBe("$cwd");
1520
+ expect(raw.components.footer.styles.starship).toMatchObject({
1521
+ format: "$cwd",
1522
+ responsive: false,
1523
+ compactMaxLines: 3,
1524
+ });
1525
+ expect(config.responsiveFooter).toBe(false);
1526
+ expect(config.compactFooterMaxLines).toBe(3);
1527
+ } finally {
1528
+ rmSync(dir, { recursive: true, force: true });
1529
+ }
1530
+ });
1531
+
1532
+ it("defaults editorMetadataFormat and preserves non-empty strings", () => {
1533
+ expect(defaultConfig.editorMetadataFormat).toBe(DEFAULT_EDITOR_METADATA_FORMAT);
1534
+ for (const value of [undefined, null, 123, true, ""]) {
1535
+ expect(mergeConfig({ editorMetadataFormat: value }).editorMetadataFormat).toBe(
1536
+ DEFAULT_EDITOR_METADATA_FORMAT,
1537
+ );
1538
+ }
1539
+ expect(mergeConfig({ editorMetadataFormat: "$model · $provider" }).editorMetadataFormat).toBe(
1540
+ "$model · $provider",
1541
+ );
1542
+ expect(mergeConfig({ editorMetadataFormat: " " }).editorMetadataFormat).toBe(" ");
1543
+ });
1544
+
1545
+ it("ignores non-string footerFormat values", () => {
1546
+ expect(mergeConfig({ footerFormat: 123 }).footerFormat).toBe("");
1547
+ expect(mergeConfig({ footerFormat: null }).footerFormat).toBe("");
1548
+ expect(mergeConfig({ footerFormat: true }).footerFormat).toBe("");
1549
+ });
1550
+
1551
+ it("accepts custom project refresh intervals and 0 to disable polling", () => {
1552
+ expect(mergeConfig({ projectRefreshIntervalMs: 60_000 }).projectRefreshIntervalMs).toBe(60_000);
1553
+ expect(mergeConfig({ projectRefreshIntervalMs: 0 }).projectRefreshIntervalMs).toBe(0);
1554
+ });
1555
+
1556
+ it("clamps short project refresh intervals up to 5 seconds", () => {
1557
+ expect(mergeConfig({ projectRefreshIntervalMs: 100 }).projectRefreshIntervalMs).toBe(5_000);
1558
+ expect(mergeConfig({ projectRefreshIntervalMs: 4_999 }).projectRefreshIntervalMs).toBe(5_000);
1559
+ expect(mergeConfig({ projectRefreshIntervalMs: 5_000 }).projectRefreshIntervalMs).toBe(5_000);
1560
+ });
1561
+
1562
+ it("ignores invalid project refresh intervals", () => {
1563
+ expect(mergeConfig({ projectRefreshIntervalMs: "30000" }).projectRefreshIntervalMs).toBe(
1564
+ 30_000,
1565
+ );
1566
+ expect(
1567
+ mergeConfig({ projectRefreshIntervalMs: Number.POSITIVE_INFINITY }).projectRefreshIntervalMs,
1568
+ ).toBe(30_000);
1569
+ });
1570
+
1571
+ it("defaults separator style to none and accepts supported values", () => {
1572
+ expect(mergeConfig({}).separator).toBe("none");
1573
+ expect(defaultConfig.separator).toBe("none");
1574
+ for (const separator of ["pipe", "dot", "chevron", "none"] as const) {
1575
+ expect(mergeConfig({ separator }).separator).toBe(separator);
1576
+ }
1577
+ });
1578
+
1579
+ it("falls back to none for invalid separator styles", () => {
1580
+ for (const separator of ["arrow", "", 123, null, true]) {
1581
+ expect(mergeConfig({ separator }).separator).toBe("none");
1582
+ }
1583
+ });
1584
+
1585
+ it("saves separator style without erasing unknown config", () => {
1586
+ const dir = mkdtempSync(join(tmpdir(), "zentui-config-"));
1587
+ const path = join(dir, "zentui.json");
1588
+ try {
1589
+ writeFileSync(path, `${JSON.stringify({ unknown: true, contextStyle: "gauge" }, null, 2)}\n`);
1590
+
1591
+ const config = saveSeparatorPatch("chevron", path);
1592
+ const raw = JSON.parse(readFileSync(path, "utf8"));
1593
+
1594
+ expect(config.separator).toBe("chevron");
1595
+ expect(raw.unknown).toBe(true);
1596
+ expect(raw.contextStyle).toBe("gauge");
1597
+ expect(raw.separator).toBeUndefined();
1598
+ expect(raw.components.footer.styles.starship).toMatchObject({
1599
+ contextStyle: "gauge",
1600
+ separator: "chevron",
1601
+ });
1602
+ } finally {
1603
+ rmSync(dir, { recursive: true, force: true });
1604
+ }
1605
+ });
1606
+
1607
+ it("defaults context style/thresholds and accepts valid overrides", () => {
1608
+ expect(mergeConfig({}).contextStyle).toBe("text");
1609
+ expect(mergeConfig({}).contextThresholds).toEqual({ warning: 70, error: 90 });
1610
+ expect(mergeConfig({ contextStyle: "gauge" }).contextStyle).toBe("gauge");
1611
+ expect(mergeConfig({ contextStyle: "text+gauge" }).contextStyle).toBe("text+gauge");
1612
+ expect(mergeConfig({ contextStyle: "bars" }).contextStyle).toBe("text");
1613
+ expect(
1614
+ mergeConfig({ contextThresholds: { warning: 50, error: 80 } }).contextThresholds,
1615
+ ).toEqual({ warning: 50, error: 80 });
1616
+ expect(
1617
+ mergeConfig({ contextThresholds: { warning: 90, error: 70 } }).contextThresholds,
1618
+ ).toEqual({ warning: 70, error: 90 });
1619
+ });
1620
+
1621
+ it("defaults editorModelLabel to name and accepts valid overrides", () => {
1622
+ expect(mergeConfig({}).editorModelLabel).toBe("name");
1623
+ expect(mergeConfig({ editorModelLabel: "name" }).editorModelLabel).toBe("name");
1624
+ expect(mergeConfig({ editorModelLabel: "id" }).editorModelLabel).toBe("id");
1625
+ expect(mergeConfig({ editorModelLabel: "title" }).editorModelLabel).toBe("name");
1626
+ });
1627
+
1628
+ it("saves minimalist patches while preserving unknown nested config", () => {
1629
+ const dir = mkdtempSync(join(tmpdir(), "zentui-config-"));
1630
+ const path = join(dir, "zentui.json");
1631
+ try {
1632
+ writeFileSync(
1633
+ path,
1634
+ JSON.stringify({
1635
+ unknownTop: true,
1636
+ editorStyles: {
1637
+ unknownStyle: { keep: true },
1638
+ minimalist: { unknownNested: "keep", showGit: true },
1639
+ },
1640
+ }),
1641
+ );
1642
+ const config = saveMinimalistPatch(
1643
+ {
1644
+ pathDisplay: "full",
1645
+ contextFormat: "percent-total",
1646
+ showSessionName: false,
1647
+ showGit: false,
1648
+ },
1649
+ path,
1650
+ );
1651
+ expect(config.editorStyles.minimalist.pathDisplay).toBe("full");
1652
+ expect(config.editorStyles.minimalist.contextFormat).toBe("percent-total");
1653
+ expect(config.editorStyles.minimalist.showSessionName).toBe(false);
1654
+ expect(config.editorStyles.minimalist.showGit).toBe(false);
1655
+ const raw = JSON.parse(readFileSync(path, "utf8"));
1656
+ expect(raw).toMatchObject({
1657
+ unknownTop: true,
1658
+ editorStyles: {
1659
+ unknownStyle: { keep: true },
1660
+ minimalist: { unknownNested: "keep", showGit: true },
1661
+ },
1662
+ });
1663
+ expect(raw.components.editor.styles.minimalist).toMatchObject({
1664
+ pathDisplay: "full",
1665
+ contextFormat: "percent-total",
1666
+ showSessionName: false,
1667
+ showGit: false,
1668
+ });
1669
+ } finally {
1670
+ rmSync(dir, { recursive: true, force: true });
1671
+ }
1672
+ });
1673
+
1674
+ it("saves editor style without erasing sibling config", () => {
1675
+ const dir = mkdtempSync(join(tmpdir(), "zentui-config-"));
1676
+ const path = join(dir, "zentui.json");
1677
+ try {
1678
+ writeFileSync(path, JSON.stringify({ unknown: { keep: true }, editorModelLabel: "name" }));
1679
+ const minimalist = saveEditorStyle("minimalist", path);
1680
+ let raw = JSON.parse(readFileSync(path, "utf8"));
1681
+ expect(raw.unknown).toEqual({ keep: true });
1682
+ expect(raw.editorModelLabel).toBe("name");
1683
+ expect(raw.editorStyle).toBeUndefined();
1684
+ expect(raw.components.editor.style).toBe("minimalist");
1685
+ expect(minimalist.editorStyle).toBe("minimalist");
1686
+
1687
+ const accentRail = saveEditorStyle("accent-rail", path);
1688
+ raw = JSON.parse(readFileSync(path, "utf8"));
1689
+ expect(accentRail.editorStyle).toBe("accent-rail");
1690
+ expect(raw.components.editor.style).toBe("accent-rail");
1691
+ expect(raw.components.editor.styles["accent-rail"]).toEqual({
1692
+ rail: "▎",
1693
+ asciiRail: "|",
1694
+ transparent: false,
1695
+ });
1696
+
1697
+ const polished = saveEditorStyle("opencode", path);
1698
+ raw = JSON.parse(readFileSync(path, "utf8"));
1699
+ expect(polished.editorStyle).toBe("opencode");
1700
+ expect(raw.unknown).toEqual({ keep: true });
1701
+ expect(raw.editorModelLabel).toBe("name");
1702
+ expect(raw.components.editor.style).toBe("opencode");
1703
+ } finally {
1704
+ rmSync(dir, { recursive: true, force: true });
1705
+ }
1706
+ });
1707
+
1708
+ it("defaults and normalizes editor border color mode", () => {
1709
+ expect(defaultConfig.editorBorderColorMode).toBe("adaptive");
1710
+ expect(mergeConfig({}).editorBorderColorMode).toBe("adaptive");
1711
+ expect(mergeConfig({ editorBorderColorMode: "static" }).editorBorderColorMode).toBe("static");
1712
+ expect(mergeConfig({ editorBorderColorMode: "adaptive" }).editorBorderColorMode).toBe(
1713
+ "adaptive",
1714
+ );
1715
+ for (const value of ["dynamic", "", 1, null, true]) {
1716
+ expect(mergeConfig({ editorBorderColorMode: value }).editorBorderColorMode).toBe("adaptive");
1717
+ }
1718
+ });
1719
+
1720
+ it("saves editor border color mode without erasing sibling config", () => {
1721
+ const dir = mkdtempSync(join(tmpdir(), "zentui-config-"));
1722
+ const path = join(dir, "zentui.json");
1723
+ try {
1724
+ writeFileSync(
1725
+ path,
1726
+ `${JSON.stringify({ unknown: { keep: true }, editorModelLabel: "name" }, null, 2)}\n`,
1727
+ );
1728
+
1729
+ const config = saveEditorBorderColorMode("adaptive", path);
1730
+ const raw = JSON.parse(readFileSync(path, "utf8"));
1731
+ expect(raw.unknown).toEqual({ keep: true });
1732
+ expect(raw.editorModelLabel).toBe("name");
1733
+ expect(raw.editorBorderColorMode).toBeUndefined();
1734
+ expect(raw.components.editor.borderColorMode).toBe("adaptive");
1735
+ expect(config.editorBorderColorMode).toBe("adaptive");
1736
+ expect(config.editorModelLabel).toBe("name");
1737
+ expect(configTempFiles(dir)).toEqual([]);
1738
+ } finally {
1739
+ rmSync(dir, { recursive: true, force: true });
1740
+ }
1741
+ });
1742
+
1743
+ it("defaults pathDisplay and accepts mode/depth overrides", () => {
1744
+ expect(mergeConfig({}).pathDisplay).toEqual({ mode: "basename", depth: 2 });
1745
+ expect(mergeConfig({ pathDisplay: { mode: "full" } }).pathDisplay).toEqual({
1746
+ mode: "full",
1747
+ depth: 2,
1748
+ });
1749
+ expect(mergeConfig({ pathDisplay: { mode: "full", depth: 3 } }).pathDisplay).toEqual({
1750
+ mode: "full",
1751
+ depth: 3,
1752
+ });
1753
+ expect(mergeConfig({ pathDisplay: { mode: "repository", depth: 2 } }).pathDisplay).toEqual({
1754
+ mode: "repository",
1755
+ depth: 2,
1756
+ });
1757
+ expect(
1758
+ mergeConfig({
1759
+ components: { footer: { styles: { starship: { pathDisplay: { mode: "repository" } } } } },
1760
+ }).components.footer.styles.starship.pathDisplay,
1761
+ ).toEqual({ mode: "repository", depth: 2 });
1762
+ expect(mergeConfig({ pathDisplay: { mode: "fish", depth: -3 } }).pathDisplay).toEqual({
1763
+ mode: "basename",
1764
+ depth: 2,
1765
+ });
1766
+ expect(mergeConfig({ pathDisplay: { depth: 12.8 } }).pathDisplay).toEqual({
1767
+ mode: "basename",
1768
+ depth: 5,
1769
+ });
1770
+ expect(mergeConfig({ pathDisplay: "full" }).pathDisplay).toEqual({
1771
+ mode: "basename",
1772
+ depth: 2,
1773
+ });
1774
+ expect(
1775
+ mergeConfig({ pathDisplay: { mode: "full", depth: Number.POSITIVE_INFINITY } }).pathDisplay,
1776
+ ).toEqual({ mode: "full", depth: 2 });
1777
+ });
1778
+
1779
+ it("saves pathDisplay patches and keeps unknown keys", () => {
1780
+ const dir = mkdtempSync(join(tmpdir(), "zentui-config-"));
1781
+ const path = join(dir, "zentui.json");
1782
+ try {
1783
+ writeFileSync(
1784
+ path,
1785
+ `${JSON.stringify(
1786
+ {
1787
+ unknown: true,
1788
+ pathDisplay: {
1789
+ mode: "basename",
1790
+ depth: 3,
1791
+ futureKey: "future",
1792
+ },
1793
+ },
1794
+ null,
1795
+ 2,
1796
+ )}
1797
+ `,
1798
+ );
1799
+
1800
+ const config = savePathDisplayPatch({ mode: "full" }, path);
1801
+ const raw = JSON.parse(readFileSync(path, "utf8"));
1802
+
1803
+ expect(config.pathDisplay).toEqual({ mode: "full", depth: 3 });
1804
+ expect(raw.unknown).toBe(true);
1805
+ expect(raw.pathDisplay).toEqual({
1806
+ mode: "basename",
1807
+ depth: 3,
1808
+ futureKey: "future",
1809
+ });
1810
+ expect(raw.components.footer.styles.starship.pathDisplay).toEqual({
1811
+ mode: "full",
1812
+ depth: 3,
1813
+ });
1814
+
1815
+ const depthConfig = savePathDisplayPatch({ depth: 1 }, path);
1816
+ expect(depthConfig.pathDisplay).toEqual({ mode: "full", depth: 1 });
1817
+ } finally {
1818
+ rmSync(dir, { recursive: true, force: true });
1819
+ }
1820
+ });
1821
+
1822
+ it("defaults git branch length to full and accepts positive integer values", () => {
1823
+ expect(mergeConfig({}).gitBranch).toEqual({ maxLength: "full" });
1824
+ expect(defaultConfig.gitBranch).toEqual({ maxLength: "full" });
1825
+ for (const maxLength of [1, 10, 17, 20, 30, 40, 50, 10_000]) {
1826
+ expect(mergeConfig({ gitBranch: { maxLength } }).gitBranch).toEqual({ maxLength });
1827
+ }
1828
+ expect(mergeConfig({ gitBranch: { maxLength: "full" } }).gitBranch).toEqual({
1829
+ maxLength: "full",
1830
+ });
1831
+ });
1832
+
1833
+ it("falls back to full for invalid git branch lengths", () => {
1834
+ for (const maxLength of [0, -1, 1.5, "10", "short", null, true]) {
1835
+ expect(mergeConfig({ gitBranch: { maxLength } }).gitBranch).toEqual({
1836
+ maxLength: "full",
1837
+ });
1838
+ }
1839
+ expect(mergeConfig({ gitBranch: 20 }).gitBranch).toEqual({ maxLength: "full" });
1840
+ });
1841
+
1842
+ it("saves git branch length without erasing unknown config", () => {
1843
+ const dir = mkdtempSync(join(tmpdir(), "zentui-config-"));
1844
+ const path = join(dir, "zentui.json");
1845
+ try {
1846
+ writeFileSync(
1847
+ path,
1848
+ `${JSON.stringify({ unknown: true, gitBranch: { maxLength: 17, future: true } }, null, 2)}\n`,
1849
+ );
1850
+
1851
+ const config = saveGitBranchPatch({ maxLength: 30 }, path);
1852
+ const raw = JSON.parse(readFileSync(path, "utf8"));
1853
+ expect(config.gitBranch).toEqual({ maxLength: 30 });
1854
+ expect(raw.unknown).toBe(true);
1855
+ expect(raw.gitBranch).toEqual({ maxLength: 17, future: true });
1856
+ expect(raw.components.footer.styles.starship.gitBranch).toEqual({ maxLength: 30 });
1857
+ } finally {
1858
+ rmSync(dir, { recursive: true, force: true });
1859
+ }
1860
+ });
1861
+
1862
+ it("defaults icon mode to ascii and accepts nerd/ascii", () => {
1863
+ expect(mergeConfig({}).icons.mode).toBe("ascii");
1864
+ expect(mergeConfig({ icons: { mode: "ascii" } }).icons.mode).toBe("ascii");
1865
+ expect(mergeConfig({ icons: { mode: "nerd" } }).icons.mode).toBe("nerd");
1866
+ expect(mergeConfig({ icons: { mode: "emoji" } }).icons.mode).toBe("ascii");
1867
+ expect(mergeConfig({ icons: { mode: "ascii" } }).icons.cwd).toBe("");
1868
+ expect(mergeConfig({ icons: { mode: "ascii", cwd: "DIR" } }).icons.cwd).toBe("DIR");
1869
+ });
1870
+
1871
+ it("accepts Starship colors and old color key aliases", () => {
1872
+ expect(mergeConfig({ colors: { gitBranch: "bold purple" } }).colors.gitBranch).toBe(
1873
+ "bold purple",
1874
+ );
1875
+ expect(mergeConfig({ colors: { packageVersion: "bold green" } }).colors.packageVersion).toBe(
1876
+ "bold green",
1877
+ );
1878
+ expect(mergeConfig({ colors: { gitCommit: "bold yellow" } }).colors.gitCommit).toBe(
1879
+ "bold yellow",
1880
+ );
1881
+ expect(mergeConfig({ colors: { gitMetricsAdded: "green" } }).colors.gitMetricsAdded).toBe(
1882
+ "green",
1883
+ );
1884
+ expect(mergeConfig({ colors: { gitMetricsDeleted: "red" } }).colors.gitMetricsDeleted).toBe(
1885
+ "red",
1886
+ );
1887
+ expect(mergeConfig({ colors: { git: "syntaxKeyword" } }).colors.gitBranch).toBe(
1888
+ "syntaxKeyword",
1889
+ );
1890
+ expect(mergeConfig({ colors: { extensionStatus: "warning" } }).colors.extensionStatus).toBe(
1891
+ "warning",
1892
+ );
1893
+ expect(mergeConfig({ colors: { extensionStatus: "neon" } }).colors.extensionStatus).toBe(
1894
+ defaultConfig.colors.extensionStatus,
1895
+ );
1896
+ });
1897
+
1898
+ it("accepts extension status placement and color mode config", () => {
1899
+ const config = mergeConfig({
1900
+ extensionStatuses: {
1901
+ defaultPlacement: "middle",
1902
+ placements: {
1903
+ alpha: "left",
1904
+ beta: "off",
1905
+ gamma: "right",
1906
+ },
1907
+ colorModes: {
1908
+ alpha: "original",
1909
+ beta: "zentui",
1910
+ },
1911
+ },
1912
+ });
1913
+
1914
+ expect(config.extensionStatuses).toEqual({
1915
+ defaultPlacement: "middle",
1916
+ placements: {
1917
+ alpha: "left",
1918
+ beta: "off",
1919
+ gamma: "right",
1920
+ },
1921
+ colorModes: {
1922
+ alpha: "original",
1923
+ beta: "zentui",
1924
+ },
1925
+ });
1926
+ });
1927
+
1928
+ it("normalizes invalid extension status placement config", () => {
1929
+ expect(
1930
+ mergeConfig({
1931
+ extensionStatuses: {
1932
+ defaultPlacement: "center",
1933
+ placements: {
1934
+ alpha: "left",
1935
+ beta: "center",
1936
+ gamma: 1,
1937
+ },
1938
+ colorModes: {
1939
+ alpha: "original",
1940
+ beta: "muted",
1941
+ gamma: 1,
1942
+ },
1943
+ },
1944
+ }).extensionStatuses,
1945
+ ).toEqual({
1946
+ defaultPlacement: "right",
1947
+ placements: { alpha: "left" },
1948
+ colorModes: { alpha: "original" },
1949
+ });
1950
+ expect(mergeConfig({ extensionStatuses: { placements: "none" } }).extensionStatuses).toEqual({
1951
+ defaultPlacement: "right",
1952
+ placements: {},
1953
+ colorModes: {},
1954
+ });
1955
+ });
1956
+
1957
+ it("accepts optional editor and user-message chrome color overrides", () => {
1958
+ const config = mergeConfig({
1959
+ colors: {
1960
+ editorAccent: "bold purple",
1961
+ editorBorder: "#89b4fa",
1962
+ editorModel: "accent",
1963
+ editorProvider: "text",
1964
+ editorThinking: "muted",
1965
+ editorThinkingMinimal: "thinkingMinimal",
1966
+ editorThinkingLow: "thinkingLow",
1967
+ editorThinkingMedium: "thinkingMedium",
1968
+ editorThinkingHigh: "thinkingHigh",
1969
+ editorThinkingXhigh: "thinkingXhigh",
1970
+ editorThinkingMax: "thinkingMax",
1971
+ },
1972
+ });
1973
+
1974
+ expect(config.colors.editorAccent).toBe("bold purple");
1975
+ expect(config.colors.editorBorder).toBe("#89b4fa");
1976
+ expect(config.colors.editorModel).toBe("accent");
1977
+ expect(config.colors.editorProvider).toBe("text");
1978
+ expect(config.colors.editorThinking).toBe("muted");
1979
+ expect(config.colors.editorThinkingMinimal).toBe("thinkingMinimal");
1980
+ expect(config.colors.editorThinkingLow).toBe("thinkingLow");
1981
+ expect(config.colors.editorThinkingMedium).toBe("thinkingMedium");
1982
+ expect(config.colors.editorThinkingHigh).toBe("thinkingHigh");
1983
+ expect(config.colors.editorThinkingXhigh).toBe("thinkingXhigh");
1984
+ expect(config.colors.editorThinkingMax).toBe("thinkingMax");
1985
+ });
1986
+
1987
+ it("ignores invalid known values at runtime instead of trusting zentui.json", () => {
1988
+ const config = mergeConfig({
1989
+ projectRefreshIntervalMs: "fast",
1990
+ icons: {
1991
+ cwd: 42,
1992
+ git: "git",
1993
+ cacheHit: "CH",
1994
+ editorPrompt: ">",
1995
+ },
1996
+ colors: {
1997
+ cwd: 123,
1998
+ gitStatus: "not-a-color",
1999
+ separator: "dimmed",
2000
+ editorAccent: "neon",
2001
+ editorPrompt: "accent",
2002
+ editorBorder: "also-neon",
2003
+ editorThinkingHigh: "thinkingHigh",
2004
+ },
2005
+ colorSources: {
2006
+ starship: "neon",
2007
+ editor: "terminal",
2008
+ },
2009
+ });
2010
+
2011
+ expect(config.projectRefreshIntervalMs).toBe(defaultConfig.projectRefreshIntervalMs);
2012
+ expect(config.icons.cwd).toBe(defaultConfig.icons.cwd);
2013
+ expect(config.icons.git).toBe("git");
2014
+ expect(config.icons.cacheHit).toBe("CH");
2015
+ expect(config.icons.editorPrompt).toBe(">");
2016
+ expect(config.colors.cwd).toBe(defaultConfig.colors.cwd);
2017
+ expect(config.colors.gitStatus).toBe(defaultConfig.colors.gitStatus);
2018
+ expect(config.colors.separator).toBe("dimmed");
2019
+ expect(config.colors.editorAccent).toBeUndefined();
2020
+ expect(config.colors.editorPrompt).toBe("accent");
2021
+ expect(config.colors.editorBorder).toBeUndefined();
2022
+ expect(config.colors.editorThinkingHigh).toBe("thinkingHigh");
2023
+ expect(config.colorSources).toEqual({
2024
+ starship: "theme",
2025
+ editor: "terminal",
2026
+ userMessages: "theme",
2027
+ });
2028
+ });
2029
+
2030
+ it("accepts valid color source preferences and ignores invalid values", () => {
2031
+ expect(
2032
+ mergeConfig({ colorSources: { starship: "terminal", editor: "theme" } }).colorSources,
2033
+ ).toEqual({ starship: "terminal", editor: "theme", userMessages: "theme" });
2034
+ expect(
2035
+ mergeConfig({ colorSources: { starship: "neon", userMessages: "terminal" } }).colorSources,
2036
+ ).toEqual({ starship: "theme", editor: "theme", userMessages: "terminal" });
2037
+ });
2038
+
2039
+ it("accepts valid UI feature preferences and ignores invalid values", () => {
2040
+ expect(mergeConfig({ features: { editor: false } }).features).toEqual({
2041
+ editor: false,
2042
+ statusLine: false,
2043
+ viewportIndicators: false,
2044
+ });
2045
+ expect(
2046
+ mergeConfig({
2047
+ features: {
2048
+ editor: "off",
2049
+ statusLine: false,
2050
+ copyFriendly: true,
2051
+ viewportIndicators: false,
2052
+ },
2053
+ }).features,
2054
+ ).toEqual({
2055
+ editor: false,
2056
+ statusLine: false,
2057
+ viewportIndicators: false,
2058
+ });
2059
+ expect(
2060
+ mergeConfig({ features: { copyFriendly: "on", viewportIndicators: "off" } }).features,
2061
+ ).toEqual({ editor: false, statusLine: false, viewportIndicators: false });
2062
+ });
2063
+
2064
+ it("accepts valid footer segment preferences and ignores invalid values", () => {
2065
+ expect(
2066
+ mergeConfig({ footerSegments: { cwd: false, modelInfo: true, tokens: false } })
2067
+ .footerSegments,
2068
+ ).toEqual({
2069
+ cwd: false,
2070
+ sessionName: true,
2071
+ gitBranch: true,
2072
+ gitStatus: true,
2073
+ runtime: true,
2074
+ modelInfo: true,
2075
+ context: true,
2076
+ gitCounts: false,
2077
+ sessionDuration: false,
2078
+ username: false,
2079
+ time: false,
2080
+ os: false,
2081
+ packageVersion: false,
2082
+ gitCommit: false,
2083
+ gitMetrics: true,
2084
+ tokens: false,
2085
+ cost: true,
2086
+ });
2087
+ expect(
2088
+ mergeConfig({
2089
+ footerSegments: { cost: "off", gitBranch: false, gitStatus: false, modelInfo: "on" },
2090
+ }).footerSegments,
2091
+ ).toEqual({
2092
+ cwd: true,
2093
+ sessionName: true,
2094
+ gitBranch: false,
2095
+ gitStatus: false,
2096
+ runtime: true,
2097
+ modelInfo: false,
2098
+ context: true,
2099
+ gitCounts: false,
2100
+ sessionDuration: false,
2101
+ username: false,
2102
+ time: false,
2103
+ os: false,
2104
+ packageVersion: false,
2105
+ gitCommit: false,
2106
+ gitMetrics: true,
2107
+ tokens: true,
2108
+ cost: true,
2109
+ });
2110
+ });
2111
+
2112
+ it("normalizes session-name preferences", () => {
2113
+ expect(mergeConfig({ colors: { sessionName: "success" } }).colors.sessionName).toBe("success");
2114
+ expect(mergeConfig({ footerSegments: { sessionName: false } }).footerSegments.sessionName).toBe(
2115
+ false,
2116
+ );
2117
+ expect(mergeConfig({ colors: { sessionName: "not-a-color" } }).colors.sessionName).toBe(
2118
+ "bold green",
2119
+ );
2120
+ expect(mergeConfig({ footerSegments: { sessionName: "on" } }).footerSegments.sessionName).toBe(
2121
+ true,
2122
+ );
2123
+ });
2124
+
2125
+ it("saves color source patches without erasing unknown user config", () => {
2126
+ const dir = mkdtempSync(join(tmpdir(), "zentui-config-"));
2127
+ const path = join(dir, "zentui.json");
2128
+ try {
2129
+ writeFileSync(
2130
+ path,
2131
+ `${JSON.stringify(
2132
+ {
2133
+ unknown: true,
2134
+ icons: { git: "git" },
2135
+ colors: {
2136
+ futureKey: "future",
2137
+ cwd: "bold cyan",
2138
+ gitBranch: "syntaxKeyword",
2139
+ cost: "success",
2140
+ },
2141
+ colorSources: { editor: "terminal" },
2142
+ },
2143
+ null,
2144
+ 2,
2145
+ )}\n`,
2146
+ );
2147
+
2148
+ const config = saveColorSourcesPatch({ starship: "terminal" }, path);
2149
+ const raw = JSON.parse(readFileSync(path, "utf8"));
2150
+
2151
+ expect(config.colorSources).toEqual({
2152
+ starship: "terminal",
2153
+ editor: "terminal",
2154
+ userMessages: "theme",
2155
+ });
2156
+ expect(raw.unknown).toBe(true);
2157
+ expect(raw.icons.git).toBe("git");
2158
+ expect(raw.colors.cwd).toBe("bold cyan");
2159
+ expect(raw.colors.futureKey).toBe("future");
2160
+ expect(raw.colors.gitBranch).toBe("syntaxKeyword");
2161
+ expect(raw.colors.cost).toBe("success");
2162
+ expect(raw.colorSources).toEqual({ editor: "terminal" });
2163
+ expect(raw.components.footer.colorSource).toBe("terminal");
2164
+ expect(raw.components.editor.colorSource).toBe("terminal");
2165
+ } finally {
2166
+ rmSync(dir, { recursive: true, force: true });
2167
+ }
2168
+ });
2169
+
2170
+ it("preserves invalid and unknown color source data on disk while normalizing runtime", () => {
2171
+ const dir = mkdtempSync(join(tmpdir(), "zentui-config-"));
2172
+ const path = join(dir, "zentui.json");
2173
+ try {
2174
+ writeFileSync(
2175
+ path,
2176
+ `${JSON.stringify(
2177
+ {
2178
+ colorSources: {
2179
+ starship: "neon",
2180
+ editor: "terminal",
2181
+ userMessages: "invalid",
2182
+ extra: "terminal",
2183
+ },
2184
+ },
2185
+ null,
2186
+ 2,
2187
+ )}\n`,
2188
+ );
2189
+
2190
+ const config = saveColorSourcesPatch({ userMessages: "terminal" }, path);
2191
+ const raw = JSON.parse(readFileSync(path, "utf8"));
2192
+
2193
+ expect(config.colorSources).toEqual({
2194
+ starship: "theme",
2195
+ editor: "terminal",
2196
+ userMessages: "terminal",
2197
+ });
2198
+ expect(raw.colorSources).toEqual({
2199
+ starship: "neon",
2200
+ editor: "terminal",
2201
+ userMessages: "invalid",
2202
+ extra: "terminal",
2203
+ });
2204
+ expect(raw.components.userMessages.colorSource).toBe("terminal");
2205
+ } finally {
2206
+ rmSync(dir, { recursive: true, force: true });
2207
+ }
2208
+ });
2209
+
2210
+ it("writes only the requested settings when creating zentui.json", () => {
2211
+ const dir = mkdtempSync(join(tmpdir(), "zentui-config-"));
2212
+ const path = join(dir, "zentui.json");
2213
+ try {
2214
+ const config = saveColorSourcesPatch({ starship: "terminal" }, path);
2215
+ const raw = JSON.parse(readFileSync(path, "utf8"));
2216
+
2217
+ expect(config.colorSources).toEqual({
2218
+ starship: "terminal",
2219
+ editor: "theme",
2220
+ userMessages: "theme",
2221
+ });
2222
+ expect(Object.keys(raw)).toEqual(["components"]);
2223
+ expect(raw.components.footer.colorSource).toBe("terminal");
2224
+ expect(raw.components.editor.colorSource).toBe("theme");
2225
+ expect(raw.components.userMessages.colorSource).toBe("theme");
2226
+ } finally {
2227
+ rmSync(dir, { recursive: true, force: true });
2228
+ }
2229
+ });
2230
+
2231
+ it("saves UI feature patches without erasing unknown user config", () => {
2232
+ const dir = mkdtempSync(join(tmpdir(), "zentui-config-"));
2233
+ const path = join(dir, "zentui.json");
2234
+ try {
2235
+ writeFileSync(
2236
+ path,
2237
+ `${JSON.stringify(
2238
+ {
2239
+ unknown: true,
2240
+ features: {
2241
+ editor: true,
2242
+ futureKey: "future",
2243
+ },
2244
+ },
2245
+ null,
2246
+ 2,
2247
+ )}\n`,
2248
+ );
2249
+
2250
+ const config = saveUiFeaturesPatch({ statusLine: false, viewportIndicators: false }, path);
2251
+ const raw = JSON.parse(readFileSync(path, "utf8"));
2252
+
2253
+ expect(config.features).toEqual({
2254
+ editor: true,
2255
+ statusLine: false,
2256
+ viewportIndicators: false,
2257
+ });
2258
+ expect(raw.unknown).toBe(true);
2259
+ expect(raw.features).toEqual({ editor: true, futureKey: "future" });
2260
+ expect(raw.components.editor.enabled).toBe(true);
2261
+ expect(raw.components.editor.viewportIndicators).toBe(false);
2262
+ expect(raw.components.footer.style).toBe("native");
2263
+ } finally {
2264
+ rmSync(dir, { recursive: true, force: true });
2265
+ }
2266
+ });
2267
+
2268
+ it("writes only the requested UI feature setting when creating zentui.json", () => {
2269
+ const dir = mkdtempSync(join(tmpdir(), "zentui-config-"));
2270
+ const path = join(dir, "zentui.json");
2271
+ try {
2272
+ const config = saveUiFeaturesPatch({ editor: false }, path);
2273
+ const raw = JSON.parse(readFileSync(path, "utf8"));
2274
+
2275
+ expect(config.features).toEqual({
2276
+ editor: false,
2277
+ statusLine: false,
2278
+ viewportIndicators: false,
2279
+ });
2280
+ expect(Object.keys(raw)).toEqual(["components"]);
2281
+ expect(raw.components.editor.enabled).toBe(false);
2282
+ expect(raw.components.userMessages.enabled).toBe(false);
2283
+ expect(raw.components.selectorBorders.enabled).toBe(false);
2284
+ } finally {
2285
+ rmSync(dir, { recursive: true, force: true });
2286
+ }
2287
+ });
2288
+
2289
+ it("saves footer segment patches without erasing unknown user config", () => {
2290
+ const dir = mkdtempSync(join(tmpdir(), "zentui-config-"));
2291
+ const path = join(dir, "zentui.json");
2292
+ try {
2293
+ writeFileSync(
2294
+ path,
2295
+ `${JSON.stringify(
2296
+ {
2297
+ unknown: true,
2298
+ footerSegments: {
2299
+ cwd: true,
2300
+ futureKey: "future",
2301
+ },
2302
+ },
2303
+ null,
2304
+ 2,
2305
+ )}\n`,
2306
+ );
2307
+
2308
+ const config = saveFooterSegmentsPatch({ modelInfo: true, tokens: false, cost: false }, path);
2309
+ const raw = JSON.parse(readFileSync(path, "utf8"));
2310
+
2311
+ expect(config.footerSegments).toEqual({
2312
+ cwd: true,
2313
+ sessionName: true,
2314
+ gitBranch: true,
2315
+ gitStatus: true,
2316
+ runtime: true,
2317
+ modelInfo: true,
2318
+ context: true,
2319
+ gitCounts: false,
2320
+ sessionDuration: false,
2321
+ username: false,
2322
+ time: false,
2323
+ os: false,
2324
+ packageVersion: false,
2325
+ gitCommit: false,
2326
+ gitMetrics: true,
2327
+ tokens: false,
2328
+ cost: false,
2329
+ });
2330
+ expect(raw.unknown).toBe(true);
2331
+ expect(raw.footerSegments).toEqual({ cwd: true, futureKey: "future" });
2332
+ expect(raw.components.footer.styles.starship.segments).toMatchObject({
2333
+ cwd: true,
2334
+ modelInfo: true,
2335
+ tokens: false,
2336
+ cost: false,
2337
+ });
2338
+ expect(mergeConfig(raw).footerSegments.modelInfo).toBe(true);
2339
+
2340
+ const disabled = saveFooterSegmentsPatch({ modelInfo: false }, path);
2341
+ const disabledRaw = JSON.parse(readFileSync(path, "utf8"));
2342
+ expect(disabled.footerSegments.modelInfo).toBe(false);
2343
+ expect(mergeConfig(disabledRaw).footerSegments.modelInfo).toBe(false);
2344
+ expect(disabledRaw.unknown).toBe(true);
2345
+ expect(disabledRaw.footerSegments).toEqual({ cwd: true, futureKey: "future" });
2346
+ expect(disabledRaw.components.footer.styles.starship.segments).toMatchObject({
2347
+ modelInfo: false,
2348
+ tokens: false,
2349
+ cost: false,
2350
+ });
2351
+ } finally {
2352
+ rmSync(dir, { recursive: true, force: true });
2353
+ }
2354
+ });
2355
+
2356
+ it("writes only the requested footer segment setting when creating zentui.json", () => {
2357
+ const dir = mkdtempSync(join(tmpdir(), "zentui-config-"));
2358
+ const path = join(dir, "zentui.json");
2359
+ try {
2360
+ const config = saveFooterSegmentsPatch({ runtime: false }, path);
2361
+ const raw = JSON.parse(readFileSync(path, "utf8"));
2362
+
2363
+ expect(config.footerSegments).toEqual({
2364
+ cwd: true,
2365
+ sessionName: true,
2366
+ gitBranch: true,
2367
+ gitStatus: true,
2368
+ runtime: false,
2369
+ modelInfo: false,
2370
+ context: true,
2371
+ gitCounts: false,
2372
+ sessionDuration: false,
2373
+ username: false,
2374
+ time: false,
2375
+ os: false,
2376
+ packageVersion: false,
2377
+ gitCommit: false,
2378
+ gitMetrics: true,
2379
+ tokens: true,
2380
+ cost: true,
2381
+ });
2382
+ expect(Object.keys(raw)).toEqual(["components"]);
2383
+ expect(raw.components.footer.styles.starship.segments.runtime).toBe(false);
2384
+ } finally {
2385
+ rmSync(dir, { recursive: true, force: true });
2386
+ }
2387
+ });
2388
+
2389
+ it("toggles and persists the packageVersion footer segment", () => {
2390
+ const dir = mkdtempSync(join(tmpdir(), "zentui-config-"));
2391
+ const path = join(dir, "zentui.json");
2392
+ try {
2393
+ const config = saveFooterSegmentsPatch({ packageVersion: true }, path);
2394
+ expect(config.footerSegments.packageVersion).toBe(true);
2395
+
2396
+ const raw = JSON.parse(readFileSync(path, "utf8"));
2397
+ expect(raw.components.footer.styles.starship.segments.packageVersion).toBe(true);
2398
+
2399
+ const reloaded = mergeConfig(raw);
2400
+ expect(reloaded.footerSegments.packageVersion).toBe(true);
2401
+ } finally {
2402
+ rmSync(dir, { recursive: true, force: true });
2403
+ }
2404
+ });
2405
+
2406
+ it("toggles and persists gitCommit and gitMetrics footer segments", () => {
2407
+ const dir = mkdtempSync(join(tmpdir(), "zentui-config-"));
2408
+ const path = join(dir, "zentui.json");
2409
+ try {
2410
+ const config = saveFooterSegmentsPatch({ gitCommit: true, gitMetrics: true }, path);
2411
+ expect(config.footerSegments.gitCommit).toBe(true);
2412
+ expect(config.footerSegments.gitMetrics).toBe(true);
2413
+
2414
+ const raw = JSON.parse(readFileSync(path, "utf8"));
2415
+ expect(raw.components.footer.styles.starship.segments).toMatchObject({
2416
+ gitCommit: true,
2417
+ gitMetrics: true,
2418
+ });
2419
+
2420
+ const reloaded = mergeConfig(raw);
2421
+ expect(reloaded.footerSegments.gitCommit).toBe(true);
2422
+ expect(reloaded.footerSegments.gitMetrics).toBe(true);
2423
+ } finally {
2424
+ rmSync(dir, { recursive: true, force: true });
2425
+ }
2426
+ });
2427
+
2428
+ it("gitCommit config defaults and normalizes hashLength", () => {
2429
+ expect(defaultConfig.gitCommit).toEqual({ hashLength: 7, onlyDetached: true, showTag: true });
2430
+ expect(mergeConfig({ gitCommit: { hashLength: 3 } }).gitCommit.hashLength).toBe(4);
2431
+ expect(mergeConfig({ gitCommit: { hashLength: 100 } }).gitCommit.hashLength).toBe(40);
2432
+ expect(mergeConfig({ gitCommit: { hashLength: 10 } }).gitCommit.hashLength).toBe(10);
2433
+ expect(mergeConfig({ gitCommit: { onlyDetached: false } }).gitCommit.onlyDetached).toBe(false);
2434
+ expect(mergeConfig({ gitCommit: { showTag: false } }).gitCommit.showTag).toBe(false);
2435
+ // Missing fields fall back to defaults.
2436
+ expect(mergeConfig({ gitCommit: {} }).gitCommit).toEqual({
2437
+ hashLength: 7,
2438
+ onlyDetached: true,
2439
+ showTag: true,
2440
+ });
2441
+ });
2442
+
2443
+ it("gitMetrics config defaults", () => {
2444
+ expect(defaultConfig.gitMetrics).toEqual({ onlyNonzero: true, ignoreSubmodules: false });
2445
+ expect(mergeConfig({ gitMetrics: { onlyNonzero: false } }).gitMetrics.onlyNonzero).toBe(false);
2446
+ expect(
2447
+ mergeConfig({ gitMetrics: { ignoreSubmodules: true } }).gitMetrics.ignoreSubmodules,
2448
+ ).toBe(true);
2449
+ });
2450
+
2451
+ it("writes and reads back footerFormat", () => {
2452
+ const dir = mkdtempSync(join(tmpdir(), "zentui-config-"));
2453
+ const path = join(dir, "zentui.json");
2454
+ try {
2455
+ const config = saveFooterFormatPatch("$cwd on $git_branch $fill $cost", path);
2456
+ const raw = JSON.parse(readFileSync(path, "utf8"));
2457
+
2458
+ expect(config.footerFormat).toBe("$cwd on $git_branch $fill $cost");
2459
+ expect(Object.keys(raw)).toEqual(["components"]);
2460
+ expect(raw.components.footer.styles.starship.format).toBe("$cwd on $git_branch $fill $cost");
2461
+ } finally {
2462
+ rmSync(dir, { recursive: true, force: true });
2463
+ }
2464
+ });
2465
+
2466
+ it("clears footerFormat when saving empty string", () => {
2467
+ const dir = mkdtempSync(join(tmpdir(), "zentui-config-"));
2468
+ const path = join(dir, "zentui.json");
2469
+ try {
2470
+ const config = saveFooterFormatPatch("", path);
2471
+ expect(config.footerFormat).toBe("");
2472
+ } finally {
2473
+ rmSync(dir, { recursive: true, force: true });
2474
+ }
2475
+ });
2476
+
2477
+ it("saves extension status placement when creating zentui.json", () => {
2478
+ const dir = mkdtempSync(join(tmpdir(), "zentui-config-"));
2479
+ const path = join(dir, "zentui.json");
2480
+ try {
2481
+ const config = saveExtensionStatusPlacement("plugin.key", "middle", path);
2482
+ const raw = JSON.parse(readFileSync(path, "utf8"));
2483
+
2484
+ expect(config.extensionStatuses.placements).toEqual({ "plugin.key": "middle" });
2485
+ expect(Object.keys(raw)).toEqual(["components"]);
2486
+ expect(raw.components.footer.styles.starship.extensionStatuses.placements).toEqual({
2487
+ "plugin.key": "middle",
2488
+ });
2489
+ } finally {
2490
+ rmSync(dir, { recursive: true, force: true });
2491
+ }
2492
+ });
2493
+
2494
+ it("saves extension status color mode when creating zentui.json", () => {
2495
+ const dir = mkdtempSync(join(tmpdir(), "zentui-config-"));
2496
+ const path = join(dir, "zentui.json");
2497
+ try {
2498
+ const config = saveExtensionStatusColorMode("plugin.key", "original", path);
2499
+ const raw = JSON.parse(readFileSync(path, "utf8"));
2500
+
2501
+ expect(config.extensionStatuses.colorModes).toEqual({ "plugin.key": "original" });
2502
+ expect(Object.keys(raw)).toEqual(["components"]);
2503
+ expect(raw.components.footer.styles.starship.extensionStatuses.colorModes).toEqual({
2504
+ "plugin.key": "original",
2505
+ });
2506
+ } finally {
2507
+ rmSync(dir, { recursive: true, force: true });
2508
+ }
2509
+ });
2510
+
2511
+ it("saves extension status color mode without erasing placement config", () => {
2512
+ const dir = mkdtempSync(join(tmpdir(), "zentui-config-"));
2513
+ const path = join(dir, "zentui.json");
2514
+ try {
2515
+ writeFileSync(
2516
+ path,
2517
+ `${JSON.stringify(
2518
+ {
2519
+ unknown: true,
2520
+ colors: { futureKey: "future" },
2521
+ extensionStatuses: {
2522
+ defaultPlacement: "left",
2523
+ futureKey: "future",
2524
+ placements: {
2525
+ alpha: "right",
2526
+ invalid: "center",
2527
+ },
2528
+ colorModes: {
2529
+ alpha: "zentui",
2530
+ invalid: "muted",
2531
+ },
2532
+ },
2533
+ },
2534
+ null,
2535
+ 2,
2536
+ )}\n`,
2537
+ );
2538
+
2539
+ const config = saveExtensionStatusColorMode("beta", "original", path);
2540
+ const raw = JSON.parse(readFileSync(path, "utf8"));
2541
+
2542
+ expect(config.extensionStatuses).toEqual({
2543
+ defaultPlacement: "left",
2544
+ placements: { alpha: "right" },
2545
+ colorModes: { alpha: "zentui", beta: "original" },
2546
+ });
2547
+ expect(raw.unknown).toBe(true);
2548
+ expect(raw.colors.futureKey).toBe("future");
2549
+ expect(raw.extensionStatuses.futureKey).toBe("future");
2550
+ expect(raw.extensionStatuses.placements).toEqual({
2551
+ alpha: "right",
2552
+ invalid: "center",
2553
+ });
2554
+ expect(raw.extensionStatuses.colorModes).toEqual({
2555
+ alpha: "zentui",
2556
+ invalid: "muted",
2557
+ });
2558
+ expect(raw.components.footer.styles.starship.extensionStatuses.colorModes).toEqual({
2559
+ alpha: "zentui",
2560
+ beta: "original",
2561
+ });
2562
+ } finally {
2563
+ rmSync(dir, { recursive: true, force: true });
2564
+ }
2565
+ });
2566
+
2567
+ it("saves extension status placement without erasing unknown user config", () => {
2568
+ const dir = mkdtempSync(join(tmpdir(), "zentui-config-"));
2569
+ const path = join(dir, "zentui.json");
2570
+ try {
2571
+ writeFileSync(
2572
+ path,
2573
+ `${JSON.stringify(
2574
+ {
2575
+ unknown: true,
2576
+ colors: { futureKey: "future" },
2577
+ extensionStatuses: {
2578
+ defaultPlacement: "left",
2579
+ futureKey: "future",
2580
+ placements: {
2581
+ alpha: "right",
2582
+ invalid: "center",
2583
+ },
2584
+ },
2585
+ },
2586
+ null,
2587
+ 2,
2588
+ )}\n`,
2589
+ );
2590
+
2591
+ const config = saveExtensionStatusPlacement("beta", "off", path);
2592
+ const raw = JSON.parse(readFileSync(path, "utf8"));
2593
+
2594
+ expect(config.extensionStatuses).toEqual({
2595
+ defaultPlacement: "left",
2596
+ placements: { alpha: "right", beta: "off" },
2597
+ colorModes: {},
2598
+ });
2599
+ expect(raw.unknown).toBe(true);
2600
+ expect(raw.colors.futureKey).toBe("future");
2601
+ expect(raw.extensionStatuses.futureKey).toBe("future");
2602
+ expect(raw.extensionStatuses.placements).toEqual({
2603
+ alpha: "right",
2604
+ invalid: "center",
2605
+ });
2606
+ expect(raw.components.footer.styles.starship.extensionStatuses.placements).toEqual({
2607
+ alpha: "right",
2608
+ beta: "off",
2609
+ });
2610
+ } finally {
2611
+ rmSync(dir, { recursive: true, force: true });
2612
+ }
2613
+ });
2614
+ });
2615
+
2616
+ describe("renderTerminalStyle", () => {
2617
+ it("renders Starship bold green with terminal palette ANSI codes", () => {
2618
+ expect(renderTerminalStyle("bold green", " v22.0.0")).toBe("\u001b[1;32m v22.0.0\u001b[0m");
2619
+ });
2620
+
2621
+ it("supports 256-color, fg/bg aliases, dimmed, and Starship hex styles", () => {
2622
+ expect(renderTerminalStyle("bold 149", "C")).toBe("\u001b[1;38;5;149mC\u001b[0m");
2623
+ expect(renderTerminalStyle("bold fg:202", "Haxe")).toBe("\u001b[1;38;5;202mHaxe\u001b[0m");
2624
+ expect(renderTerminalStyle("red dimmed", "Java")).toBe("\u001b[31;2mJava\u001b[0m");
2625
+ expect(renderTerminalStyle("bg:blue fg:bright-green", "ok")).toBe("\u001b[44;92mok\u001b[0m");
2626
+ expect(renderTerminalStyle("bold #FFAFF3", "Gleam")).toBe(
2627
+ "\u001b[1;38;2;255;175;243mGleam\u001b[0m",
2628
+ );
2629
+ });
2630
+ });
2631
+
2632
+ describe("style rendering", () => {
2633
+ const theme = {
2634
+ fg(token: string, text: string) {
2635
+ return `<${token}>${text}</${token}>`;
2636
+ },
2637
+ };
2638
+
2639
+ it("uses theme tokens when provided to colorize", () => {
2640
+ expect(colorize(theme, "accent", "hello")).toBe("<accent>hello</accent>");
2641
+ });
2642
+
2643
+ it("falls back to plain text for invalid theme tokens", () => {
2644
+ const throwingTheme = {
2645
+ fg(token: string, text: string) {
2646
+ if (token === "text") return `<text>${text}</text>`;
2647
+ throw new Error(`Unknown color: ${token}`);
2648
+ },
2649
+ };
2650
+
2651
+ expect(colorize(throwingTheme, "doesNotExist", "hello")).toBe("hello");
2652
+ expect(renderStyle(throwingTheme, "doesNotExist", "hello")).toBe("hello");
2653
+ expect(renderStyleForSource(throwingTheme, "theme", "doesNotExist", "hello")).toBe("hello");
2654
+ });
2655
+
2656
+ it("maps Starship modifiers to safe theme colors when the theme rejects unknown tokens", () => {
2657
+ const strictTheme = {
2658
+ fg(token: string, text: string) {
2659
+ if (!["muted", "syntaxKeyword", "text"].includes(token)) {
2660
+ throw new Error(`Unknown theme color: ${token}`);
2661
+ }
2662
+ return `<${token}>${text}</${token}>`;
2663
+ },
2664
+ bold(text: string) {
2665
+ return `<bold>${text}</bold>`;
2666
+ },
2667
+ };
2668
+
2669
+ expect(renderStyleForSource(strictTheme, "theme", "dimmed", "tokens")).toBe(
2670
+ "<muted>tokens</muted>",
2671
+ );
2672
+ expect(renderStyleForSource(strictTheme, "theme", "bold purple", "git")).toBe(
2673
+ "<syntaxKeyword><bold>git</bold></syntaxKeyword>",
2674
+ );
2675
+ expect(renderStyleForSource(strictTheme, "theme", "unknownColor", "text")).toBe("text");
2676
+ });
2677
+
2678
+ it("supports hex colors", () => {
2679
+ expect(colorize(theme, "#89b4fa", "hello")).toBe("\u001b[38;2;137;180;250mhello\u001b[39m");
2680
+ });
2681
+
2682
+ it("supports short #rgb hex colors by expanding to rrggbb", () => {
2683
+ expect(colorize(theme, "#89b", "hello")).toBe("\u001b[38;2;136;153;187mhello\u001b[39m");
2684
+ expect(renderTerminalStyle("bold #89b", "x")).toBe("\u001b[1;38;2;136;153;187mx\u001b[0m");
2685
+ });
2686
+
2687
+ it("renders Starship styles before falling back to theme tokens", () => {
2688
+ expect(renderStyle(theme, "bold purple", "git")).toBe("\u001b[1;35mgit\u001b[0m");
2689
+ expect(renderStyle(theme, "syntaxKeyword", "git")).toBe("<syntaxKeyword>git</syntaxKeyword>");
2690
+ });
2691
+
2692
+ it("renders theme-source Starship colors through Pi theme tokens", () => {
2693
+ expect(renderStyleForSource(theme, "theme", "bold cyan", "cwd")).toBe(
2694
+ "<syntaxFunction>cwd</syntaxFunction>",
2695
+ );
2696
+ expect(renderStyleForSource(theme, "theme", "bold purple", "git")).toBe(
2697
+ "<syntaxKeyword>git</syntaxKeyword>",
2698
+ );
2699
+ expect(renderStyleForSource(theme, "theme", "bold red", "!")).toBe("<error>!</error>");
2700
+ expect(renderStyleForSource(theme, "theme", "dimmed", "tokens")).toBe("<muted>tokens</muted>");
2701
+ expect(renderStyleForSource(theme, "theme", "bold green", "cost")).toBe(
2702
+ "<success>cost</success>",
2703
+ );
2704
+ expect(renderStyleForSource(theme, "theme", "syntaxKeyword", "git")).toBe(
2705
+ "<syntaxKeyword>git</syntaxKeyword>",
2706
+ );
2707
+ });
2708
+
2709
+ it("keeps explicit terminal styles available for terminal source", () => {
2710
+ expect(renderStyleForSource(theme, "terminal", "bold purple", "git")).toBe(
2711
+ "\u001b[1;35mgit\u001b[0m",
2712
+ );
2713
+ expect(renderStyleForSource(theme, "theme", "fg:202", "git")).toBe(
2714
+ "\u001b[38;5;202mgit\u001b[0m",
2715
+ );
2716
+ });
2717
+
2718
+ it("renders theme borders with borderMuted and terminal borders with bright black", () => {
2719
+ const thinkingTheme = {
2720
+ fg(token: string, text: string) {
2721
+ return `<${token}>${text}</${token}>`;
2722
+ },
2723
+ };
2724
+
2725
+ expect(renderChromeBorder(thinkingTheme, "theme", "bright-black", "────")).toBe(
2726
+ "<borderMuted>────</borderMuted>",
2727
+ );
2728
+ expect(renderChromeBorder(thinkingTheme, "terminal", "bright-black", "────")).toBe(
2729
+ "\u001b[90m────\u001b[0m",
2730
+ );
2731
+ });
2732
+ });
2733
+
2734
+ describe("bounded settings persistence", () => {
2735
+ function withConfig(initial: Record<string, unknown>, assertions: (path: string) => void): void {
2736
+ const dir = mkdtempSync(join(tmpdir(), "zentui-bounded-config-"));
2737
+ const path = join(dir, "zentui.json");
2738
+ try {
2739
+ writeFileSync(path, `${JSON.stringify(initial, null, 2)}\n`);
2740
+ assertions(path);
2741
+ } finally {
2742
+ rmSync(dir, { recursive: true, force: true });
2743
+ }
2744
+ }
2745
+
2746
+ it("saves editorModelLabel while preserving unrelated root config", () => {
2747
+ withConfig({ editorModelLabel: "id", unknown: { keep: true } }, (path) => {
2748
+ const config = saveEditorModelLabel("name", path);
2749
+ const raw = JSON.parse(readFileSync(path, "utf8"));
2750
+ expect(config.editorModelLabel).toBe("name");
2751
+ expect(raw.editorModelLabel).toBe("id");
2752
+ expect(raw.unknown).toEqual({ keep: true });
2753
+ expect(raw.components.editor.modelLabel).toBe("name");
2754
+ expect(raw.components.footer.modelLabel).toBe("name");
2755
+ });
2756
+ });
2757
+
2758
+ it("saves git commit booleans while preserving hashLength and unknown siblings", () => {
2759
+ withConfig(
2760
+ {
2761
+ unknown: true,
2762
+ gitCommit: { hashLength: 12, onlyDetached: true, showTag: true, future: "keep" },
2763
+ },
2764
+ (path) => {
2765
+ const config = saveGitCommitPatch({ onlyDetached: false, showTag: false }, path);
2766
+ const raw = JSON.parse(readFileSync(path, "utf8"));
2767
+ expect(config.gitCommit).toEqual({ hashLength: 12, onlyDetached: false, showTag: false });
2768
+ expect(raw.gitCommit).toEqual({
2769
+ hashLength: 12,
2770
+ onlyDetached: true,
2771
+ showTag: true,
2772
+ future: "keep",
2773
+ });
2774
+ expect(raw.components.footer.styles.starship.gitCommit).toEqual({
2775
+ hashLength: 12,
2776
+ onlyDetached: false,
2777
+ showTag: false,
2778
+ });
2779
+ expect(raw.unknown).toBe(true);
2780
+ },
2781
+ );
2782
+ });
2783
+
2784
+ it("saves git metrics booleans while preserving unknown siblings", () => {
2785
+ withConfig(
2786
+ { unknown: true, gitMetrics: { onlyNonzero: true, ignoreSubmodules: false, future: 1 } },
2787
+ (path) => {
2788
+ const config = saveGitMetricsPatch({ onlyNonzero: false, ignoreSubmodules: true }, path);
2789
+ const raw = JSON.parse(readFileSync(path, "utf8"));
2790
+ expect(config.gitMetrics).toEqual({ onlyNonzero: false, ignoreSubmodules: true });
2791
+ expect(raw.gitMetrics).toEqual({
2792
+ onlyNonzero: true,
2793
+ ignoreSubmodules: false,
2794
+ future: 1,
2795
+ });
2796
+ expect(raw.components.footer.styles.starship.gitMetrics).toEqual({
2797
+ onlyNonzero: false,
2798
+ ignoreSubmodules: true,
2799
+ });
2800
+ expect(raw.unknown).toBe(true);
2801
+ },
2802
+ );
2803
+ });
2804
+
2805
+ it("saves default extension placement while preserving keyed and unknown config", () => {
2806
+ withConfig(
2807
+ {
2808
+ unknown: true,
2809
+ extensionStatuses: {
2810
+ defaultPlacement: "right",
2811
+ placements: { alpha: "left" },
2812
+ colorModes: { alpha: "original" },
2813
+ future: "keep",
2814
+ },
2815
+ },
2816
+ (path) => {
2817
+ const config = saveExtensionStatusDefaultPlacement("middle", path);
2818
+ const raw = JSON.parse(readFileSync(path, "utf8"));
2819
+ expect(config.extensionStatuses).toEqual({
2820
+ defaultPlacement: "middle",
2821
+ placements: { alpha: "left" },
2822
+ colorModes: { alpha: "original" },
2823
+ });
2824
+ expect(raw.extensionStatuses.future).toBe("keep");
2825
+ expect(raw.extensionStatuses.placements).toEqual({ alpha: "left" });
2826
+ expect(raw.extensionStatuses.colorModes).toEqual({ alpha: "original" });
2827
+ expect(raw.components.footer.styles.starship.extensionStatuses.defaultPlacement).toBe(
2828
+ "middle",
2829
+ );
2830
+ expect(raw.unknown).toBe(true);
2831
+ },
2832
+ );
2833
+ });
2834
+ });
2835
+
2836
+ describe("startup and file safety", () => {
2837
+ it("does not create, rewrite, or materialize config at startup", () => {
2838
+ withConfig(undefined, (path) => {
2839
+ ensureConfigExists(path);
2840
+ expect(existsSync(path)).toBe(false);
2841
+ });
2842
+ withConfig({ features: { editor: false }, unknown: true }, (path) => {
2843
+ const before = readFileSync(path, "utf8");
2844
+ ensureConfigExists(path);
2845
+ expect(readFileSync(path, "utf8")).toBe(before);
2846
+ expect(readRaw(path)).not.toHaveProperty("components");
2847
+ });
2848
+ });
2849
+
2850
+ it("refuses corrupt config without changing bytes or leaving a temporary file", () => {
2851
+ withConfig(undefined, (path, dir) => {
2852
+ const original = "{ invalid json\n";
2853
+ writeFileSync(path, original);
2854
+ expect(() => saveSeparatorPatch("dot", path)).toThrow(
2855
+ /Refusing to save Zentui config.*corrupt/,
2856
+ );
2857
+ expect(readFileSync(path, "utf8")).toBe(original);
2858
+ expect(configTempFiles(dir)).toEqual([]);
2859
+ });
2860
+ });
2861
+
2862
+ it("creates a missing config atomically and returns the re-merged file", () => {
2863
+ withConfig(undefined, (path, dir) => {
2864
+ const config = saveFooterFormatPatch("$cwd", path);
2865
+ const raw = readRaw(path);
2866
+ expect(raw.components.footer.styles.starship.format).toBe("$cwd");
2867
+ expect(raw).not.toHaveProperty("fixedEditor");
2868
+ expect(raw).not.toHaveProperty("layout.fixedEditor");
2869
+ expect(config).toEqual(mergeConfig(raw));
2870
+ expect(configTempFiles(dir)).toEqual([]);
2871
+ });
2872
+ });
2873
+
2874
+ it("preserves destination mode during atomic replacement", () => {
2875
+ withConfig({ separator: "pipe" }, (path, dir) => {
2876
+ chmodSync(path, 0o600);
2877
+ saveSeparatorPatch("dot", path);
2878
+ expect(statSync(path).mode & 0o777).toBe(0o600);
2879
+ expect(readRaw(path).components.footer.styles.starship.separator).toBe("dot");
2880
+ expect(configTempFiles(dir)).toEqual([]);
2881
+ });
2882
+ });
2883
+
2884
+ it("updates a symlink target atomically without replacing the symlink", () => {
2885
+ const dir = mkdtempSync(join(tmpdir(), "zentui-symlink-config-"));
2886
+ const targetDir = join(dir, "target");
2887
+ const targetPath = join(targetDir, "actual.json");
2888
+ const linkPath = join(dir, "zentui.json");
2889
+ try {
2890
+ mkdirSync(targetDir);
2891
+ writeFileSync(targetPath, `${JSON.stringify({ unknown: true }, null, 2)}\n`);
2892
+ chmodSync(targetPath, 0o600);
2893
+ symlinkSync(targetPath, linkPath);
2894
+ const originalLink = readlinkSync(linkPath);
2895
+ const config = saveSeparatorPatch("chevron", linkPath);
2896
+ const raw = readRaw(targetPath);
2897
+ expect(lstatSync(linkPath).isSymbolicLink()).toBe(true);
2898
+ expect(readlinkSync(linkPath)).toBe(originalLink);
2899
+ expect(raw.unknown).toBe(true);
2900
+ expect(raw.components.footer.styles.starship.separator).toBe("chevron");
2901
+ expect(config).toEqual(mergeConfig(raw));
2902
+ expect(statSync(targetPath).mode & 0o777).toBe(0o600);
2903
+ expect(configTempFiles(targetDir, "actual.json")).toEqual([]);
2904
+ expect(configTempFiles(dir)).toEqual([]);
2905
+ } finally {
2906
+ rmSync(dir, { recursive: true, force: true });
2907
+ }
2908
+ });
2909
+
2910
+ it("refuses a dangling symlink without changing it", () => {
2911
+ const dir = mkdtempSync(join(tmpdir(), "zentui-dangling-config-"));
2912
+ const targetDir = join(dir, "target");
2913
+ const missingTarget = join(targetDir, "missing.json");
2914
+ const linkPath = join(dir, "zentui.json");
2915
+ try {
2916
+ mkdirSync(targetDir);
2917
+ symlinkSync(missingTarget, linkPath);
2918
+ const originalLink = readlinkSync(linkPath);
2919
+ expect(() => saveSeparatorPatch("dot", linkPath)).toThrow(/Refusing to save Zentui config/);
2920
+ expect(lstatSync(linkPath).isSymbolicLink()).toBe(true);
2921
+ expect(readlinkSync(linkPath)).toBe(originalLink);
2922
+ expect(existsSync(missingTarget)).toBe(false);
2923
+ expect(configTempFiles(targetDir, "missing.json")).toEqual([]);
2924
+ expect(configTempFiles(dir)).toEqual([]);
2925
+ } finally {
2926
+ rmSync(dir, { recursive: true, force: true });
2927
+ }
2928
+ });
2929
+
2930
+ it("keeps the destination and leaves no temp file when serialization input is invalid", () => {
2931
+ withConfig({ unknown: true }, (path, dir) => {
2932
+ const original = readFileSync(path, "utf8");
2933
+ expect(() => saveContextThresholdsPatch({ warning: 1n as never }, path)).toThrow();
2934
+ expect(readFileSync(path, "utf8")).toBe(original);
2935
+ expect(configTempFiles(dir)).toEqual([]);
2936
+ });
2937
+ });
2938
+ });
2939
+
2940
+ describe("Opencode and Footer follow-up migration", () => {
2941
+ it("resolves canonical and alias editor metadata in documented precedence", () => {
2942
+ const config = mergeConfig({
2943
+ editorMetadataFormat: "$flat",
2944
+ components: {
2945
+ editor: {
2946
+ styles: {
2947
+ opencode: { metadataFormat: "$canonical" },
2948
+ polished: { metadataFormat: "$alias" },
2949
+ "polished-copy-friendly": { metadataFormat: "$alias-copy" },
2950
+ },
2951
+ },
2952
+ },
2953
+ });
2954
+ expect(config.components.editor.styles.opencode.metadataFormat).toBe("$canonical");
2955
+ expect(config.components.editor.styles["opencode-copy-friendly"].metadataFormat).toBe(
2956
+ "$alias-copy",
2957
+ );
2958
+ expect(
2959
+ mergeConfig({
2960
+ editorMetadataFormat: "$flat",
2961
+ components: { editor: { styles: { polished: { metadataFormat: "$alias" } } } },
2962
+ }).components.editor.styles.opencode.metadataFormat,
2963
+ ).toBe("$alias");
2964
+ });
2965
+
2966
+ it("resolves Footer style from valid style, enabled, released statusLine, then default", () => {
2967
+ expect(
2968
+ mergeConfig({
2969
+ features: { statusLine: false },
2970
+ components: { footer: { style: "hidden", enabled: true } },
2971
+ }).components.footer.style,
2972
+ ).toBe("hidden");
2973
+ expect(
2974
+ mergeConfig({
2975
+ features: { statusLine: true },
2976
+ components: { footer: { style: "future", enabled: false } },
2977
+ }).components.footer.style,
2978
+ ).toBe("native");
2979
+ expect(mergeConfig({ features: { statusLine: false } }).components.footer.style).toBe("native");
2980
+ expect(mergeConfig({}).components.footer.style).toBe("native");
2981
+ expect(mergeConfig({ components: { footer: { style: "hidden" } } }).features.statusLine).toBe(
2982
+ false,
2983
+ );
2984
+ });
2985
+
2986
+ it("saves Footer styles canonically and removes only obsolete enabled", () => {
2987
+ withConfig(
2988
+ {
2989
+ features: { statusLine: true, future: "keep" },
2990
+ components: {
2991
+ footer: {
2992
+ enabled: true,
2993
+ future: "keep",
2994
+ styles: { starship: { format: "$cwd", future: "keep" }, future: { keep: true } },
2995
+ },
2996
+ },
2997
+ },
2998
+ (path) => {
2999
+ saveFooterComponentPatch({ style: "hidden" }, path);
3000
+ const raw = readRaw(path);
3001
+ expect(raw.components.footer.style).toBe("hidden");
3002
+ expect(raw.components.footer).not.toHaveProperty("enabled");
3003
+ expect(raw.components.footer.future).toBe("keep");
3004
+ expect(raw.components.footer.styles.starship).toMatchObject({
3005
+ format: "$cwd",
3006
+ future: "keep",
3007
+ });
3008
+ expect(raw.components.footer.styles.future).toEqual({ keep: true });
3009
+ expect(raw.features).toEqual({ statusLine: true, future: "keep" });
3010
+ },
3011
+ );
3012
+ });
3013
+
3014
+ it("keeps saveUiFeaturesPatch as a Starship/Native compatibility API", () => {
3015
+ withConfig({ components: { footer: { style: "hidden" } } }, (path) => {
3016
+ expect(saveUiFeaturesPatch({ statusLine: true }, path).components.footer.style).toBe(
3017
+ "starship",
3018
+ );
3019
+ expect(saveUiFeaturesPatch({ statusLine: false }, path).components.footer.style).toBe(
3020
+ "native",
3021
+ );
3022
+ });
3023
+ });
3024
+
3025
+ it("ignores inherited extension-status overrides and validates runtime mutations", () => {
3026
+ const config = structuredClone(defaultConfig);
3027
+ const statuses = config.components.footer.styles.starship.extensionStatuses;
3028
+ statuses.placements = Object.create({ constructor: "left" }) as Record<
3029
+ string,
3030
+ (typeof statuses.placements)[string]
3031
+ >;
3032
+ statuses.colorModes = Object.create({ constructor: "original" }) as Record<
3033
+ string,
3034
+ (typeof statuses.colorModes)[string]
3035
+ >;
3036
+ expect(getExtensionStatusPlacement(config, "constructor")).toBe("right");
3037
+ expect(getExtensionStatusColorMode(config, "constructor")).toBe("zentui");
3038
+
3039
+ Object.defineProperty(statuses.placements, "constructor", {
3040
+ value: "left",
3041
+ enumerable: true,
3042
+ configurable: true,
3043
+ });
3044
+ Object.defineProperty(statuses.colorModes, "constructor", {
3045
+ value: "original",
3046
+ enumerable: true,
3047
+ configurable: true,
3048
+ });
3049
+ expect(getExtensionStatusPlacement(config, "constructor")).toBe("left");
3050
+ expect(getExtensionStatusColorMode(config, "constructor")).toBe("original");
3051
+
3052
+ (statuses as unknown as { defaultPlacement: string }).defaultPlacement = "center";
3053
+ (statuses.placements as unknown as Record<string, string>).alpha = "explode";
3054
+ (statuses.colorModes as unknown as Record<string, string>).alpha = "rainbow";
3055
+ expect(getExtensionStatusPlacement(config, "missing")).toBe("right");
3056
+ expect(getExtensionStatusPlacement(config, "alpha")).toBe("right");
3057
+ expect(getExtensionStatusColorMode(config, "alpha")).toBe("zentui");
3058
+ });
3059
+
3060
+ it("marks only explicit non-empty unknown canonical component styles", () => {
3061
+ const future = mergeConfig({
3062
+ components: {
3063
+ editor: { style: "future-editor" },
3064
+ userMessages: { style: "future-messages" },
3065
+ selectorBorders: { style: "future-selectors" },
3066
+ footer: { style: "future-footer" },
3067
+ },
3068
+ });
3069
+ for (const owner of ["editor", "userMessages", "selectorBorders", "footer"] as const) {
3070
+ expect(hasUnsupportedComponentStyle(future, owner)).toBe(true);
3071
+ }
3072
+
3073
+ for (const style of [undefined, "", " ", null, false, {}, []]) {
3074
+ const ordinary = mergeConfig({ components: { editor: { style } } });
3075
+ expect(hasUnsupportedComponentStyle(ordinary, "editor")).toBe(false);
3076
+ }
3077
+ expect(
3078
+ hasUnsupportedComponentStyle(
3079
+ mergeConfig({ components: { editor: { style: "polished" } } }),
3080
+ "editor",
3081
+ ),
3082
+ ).toBe(false);
3083
+ });
3084
+
3085
+ it.each([
3086
+ [true, "starship"],
3087
+ [false, "native"],
3088
+ ] as const)(
3089
+ "makes compatibility statusLine=%s authoritative over unknown Footer state",
3090
+ (statusLine, expectedStyle) => {
3091
+ withConfig(
3092
+ {
3093
+ features: { statusLine: !statusLine, future: "keep" },
3094
+ components: {
3095
+ footer: {
3096
+ style: "future-footer",
3097
+ enabled: true,
3098
+ future: "keep",
3099
+ styles: {
3100
+ starship: { format: "$cwd" },
3101
+ future: { keep: true },
3102
+ },
3103
+ },
3104
+ },
3105
+ },
3106
+ (path) => {
3107
+ const config = saveUiFeaturesPatch({ statusLine }, path);
3108
+ const raw = readRaw(path);
3109
+ expect(config.components.footer.style).toBe(expectedStyle);
3110
+ expect(raw.components.footer.style).toBe(expectedStyle);
3111
+ expect(raw.components.footer).not.toHaveProperty("enabled");
3112
+ expect(raw.components.footer.future).toBe("keep");
3113
+ expect(raw.components.footer.styles.future).toEqual({ keep: true });
3114
+ expect(raw.features).toEqual({ statusLine: !statusLine, future: "keep" });
3115
+ },
3116
+ );
3117
+ },
3118
+ );
3119
+ });