@selesai/code 0.9.15 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. package/CHANGELOG.md +22 -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/skills/design-references/SKILL.md +31 -0
  118. package/dist/skills/design-references/references/clickhouse/DESIGN.md +544 -0
  119. package/dist/themes/adventure.json +84 -0
  120. package/dist/themes/adwaita-dark.json +84 -0
  121. package/dist/themes/arcoiris.json +84 -0
  122. package/dist/themes/arthur.json +84 -0
  123. package/dist/themes/atom.json +84 -0
  124. package/dist/themes/aura.json +84 -0
  125. package/dist/themes/black-metal-bathory.json +84 -0
  126. package/dist/themes/black-metal-burzum.json +84 -0
  127. package/dist/themes/black-metal-khold.json +84 -0
  128. package/dist/themes/box.json +84 -0
  129. package/dist/themes/brogrammer.json +84 -0
  130. package/dist/themes/carbonfox.json +84 -0
  131. package/dist/themes/catppuccin-mocha.json +84 -0
  132. package/dist/themes/citruszest.json +84 -0
  133. package/dist/themes/cursor-dark.json +84 -0
  134. package/dist/themes/cutie-pro.json +84 -0
  135. package/dist/themes/dark-modern.json +84 -0
  136. package/dist/themes/dark-pastel.json +84 -0
  137. package/dist/themes/dimmed-monokai.json +84 -0
  138. package/dist/themes/doom-peacock.json +84 -0
  139. package/dist/themes/dracula-plus.json +84 -0
  140. package/dist/themes/dracula.json +80 -0
  141. package/dist/themes/earthsong.json +84 -0
  142. package/dist/themes/everforest-dark-hard.json +84 -0
  143. package/dist/themes/everforest-dark.json +80 -0
  144. package/dist/themes/fahrenheit.json +84 -0
  145. package/dist/themes/flatland.json +84 -0
  146. package/dist/themes/flexoki-dark.json +84 -0
  147. package/dist/themes/front-end-delight.json +84 -0
  148. package/dist/themes/fun-forrest.json +84 -0
  149. package/dist/themes/galizur.json +84 -0
  150. package/dist/themes/github-dark-colorblind.json +84 -0
  151. package/dist/themes/github-dark-high-contrast.json +84 -0
  152. package/dist/themes/glacier.json +84 -0
  153. package/dist/themes/gruber-darker.json +84 -0
  154. package/dist/themes/gruvbox-dark-hard.json +84 -0
  155. package/dist/themes/gruvbox-dark.json +84 -0
  156. package/dist/themes/gruvbox-material.json +84 -0
  157. package/dist/themes/guezwhoz.json +84 -0
  158. package/dist/themes/hacktober.json +84 -0
  159. package/dist/themes/hardcore.json +84 -0
  160. package/dist/themes/havn-skumring.json +84 -0
  161. package/dist/themes/ic-orange-ppl.json +84 -0
  162. package/dist/themes/iterm2-smoooooth.json +84 -0
  163. package/dist/themes/iterm2-tango-dark.json +84 -0
  164. package/dist/themes/japanesque.json +84 -0
  165. package/dist/themes/jellybeans.json +84 -0
  166. package/dist/themes/kanagawa-wave.json +84 -0
  167. package/dist/themes/kurokula.json +84 -0
  168. package/dist/themes/later-this-evening.json +84 -0
  169. package/dist/themes/lovelace.json +84 -0
  170. package/dist/themes/material-darker.json +84 -0
  171. package/dist/themes/matte-black.json +84 -0
  172. package/dist/themes/mellow.json +84 -0
  173. package/dist/themes/miasma.json +84 -0
  174. package/dist/themes/nord.json +80 -0
  175. package/dist/themes/nvim-dark.json +84 -0
  176. package/dist/themes/one-dark.json +80 -0
  177. package/dist/themes/popping-and-locking.json +84 -0
  178. package/dist/themes/rose-pine.json +80 -0
  179. package/dist/themes/sea-shells.json +84 -0
  180. package/dist/themes/sleepy-hollow.json +84 -0
  181. package/dist/themes/smyck.json +84 -0
  182. package/dist/themes/solarized-dark.json +80 -0
  183. package/dist/themes/tokyo-night.json +80 -0
  184. package/dist/themes/tomorrow-night-bright.json +84 -0
  185. package/dist/themes/tomorrow-night-burns.json +84 -0
  186. package/dist/themes/tomorrow-night.json +84 -0
  187. package/dist/themes/twilight.json +84 -0
  188. package/dist/themes/vague.json +84 -0
  189. package/dist/themes/vesper.json +84 -0
  190. package/dist/themes/xcode-dark-hc.json +84 -0
  191. package/docs/custom-provider.md +0 -1
  192. package/package.json +3 -3
  193. package/dist/extensions/auto-model/auto-model.test.ts +0 -18
  194. package/dist/extensions/auto-model/classifier.ts +0 -23
  195. package/dist/extensions/auto-model/config.ts +0 -51
  196. package/dist/extensions/auto-model/index.ts +0 -22
  197. package/dist/extensions/auto-model/lifecycle.test.ts +0 -24
  198. package/dist/extensions/pi-powerline-footer/.github/workflows/test.yml +0 -27
  199. package/dist/extensions/pi-powerline-footer/CHANGELOG.md +0 -687
  200. package/dist/extensions/pi-powerline-footer/CONTRIBUTING.md +0 -16
  201. package/dist/extensions/pi-powerline-footer/README.md +0 -513
  202. package/dist/extensions/pi-powerline-footer/banner.png +0 -0
  203. package/dist/extensions/pi-powerline-footer/bash-mode/completion.ts +0 -586
  204. package/dist/extensions/pi-powerline-footer/bash-mode/editor.ts +0 -583
  205. package/dist/extensions/pi-powerline-footer/bash-mode/history.ts +0 -204
  206. package/dist/extensions/pi-powerline-footer/bash-mode/shell-session.ts +0 -286
  207. package/dist/extensions/pi-powerline-footer/bash-mode/transcript.ts +0 -108
  208. package/dist/extensions/pi-powerline-footer/bash-mode/types.ts +0 -60
  209. package/dist/extensions/pi-powerline-footer/cd-command.ts +0 -190
  210. package/dist/extensions/pi-powerline-footer/colors.ts +0 -70
  211. package/dist/extensions/pi-powerline-footer/context-usage.ts +0 -156
  212. package/dist/extensions/pi-powerline-footer/currency-rates.ts +0 -142
  213. package/dist/extensions/pi-powerline-footer/editor-composition.ts +0 -32
  214. package/dist/extensions/pi-powerline-footer/editor-performance.ts +0 -99
  215. package/dist/extensions/pi-powerline-footer/git-status.ts +0 -329
  216. package/dist/extensions/pi-powerline-footer/guide.ts +0 -220
  217. package/dist/extensions/pi-powerline-footer/icons.ts +0 -190
  218. package/dist/extensions/pi-powerline-footer/index.ts +0 -3428
  219. package/dist/extensions/pi-powerline-footer/lifecycle.ts +0 -10
  220. package/dist/extensions/pi-powerline-footer/package.json +0 -49
  221. package/dist/extensions/pi-powerline-footer/paths.ts +0 -22
  222. package/dist/extensions/pi-powerline-footer/powerline-config.ts +0 -446
  223. package/dist/extensions/pi-powerline-footer/presets.ts +0 -102
  224. package/dist/extensions/pi-powerline-footer/queue/store.ts +0 -313
  225. package/dist/extensions/pi-powerline-footer/queue/types.ts +0 -53
  226. package/dist/extensions/pi-powerline-footer/render-scheduler.ts +0 -29
  227. package/dist/extensions/pi-powerline-footer/segments.ts +0 -570
  228. package/dist/extensions/pi-powerline-footer/separators.ts +0 -57
  229. package/dist/extensions/pi-powerline-footer/session-usage.ts +0 -44
  230. package/dist/extensions/pi-powerline-footer/shortcuts.ts +0 -61
  231. package/dist/extensions/pi-powerline-footer/tests/bash-mode.test.ts +0 -1905
  232. package/dist/extensions/pi-powerline-footer/tests/cd-command.test.ts +0 -144
  233. package/dist/extensions/pi-powerline-footer/tests/context-usage.test.ts +0 -156
  234. package/dist/extensions/pi-powerline-footer/tests/custom-items.test.ts +0 -344
  235. package/dist/extensions/pi-powerline-footer/tests/editor-composition.test.ts +0 -48
  236. package/dist/extensions/pi-powerline-footer/tests/editor-performance.test.ts +0 -43
  237. package/dist/extensions/pi-powerline-footer/tests/editor-responsiveness.test.ts +0 -136
  238. package/dist/extensions/pi-powerline-footer/tests/fixed-editor-border.test.ts +0 -39
  239. package/dist/extensions/pi-powerline-footer/tests/git-optional-locks.test.ts +0 -64
  240. package/dist/extensions/pi-powerline-footer/tests/git-status.test.ts +0 -92
  241. package/dist/extensions/pi-powerline-footer/tests/guide.test.ts +0 -129
  242. package/dist/extensions/pi-powerline-footer/tests/jump-shortcuts.test.ts +0 -116
  243. package/dist/extensions/pi-powerline-footer/tests/mode-aware-trigger.test.ts +0 -79
  244. package/dist/extensions/pi-powerline-footer/tests/paths.test.ts +0 -63
  245. package/dist/extensions/pi-powerline-footer/tests/queue-store.test.ts +0 -65
  246. package/dist/extensions/pi-powerline-footer/tests/quit-cursor.test.ts +0 -14
  247. package/dist/extensions/pi-powerline-footer/tests/remaining-regressions.test.ts +0 -173
  248. package/dist/extensions/pi-powerline-footer/tests/session-usage.test.ts +0 -47
  249. package/dist/extensions/pi-powerline-footer/tests/stash-shortcut.test.ts +0 -37
  250. package/dist/extensions/pi-powerline-footer/tests/thinking-segment.test.ts +0 -76
  251. package/dist/extensions/pi-powerline-footer/tests/token-stats.test.ts +0 -249
  252. package/dist/extensions/pi-powerline-footer/tests/tps.test.ts +0 -39
  253. package/dist/extensions/pi-powerline-footer/tests/usage-display.test.ts +0 -237
  254. package/dist/extensions/pi-powerline-footer/tests/welcome.test.ts +0 -199
  255. package/dist/extensions/pi-powerline-footer/tests/working-vibes.test.ts +0 -317
  256. package/dist/extensions/pi-powerline-footer/theme.example.json +0 -24
  257. package/dist/extensions/pi-powerline-footer/theme.json +0 -12
  258. package/dist/extensions/pi-powerline-footer/theme.ts +0 -241
  259. package/dist/extensions/pi-powerline-footer/token-stats.ts +0 -304
  260. package/dist/extensions/pi-powerline-footer/tsconfig.json +0 -13
  261. package/dist/extensions/pi-powerline-footer/types.ts +0 -235
  262. package/dist/extensions/pi-powerline-footer/welcome-dismiss.ts +0 -34
  263. package/dist/extensions/pi-powerline-footer/welcome.ts +0 -709
  264. package/dist/extensions/pi-powerline-footer/working-vibes.ts +0 -721
  265. package/dist/extensions/preview-tools-disabled.test.ts +0 -37
  266. package/dist/extensions/preview-tools-disabled.ts +0 -11
  267. package/dist/themes/powerline-footer/theme.json +0 -33
@@ -0,0 +1,1286 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import {
5
+ defaultConfig,
6
+ type EditorStyle,
7
+ type PolishedTuiConfig,
8
+ } from "../extensions/zentui/config";
9
+ import {
10
+ PolishedEditor,
11
+ renderWithAutocompleteCapture,
12
+ WrappedPolishedEditor,
13
+ } from "../extensions/zentui/ui";
14
+
15
+ function theme(): Theme {
16
+ return {
17
+ fg(_color: string, text: string) {
18
+ return text;
19
+ },
20
+ bold: (text: string) => text,
21
+ italic: (text: string) => text,
22
+ underline: (text: string) => text,
23
+ strikethrough: (text: string) => text,
24
+ inverse: (text: string) => text,
25
+ } as Theme;
26
+ }
27
+
28
+ type EditorOptions = Partial<PolishedTuiConfig["features"]> & {
29
+ style?: EditorStyle;
30
+ completionMenu?: "native" | "palette";
31
+ };
32
+
33
+ function config(
34
+ options: EditorOptions = {},
35
+ editorBorderColorMode: PolishedTuiConfig["editorBorderColorMode"] = "static",
36
+ ): PolishedTuiConfig {
37
+ return {
38
+ ...defaultConfig,
39
+ icons: { ...defaultConfig.icons, mode: "nerd" },
40
+ components: {
41
+ ...defaultConfig.components,
42
+ editor: {
43
+ ...defaultConfig.components.editor,
44
+ enabled: true,
45
+ style: options.style ?? defaultConfig.components.editor.style,
46
+ borderColorMode: editorBorderColorMode,
47
+ viewportIndicators: options.viewportIndicators ?? true,
48
+ styles: {
49
+ ...defaultConfig.components.editor.styles,
50
+ opencode: {
51
+ ...defaultConfig.components.editor.styles.opencode,
52
+ completionMenu: options.completionMenu ?? "palette",
53
+ },
54
+ "opencode-copy-friendly": {
55
+ ...defaultConfig.components.editor.styles["opencode-copy-friendly"],
56
+ completionMenu: options.completionMenu ?? "palette",
57
+ },
58
+ },
59
+ },
60
+ },
61
+ features: {
62
+ ...defaultConfig.features,
63
+ ...(options.editor === undefined ? {} : { editor: options.editor }),
64
+ ...(options.statusLine === undefined ? {} : { statusLine: options.statusLine }),
65
+ ...(options.viewportIndicators === undefined
66
+ ? {}
67
+ : { viewportIndicators: options.viewportIndicators }),
68
+ },
69
+ };
70
+ }
71
+
72
+ function withEditorStyle(
73
+ base: PolishedTuiConfig,
74
+ style: PolishedTuiConfig["components"]["editor"]["style"],
75
+ ): PolishedTuiConfig {
76
+ return {
77
+ ...base,
78
+ components: {
79
+ ...base.components,
80
+ editor: { ...base.components.editor, style },
81
+ },
82
+ };
83
+ }
84
+
85
+ function nativeBorder(
86
+ width: number,
87
+ direction: "above" | "below",
88
+ count?: number,
89
+ ansi = false,
90
+ ): string {
91
+ const plain = count
92
+ ? `─── ${direction === "above" ? "↑" : "↓"} ${count} more ${"─".repeat(
93
+ Math.max(0, width - `─── ↑ ${count} more `.length),
94
+ )}`
95
+ : "─".repeat(width);
96
+ return ansi ? `\x1b[90m${plain}\x1b[0m` : plain;
97
+ }
98
+
99
+ function baseEditor(options: {
100
+ above?: number;
101
+ below?: number;
102
+ ansi?: boolean;
103
+ malformedTop?: string;
104
+ autocomplete?: string[];
105
+ }) {
106
+ const autocomplete = options.autocomplete ?? [];
107
+ const autocompleteList = {
108
+ filteredItems: autocomplete.map((value) => ({ value })),
109
+ selectedIndex: 0,
110
+ maxVisible: Math.max(1, autocomplete.length),
111
+ render: (_width?: number) => autocomplete,
112
+ };
113
+ return {
114
+ render(width: number) {
115
+ return [
116
+ options.malformedTop ?? nativeBorder(width, "above", options.above, options.ansi),
117
+ "typed text",
118
+ nativeBorder(width, "below", options.below, options.ansi),
119
+ ...(autocomplete.length > 0 ? autocompleteList.render(width) : []),
120
+ ];
121
+ },
122
+ invalidate() {},
123
+ handleInput() {},
124
+ getText: () => "typed text",
125
+ setText() {},
126
+ isShowingAutocomplete: () => autocomplete.length > 0,
127
+ autocompleteList,
128
+ };
129
+ }
130
+
131
+ function wrapped(
132
+ base: ReturnType<typeof baseEditor> | WrappedPolishedEditor,
133
+ features: EditorOptions = {},
134
+ editorBorderColorMode: PolishedTuiConfig["editorBorderColorMode"] = "static",
135
+ ): WrappedPolishedEditor {
136
+ return new WrappedPolishedEditor(
137
+ base as never,
138
+ theme(),
139
+ () => config(features, editorBorderColorMode),
140
+ () => ({ modelLabel: "model", providerLabel: "provider" }),
141
+ () => "off",
142
+ );
143
+ }
144
+
145
+ function standalone(style: EditorStyle = "opencode"): PolishedEditor {
146
+ const editor = new PolishedEditor(
147
+ { requestRender() {}, terminal: { rows: 24, cols: 80 } } as never,
148
+ { borderColor: (text: string) => text, selectList: {} } as never,
149
+ {} as never,
150
+ theme(),
151
+ () => config({ style }),
152
+ () => ({ modelLabel: "model", providerLabel: "provider" }),
153
+ () => "off",
154
+ );
155
+ editor.setText("typed text");
156
+ return editor;
157
+ }
158
+
159
+ const thinkingLevels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
160
+ type SemanticBorderState = (typeof thinkingLevels)[number] | "shell";
161
+
162
+ const semanticBorderCodes: Record<SemanticBorderState, number> = {
163
+ off: 30,
164
+ minimal: 31,
165
+ low: 32,
166
+ medium: 33,
167
+ high: 34,
168
+ xhigh: 35,
169
+ max: 37,
170
+ shell: 36,
171
+ };
172
+
173
+ function semanticTheme(): Theme {
174
+ const base = theme();
175
+ return {
176
+ ...base,
177
+ getThinkingBorderColor(level) {
178
+ const code = semanticBorderCodes[level as (typeof thinkingLevels)[number]] ?? 37;
179
+ return (text: string) => `\x1b[${code}m${text}\x1b[0m`;
180
+ },
181
+ getBashModeBorderColor() {
182
+ return (text: string) => `\x1b[${semanticBorderCodes.shell}m${text}\x1b[0m`;
183
+ },
184
+ } as Theme;
185
+ }
186
+
187
+ describe("adaptive editor border colors", () => {
188
+ it("keeps static wrapped-editor rendering independent of Pi's callback", () => {
189
+ const editor = wrapped(baseEditor({ above: 2, below: 3 }));
190
+ editor.borderColor = (text) => `\x1b[35m${text}\x1b[0m`;
191
+
192
+ const lines = editor.render(80);
193
+ expect(lines[0]).not.toContain("\x1b[35m");
194
+ expect(lines.at(-1)).not.toContain("\x1b[35m");
195
+ expect(lines[0]).toContain("↑ 2 more");
196
+ expect(lines.at(-1)).toContain("↓ 3 more");
197
+ });
198
+
199
+ it.each(["standalone", "wrapped"] as const)(
200
+ "follows Pi thinking and shell callback transitions in the %s editor path",
201
+ (editorKind) => {
202
+ const piTheme = semanticTheme();
203
+ let thinkingLevel: (typeof thinkingLevels)[number] = "off";
204
+ const editor =
205
+ editorKind === "standalone"
206
+ ? new PolishedEditor(
207
+ { requestRender() {}, terminal: { rows: 24, cols: 80 } } as never,
208
+ { borderColor: (text: string) => text, selectList: {} } as never,
209
+ {} as never,
210
+ piTheme,
211
+ () => config({}, "adaptive"),
212
+ () => ({ modelLabel: "model", providerLabel: "provider" }),
213
+ () => thinkingLevel,
214
+ )
215
+ : new WrappedPolishedEditor(
216
+ baseEditor({ above: 2, below: 3 }) as never,
217
+ piTheme,
218
+ () => config({}, "adaptive"),
219
+ () => ({ modelLabel: "model", providerLabel: "provider" }),
220
+ () => thinkingLevel,
221
+ );
222
+ if (editorKind === "standalone") editor.setText("draft");
223
+
224
+ const transitions: SemanticBorderState[] = [...thinkingLevels, "shell", "xhigh", "off"];
225
+ for (const state of transitions) {
226
+ if (state === "shell") {
227
+ editor.borderColor = piTheme.getBashModeBorderColor();
228
+ } else {
229
+ thinkingLevel = state;
230
+ editor.borderColor = piTheme.getThinkingBorderColor(state);
231
+ }
232
+
233
+ const lines = editor.render(80);
234
+ const expectedCode = semanticBorderCodes[state];
235
+ for (const border of [lines[0] ?? "", lines.at(-1) ?? ""]) {
236
+ expect(border).toMatch(new RegExp(`^\\x1b\\[${expectedCode}m`));
237
+ for (const code of Object.values(semanticBorderCodes)) {
238
+ if (code !== expectedCode) expect(border).not.toContain(`\x1b[${code}m`);
239
+ }
240
+ }
241
+ expect(lines.every((line) => visibleWidth(line) <= 80)).toBe(true);
242
+ }
243
+ },
244
+ );
245
+
246
+ it("does not double-apply adaptive colors through nested branded frames", () => {
247
+ const inner = wrapped(baseEditor({ above: 3, below: 8 }), {}, "adaptive");
248
+ inner.borderColor = (text) => `\x1b[32m${text}\x1b[0m`;
249
+ const outer = wrapped(inner, {}, "adaptive");
250
+
251
+ const rendered = outer.render(80).join("\n");
252
+ expect(rendered.match(/\x1b\[32m/g)).toHaveLength(2);
253
+ expect(rendered.match(/↑ 3 more/g)).toHaveLength(1);
254
+ expect(rendered.match(/↓ 8 more/g)).toHaveLength(1);
255
+ expect(rendered.match(/model/g)).toHaveLength(1);
256
+ });
257
+
258
+ it("preserves low-rail polished viewport layout, clamping, and static fallback", () => {
259
+ const editor = wrapped(
260
+ baseEditor({ above: 123456, below: 654321, ansi: true }),
261
+ { style: "opencode-copy-friendly" },
262
+ "adaptive",
263
+ );
264
+ editor.borderColor = () => {
265
+ throw new Error("theme callback failed");
266
+ };
267
+
268
+ const lines = editor.render(12);
269
+ expect(lines[0]).toContain("↑");
270
+ expect(lines.at(-1)).toContain("↓");
271
+ expect(lines.join("\n")).not.toContain("│");
272
+ expect(lines.every((line) => visibleWidth(line) <= 12)).toBe(true);
273
+ });
274
+ });
275
+
276
+ describe("editor viewport indicators", () => {
277
+ it.each([
278
+ [7, undefined, "↑ 7 more", undefined],
279
+ [undefined, 11, undefined, "↓ 11 more"],
280
+ [7, 11, "↑ 7 more", "↓ 11 more"],
281
+ ] as const)(
282
+ "preserves top and bottom native counts (above=%s, below=%s)",
283
+ (above, below, topText, bottomText) => {
284
+ const lines = wrapped(baseEditor({ above, below, ansi: true })).render(80);
285
+ if (topText) expect(lines[0]).toContain(topText);
286
+ else expect(lines[0]).not.toContain("more");
287
+ if (bottomText) expect(lines.at(-1)).toContain(bottomText);
288
+ else expect(lines.at(-1)).not.toContain("more");
289
+ },
290
+ );
291
+
292
+ it("preserves large counts at ample width and clamps them at narrow ANSI-aware widths", () => {
293
+ const count = 123456789;
294
+ const editor = wrapped(baseEditor({ above: count, below: count, ansi: true }));
295
+
296
+ const ampleLines = editor.render(80);
297
+ expect(ampleLines[0]).toContain(`↑ ${count} more`);
298
+ expect(ampleLines.at(-1)).toContain(`↓ ${count} more`);
299
+
300
+ const narrowLines = editor.render(12);
301
+ expect(visibleWidth(narrowLines[0] ?? "")).toBe(12);
302
+ expect(visibleWidth(narrowLines.at(-1) ?? "")).toBe(12);
303
+ expect(narrowLines[0]).toContain("↑");
304
+ expect(narrowLines.at(-1)).toContain("↓");
305
+ expect(narrowLines[0]).not.toContain(`${count} more`);
306
+ expect(narrowLines.at(-1)).not.toContain(`${count} more`);
307
+ });
308
+
309
+ it("uses plain polished borders when viewport indicators are disabled", () => {
310
+ const lines = wrapped(baseEditor({ above: 4, below: 9 }), {
311
+ viewportIndicators: false,
312
+ }).render(80);
313
+ expect(lines[0]).not.toContain("more");
314
+ expect(lines.at(-1)).not.toContain("more");
315
+ expect(lines[0]).toMatch(/^─+$/);
316
+ expect(lines.at(-1)).toMatch(/^─+$/);
317
+ });
318
+
319
+ it("carries counts through nested polished wrappers without double-framing", () => {
320
+ const inner = wrapped(baseEditor({ above: 3, below: 8 }));
321
+ const lines = wrapped(inner).render(80);
322
+ const rendered = lines.join("\n");
323
+ expect(rendered.match(/↑ 3 more/g)).toHaveLength(1);
324
+ expect(rendered.match(/↓ 8 more/g)).toHaveLength(1);
325
+ expect(lines.filter((line) => line.startsWith("───") || /^─+$/.test(line))).toHaveLength(2);
326
+ expect(rendered.match(/model/g)).toHaveLength(1);
327
+
328
+ const narrowLines = wrapped(inner).render(8);
329
+ expect(narrowLines).toHaveLength(6);
330
+ expect(narrowLines[0]).toContain("↑");
331
+ expect(narrowLines.at(-1)).toContain("↓");
332
+ });
333
+
334
+ it.each(["opencode", "opencode-copy-friendly"] as const)(
335
+ "renders one completion palette through nested %s wrappers",
336
+ (style) => {
337
+ const base = baseEditor({ autocomplete: ["→ suggestion-one", " suggestion-two"] });
338
+ const renderAutocomplete = base.autocompleteList.render;
339
+ let calls = 0;
340
+ base.autocompleteList.render = (width) => {
341
+ calls += 1;
342
+ return renderAutocomplete(width);
343
+ };
344
+ const inner = wrapped(base, { style });
345
+ const lines = wrapped(inner, { style }).render(50);
346
+ const rendered = lines.join("\n");
347
+ expect(calls).toBe(1);
348
+ expect(rendered.match(/suggestion-one/g)).toHaveLength(1);
349
+ expect(rendered.match(/suggestion-two/g)).toHaveLength(1);
350
+ expect(rendered.match(/↑↓ Navigate/g)).toHaveLength(1);
351
+ expect(rendered).not.toContain("→ suggestion-one");
352
+ },
353
+ );
354
+
355
+ it.each(["opencode", "opencode-copy-friendly"] as const)(
356
+ "keeps ANSI, Unicode, and native count autocomplete rows raw after the terminal bottom border in %s",
357
+ (style) => {
358
+ const suggestions = [
359
+ "\x1b[31msuggestion-one\x1b[0m",
360
+ "emoji 😀 e\u0301 界",
361
+ "\x1b[90m (1/47) \x1b[0m",
362
+ ];
363
+ const lines = wrapped(baseEditor({ below: 5, autocomplete: suggestions }), {
364
+ style,
365
+ completionMenu: "native",
366
+ }).render(80);
367
+ const bottom = lines.findIndex((line) => line.includes("↓ 5 more"));
368
+ expect(lines.some((line) => line.includes("├") || line.includes("┤"))).toBe(false);
369
+ expect(bottom).toBe(lines.length - suggestions.length - 1);
370
+ expect(lines.slice(bottom + 1)).toEqual(suggestions);
371
+ expect(lines.every((line) => visibleWidth(line) <= 80)).toBe(true);
372
+ },
373
+ );
374
+
375
+ it.each(["opencode", "opencode-copy-friendly"] as const)(
376
+ "renders the default completion palette once without the native count row in %s",
377
+ (style) => {
378
+ const suggestions = ["\x1b[1m→ settings\x1b[0m", " files", "\x1b[90m (1/47) \x1b[0m"];
379
+ const lines = wrapped(baseEditor({ autocomplete: suggestions }), { style }).render(40);
380
+ const plain = lines.map((line) => line.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, ""));
381
+ expect(plain).toContain(" settings".padEnd(40));
382
+ expect(plain).toContain(" files".padEnd(40));
383
+ expect(plain.some((line) => line.includes("→"))).toBe(false);
384
+ expect(plain.some((line) => line.includes("(1/47)"))).toBe(false);
385
+ expect(plain.at(-1)).toContain("↑↓ Navigate");
386
+ expect(plain.filter((line) => /^─+$/.test(line.trim()))).toHaveLength(3);
387
+ expect(lines.every((line) => visibleWidth(line) <= 40)).toBe(true);
388
+ },
389
+ );
390
+
391
+ it.each(["opencode", "accent-rail"] as const)(
392
+ "fails open from the same render when a third-party %s editor rewrites captured autocomplete rows",
393
+ (style) => {
394
+ for (const mutation of ["replace", "reorder"] as const) {
395
+ const suggestions = ["→ original-one", " original-two"];
396
+ const base = baseEditor({ autocomplete: suggestions });
397
+ const nativeRender = base.render.bind(base);
398
+ const widths: number[] = [];
399
+ let lastOutput: string[] = [];
400
+ base.render = (width: number) => {
401
+ widths.push(width);
402
+ const rendered = nativeRender(width);
403
+ const editorRows = rendered.slice(0, -suggestions.length);
404
+ const autocompleteRows = rendered.slice(-suggestions.length);
405
+ const mutatedRows =
406
+ mutation === "replace"
407
+ ? ["replacement-one", "replacement-two"]
408
+ : [...autocompleteRows].reverse();
409
+ lastOutput = [...editorRows, ...mutatedRows];
410
+ return lastOutput;
411
+ };
412
+
413
+ const lines = wrapped(base, { style }).render(40);
414
+ expect(lines).toEqual(lastOutput);
415
+ expect(widths).toHaveLength(1);
416
+ expect(widths[0]).toBeLessThan(40);
417
+ expect(lines.join("\n")).not.toContain("↑↓ Navigate");
418
+ expect(lines.some((line) => line.startsWith("▎"))).toBe(false);
419
+ }
420
+ },
421
+ );
422
+
423
+ it.each([
424
+ ["ASCII spaces", " ", true],
425
+ ["a tab", "\t", false],
426
+ ["a newline", "\n", false],
427
+ ["a non-breaking space", "\u00a0", false],
428
+ ["a Unicode em space", "\u2003", false],
429
+ ] as const)("treats trailing %s as %s autocomplete padding", (_label, trailing, accepted) => {
430
+ const suggestions = ["→ original-one", " original-two"];
431
+ const base = baseEditor({ autocomplete: suggestions });
432
+ const nativeRender = base.render.bind(base);
433
+ const widths: number[] = [];
434
+ let sameRenderRows: string[] = [];
435
+ base.render = (width: number) => {
436
+ widths.push(width);
437
+ const rendered = nativeRender(width);
438
+ const editorRows = rendered.slice(0, -suggestions.length);
439
+ sameRenderRows = [
440
+ ...editorRows,
441
+ ...rendered.slice(-suggestions.length).map((line) => `${line}${trailing}`),
442
+ ];
443
+ return sameRenderRows;
444
+ };
445
+
446
+ const lines = wrapped(base, { style: "opencode" }).render(40);
447
+ expect(widths).toHaveLength(1);
448
+ if (accepted) {
449
+ expect(lines.join("\n")).toContain("↑↓ Navigate");
450
+ } else {
451
+ expect(lines).toEqual(sameRenderRows);
452
+ expect(lines.join("\n")).not.toContain("↑↓ Navigate");
453
+ }
454
+ });
455
+
456
+ it.each(["opencode", "accent-rail"] as const)(
457
+ "preserves same-render %s rows when autocomplete ownership changes during rendering",
458
+ (style) => {
459
+ const suggestions = ["→ original-one", " original-two"];
460
+ const base = baseEditor({ autocomplete: suggestions });
461
+ const nativeRender = base.render.bind(base);
462
+ const replacement = vi.fn(() => ["replacement"]);
463
+ const widths: number[] = [];
464
+ let sameRenderRows: string[] = [];
465
+ base.render = (width: number) => {
466
+ widths.push(width);
467
+ sameRenderRows = nativeRender(width);
468
+ base.autocompleteList.render = replacement;
469
+ return sameRenderRows;
470
+ };
471
+
472
+ const lines = wrapped(base, { style }).render(40);
473
+ expect(lines).toEqual(sameRenderRows);
474
+ expect(widths).toHaveLength(1);
475
+ expect(replacement).not.toHaveBeenCalled();
476
+ expect(base.autocompleteList.render).toBe(replacement);
477
+ expect(lines.join("\n")).not.toContain("↑↓ Navigate");
478
+ expect(lines.some((line) => line.startsWith("▎"))).toBe(false);
479
+ },
480
+ );
481
+
482
+ it.each(["opencode", "opencode-copy-friendly"] as const)(
483
+ "keeps exact no-autocomplete output in %s",
484
+ (style) => {
485
+ const ordinary = baseEditor({ below: 2 });
486
+ const guarded = baseEditor({ below: 2 });
487
+ guarded.autocompleteList.render = () => {
488
+ throw new Error("must not inspect an inactive menu");
489
+ };
490
+ expect(wrapped(guarded, { style }).render(80)).toEqual(
491
+ wrapped(ordinary, { style }).render(80),
492
+ );
493
+ },
494
+ );
495
+
496
+ it.each(["opencode", "opencode-copy-friendly"] as const)(
497
+ "keeps raw trailing autocomplete width-safe at widths 5 and 4 in %s",
498
+ (style) => {
499
+ for (const width of [5, 4]) {
500
+ const lines = wrapped(baseEditor({ autocomplete: ["界😀e\u0301"] }), {
501
+ style,
502
+ completionMenu: "native",
503
+ }).render(width);
504
+ expect(lines.some((line) => line.includes("├") || line.includes("┤"))).toBe(false);
505
+ expect(lines.at(-2)).toMatch(/^─+$/);
506
+ expect(visibleWidth(lines.at(-1) ?? "")).toBeLessThanOrEqual(width);
507
+ expect(lines.every((line) => visibleWidth(line) <= width)).toBe(true);
508
+ }
509
+ },
510
+ );
511
+
512
+ it("rerenders standalone Opencode at caller width when reduced-width autocomplete rendering throws", () => {
513
+ const editor = standalone();
514
+ let calls = 0;
515
+ Object.assign(editor as unknown as Record<string, unknown>, {
516
+ autocompleteState: {},
517
+ autocompleteList: {
518
+ render() {
519
+ calls += 1;
520
+ if (calls === 1) throw new Error("reduced-width autocomplete failed");
521
+ return ["caller-width-suggestion"];
522
+ },
523
+ },
524
+ });
525
+ const lines = editor.render(80);
526
+ expect(calls).toBe(2);
527
+ expect(lines.some((line) => line.includes("├"))).toBe(false);
528
+ expect(lines).toContain("caller-width-suggestion".padEnd(80));
529
+ expect(lines.every((line) => visibleWidth(line) <= 80)).toBe(true);
530
+ });
531
+
532
+ it("captures standalone Opencode autocomplete from the base render without an extra render", () => {
533
+ const editor = standalone("opencode-copy-friendly");
534
+ let calls = 0;
535
+ Object.assign(editor as unknown as Record<string, unknown>, {
536
+ autocompleteState: {},
537
+ autocompleteList: {
538
+ render() {
539
+ calls += 1;
540
+ return calls === 2 ? [] : ["caller-width-suggestion"];
541
+ },
542
+ },
543
+ });
544
+ const lines = editor.render(80);
545
+ expect(calls).toBe(1);
546
+ expect(lines.some((line) => line.includes("├"))).toBe(false);
547
+ expect(lines.some((line) => line.includes("caller-width-suggestion"))).toBe(true);
548
+ expect(lines.every((line) => visibleWidth(line) <= 80)).toBe(true);
549
+ });
550
+
551
+ it.each(["opencode", "accent-rail"] as const)(
552
+ "keeps standalone %s autocomplete to one render when ownership changes",
553
+ (style) => {
554
+ const editor = standalone(style);
555
+ const replacement = vi.fn(() => ["replacement"]);
556
+ let calls = 0;
557
+ const autocompleteList = {
558
+ filteredItems: [{ value: "original" }],
559
+ selectedIndex: 0,
560
+ maxVisible: 1,
561
+ render() {
562
+ calls += 1;
563
+ autocompleteList.render = replacement;
564
+ return ["→ original"];
565
+ },
566
+ };
567
+ Object.assign(editor as unknown as Record<string, unknown>, {
568
+ autocompleteState: {},
569
+ autocompleteList,
570
+ });
571
+
572
+ const lines = editor.render(80);
573
+ expect(calls).toBe(1);
574
+ expect(replacement).not.toHaveBeenCalled();
575
+ expect(autocompleteList.render).toBe(replacement);
576
+ expect(lines.some((line) => line.includes("→ original"))).toBe(true);
577
+ expect(lines.join("\n")).not.toContain("↑↓ Navigate");
578
+ expect(lines.some((line) => line.startsWith("▎"))).toBe(false);
579
+ },
580
+ );
581
+
582
+ it.each([
583
+ "── ↑ 7 more ─────────",
584
+ "─── ↓ 7 more ─────────",
585
+ "─── ↑ 07 more ─────────",
586
+ "prefix ─── ↑ 7 more ─────────",
587
+ "[muted]─── ↑ 7 more ─────────[/muted]",
588
+ ])("fails open for an unknown top border form: %s", (malformedTop) => {
589
+ const lines = wrapped(baseEditor({ below: 2, malformedTop })).render(80);
590
+ expect(lines[0]).toBe(malformedTop);
591
+ expect(lines).toContain("typed text");
592
+ expect(lines.at(-1)).toContain("↓ 2 more");
593
+ });
594
+
595
+ it("preserves every row from a minimal public-contract third-party editor", () => {
596
+ let text = "draft";
597
+ const inputs: string[] = [];
598
+ const base = {
599
+ render: () => ["third-party header", text, "third-party help"],
600
+ invalidate() {},
601
+ handleInput(data: string) {
602
+ inputs.push(data);
603
+ text += data;
604
+ },
605
+ getText: () => text,
606
+ setText(next: string) {
607
+ text = next;
608
+ },
609
+ };
610
+ const editor = wrapped(base as never);
611
+
612
+ expect(editor.render(80)).toEqual(["third-party header", "draft", "third-party help"]);
613
+ editor.handleInput("!");
614
+ expect(editor.getText()).toBe("draft!");
615
+ expect(inputs).toEqual(["!"]);
616
+ });
617
+
618
+ it("preserves autocomplete rows when Pi-private inspection fields are absent", () => {
619
+ const base = {
620
+ render: (width: number) => [
621
+ nativeBorder(width, "above"),
622
+ "typed text",
623
+ nativeBorder(width, "below"),
624
+ "suggestion-one",
625
+ "suggestion-two",
626
+ ],
627
+ invalidate() {},
628
+ handleInput() {},
629
+ getText: () => "typed text",
630
+ setText() {},
631
+ };
632
+
633
+ const lines = wrapped(base as never).render(80);
634
+ expect(lines).toContain("typed text");
635
+ expect(lines).toContain("suggestion-one");
636
+ expect(lines).toContain("suggestion-two");
637
+ expect(lines).toHaveLength(5);
638
+ });
639
+
640
+ it.each(["visibility method", "autocomplete getter", "autocomplete render"])(
641
+ "returns base rows when %s throws",
642
+ (failure) => {
643
+ const rendered = ["header", "typed text", "suggestion"];
644
+ const base: Record<string, unknown> = {
645
+ render: () => rendered,
646
+ invalidate() {},
647
+ handleInput() {},
648
+ getText: () => "typed text",
649
+ setText() {},
650
+ };
651
+ if (failure === "visibility method") {
652
+ base.isShowingAutocomplete = () => {
653
+ throw new Error("visibility failed");
654
+ };
655
+ } else {
656
+ base.isShowingAutocomplete = () => true;
657
+ if (failure === "autocomplete getter") {
658
+ Object.defineProperty(base, "autocompleteList", {
659
+ get() {
660
+ throw new Error("getter failed");
661
+ },
662
+ });
663
+ } else {
664
+ base.autocompleteList = {
665
+ render() {
666
+ throw new Error("render failed");
667
+ },
668
+ };
669
+ }
670
+ }
671
+
672
+ expect(wrapped(base as never).render(80)).toEqual(rendered);
673
+ },
674
+ );
675
+
676
+ it("does not trust or invoke a generic editor's spoofed polished-frame splitter", () => {
677
+ const widths: number[] = [];
678
+ const spoofedSplit = vi.fn(() => ({
679
+ editorLines: ["spoofed"],
680
+ trailingLines: [],
681
+ viewport: {},
682
+ }));
683
+ const base = {
684
+ render(width: number) {
685
+ widths.push(width);
686
+ return [nativeBorder(width, "above"), "typed text", nativeBorder(width, "below")];
687
+ },
688
+ invalidate() {},
689
+ handleInput() {},
690
+ getText: () => "typed text",
691
+ setText() {},
692
+ [Symbol.for("pi-zentui.polished-frame")]: spoofedSplit,
693
+ };
694
+ expect(wrapped(base as never).render(80)).toEqual([
695
+ nativeBorder(78, "above"),
696
+ "typed text",
697
+ nativeBorder(78, "below"),
698
+ ]);
699
+ expect(widths).toEqual([78]);
700
+ expect(spoofedSplit).not.toHaveBeenCalled();
701
+ });
702
+
703
+ it("fails open for polished-looking rows without exact module-owned array provenance", () => {
704
+ const trusted = wrapped(baseEditor({ above: 2, below: 3 })).render(80);
705
+ const staleClone = trusted.slice();
706
+ const base = {
707
+ render: () => staleClone,
708
+ invalidate() {},
709
+ handleInput() {},
710
+ getText: () => "typed text",
711
+ setText() {},
712
+ };
713
+
714
+ const lines = wrapped(base as never).render(80);
715
+ expect(lines).toEqual(staleClone);
716
+ expect(lines.join("\n").match(/model/g)).toHaveLength(1);
717
+ });
718
+
719
+ it("rejects in-place mutation of an otherwise provenance-owned rendered array", () => {
720
+ const rendered = wrapped(baseEditor({ above: 2, below: 3 })).render(80);
721
+ rendered[1] = "changed-row";
722
+ rendered.splice(2, 0, "added-row");
723
+ const base = {
724
+ render: () => rendered,
725
+ invalidate() {},
726
+ handleInput() {},
727
+ getText: () => "typed text",
728
+ setText() {},
729
+ };
730
+
731
+ const lines = wrapped(base as never).render(80);
732
+ expect(lines).toEqual(rendered);
733
+ expect(lines).toContain("changed-row");
734
+ expect(lines).toContain("added-row");
735
+ });
736
+
737
+ it("falls back to caller-width rendering when the inner-width probe throws", () => {
738
+ const widths: number[] = [];
739
+ const base = {
740
+ render(width: number) {
741
+ widths.push(width);
742
+ if (width < 80) throw new Error("inner-width render failed");
743
+ return ["caller-width-header", "x".repeat(width + 4), "caller-width-help"];
744
+ },
745
+ invalidate() {},
746
+ handleInput() {},
747
+ getText: () => "typed text",
748
+ setText() {},
749
+ };
750
+
751
+ const lines = wrapped(base as never).render(80);
752
+ expect(widths).toEqual([78, 80]);
753
+ expect(lines[0]).toBe("caller-width-header");
754
+ expect(lines.at(-1)).toBe("caller-width-help");
755
+ expect(lines.every((line) => visibleWidth(line) <= 80)).toBe(true);
756
+ });
757
+
758
+ it("returns unknown third-party output from the completed same-width probe", () => {
759
+ const widths: number[] = [];
760
+ const base = {
761
+ render(width: number) {
762
+ widths.push(width);
763
+ return [`header-${width}`, "x".repeat(width + 5), `help-${width}`];
764
+ },
765
+ invalidate() {},
766
+ handleInput() {},
767
+ getText: () => "typed text",
768
+ setText() {},
769
+ };
770
+
771
+ const lines = wrapped(base as never).render(80);
772
+ expect(widths).toEqual([78]);
773
+ expect(lines[0]).toBe("header-78");
774
+ expect(lines.at(-1)).toBe("help-78");
775
+ expect(lines.every((line) => visibleWidth(line) <= 80)).toBe(true);
776
+ });
777
+
778
+ it("only exposes optional editor methods implemented by the base", () => {
779
+ const withoutCapabilities = wrapped({
780
+ render: () => ["plain"],
781
+ invalidate() {},
782
+ handleInput() {},
783
+ getText: () => "",
784
+ setText() {},
785
+ } as never);
786
+ for (const method of [
787
+ "addToHistory",
788
+ "insertTextAtCursor",
789
+ "setAutocompleteProvider",
790
+ "setPaddingX",
791
+ "setAutocompleteMaxVisible",
792
+ ]) {
793
+ expect(method in withoutCapabilities).toBe(false);
794
+ }
795
+
796
+ const calls: string[] = [];
797
+ const withCapabilities = wrapped({
798
+ render: () => ["plain"],
799
+ invalidate() {},
800
+ handleInput() {},
801
+ getText: () => "",
802
+ setText() {},
803
+ addToHistory: () => calls.push("history"),
804
+ insertTextAtCursor: () => calls.push("insert"),
805
+ setAutocompleteProvider: () => calls.push("autocomplete"),
806
+ setPaddingX: () => calls.push("padding"),
807
+ setAutocompleteMaxVisible: () => calls.push("max-visible"),
808
+ } as never);
809
+ withCapabilities.addToHistory?.("history");
810
+ withCapabilities.insertTextAtCursor?.("insert");
811
+ withCapabilities.setAutocompleteProvider?.({} as never);
812
+ withCapabilities.setPaddingX?.(1);
813
+ withCapabilities.setAutocompleteMaxVisible?.(5);
814
+ expect(calls).toEqual(["history", "insert", "autocomplete", "padding", "max-visible"]);
815
+ });
816
+
817
+ it("keeps every rendered line within narrow ANSI-aware widths in both chrome modes", () => {
818
+ for (const style of ["opencode", "opencode-copy-friendly"] as const) {
819
+ for (const width of [3, 8, 12, 16]) {
820
+ const lines = wrapped(baseEditor({ above: 12, below: 34, ansi: true }), {
821
+ style,
822
+ }).render(width);
823
+ expect(lines.every((line) => visibleWidth(line) <= width)).toBe(true);
824
+ }
825
+ }
826
+ });
827
+
828
+ it("matches Pi's complete native form rather than reconstructing a truncated count", () => {
829
+ const truncatedNativeTop = truncateToWidth("─── ↑ 12 more ", 10, "");
830
+ const lines = wrapped(baseEditor({ below: 1, malformedTop: truncatedNativeTop })).render(40);
831
+ expect(lines[0]).toBe(truncatedNativeTop);
832
+ });
833
+ });
834
+
835
+ describe("same-render autocomplete capture", () => {
836
+ function source(render: (width: number) => string[]) {
837
+ return {
838
+ isShowingAutocomplete: () => true,
839
+ autocompleteList: {
840
+ filteredItems: [{ value: "one" }],
841
+ selectedIndex: 0,
842
+ maxVisible: 1,
843
+ render,
844
+ },
845
+ };
846
+ }
847
+
848
+ it("restores the exact predecessor after descriptor flags mutate", () => {
849
+ const predecessor = vi.fn(() => ["one"]);
850
+ const autocomplete = source(predecessor);
851
+ const original = Object.getOwnPropertyDescriptor(autocomplete.autocompleteList, "render");
852
+ const result = renderWithAutocompleteCapture(autocomplete as never, () => {
853
+ const rows = autocomplete.autocompleteList.render(20);
854
+ const wrapper = autocomplete.autocompleteList.render;
855
+ Object.defineProperty(autocomplete.autocompleteList, "render", {
856
+ value: wrapper,
857
+ configurable: true,
858
+ enumerable: false,
859
+ writable: false,
860
+ });
861
+ return rows;
862
+ });
863
+ expect(result.capture?.compatible).toBe(false);
864
+ expect(Object.getOwnPropertyDescriptor(autocomplete.autocompleteList, "render")).toEqual(
865
+ original,
866
+ );
867
+ expect(predecessor).toHaveBeenCalledTimes(1);
868
+ });
869
+
870
+ it("preserves a synchronous third-party replacement and suppresses metadata", () => {
871
+ const autocomplete = source(vi.fn(() => ["one"]));
872
+ const replacement = vi.fn(() => ["replacement"]);
873
+ const result = renderWithAutocompleteCapture(autocomplete as never, () => {
874
+ const rows = autocomplete.autocompleteList.render(20);
875
+ autocomplete.autocompleteList.render = replacement;
876
+ return rows;
877
+ });
878
+ expect(result.capture?.compatible).toBe(false);
879
+ expect(autocomplete.autocompleteList.render).toBe(replacement);
880
+ });
881
+
882
+ it("restores through a cleanup descriptor trap when the wrapper is still owned", () => {
883
+ const predecessor = vi.fn(() => ["one"]);
884
+ const target = source(predecessor).autocompleteList;
885
+ let descriptorReads = 0;
886
+ const proxy = new Proxy(target, {
887
+ getOwnPropertyDescriptor(current, property) {
888
+ descriptorReads++;
889
+ if (descriptorReads === 2) throw new Error("descriptor trap");
890
+ return Reflect.getOwnPropertyDescriptor(current, property);
891
+ },
892
+ });
893
+ const result = renderWithAutocompleteCapture(
894
+ { isShowingAutocomplete: () => true, autocompleteList: proxy } as never,
895
+ () => proxy.render(20),
896
+ );
897
+ expect(result.capture?.compatible).toBe(false);
898
+ expect(target.render).toBe(predecessor);
899
+ expect(predecessor).toHaveBeenCalledTimes(1);
900
+ });
901
+
902
+ it.each(["opencode", "accent-rail"] as const)(
903
+ "fails open from one %s render when inherited-renderer cleanup is rejected",
904
+ (style) => {
905
+ const predecessor = vi.fn((_width: number) => ["→ original"]);
906
+ const target = Object.assign(
907
+ Object.create({ render: predecessor }) as {
908
+ render: (width: number) => string[];
909
+ filteredItems: Array<{ value: string }>;
910
+ selectedIndex: number;
911
+ maxVisible: number;
912
+ },
913
+ {
914
+ filteredItems: [{ value: "original" }],
915
+ selectedIndex: 0,
916
+ maxVisible: 1,
917
+ },
918
+ );
919
+ const autocompleteList = new Proxy(target, {
920
+ deleteProperty: () => false,
921
+ });
922
+ let renderCalls = 0;
923
+ let sameRenderRows: string[] = [];
924
+ const base = {
925
+ render(width: number) {
926
+ renderCalls++;
927
+ sameRenderRows = [
928
+ nativeBorder(width, "above"),
929
+ "typed text",
930
+ nativeBorder(width, "below"),
931
+ ...autocompleteList.render(width),
932
+ ];
933
+ return sameRenderRows;
934
+ },
935
+ invalidate() {},
936
+ handleInput() {},
937
+ getText: () => "typed text",
938
+ setText() {},
939
+ isShowingAutocomplete: () => true,
940
+ autocompleteList,
941
+ };
942
+
943
+ const lines = wrapped(base as never, { style }).render(40);
944
+ expect(lines).toEqual(sameRenderRows);
945
+ expect(renderCalls).toBe(1);
946
+ expect(predecessor).toHaveBeenCalledTimes(1);
947
+ expect(Object.hasOwn(target, "render")).toBe(true);
948
+ expect(lines.join("\n")).not.toContain("↑↓ Navigate");
949
+ expect(lines.some((line) => line.startsWith("▎"))).toBe(false);
950
+ },
951
+ );
952
+
953
+ it("restores after throwing and supports nested capture without extra renders", () => {
954
+ const predecessor = vi.fn(() => ["one"]);
955
+ const autocomplete = source(predecessor);
956
+ const original = autocomplete.autocompleteList.render;
957
+ expect(() =>
958
+ renderWithAutocompleteCapture(autocomplete as never, () => {
959
+ autocomplete.autocompleteList.render(20);
960
+ throw new Error("render failed");
961
+ }),
962
+ ).toThrow("render failed");
963
+ expect(autocomplete.autocompleteList.render).toBe(original);
964
+
965
+ predecessor.mockClear();
966
+ const outer = renderWithAutocompleteCapture(autocomplete as never, () =>
967
+ renderWithAutocompleteCapture(autocomplete as never, () =>
968
+ autocomplete.autocompleteList.render(20),
969
+ ),
970
+ );
971
+ expect(predecessor).toHaveBeenCalledTimes(1);
972
+ expect(outer.capture).toMatchObject({ compatible: true, called: 1 });
973
+ expect(outer.value.capture).toMatchObject({ compatible: true, called: 1 });
974
+ expect(autocomplete.autocompleteList.render).toBe(original);
975
+ });
976
+ });
977
+
978
+ describe("accent rail editor integration", () => {
979
+ it("decorates trusted wrapped input and autocomplete rows", () => {
980
+ const editor = wrapped(baseEditor({ autocomplete: ["one", "two"] }), {
981
+ style: "accent-rail",
982
+ });
983
+ const lines = editor.render(24);
984
+ expect(lines).toHaveLength(3);
985
+ expect(lines[0]).toMatch(/^▎ typed text/);
986
+ expect(lines.every((line) => visibleWidth(line) === 24)).toBe(true);
987
+ expect(lines.slice(1).map((line) => line.trimEnd())).toEqual(["one", "two"]);
988
+ });
989
+
990
+ it("fails open from the reduced same-render rows for untrusted third-party output", () => {
991
+ const base = baseEditor({ malformedTop: "third-party header" });
992
+ const editor = wrapped(base, { style: "accent-rail" });
993
+ const lines = editor.render(24);
994
+ expect(lines).toEqual(base.render(22));
995
+ expect(lines.join("\n")).not.toContain("▎");
996
+ });
997
+
998
+ it("renders the standalone editor with the same accent rail", () => {
999
+ const lines = standalone("accent-rail").render(24);
1000
+ expect(lines.some((line) => line.startsWith("▎ "))).toBe(true);
1001
+ expect(lines.every((line) => visibleWidth(line) <= 24)).toBe(true);
1002
+ });
1003
+ });
1004
+
1005
+ describe("minimalist editor integration", () => {
1006
+ it("switches renderer modes without replacing the wrapped editor", () => {
1007
+ let current = config();
1008
+ const base = baseEditor({});
1009
+ const editor = new WrappedPolishedEditor(
1010
+ base as never,
1011
+ theme(),
1012
+ () => current,
1013
+ () => ({ modelLabel: "model", providerLabel: "provider" }),
1014
+ () => "off",
1015
+ () => ({ cwd: "/tmp/project", modelLabel: "model", costLabel: "$0.100" }),
1016
+ );
1017
+
1018
+ expect(editor.render(80)[0]).toMatch(/^─+$/);
1019
+ current = withEditorStyle(current, "minimalist");
1020
+ const minimalist = editor.render(80);
1021
+ expect(minimalist[0]).toMatch(/^╭.*╮$/);
1022
+ expect(minimalist.at(-1)).toMatch(/^╰.*╯$/);
1023
+ expect(minimalist.join("\n")).toContain("$0.100 – model");
1024
+ });
1025
+
1026
+ it("cycles one wrapped adapter through every style without nesting owned chrome", () => {
1027
+ let current = config({ style: "opencode" });
1028
+ const rawWidths: number[] = [];
1029
+ const raw = baseEditor({});
1030
+ const rawRender = raw.render.bind(raw);
1031
+ raw.render = (width: number) => {
1032
+ rawWidths.push(width);
1033
+ return rawRender(width);
1034
+ };
1035
+ const inner = new WrappedPolishedEditor(
1036
+ raw as never,
1037
+ theme(),
1038
+ () => config({ style: "opencode" }),
1039
+ () => ({ modelLabel: "model", providerLabel: "provider" }),
1040
+ () => "off",
1041
+ );
1042
+ const decoration = vi.fn();
1043
+ const editor = new WrappedPolishedEditor(
1044
+ inner as never,
1045
+ theme(),
1046
+ () => current,
1047
+ () => ({ modelLabel: "model", providerLabel: "provider" }),
1048
+ () => "off",
1049
+ () => ({ cwd: "/tmp/project", modelLabel: "model", costLabel: "$0.100" }),
1050
+ decoration,
1051
+ );
1052
+
1053
+ const polished = editor.render(40);
1054
+ current = withEditorStyle(current, "opencode-copy-friendly");
1055
+ const lowRail = editor.render(40);
1056
+ current = withEditorStyle(current, "accent-rail");
1057
+ const accentRail = editor.render(40);
1058
+ current = withEditorStyle(current, "minimalist");
1059
+ const minimalist = editor.render(40);
1060
+ current = withEditorStyle(current, "opencode");
1061
+ const polishedAgain = editor.render(40);
1062
+
1063
+ expect(rawWidths).toEqual([36, 38, 36, 34, 36]);
1064
+ expect(polished.join("\n").match(/provider/g)).toHaveLength(1);
1065
+ expect(lowRail.join("\n").match(/provider/g)).toHaveLength(1);
1066
+ expect(accentRail[0]).toMatch(/^▎ typed text/);
1067
+ expect(accentRail.join("\n")).not.toContain("provider");
1068
+ expect(minimalist[0]).toMatch(/^╭.*╮$/);
1069
+ expect(minimalist.at(-1)).toMatch(/^╰.*╯$/);
1070
+ expect(polishedAgain.join("\n").match(/provider/g)).toHaveLength(1);
1071
+ expect(polishedAgain.join("\n")).not.toContain("╭");
1072
+ expect(decoration.mock.calls.map(([active]) => active)).toEqual([
1073
+ false,
1074
+ false,
1075
+ false,
1076
+ true,
1077
+ false,
1078
+ ]);
1079
+ });
1080
+
1081
+ it("places native viewport counts at the far left before minimalist metadata", () => {
1082
+ const editor = new WrappedPolishedEditor(
1083
+ baseEditor({ above: 7, below: 11 }) as never,
1084
+ theme(),
1085
+ () => withEditorStyle(config(), "minimalist"),
1086
+ () => ({ modelLabel: "model", providerLabel: "provider" }),
1087
+ () => "off",
1088
+ () => ({
1089
+ cwd: "/tmp/project",
1090
+ branch: "main",
1091
+ ahead: 2,
1092
+ behind: 1,
1093
+ agentDurationMs: 5000,
1094
+ }),
1095
+ );
1096
+
1097
+ const lines = editor.render(80);
1098
+ expect(lines[0]).toMatch(/^╭─ ↑ 7 more · 5s/);
1099
+ expect(lines.at(-1)).toMatch(/^╰─ ↓ 11 more · main ↑2 ↓1/);
1100
+ });
1101
+
1102
+ it("honors the shared viewport indicator toggle in minimalist mode", () => {
1103
+ const editor = new WrappedPolishedEditor(
1104
+ baseEditor({ above: 7, below: 11 }) as never,
1105
+ theme(),
1106
+ () => withEditorStyle(config({ viewportIndicators: false }), "minimalist"),
1107
+ () => ({ modelLabel: "model", providerLabel: "provider" }),
1108
+ () => "off",
1109
+ () => ({ cwd: "/tmp/project" }),
1110
+ );
1111
+
1112
+ expect(editor.render(80).join("\n")).not.toContain("more");
1113
+ });
1114
+
1115
+ it("keeps known autocomplete rows inside the minimalist frame", () => {
1116
+ const base = baseEditor({ below: 5, autocomplete: ["one", "two"] });
1117
+ const editor = new WrappedPolishedEditor(
1118
+ base as never,
1119
+ theme(),
1120
+ () => withEditorStyle(config(), "minimalist"),
1121
+ () => ({ modelLabel: "model", providerLabel: "provider" }),
1122
+ () => "off",
1123
+ () => ({ cwd: "/tmp" }),
1124
+ );
1125
+ const lines = editor.render(40);
1126
+ expect(lines.some((line) => /^├─+┤$/.test(line))).toBe(true);
1127
+ expect(lines.findIndex((line) => line.includes("one"))).toBeGreaterThan(
1128
+ lines.findIndex((line) => line.startsWith("├")),
1129
+ );
1130
+ expect(lines.findIndex((line) => line.includes("one"))).toBeLessThan(lines.length - 1);
1131
+ expect(lines.at(-1)).toContain("↓ 5 more");
1132
+ expect(lines.at(-1)).toMatch(/^╰.*╯$/);
1133
+ });
1134
+
1135
+ it("fails open from the reduced same-render rows for unknown third-party output", () => {
1136
+ const widths: number[] = [];
1137
+ const decoration = vi.fn();
1138
+ const base = {
1139
+ render(width: number) {
1140
+ widths.push(width);
1141
+ return [`header-${width}`, "body", `help-${width}`];
1142
+ },
1143
+ invalidate() {},
1144
+ handleInput() {},
1145
+ getText: () => "body",
1146
+ setText() {},
1147
+ };
1148
+ const editor = new WrappedPolishedEditor(
1149
+ base as never,
1150
+ theme(),
1151
+ () => withEditorStyle(config(), "minimalist"),
1152
+ () => ({ modelLabel: "model", providerLabel: "provider" }),
1153
+ () => "off",
1154
+ () => ({ cwd: "/tmp" }),
1155
+ decoration,
1156
+ );
1157
+ expect(editor.render(40)).toEqual(["header-36", "body", "help-36"]);
1158
+ expect(widths).toEqual([36]);
1159
+ expect(decoration).toHaveBeenLastCalledWith(false);
1160
+ });
1161
+
1162
+ it("unwraps module-owned polished chrome, viewport counts, and autocomplete", () => {
1163
+ const inner = wrapped(baseEditor({ above: 3, below: 8, autocomplete: ["one", "two"] }));
1164
+ const outer = new WrappedPolishedEditor(
1165
+ inner as never,
1166
+ theme(),
1167
+ () => withEditorStyle(config(), "minimalist"),
1168
+ () => ({ modelLabel: "outer", providerLabel: "provider" }),
1169
+ () => "off",
1170
+ () => ({ cwd: "/tmp", modelLabel: "minimal-model" }),
1171
+ );
1172
+
1173
+ const lines = outer.render(80);
1174
+ const rendered = lines.join("\n");
1175
+ expect(lines[0]).toMatch(/^╭.*╮$/);
1176
+ expect(lines.at(-1)).toMatch(/^╰.*╯$/);
1177
+ expect(rendered).toContain("minimal-model");
1178
+ expect(rendered).not.toContain("provider");
1179
+ expect(rendered.match(/minimal-model/g)).toHaveLength(1);
1180
+ expect(rendered.match(/↑ 3 more/g)).toHaveLength(1);
1181
+ expect(rendered.match(/↓ 8 more/g)).toHaveLength(1);
1182
+ expect(lines.some((line) => /^├─+┤$/.test(line))).toBe(true);
1183
+ expect(lines.filter((line) => line.includes("one") || line.includes("two"))).toHaveLength(2);
1184
+ });
1185
+
1186
+ it("rejects mutated module-owned polished provenance in minimalist mode", () => {
1187
+ const owned = wrapped(baseEditor({ above: 2, below: 3 })).render(36);
1188
+ owned[1] = "mutated-row";
1189
+ const base = {
1190
+ render: () => owned,
1191
+ invalidate() {},
1192
+ handleInput() {},
1193
+ getText: () => "typed text",
1194
+ setText() {},
1195
+ };
1196
+ const decoration = vi.fn();
1197
+ const editor = new WrappedPolishedEditor(
1198
+ base as never,
1199
+ theme(),
1200
+ () => withEditorStyle(config(), "minimalist"),
1201
+ () => ({ modelLabel: "model", providerLabel: "provider" }),
1202
+ () => "off",
1203
+ () => ({ cwd: "/tmp" }),
1204
+ decoration,
1205
+ );
1206
+
1207
+ expect(editor.render(40)).toEqual(owned);
1208
+ expect(decoration).toHaveBeenLastCalledWith(false);
1209
+ });
1210
+
1211
+ it("falls back at four columns and decorates after resizing to five", () => {
1212
+ const widths: number[] = [];
1213
+ const decoration = vi.fn();
1214
+ const base = baseEditor({});
1215
+ const renderBase = base.render.bind(base);
1216
+ base.render = (width: number) => {
1217
+ widths.push(width);
1218
+ return renderBase(width);
1219
+ };
1220
+ const editor = new WrappedPolishedEditor(
1221
+ base as never,
1222
+ theme(),
1223
+ () => withEditorStyle(config(), "minimalist"),
1224
+ () => ({ modelLabel: "model", providerLabel: "provider" }),
1225
+ () => "off",
1226
+ () => ({ cwd: "/tmp" }),
1227
+ decoration,
1228
+ );
1229
+
1230
+ const narrow = editor.render(4);
1231
+ expect(narrow[0]).not.toContain("╭");
1232
+ expect(narrow.every((line) => visibleWidth(line) <= 4)).toBe(true);
1233
+ const decorated = editor.render(5);
1234
+ expect(decorated[0]).toMatch(/^╭.*╮$/);
1235
+ expect(decorated.every((line) => visibleWidth(line) <= 5)).toBe(true);
1236
+ expect(widths).toEqual([4, 1]);
1237
+ expect(decoration.mock.calls.map(([active]) => active)).toEqual([false, true]);
1238
+ });
1239
+
1240
+ it("preserves empty, multiline, blank, and inverse-video editor rows", () => {
1241
+ const inverseCursor = "\x1b[7m \x1b[0m";
1242
+ const base = {
1243
+ render: (width: number) => [
1244
+ nativeBorder(width, "above"),
1245
+ "",
1246
+ inverseCursor,
1247
+ "second line",
1248
+ nativeBorder(width, "below"),
1249
+ ],
1250
+ invalidate() {},
1251
+ handleInput() {},
1252
+ getText: () => "\nsecond line",
1253
+ setText() {},
1254
+ };
1255
+ const editor = new WrappedPolishedEditor(
1256
+ base as never,
1257
+ theme(),
1258
+ () => withEditorStyle(config(), "minimalist"),
1259
+ () => ({ modelLabel: "model", providerLabel: "provider" }),
1260
+ () => "off",
1261
+ () => ({ cwd: "/tmp" }),
1262
+ );
1263
+ const lines = editor.render(40);
1264
+ expect(lines[1]).toMatch(/^│\s+│$/);
1265
+ expect(lines.join("\n")).toContain(inverseCursor);
1266
+ expect(lines.join("\n")).toContain("second line");
1267
+
1268
+ const empty = new WrappedPolishedEditor(
1269
+ {
1270
+ render: (width: number) => [nativeBorder(width, "above"), nativeBorder(width, "below")],
1271
+ invalidate() {},
1272
+ handleInput() {},
1273
+ getText: () => "",
1274
+ setText() {},
1275
+ } as never,
1276
+ theme(),
1277
+ () => withEditorStyle(config(), "minimalist"),
1278
+ () => ({ modelLabel: "model", providerLabel: "provider" }),
1279
+ () => "off",
1280
+ () => ({ cwd: "/tmp" }),
1281
+ ).render(40);
1282
+ expect(empty).toHaveLength(2);
1283
+ expect(empty[0]).toMatch(/^╭.*╮$/);
1284
+ expect(empty[1]).toMatch(/^╰.*╯$/);
1285
+ });
1286
+ });