@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,1381 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { visibleWidth } from "@earendil-works/pi-tui";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import {
5
+ defaultConfig,
6
+ type EditorComponentConfig,
7
+ type ExtensionStatusPlacement,
8
+ type FooterComponentConfig,
9
+ type PolishedTuiConfig,
10
+ type SelectorBordersComponentConfig,
11
+ type UserMessagesComponentConfig,
12
+ type WorkingLineComponentPatch,
13
+ } from "../extensions/zentui/config";
14
+ import { SessionLifecycle } from "../extensions/zentui/session-lifecycle";
15
+ import { registerZentuiSettingsCommand } from "../extensions/zentui/settings-command";
16
+
17
+ type Component = { render(width: number): string[]; handleInput(data: string): void };
18
+ type Command = {
19
+ handler(args: string, ctx: unknown): Promise<void>;
20
+ getArgumentCompletions(prefix: string): Array<{ value: string }> | null;
21
+ };
22
+ const sectionNames = [
23
+ "Appearance",
24
+ "Editor",
25
+ "User messages",
26
+ "Working line",
27
+ "Footer",
28
+ "Segments",
29
+ "Git",
30
+ "Extensions",
31
+ ] as const;
32
+ type SectionName = (typeof sectionNames)[number];
33
+
34
+ function theme(): Theme {
35
+ return {
36
+ fg: (_color: string, text: string) => text,
37
+ bold: (text: string) => `[${text}]`,
38
+ italic: (text: string) => text,
39
+ underline: (text: string) => text,
40
+ strikethrough: (text: string) => text,
41
+ getThinkingBorderColor: () => (text: string) => text,
42
+ } as unknown as Theme;
43
+ }
44
+ function cloneConfig(): PolishedTuiConfig {
45
+ const config = structuredClone(defaultConfig);
46
+ config.icons.mode = "nerd";
47
+ config.components.editor.enabled = true;
48
+ config.components.editor.borderColorMode = "static";
49
+ config.components.editor.modelLabel = "id";
50
+ config.components.editor.viewportIndicators = true;
51
+ config.components.editor.styles.opencode.completionMenu = "palette";
52
+ config.components.editor.styles["opencode-copy-friendly"].completionMenu = "palette";
53
+ config.components.editor.styles.minimalist.contextGauge = false;
54
+ config.components.userMessages.style = "framed";
55
+ config.components.workingLine.enabled = false;
56
+ config.components.footer.style = "starship";
57
+ config.components.footer.modelLabel = "id";
58
+ config.components.footer.styles.starship.compactMaxLines = 2;
59
+ config.components.footer.styles.starship.separator = "pipe";
60
+ config.components.footer.styles.starship.pathDisplay.depth = 0;
61
+ config.components.footer.styles.starship.segments.gitMetrics = false;
62
+ return config;
63
+ }
64
+ function goToSection(component: Component, section: SectionName): void {
65
+ for (let index = 0; index < sectionNames.indexOf(section); index += 1)
66
+ component.handleInput("\t");
67
+ }
68
+ function selectLabel(component: Component, label: string): void {
69
+ for (let index = 0; index < 40; index += 1) {
70
+ if (component.render(160).some((line) => line.includes(`> ${label}`))) return;
71
+ component.handleInput("\x1b[B");
72
+ }
73
+ throw new Error(`Could not select ${label}`);
74
+ }
75
+ function row(component: Component, label: string): string {
76
+ selectLabel(component, label);
77
+ return component.render(160).find((line) => line.includes(`> ${label}`)) ?? "";
78
+ }
79
+ function focusedRow(component: Component): string {
80
+ return component.render(200).find((line) => line.includes("> ")) ?? "";
81
+ }
82
+ function previewRow(rows: string[], text: string): number {
83
+ const index = rows.findIndex((line) => line.includes(text));
84
+ if (index < 0) throw new Error(`Could not find preview row containing ${text}`);
85
+ return index;
86
+ }
87
+ function expectStackedPreview(rows: string[], previewText: string): void {
88
+ const previewIndex = previewRow(rows, previewText);
89
+ const settingsIndex = rows.findIndex(
90
+ (line, index) => index > previewIndex && line.includes("> "),
91
+ );
92
+ if (settingsIndex < 0) throw new Error("Could not find settings below preview");
93
+ expect(rows[3]).toBe("");
94
+ expect(rows[4]).not.toBe("");
95
+ expect(rows[settingsIndex - 1]).toBe("");
96
+ expect(rows[settingsIndex - 2]).not.toBe("");
97
+ }
98
+ function leadingEmptyRowCount(rows: string[]): number {
99
+ let count = 0;
100
+ for (const row of rows.slice(3)) {
101
+ if (row !== "") break;
102
+ count += 1;
103
+ }
104
+ return count;
105
+ }
106
+ function expectFocusOrder(component: Component, labels: readonly string[]): void {
107
+ const first = focusedRow(component);
108
+ for (const [index, label] of labels.entries()) {
109
+ expect(focusedRow(component)).toContain(`> ${label}`);
110
+ if (index < labels.length - 1) component.handleInput("\x1b[B");
111
+ }
112
+ component.handleInput("\x1b[B");
113
+ expect(focusedRow(component)).toBe(first);
114
+ }
115
+
116
+ function createHarness(
117
+ config = cloneConfig(),
118
+ overrides: Record<string, unknown> = {},
119
+ uiOverrides: Record<string, unknown> = {},
120
+ ) {
121
+ let command: Command | undefined;
122
+ let component: Component | undefined;
123
+ const notifications: string[] = [];
124
+ let doneCalls = 0;
125
+ const sessionLifecycle = new SessionLifecycle();
126
+ const calls = {
127
+ editor: [] as Partial<EditorComponentConfig>[],
128
+ polished: [] as Array<Record<string, unknown>>,
129
+ polishedCopyFriendly: [] as Array<Record<string, unknown>>,
130
+ accentRail: [] as Array<Record<string, unknown>>,
131
+ messages: [] as Partial<UserMessagesComponentConfig>[],
132
+ workingLine: [] as WorkingLineComponentPatch[],
133
+ renders: { shared: 0, local: 0 },
134
+ selectors: [] as Partial<SelectorBordersComponentConfig>[],
135
+ footer: [] as Partial<FooterComponentConfig>[],
136
+ minimalist: [] as Array<Record<string, unknown>>,
137
+ pathDisplay: [] as Array<Record<string, unknown>>,
138
+ segments: [] as Array<Record<string, boolean>>,
139
+ gitCommit: [] as Array<Record<string, boolean>>,
140
+ gitMetrics: [] as Array<Record<string, boolean>>,
141
+ extensionDefaultPlacement: [] as ExtensionStatusPlacement[],
142
+ recipe: [] as boolean[],
143
+ };
144
+ const deps = {
145
+ sessionLifecycle,
146
+ getConfig: () => config,
147
+ setEditorComponent(patch: Partial<EditorComponentConfig>) {
148
+ calls.editor.push(patch);
149
+ Object.assign(config.components.editor, patch);
150
+ return { applied: true };
151
+ },
152
+ setPolished(patch: Record<string, unknown>) {
153
+ calls.polished.push(patch);
154
+ Object.assign(config.components.editor.styles.opencode, patch);
155
+ },
156
+ setPolishedCopyFriendly(patch: Record<string, unknown>) {
157
+ calls.polishedCopyFriendly.push(patch);
158
+ Object.assign(config.components.editor.styles["opencode-copy-friendly"], patch);
159
+ },
160
+ setAccentRail(patch: Record<string, unknown>) {
161
+ calls.accentRail.push(patch);
162
+ Object.assign(config.components.editor.styles["accent-rail"], patch);
163
+ },
164
+ setMinimalist(patch: Record<string, unknown>) {
165
+ calls.minimalist.push(patch);
166
+ Object.assign(config.components.editor.styles.minimalist, patch);
167
+ },
168
+ setUserMessagesComponent(patch: Partial<UserMessagesComponentConfig>) {
169
+ calls.messages.push(patch);
170
+ Object.assign(config.components.userMessages, patch);
171
+ },
172
+ setWorkingLineComponent(patch: WorkingLineComponentPatch) {
173
+ calls.workingLine.push(patch);
174
+ const { messages, segments, ...componentPatch } = patch;
175
+ Object.assign(config.components.workingLine, componentPatch);
176
+ if (messages) Object.assign(config.components.workingLine.messages, messages);
177
+ if (segments) Object.assign(config.components.workingLine.segments, segments);
178
+ return { applied: true };
179
+ },
180
+ setSelectorBordersComponent(patch: Partial<SelectorBordersComponentConfig>) {
181
+ calls.selectors.push(patch);
182
+ Object.assign(config.components.selectorBorders, patch);
183
+ },
184
+ setFooterComponent(patch: Partial<FooterComponentConfig>) {
185
+ calls.footer.push(patch);
186
+ Object.assign(config.components.footer, patch);
187
+ },
188
+ setFooterSegments(patch: Record<string, boolean>) {
189
+ calls.segments.push(patch);
190
+ Object.assign(config.components.footer.styles.starship.segments, patch);
191
+ },
192
+ setFooterFormat() {},
193
+ setResponsiveFooter() {},
194
+ setIconMode() {},
195
+ setContextStyle() {},
196
+ setSeparator() {},
197
+ setPathDisplay(patch: Record<string, unknown>) {
198
+ calls.pathDisplay.push(patch);
199
+ Object.assign(config.components.footer.styles.starship.pathDisplay, patch);
200
+ },
201
+ setGitBranch() {},
202
+ setGitCommit(patch: Record<string, boolean>) {
203
+ calls.gitCommit.push(patch);
204
+ Object.assign(config.components.footer.styles.starship.gitCommit, patch);
205
+ },
206
+ setGitMetrics(patch: Record<string, boolean>) {
207
+ calls.gitMetrics.push(patch);
208
+ Object.assign(config.components.footer.styles.starship.gitMetrics, patch);
209
+ },
210
+ getActiveExtensionStatuses: () => new Map(),
211
+ setExtensionStatusDefaultPlacement(placement: ExtensionStatusPlacement) {
212
+ calls.extensionDefaultPlacement.push(placement);
213
+ config.components.footer.styles.starship.extensionStatuses.defaultPlacement = placement;
214
+ },
215
+ setExtensionStatusPlacement() {},
216
+ setExtensionStatusColorMode() {},
217
+ requestRender() {
218
+ calls.renders.shared += 1;
219
+ },
220
+ settingsListTheme: {
221
+ label: (text: string) => text,
222
+ value: (text: string) => text,
223
+ description: (text: string) => text,
224
+ cursor: "> ",
225
+ hint: (text: string) => text,
226
+ },
227
+ ...overrides,
228
+ };
229
+ registerZentuiSettingsCommand(
230
+ {
231
+ registerCommand(_name: string, value: unknown) {
232
+ command = value as Command;
233
+ },
234
+ } as never,
235
+ deps as never,
236
+ );
237
+ const ctx = {
238
+ hasUI: true,
239
+ mode: "tui",
240
+ cwd: process.cwd(),
241
+ ui: {
242
+ theme: theme(),
243
+ notify(message: string) {
244
+ notifications.push(message);
245
+ },
246
+ async custom(factory: (...args: unknown[]) => unknown) {
247
+ component = factory(
248
+ {
249
+ requestRender() {
250
+ calls.renders.local += 1;
251
+ },
252
+ },
253
+ theme(),
254
+ {},
255
+ () => {
256
+ doneCalls += 1;
257
+ },
258
+ ) as Component;
259
+ },
260
+ async editor() {
261
+ return undefined;
262
+ },
263
+ ...uiOverrides,
264
+ },
265
+ };
266
+ return {
267
+ config,
268
+ command: () => {
269
+ if (!command) throw new Error("Command was not registered");
270
+ return command;
271
+ },
272
+ component: () => {
273
+ if (!component) throw new Error("Settings component was not opened");
274
+ return component;
275
+ },
276
+ ctx,
277
+ calls,
278
+ notifications,
279
+ sessionLifecycle,
280
+ doneCalls: () => doneCalls,
281
+ };
282
+ }
283
+
284
+ afterEach(() => {
285
+ vi.useRealTimers();
286
+ });
287
+
288
+ describe("component-oriented /zentui settings", () => {
289
+ it("uses the exact eight-section order in wide and narrow navigation", async () => {
290
+ const harness = createHarness();
291
+ await harness.command().handler("", harness.ctx);
292
+ const component = harness.component();
293
+ const wide = component.render(200).join("\n");
294
+ let previous = -1;
295
+ for (const name of sectionNames) {
296
+ const index = wide.indexOf(name);
297
+ expect(index).toBeGreaterThan(previous);
298
+ previous = index;
299
+ }
300
+ for (const [index, name] of sectionNames.entries()) {
301
+ const lines = component.render(40);
302
+ expect(lines[1]).toContain(name);
303
+ expect(lines[1]).toContain(`(${index + 1}/8)`);
304
+ expect(lines.every((line) => visibleWidth(line) <= 40)).toBe(true);
305
+ component.handleInput("\t");
306
+ }
307
+ });
308
+
309
+ it("describes elapsed and tokens unequivocally as whole-interaction totals", async () => {
310
+ const harness = createHarness();
311
+ await harness.command().handler("working-line", harness.ctx);
312
+ const component = harness.component();
313
+ selectLabel(component, "Elapsed");
314
+ expect(component.render(100).join("\n")).toContain("Show whole-interaction elapsed time.");
315
+ selectLabel(component, "Tokens");
316
+ const tokenRows = component.render(100).join("\n");
317
+ expect(tokenRows).toContain("Show whole-interaction tokens as ↑input");
318
+ expect(tokenRows).toContain("until final usage");
319
+ expect(tokenRows).toContain("reconciles.");
320
+ });
321
+
322
+ it("uses exact component-owned row sets and ordering", async () => {
323
+ const harness = createHarness();
324
+ await harness.command().handler("", harness.ctx);
325
+ const component = harness.component();
326
+ expectFocusOrder(component, [
327
+ "Selector borders",
328
+ "Selector border style",
329
+ "Selector border colors",
330
+ "Icon mode",
331
+ ]);
332
+
333
+ goToSection(component, "Editor");
334
+ expectFocusOrder(component, [
335
+ "Editor",
336
+ "Editor style",
337
+ "Editor colors",
338
+ "Editor model label",
339
+ "Editor border color",
340
+ "Editor viewport indicators",
341
+ "Completion menu",
342
+ ]);
343
+
344
+ component.handleInput("\t");
345
+ expectFocusOrder(component, ["User messages", "Message style", "Message colors"]);
346
+ component.handleInput("\t");
347
+ expectFocusOrder(component, [
348
+ "Enabled",
349
+ "Turn summary",
350
+ "Spinner",
351
+ "Spinner speed",
352
+ "Animate spinner color",
353
+ "Text animation",
354
+ "Text motion speed",
355
+ "Color source",
356
+ "Custom messages",
357
+ "Tool",
358
+ "Elapsed",
359
+ "Thinking",
360
+ "Tokens",
361
+ "Message list",
362
+ ]);
363
+ component.handleInput("\t");
364
+ expectFocusOrder(component, [
365
+ "Footer style",
366
+ "Footer colors",
367
+ "Footer model label",
368
+ "Responsive footer",
369
+ "Compact footer rows",
370
+ "Context style",
371
+ "Separator",
372
+ "Path display",
373
+ "Path depth",
374
+ ]);
375
+ component.handleInput("\t");
376
+ expectFocusOrder(component, [
377
+ "Current directory",
378
+ "Session name",
379
+ "Runtime",
380
+ "Model info",
381
+ "Context usage",
382
+ "Token counts",
383
+ "Session cost",
384
+ "Session duration",
385
+ "Username@host",
386
+ "Current time",
387
+ "OS icon",
388
+ "Package version",
389
+ ]);
390
+ component.handleInput("\t");
391
+ expectFocusOrder(component, [
392
+ "Git branch",
393
+ "Branch length",
394
+ "Git status",
395
+ "Git counts",
396
+ "Git commit",
397
+ "Commit only on detached HEAD",
398
+ "Show exact-match tag",
399
+ "Git line metrics",
400
+ "Hide zero metrics",
401
+ "Ignore submodules",
402
+ ]);
403
+ component.handleInput("\t");
404
+ expectFocusOrder(component, ["Default placement", "No active statuses"]);
405
+ });
406
+
407
+ it.each(["native", "hidden"] as const)(
408
+ "shows only Footer style for %s while retaining Starship preconfiguration sections",
409
+ async (style) => {
410
+ const config = cloneConfig();
411
+ config.components.footer.style = style;
412
+ const harness = createHarness(config);
413
+ await harness.command().handler("", harness.ctx);
414
+ const component = harness.component();
415
+ goToSection(component, "Footer");
416
+ expectFocusOrder(component, ["Footer style"]);
417
+ component.handleInput("\t");
418
+ expectFocusOrder(component, [
419
+ "Current directory",
420
+ "Session name",
421
+ "Runtime",
422
+ "Model info",
423
+ "Context usage",
424
+ "Token counts",
425
+ "Session cost",
426
+ "Session duration",
427
+ "Username@host",
428
+ "Current time",
429
+ "OS icon",
430
+ "Package version",
431
+ ]);
432
+ component.handleInput("\t");
433
+ expect(row(component, "Git branch")).toContain("enabled");
434
+ component.handleInput("\t");
435
+ expect(row(component, "Default placement")).toContain("right");
436
+ },
437
+ );
438
+
439
+ it("keeps Footer-style focus through Native, Starship, and Hidden rebuilds", async () => {
440
+ const config = cloneConfig();
441
+ config.components.footer.style = "native";
442
+ const harness = createHarness(config);
443
+ await harness.command().handler("", harness.ctx);
444
+ const component = harness.component();
445
+ goToSection(component, "Footer");
446
+ for (const expected of ["Starship", "Hidden", "Native"]) {
447
+ component.handleInput(" ");
448
+ expect(focusedRow(component)).toContain("> Footer style");
449
+ expect(focusedRow(component)).toContain(expected);
450
+ }
451
+ expect(harness.calls.footer).toEqual([
452
+ { style: "starship" },
453
+ { style: "hidden" },
454
+ { style: "native" },
455
+ ]);
456
+ });
457
+
458
+ it("restores Footer-style focus and effective rows after persistence failure", async () => {
459
+ const config = cloneConfig();
460
+ config.components.footer.style = "native";
461
+ const harness = createHarness(config, {
462
+ setFooterComponent() {
463
+ throw new Error("read-only footer");
464
+ },
465
+ });
466
+ await harness.command().handler("", harness.ctx);
467
+ const component = harness.component();
468
+ goToSection(component, "Footer");
469
+ component.handleInput(" ");
470
+ expect(focusedRow(component)).toContain("> Footer style");
471
+ expect(focusedRow(component)).toContain("Native");
472
+ expectFocusOrder(component, ["Footer style"]);
473
+ expect(harness.notifications).toEqual(["Could not update Zentui settings: read-only footer"]);
474
+ });
475
+
476
+ it("shows exact minimalist rows while components are disabled", async () => {
477
+ const config = cloneConfig();
478
+ config.components.editor.enabled = false;
479
+ config.components.editor.style = "minimalist";
480
+ config.components.userMessages.enabled = false;
481
+ const harness = createHarness(config);
482
+ await harness.command().handler("", harness.ctx);
483
+ const component = harness.component();
484
+ goToSection(component, "Editor");
485
+ expectFocusOrder(component, [
486
+ "Editor",
487
+ "Editor style",
488
+ "Editor colors",
489
+ "Editor model label",
490
+ "Editor border color",
491
+ "Editor viewport indicators",
492
+ "Path",
493
+ "Context text",
494
+ "Context gauge",
495
+ "Session name",
496
+ "Timer",
497
+ "Cost",
498
+ "Git",
499
+ ]);
500
+ component.handleInput("\t");
501
+ expectFocusOrder(component, ["User messages", "Message style", "Message colors"]);
502
+ });
503
+
504
+ it("routes every minimalist, Git option, and default-placement action", async () => {
505
+ const config = cloneConfig();
506
+ config.components.editor.style = "minimalist";
507
+ const harness = createHarness(config);
508
+ await harness.command().handler("", harness.ctx);
509
+ const component = harness.component();
510
+ goToSection(component, "Editor");
511
+
512
+ for (const [label, value] of [
513
+ ["Path", "project"],
514
+ ["Context text", "percent-total"],
515
+ ["Context gauge", "enabled"],
516
+ ["Session name", "disabled"],
517
+ ["Timer", "disabled"],
518
+ ["Cost", "disabled"],
519
+ ["Git", "disabled"],
520
+ ] as const) {
521
+ selectLabel(component, label);
522
+ component.handleInput(" ");
523
+ expect(focusedRow(component)).toContain(`> ${label}`);
524
+ expect(focusedRow(component)).toContain(value);
525
+ }
526
+ expect(harness.calls.minimalist).toEqual([
527
+ { pathDisplay: "project" },
528
+ { contextFormat: "percent-total" },
529
+ { contextGauge: true },
530
+ { showSessionName: false },
531
+ { showTimer: false },
532
+ { showCost: false },
533
+ { showGit: false },
534
+ ]);
535
+
536
+ for (let index = 0; index < 5; index += 1) component.handleInput("\t");
537
+ for (const [label, value] of [
538
+ ["Commit only on detached HEAD", "disabled"],
539
+ ["Show exact-match tag", "disabled"],
540
+ ["Hide zero metrics", "disabled"],
541
+ ["Ignore submodules", "enabled"],
542
+ ] as const) {
543
+ selectLabel(component, label);
544
+ component.handleInput(" ");
545
+ expect(focusedRow(component)).toContain(`> ${label}`);
546
+ expect(focusedRow(component)).toContain(value);
547
+ }
548
+ expect(harness.calls.gitCommit).toEqual([{ onlyDetached: false }, { showTag: false }]);
549
+ expect(harness.calls.gitMetrics).toEqual([{ onlyNonzero: false }, { ignoreSubmodules: true }]);
550
+
551
+ component.handleInput("\t");
552
+ selectLabel(component, "Default placement");
553
+ component.handleInput(" ");
554
+ expect(focusedRow(component)).toContain("> Default placement");
555
+ expect(focusedRow(component)).toContain("off");
556
+ expect(harness.calls.extensionDefaultPlacement).toEqual(["off"]);
557
+ expect(config.components.footer.styles.starship.extensionStatuses.defaultPlacement).toBe("off");
558
+ });
559
+
560
+ it("restores editor-style focus by ID after dynamic rebuild", async () => {
561
+ const harness = createHarness();
562
+ await harness.command().handler("", harness.ctx);
563
+ const component = harness.component();
564
+ goToSection(component, "Editor");
565
+ selectLabel(component, "Editor style");
566
+ component.handleInput(" ");
567
+ expect(focusedRow(component)).toContain("> Editor style");
568
+ expect(focusedRow(component)).toContain("Opencode (copy-friendly)");
569
+ component.handleInput(" ");
570
+ expect(focusedRow(component)).toContain("> Editor style");
571
+ expect(focusedRow(component)).toContain("Accent Rail");
572
+ component.handleInput(" ");
573
+ expect(focusedRow(component)).toContain("> Editor style");
574
+ expect(focusedRow(component)).toContain("Minimalist");
575
+ expect(harness.calls.editor).toEqual([
576
+ { style: "opencode-copy-friendly" },
577
+ { style: "accent-rail" },
578
+ { style: "minimalist" },
579
+ ]);
580
+ });
581
+
582
+ it("configures Opencode completion menus independently", async () => {
583
+ const current = cloneConfig();
584
+ const harness = createHarness(current);
585
+ await harness.command().handler("", harness.ctx);
586
+ const component = harness.component();
587
+ goToSection(component, "Editor");
588
+ selectLabel(component, "Completion menu");
589
+ expect(focusedRow(component)).toContain("palette");
590
+ component.handleInput(" ");
591
+ expect(focusedRow(component)).toContain("native");
592
+ expect(harness.calls.polished).toEqual([{ completionMenu: "native" }]);
593
+ expect(harness.calls.polishedCopyFriendly).toEqual([]);
594
+
595
+ selectLabel(component, "Editor style");
596
+ component.handleInput(" ");
597
+ selectLabel(component, "Completion menu");
598
+ expect(focusedRow(component)).toContain("palette");
599
+ component.handleInput(" ");
600
+ expect(harness.calls.polishedCopyFriendly).toEqual([{ completionMenu: "native" }]);
601
+ expect(current.components.editor.styles.opencode.completionMenu).toBe("native");
602
+ expect(current.components.editor.styles["opencode-copy-friendly"].completionMenu).toBe(
603
+ "native",
604
+ );
605
+
606
+ selectLabel(component, "Editor style");
607
+ component.handleInput(" ");
608
+ expect(component.render(100).join("\n")).not.toContain("Completion menu");
609
+ });
610
+
611
+ it("shows and persists the Accent Rail surface control only for that style", async () => {
612
+ const current = cloneConfig();
613
+ current.components.editor.style = "accent-rail";
614
+ const harness = createHarness(current);
615
+ await harness.command().handler("", harness.ctx);
616
+ const component = harness.component();
617
+ goToSection(component, "Editor");
618
+ selectLabel(component, "Accent Rail surface");
619
+ expect(focusedRow(component)).toContain("filled");
620
+ component.handleInput(" ");
621
+ expect(focusedRow(component)).toContain("transparent");
622
+ expect(harness.calls.accentRail).toEqual([{ transparent: true }]);
623
+ expect(harness.calls.editor).toEqual([]);
624
+ expect(current.components.editor.styles.minimalist).toEqual({
625
+ ...defaultConfig.components.editor.styles.minimalist,
626
+ contextGauge: false,
627
+ });
628
+
629
+ selectLabel(component, "Editor style");
630
+ component.handleInput(" ");
631
+ expect(component.render(80).join("\n")).not.toContain("Accent Rail surface");
632
+ });
633
+
634
+ it("shows friendly message style labels and restores focus after rebuild", async () => {
635
+ const harness = createHarness();
636
+ await harness.command().handler("messages", harness.ctx);
637
+ const component = harness.component();
638
+ selectLabel(component, "Message style");
639
+ expect(focusedRow(component)).toContain("Framed");
640
+ component.handleInput(" ");
641
+ expect(focusedRow(component)).toContain("> Message style");
642
+ expect(focusedRow(component)).toContain("Framed (copy-friendly)");
643
+ component.handleInput(" ");
644
+ expect(focusedRow(component)).toContain("> Message style");
645
+ expect(focusedRow(component)).toContain("Compact");
646
+ component.handleInput(" ");
647
+ expect(focusedRow(component)).toContain("> Message style");
648
+ expect(focusedRow(component)).toContain("Labeled");
649
+ expect(harness.calls.messages).toEqual([
650
+ { style: "framed-copy-friendly" },
651
+ { style: "compact" },
652
+ { style: "labeled" },
653
+ ]);
654
+ });
655
+
656
+ it("offers and routes repository Footer paths with clarified depth semantics", async () => {
657
+ const harness = createHarness();
658
+ await harness.command().handler("", harness.ctx);
659
+ const component = harness.component();
660
+ goToSection(component, "Footer");
661
+ selectLabel(component, "Path display");
662
+ expect(focusedRow(component)).toContain("basename");
663
+ component.handleInput(" ");
664
+ expect(focusedRow(component)).toContain("repository");
665
+ expect(component.render(160).join("\n")).toContain("repository-relative");
666
+ selectLabel(component, "Path depth");
667
+ const depthRows = component.render(160).join("\n");
668
+ expect(depthRows).toContain("Final component count for Full and Repository");
669
+ expect(depthRows).toContain("0 = unlimited");
670
+ component.handleInput(" ");
671
+ expect(focusedRow(component)).toContain("1");
672
+ expect(harness.calls.pathDisplay).toEqual([{ mode: "repository" }, { depth: 1 }]);
673
+ });
674
+
675
+ it("routes color and model rows to separate component dependencies", async () => {
676
+ const harness = createHarness();
677
+ await harness.command().handler("", harness.ctx);
678
+ const component = harness.component();
679
+ expect(component.render(160)[0]).not.toContain("\x1b[90m");
680
+ selectLabel(component, "Selector border colors");
681
+ component.handleInput(" ");
682
+ expect(component.render(160)[0]).toContain("\x1b[90m");
683
+ expect(harness.config.components.editor.colorSource).toBe("theme");
684
+ goToSection(component, "Editor");
685
+ selectLabel(component, "Editor colors");
686
+ component.handleInput(" ");
687
+ selectLabel(component, "Editor model label");
688
+ component.handleInput(" ");
689
+ component.handleInput("\t");
690
+ selectLabel(component, "Message colors");
691
+ component.handleInput(" ");
692
+ component.handleInput("\t");
693
+ component.handleInput("\t");
694
+ selectLabel(component, "Footer colors");
695
+ component.handleInput(" ");
696
+ selectLabel(component, "Footer model label");
697
+ component.handleInput(" ");
698
+ expect(harness.calls.selectors).toEqual([{ colorSource: "terminal" }]);
699
+ expect(harness.calls.editor).toEqual([{ colorSource: "terminal" }, { modelLabel: "name" }]);
700
+ expect(harness.calls.messages).toEqual([{ colorSource: "terminal" }]);
701
+ expect(harness.calls.footer).toEqual([{ colorSource: "terminal" }, { modelLabel: "name" }]);
702
+ });
703
+
704
+ it("rebuilds failed persistence with effective values and attempted-row focus", async () => {
705
+ const config = cloneConfig();
706
+ const harness = createHarness(config, {
707
+ setEditorComponent() {
708
+ throw new Error("read-only");
709
+ },
710
+ });
711
+ await harness.command().handler("", harness.ctx);
712
+ const component = harness.component();
713
+ goToSection(component, "Editor");
714
+ selectLabel(component, "Editor model label");
715
+ component.handleInput(" ");
716
+ expect(focusedRow(component)).toContain("> Editor model label");
717
+ expect(focusedRow(component)).toContain("id");
718
+ expect(focusedRow(component)).not.toContain("name");
719
+ expect(harness.notifications).toEqual(["Could not update Zentui settings: read-only"]);
720
+ });
721
+
722
+ it("parses component direct operations and rejects removed copy commands", async () => {
723
+ const harness = createHarness();
724
+ for (const command of ["editor disable", "messages disable"]) {
725
+ await harness.command().handler(command, harness.ctx);
726
+ }
727
+ for (const command of [
728
+ "editor-copy-friendly enable",
729
+ "message-copy-friendly disable",
730
+ "copy-friendly enable",
731
+ ]) {
732
+ await harness.command().handler(command, harness.ctx);
733
+ }
734
+ expect(harness.calls.editor).toEqual([{ enabled: false }]);
735
+ expect(harness.calls.messages).toEqual([{ enabled: false }]);
736
+ expect(harness.notifications.filter((message) => message.startsWith("Usage:"))).toHaveLength(3);
737
+ const values =
738
+ harness
739
+ .command()
740
+ .getArgumentCompletions("")
741
+ ?.map((item) => item.value) ?? [];
742
+ expect(values).toContain("messages toggle");
743
+ expect(values.some((value) => value.includes("copy-friendly"))).toBe(false);
744
+ expect(values.join("\n")).not.toMatch(/fixed[-_ ]editor/i);
745
+ });
746
+
747
+ it.each([
748
+ "fixed-editor enable",
749
+ "fixed_editor disable",
750
+ "fixed editor toggle",
751
+ "layout",
752
+ ] as const)("treats removed route %s as ordinary usage without mutation", async (args) => {
753
+ const harness = createHarness();
754
+ const before = structuredClone(harness.config);
755
+ await harness.command().handler(args, harness.ctx);
756
+ expect(harness.config).toEqual(before);
757
+ expect(harness.calls.editor).toEqual([]);
758
+ expect(harness.notifications).toHaveLength(1);
759
+ expect(harness.notifications[0]).toMatch(/^Usage:/);
760
+ expect(harness.notifications[0]).not.toMatch(/fixed[-_ ]editor/i);
761
+ });
762
+
763
+ it("persists and reopens editor-border and model/segment controls", async () => {
764
+ const harness = createHarness();
765
+ await harness.command().handler("", harness.ctx);
766
+ let component = harness.component();
767
+ goToSection(component, "Editor");
768
+ selectLabel(component, "Editor border color");
769
+ component.handleInput(" ");
770
+ selectLabel(component, "Editor model label");
771
+ component.handleInput(" ");
772
+ component.handleInput("\t");
773
+ component.handleInput("\t");
774
+ component.handleInput("\t");
775
+ component.handleInput("\t");
776
+ component.handleInput("\t");
777
+ selectLabel(component, "Ignore submodules");
778
+ component.handleInput(" ");
779
+ component.handleInput("\x1b[Z");
780
+ selectLabel(component, "Model info");
781
+ component.handleInput(" ");
782
+ component.handleInput("\x1b");
783
+ expect(harness.doneCalls()).toBe(1);
784
+
785
+ await harness.command().handler("", harness.ctx);
786
+ component = harness.component();
787
+ goToSection(component, "Editor");
788
+ expect(row(component, "Editor border color")).toContain("adaptive");
789
+ expect(row(component, "Editor model label")).toContain("name");
790
+ component.handleInput("\t");
791
+ component.handleInput("\t");
792
+ component.handleInput("\t");
793
+ component.handleInput("\t");
794
+ component.handleInput("\t");
795
+ expect(row(component, "Ignore submodules")).toContain("enabled");
796
+ component.handleInput("\x1b[Z");
797
+ expect(row(component, "Model info")).toContain("enabled");
798
+ expect(harness.calls.editor).toEqual([{ borderColorMode: "adaptive" }, { modelLabel: "name" }]);
799
+ expect(harness.calls.gitMetrics).toEqual([{ ignoreSubmodules: true }]);
800
+ expect(harness.calls.segments).toEqual([{ modelInfo: true }]);
801
+ component.handleInput("\x1b");
802
+ expect(harness.doneCalls()).toBe(2);
803
+ });
804
+
805
+ it("rolls back editor-border and model-info rows when persistence fails", async () => {
806
+ const config = cloneConfig();
807
+ const harness = createHarness(config, {
808
+ setEditorComponent() {
809
+ throw new Error("read-only editor");
810
+ },
811
+ setFooterSegments() {
812
+ throw new Error("read-only segments");
813
+ },
814
+ });
815
+ await harness.command().handler("", harness.ctx);
816
+ const component = harness.component();
817
+ goToSection(component, "Editor");
818
+ selectLabel(component, "Editor border color");
819
+ component.handleInput(" ");
820
+ expect(focusedRow(component)).toContain("> Editor border color");
821
+ expect(focusedRow(component)).toContain("static");
822
+ component.handleInput("\t");
823
+ component.handleInput("\t");
824
+ component.handleInput("\t");
825
+ component.handleInput("\t");
826
+ selectLabel(component, "Model info");
827
+ component.handleInput(" ");
828
+ expect(focusedRow(component)).toContain("> Model info");
829
+ expect(focusedRow(component)).toContain("disabled");
830
+ expect(config.components.editor.borderColorMode).toBe("static");
831
+ expect(config.components.footer.styles.starship.segments.modelInfo).toBe(false);
832
+ expect(harness.notifications).toEqual([
833
+ "Could not update Zentui settings: read-only editor",
834
+ "Could not update Zentui settings: read-only segments",
835
+ ]);
836
+ });
837
+
838
+ it("routes all Working-line rows independently", async () => {
839
+ const harness = createHarness();
840
+ await harness.command().handler("working-line", harness.ctx);
841
+ const component = harness.component();
842
+ expectFocusOrder(component, [
843
+ "Enabled",
844
+ "Turn summary",
845
+ "Spinner",
846
+ "Spinner speed",
847
+ "Animate spinner color",
848
+ "Text animation",
849
+ "Text motion speed",
850
+ "Color source",
851
+ "Custom messages",
852
+ "Tool",
853
+ "Elapsed",
854
+ "Thinking",
855
+ "Tokens",
856
+ "Message list",
857
+ ]);
858
+ for (const [label, expected] of [
859
+ ["Enabled", "enabled"],
860
+ ["Turn summary", "disabled"],
861
+ ["Spinner", "ASCII Pinwheel"],
862
+ ["Spinner speed", "Slow 160 ms"],
863
+ ["Animate spinner color", "enabled"],
864
+ ["Text animation", "kitt"],
865
+ ["Text motion speed", "Slow 100 ms"],
866
+ ["Color source", "terminal"],
867
+ ["Custom messages", "disabled"],
868
+ ["Tool", "disabled"],
869
+ ["Elapsed", "disabled"],
870
+ ["Thinking", "disabled"],
871
+ ["Tokens", "disabled"],
872
+ ] as const) {
873
+ selectLabel(component, label);
874
+ component.handleInput(" ");
875
+ expect(focusedRow(component)).toContain(expected);
876
+ }
877
+ expect(harness.calls.workingLine).toEqual([
878
+ { enabled: true },
879
+ { turnSummary: false },
880
+ { spinner: "pinwheel" },
881
+ { spinnerIntervalMs: 160 },
882
+ { animateSpinnerColor: true },
883
+ { textAnimation: "kitt" },
884
+ { textIntervalMs: 100 },
885
+ { colorSource: "terminal" },
886
+ { messages: { custom: false } },
887
+ { segments: { tool: false } },
888
+ { segments: { elapsed: false } },
889
+ { segments: { thought: false } },
890
+ { segments: { tokens: false } },
891
+ ]);
892
+ expect(harness.calls.renders).toEqual({ shared: 0, local: 13 });
893
+ });
894
+
895
+ it("displays, previews, and stores all named spinner presets with canonical IDs", async () => {
896
+ const current = cloneConfig();
897
+ current.components.workingLine.spinnerIntervalMs = 180;
898
+ const harness = createHarness(current);
899
+ await harness.command().handler("working-line", harness.ctx);
900
+ selectLabel(harness.component(), "Spinner");
901
+ for (const label of [
902
+ "Star Bloom",
903
+ "ASCII Pinwheel",
904
+ "Claude-inspired",
905
+ "Pulse",
906
+ "Braille Orbit",
907
+ ]) {
908
+ expect(focusedRow(harness.component())).toContain(label);
909
+ if (label === "Pulse") {
910
+ const rows = harness.component().render(100);
911
+ expect(rows[previewRow(rows, "⠀⠶⠀")]).toContain("⠀⠶⠀");
912
+ expect(current.components.workingLine.spinnerIntervalMs).toBe(180);
913
+ }
914
+ harness.component().handleInput(" ");
915
+ }
916
+ expect(harness.calls.workingLine).toEqual([
917
+ { spinner: "pinwheel" },
918
+ { spinner: "claude-inspired" },
919
+ { spinner: "pulse" },
920
+ { spinner: "braille" },
921
+ { spinner: "star-bloom" },
922
+ ]);
923
+ expect(current.components.workingLine.spinnerIntervalMs).toBe(180);
924
+ });
925
+
926
+ it("closes for Custom speed input, saves a valid integer, and reopens with Speed focused", async () => {
927
+ vi.useFakeTimers();
928
+ const current = cloneConfig();
929
+ current.components.workingLine.spinnerIntervalMs = 160;
930
+ const focusedAfterOpen: string[] = [];
931
+ let customCount = 0;
932
+ const harness = createHarness(
933
+ current,
934
+ {},
935
+ {
936
+ async custom(factory: (...args: unknown[]) => unknown) {
937
+ let outcome: unknown;
938
+ const component = factory({ requestRender() {} }, theme(), {}, (value: unknown) => {
939
+ outcome = value;
940
+ }) as Component;
941
+ if (customCount++ === 0) {
942
+ selectLabel(component, "Spinner speed");
943
+ component.handleInput(" ");
944
+ } else {
945
+ focusedAfterOpen.push(focusedRow(component));
946
+ component.handleInput("\x1b");
947
+ }
948
+ return outcome;
949
+ },
950
+ async input(title: string, placeholder: string) {
951
+ expect(title).toBe("Spinner speed (30–1000 ms)");
952
+ expect(placeholder).toBe("160");
953
+ return " 77 ";
954
+ },
955
+ },
956
+ );
957
+ harness.sessionLifecycle.start();
958
+ await harness.command().handler("working-line", harness.ctx);
959
+ expect(harness.calls.workingLine).toEqual([{ spinnerIntervalMs: 77 }]);
960
+ expect(focusedAfterOpen).toEqual([expect.stringContaining("> Spinner speed")]);
961
+ expect(focusedAfterOpen[0]).toContain("Custom 77 ms");
962
+ expect(harness.notifications).toContain("Spinner speed: 77 ms");
963
+ expect(vi.getTimerCount()).toBe(0);
964
+ });
965
+
966
+ it("uses a separate Custom text-speed dialog and reopens on its originating row", async () => {
967
+ const current = cloneConfig();
968
+ current.components.workingLine.textIntervalMs = 100;
969
+ let customCount = 0;
970
+ const focusedAfterOpen: string[] = [];
971
+ const harness = createHarness(
972
+ current,
973
+ {},
974
+ {
975
+ async custom(factory: (...args: unknown[]) => unknown) {
976
+ let outcome: unknown;
977
+ const component = factory({ requestRender() {} }, theme(), {}, (value: unknown) => {
978
+ outcome = value;
979
+ }) as Component;
980
+ if (customCount++ === 0) {
981
+ selectLabel(component, "Text motion speed");
982
+ component.handleInput(" ");
983
+ } else {
984
+ focusedAfterOpen.push(focusedRow(component));
985
+ component.handleInput("\x1b");
986
+ }
987
+ return outcome;
988
+ },
989
+ async input(title: string, placeholder: string) {
990
+ expect(title).toBe("Text motion speed (30–1000 ms)");
991
+ expect(placeholder).toBe("100");
992
+ return "73";
993
+ },
994
+ },
995
+ );
996
+ await harness.command().handler("working-line", harness.ctx);
997
+ expect(harness.calls.workingLine).toEqual([{ textIntervalMs: 73 }]);
998
+ expect(focusedAfterOpen[0]).toContain("> Text motion speed");
999
+ expect(focusedAfterOpen[0]).toContain("Custom 73 ms");
1000
+ });
1001
+
1002
+ it.each([
1003
+ [undefined, "Spinner speed unchanged (input canceled)"],
1004
+ ["29", "Spinner speed must be a whole number from 30 to 1000 ms; unchanged."],
1005
+ ["60.5", "Spinner speed must be a whole number from 30 to 1000 ms; unchanged."],
1006
+ ] as const)(
1007
+ "preserves Custom speed on canceled or invalid input %s",
1008
+ async (response, notice) => {
1009
+ const current = cloneConfig();
1010
+ current.components.workingLine.spinnerIntervalMs = 160;
1011
+ let customCount = 0;
1012
+ const harness = createHarness(
1013
+ current,
1014
+ {},
1015
+ {
1016
+ async custom(factory: (...args: unknown[]) => unknown) {
1017
+ let outcome: unknown;
1018
+ const component = factory({ requestRender() {} }, theme(), {}, (value: unknown) => {
1019
+ outcome = value;
1020
+ }) as Component;
1021
+ if (customCount++ === 0) {
1022
+ selectLabel(component, "Spinner speed");
1023
+ component.handleInput(" ");
1024
+ } else {
1025
+ expect(focusedRow(component)).toContain("> Spinner speed");
1026
+ component.handleInput("\x1b");
1027
+ }
1028
+ return outcome;
1029
+ },
1030
+ async input() {
1031
+ return response;
1032
+ },
1033
+ },
1034
+ );
1035
+ await harness.command().handler("working-line", harness.ctx);
1036
+ expect(current.components.workingLine.spinnerIntervalMs).toBe(160);
1037
+ expect(harness.calls.workingLine).toEqual([]);
1038
+ expect(harness.notifications).toContain(notice);
1039
+ },
1040
+ );
1041
+
1042
+ it("preserves shared rendering for settings outside Working line", async () => {
1043
+ const harness = createHarness();
1044
+ await harness.command().handler("", harness.ctx);
1045
+ selectLabel(harness.component(), "Selector borders");
1046
+ harness.component().handleInput(" ");
1047
+ expect(harness.calls.renders).toEqual({ shared: 1, local: 1 });
1048
+ });
1049
+
1050
+ it.each([
1051
+ ["Editor", "Explain"],
1052
+ ["User messages", "Please review"],
1053
+ ["Working line", "Sautéing…"],
1054
+ ] as const)(
1055
+ "stacks the %s preview above settings at every width",
1056
+ async (section, previewText) => {
1057
+ vi.useFakeTimers();
1058
+ const harness = createHarness();
1059
+ harness.sessionLifecycle.start();
1060
+ await harness.command().handler("", harness.ctx);
1061
+ const component = harness.component();
1062
+ goToSection(component, section);
1063
+ const zeroWidthRows = component.render(0);
1064
+ const oneCellRows = component.render(1);
1065
+ expect(zeroWidthRows).toHaveLength(oneCellRows.length);
1066
+ for (const width of [0, 1, 4]) {
1067
+ const rows = component.render(width);
1068
+ expect(rows.every((line) => visibleWidth(line) <= width)).toBe(true);
1069
+ expect(rows.join("")).not.toContain(previewText);
1070
+ }
1071
+ for (const width of [24, 80, 99, 100, 118, 140, 160, 200]) {
1072
+ const rows = component.render(width);
1073
+ expect(rows.every((line) => visibleWidth(line) <= width)).toBe(true);
1074
+ expectStackedPreview(rows, previewText);
1075
+ expect(rows.some((line) => line.includes("> "))).toBe(true);
1076
+ }
1077
+ component.handleInput("\x1b");
1078
+ expect(vi.getTimerCount()).toBe(0);
1079
+ },
1080
+ );
1081
+
1082
+ it.each(["Appearance", "Footer", "Segments", "Git", "Extensions"] as const)(
1083
+ "does not add preview spacer rows in %s",
1084
+ async (section) => {
1085
+ const harness = createHarness();
1086
+ await harness.command().handler("", harness.ctx);
1087
+ const component = harness.component();
1088
+ goToSection(component, section);
1089
+ for (const width of [24, 80, 99, 100, 118, 140, 160, 200]) {
1090
+ const rows = component.render(width);
1091
+ expect(rows.every((line) => visibleWidth(line) <= width)).toBe(true);
1092
+ expect(leadingEmptyRowCount(rows)).toBe(0);
1093
+ }
1094
+ },
1095
+ );
1096
+
1097
+ it("shows static previews only in their owning sections without timers or extra setters", async () => {
1098
+ vi.useFakeTimers();
1099
+ const harness = createHarness();
1100
+ harness.sessionLifecycle.start();
1101
+ await harness.command().handler("", harness.ctx);
1102
+ const component = harness.component();
1103
+ const appearanceRows = component.render(100);
1104
+ expect(appearanceRows.join("\n")).not.toContain("Explain this change safely.");
1105
+ expect(leadingEmptyRowCount(appearanceRows)).toBe(0);
1106
+ component.handleInput("\t");
1107
+ const editorRows = component.render(100);
1108
+ expectStackedPreview(editorRows, "Explain this change safely.");
1109
+ const editor = editorRows.join("\n");
1110
+ expect(editor).toContain("Explain this change safely.");
1111
+ expect(editor).not.toContain("Editor preview");
1112
+ expect(vi.getTimerCount()).toBe(0);
1113
+ selectLabel(component, "Editor style");
1114
+ component.handleInput(" ");
1115
+ const copyFriendly = component.render(100).join("\n");
1116
+ expect(copyFriendly).not.toBe(editor);
1117
+ expect(harness.calls.editor).toEqual([{ style: "opencode-copy-friendly" }]);
1118
+ selectLabel(component, "Editor colors");
1119
+ component.handleInput(" ");
1120
+ expect(component.render(100).join("\n")).not.toBe(copyFriendly);
1121
+ selectLabel(component, "Editor viewport indicators");
1122
+ component.handleInput(" ");
1123
+ expect(component.render(100).join("\n")).not.toContain("↑ 2 more");
1124
+ selectLabel(component, "Editor style");
1125
+ component.handleInput(" ");
1126
+ const accentRail = component.render(100).join("\n");
1127
+ expect(accentRail).not.toBe(copyFriendly);
1128
+ selectLabel(component, "Editor style");
1129
+ component.handleInput(" ");
1130
+ const minimalist = component.render(100).join("\n");
1131
+ selectLabel(component, "Timer");
1132
+ component.handleInput(" ");
1133
+ expect(component.render(100).join("\n")).not.toBe(minimalist);
1134
+ expect(harness.calls.minimalist).toEqual([{ showTimer: false }]);
1135
+ expect(vi.getTimerCount()).toBe(0);
1136
+ component.handleInput("\t");
1137
+ const messageRows = component.render(100);
1138
+ expectStackedPreview(messageRows, "Please review");
1139
+ const messages = messageRows.join("\n");
1140
+ expect(messages).toContain("Please review [this change] safely.");
1141
+ expect(messages).not.toContain("User message preview");
1142
+ expect(harness.calls.messages).toEqual([]);
1143
+ expect(vi.getTimerCount()).toBe(0);
1144
+ selectLabel(component, "Message style");
1145
+ component.handleInput(" ");
1146
+ const copyFriendlyMessage = component.render(100).join("\n");
1147
+ expect(copyFriendlyMessage).not.toBe(messages);
1148
+ selectLabel(component, "Message colors");
1149
+ component.handleInput(" ");
1150
+ expect(component.render(100).join("\n")).not.toBe(copyFriendlyMessage);
1151
+ expect(harness.calls.messages).toEqual([
1152
+ { style: "framed-copy-friendly" },
1153
+ { colorSource: "terminal" },
1154
+ ]);
1155
+ expect(vi.getTimerCount()).toBe(0);
1156
+ component.handleInput("\t");
1157
+ const workingRows = component.render(100);
1158
+ expectStackedPreview(workingRows, "Sautéing…");
1159
+ component.handleInput("\t");
1160
+ for (const section of ["Footer", "Segments", "Git", "Extensions"] as const) {
1161
+ expect(leadingEmptyRowCount(component.render(100)), section).toBe(0);
1162
+ if (section !== "Extensions") component.handleInput("\t");
1163
+ }
1164
+ expect(vi.getTimerCount()).toBe(0);
1165
+ });
1166
+
1167
+ it("keeps disabled Editor and User-message previews visual, bounded, and focus-neutral", async () => {
1168
+ const current = cloneConfig();
1169
+ current.components.editor.enabled = false;
1170
+ current.components.userMessages.enabled = false;
1171
+ const harness = createHarness(current);
1172
+ await harness.command().handler("", harness.ctx);
1173
+ const component = harness.component();
1174
+ goToSection(component, "Editor");
1175
+ expect(component.render(80).join("\n")).toContain("Explain this change safely.");
1176
+ expect(component.render(80).join("\n")).not.toContain("Editor preview");
1177
+ expect(component.render(4).every((line) => visibleWidth(line) <= 4)).toBe(true);
1178
+ expect(focusedRow(component)).toContain("> Editor");
1179
+ component.handleInput("\t");
1180
+ expect(component.render(24).join("\n")).toContain("Please review");
1181
+ expect(component.render(80).join("\n")).not.toContain("User message preview");
1182
+ expect(focusedRow(component)).toContain("> User messages");
1183
+ expect(harness.calls.editor).toEqual([]);
1184
+ expect(harness.calls.messages).toEqual([]);
1185
+ });
1186
+
1187
+ it("animates the preview and cleans its single timer on changes, exits, errors, and shutdown", async () => {
1188
+ vi.useFakeTimers();
1189
+ const harness = createHarness();
1190
+ harness.sessionLifecycle.start();
1191
+ await harness.command().handler("working-line", harness.ctx);
1192
+ const component = harness.component();
1193
+ const animationWidth = 160;
1194
+ const initialRows = component.render(animationWidth);
1195
+ expectStackedPreview(initialRows, "Sautéing…");
1196
+ expect(leadingEmptyRowCount(component.render(1))).toBe(0);
1197
+ expect(component.render(1).every((line) => visibleWidth(line) <= 1)).toBe(true);
1198
+ const firstPreviewIndex = previewRow(initialRows, "Sautéing…");
1199
+ const firstPreview = initialRows[firstPreviewIndex];
1200
+ expect(firstPreview).toContain("Sautéing…");
1201
+ expect(firstPreview).toContain("read");
1202
+ expect(harness.calls.workingLine).toEqual([]);
1203
+ expect(vi.getTimerCount()).toBe(1);
1204
+ vi.advanceTimersByTime(300);
1205
+ expect(component.render(animationWidth)[firstPreviewIndex]).not.toBe(firstPreview);
1206
+ selectLabel(component, "Custom messages");
1207
+ component.handleInput(" ");
1208
+ const fallbackPreview = component
1209
+ .render(animationWidth)
1210
+ [firstPreviewIndex]?.replaceAll("[", "")
1211
+ .replaceAll("]", "");
1212
+ expect(fallbackPreview).toContain("Working…");
1213
+ expect(fallbackPreview).toContain("read");
1214
+ vi.advanceTimersByTime(1200);
1215
+ const stablePreview =
1216
+ component
1217
+ .render(animationWidth)
1218
+ [firstPreviewIndex]?.replaceAll("[", "")
1219
+ .replaceAll("]", "") ?? "";
1220
+ expect(stablePreview).toContain("Working…");
1221
+ expect(stablePreview).toContain("read · 1m02s · thinking 10s · ↑1.2k ↓56");
1222
+ selectLabel(component, "Tool");
1223
+ component.handleInput(" ");
1224
+ const withoutTool =
1225
+ component
1226
+ .render(animationWidth)
1227
+ [firstPreviewIndex]?.replaceAll("[", "")
1228
+ .replaceAll("]", "") ?? "";
1229
+ expect(withoutTool).not.toContain("read");
1230
+ expect(withoutTool).toContain("1m02s · thinking 10s · ↑1.2k ↓56");
1231
+ selectLabel(component, "Spinner");
1232
+ component.handleInput(" ");
1233
+ expect(vi.getTimerCount()).toBe(1);
1234
+ selectLabel(component, "Spinner speed");
1235
+ component.handleInput(" ");
1236
+ expect(vi.getTimerCount()).toBe(1);
1237
+ component.handleInput("\t");
1238
+ expect(vi.getTimerCount()).toBe(0);
1239
+ component.handleInput("\x1b[Z");
1240
+ expect(vi.getTimerCount()).toBe(1);
1241
+ component.handleInput("\x1b");
1242
+ expect(vi.getTimerCount()).toBe(0);
1243
+
1244
+ const failed = createHarness(cloneConfig(), {
1245
+ setWorkingLineComponent() {
1246
+ throw new Error("read-only working line");
1247
+ },
1248
+ });
1249
+ failed.sessionLifecycle.start();
1250
+ await failed.command().handler("working-line", failed.ctx);
1251
+ failed.component().handleInput(" ");
1252
+ expect(vi.getTimerCount()).toBe(0);
1253
+ expect(leadingEmptyRowCount(failed.component().render(100))).toBe(0);
1254
+ expect(failed.notifications).toContain(
1255
+ "Could not update Zentui settings: read-only working line",
1256
+ );
1257
+
1258
+ const shutdown = createHarness();
1259
+ shutdown.sessionLifecycle.start();
1260
+ await shutdown.command().handler("working-line", shutdown.ctx);
1261
+ expect(vi.getTimerCount()).toBe(1);
1262
+ shutdown.sessionLifecycle.shutdown();
1263
+ expect(vi.getTimerCount()).toBe(0);
1264
+ });
1265
+
1266
+ it("saves an empty Message list canonically and reopens its styled fallback preview with focus restored", async () => {
1267
+ vi.useFakeTimers();
1268
+ let openCount = 0;
1269
+ let reopenedPreview = "";
1270
+ const harness = createHarness(
1271
+ cloneConfig(),
1272
+ {},
1273
+ {
1274
+ async custom(factory: (...args: unknown[]) => unknown) {
1275
+ let outcome: unknown;
1276
+ const component = factory({ requestRender() {} }, theme(), {}, (value: unknown) => {
1277
+ outcome = value;
1278
+ }) as Component;
1279
+ if (openCount++ === 0) {
1280
+ selectLabel(component, "Message list");
1281
+ component.handleInput(" ");
1282
+ } else {
1283
+ expect(focusedRow(component)).toContain("> Message list");
1284
+ const rows = component.render(100);
1285
+ reopenedPreview = rows[previewRow(rows, "Working…")] ?? "";
1286
+ component.handleInput("\x1b");
1287
+ }
1288
+ return outcome;
1289
+ },
1290
+ async editor(title: string) {
1291
+ expect(title).toBe("Working line message list");
1292
+ return " \n\t\r\n";
1293
+ },
1294
+ },
1295
+ );
1296
+ harness.sessionLifecycle.start();
1297
+
1298
+ await harness.command().handler("working-line", harness.ctx);
1299
+
1300
+ expect(openCount).toBe(2);
1301
+ expect(harness.calls.workingLine).toEqual([{ messages: { values: [] } }]);
1302
+ expect(harness.config.components.workingLine.messages.values).toEqual([]);
1303
+ expect(harness.notifications).toContain("Message list: 0 (using styled Working…)");
1304
+ expect(reopenedPreview.replaceAll("[", "").replaceAll("]", "")).toContain("Working…");
1305
+ expect(reopenedPreview).toMatch(/\[[^\]]+\]/);
1306
+ expect(vi.getTimerCount()).toBe(0);
1307
+ });
1308
+
1309
+ it("closes for multiline message editing, normalizes save, preserves cancel, and reopens Working line", async () => {
1310
+ vi.useFakeTimers();
1311
+ const openedSections: string[] = [];
1312
+ let customCount = 0;
1313
+ const harness = createHarness(
1314
+ cloneConfig(),
1315
+ {},
1316
+ {
1317
+ async custom(factory: (...args: unknown[]) => unknown) {
1318
+ let outcome: unknown;
1319
+ const component = factory({ requestRender() {} }, theme(), {}, (value: unknown) => {
1320
+ outcome = value;
1321
+ }) as Component;
1322
+ openedSections.push(component.render(40)[1] ?? "");
1323
+ if (customCount++ === 0) {
1324
+ selectLabel(component, "Message list");
1325
+ component.handleInput(" ");
1326
+ } else component.handleInput("\x1b");
1327
+ return outcome;
1328
+ },
1329
+ async editor(title: string, prefill: string) {
1330
+ expect(title).toBe("Working line message list");
1331
+ expect(prefill).toBe(defaultConfig.components.workingLine.messages.values.join("\n"));
1332
+ return " One \nOne\n\x1b[31mTwo\x1b[0m\n";
1333
+ },
1334
+ },
1335
+ );
1336
+ harness.sessionLifecycle.start();
1337
+ await harness.command().handler("working-line", harness.ctx);
1338
+ expect(vi.getTimerCount()).toBe(0);
1339
+ expect(openedSections).toHaveLength(2);
1340
+ expect(openedSections.every((line) => line.includes("Working line"))).toBe(true);
1341
+ expect(harness.calls.workingLine).toEqual([{ messages: { values: ["One", "Two"] } }]);
1342
+ expect(harness.calls.renders.shared).toBe(0);
1343
+
1344
+ let cancelCustomCount = 0;
1345
+ const canceled = createHarness(
1346
+ cloneConfig(),
1347
+ {},
1348
+ {
1349
+ async custom(factory: (...args: unknown[]) => unknown) {
1350
+ let outcome: unknown;
1351
+ const component = factory({ requestRender() {} }, theme(), {}, (value: unknown) => {
1352
+ outcome = value;
1353
+ }) as Component;
1354
+ if (cancelCustomCount++ === 0) {
1355
+ selectLabel(component, "Message list");
1356
+ component.handleInput(" ");
1357
+ } else component.handleInput("\x1b");
1358
+ return outcome;
1359
+ },
1360
+ async editor() {
1361
+ return undefined;
1362
+ },
1363
+ },
1364
+ );
1365
+ canceled.sessionLifecycle.start();
1366
+ await canceled.command().handler("working-line", canceled.ctx);
1367
+ expect(vi.getTimerCount()).toBe(0);
1368
+ expect(cancelCustomCount).toBe(2);
1369
+ expect(canceled.calls.workingLine).toEqual([]);
1370
+ });
1371
+
1372
+ it.each([
1373
+ ["messages", "User messages"],
1374
+ ["user-messages", "User messages"],
1375
+ ["working-line", "Working line"],
1376
+ ])("opens %s directly in %s", async (argument, section) => {
1377
+ const harness = createHarness();
1378
+ await harness.command().handler(argument, harness.ctx);
1379
+ expect(harness.component().render(40)[1]).toContain(section);
1380
+ });
1381
+ });