@visual-z/xeno 0.8.4 → 0.9.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 (350) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +31 -746
  3. package/bin/xeno.exe +26 -0
  4. package/install.cjs +88 -0
  5. package/package.json +18 -48
  6. package/docs/app-server.md +0 -115
  7. package/docs/foundations.md +0 -88
  8. package/docs/install.md +0 -244
  9. package/docs/providers.md +0 -172
  10. package/docs/research/foundation-clients-2026-10-09.md +0 -27
  11. package/docs/research/foundation-editing-2026-10-09.md +0 -56
  12. package/docs/research/foundation-network-2026-10-09.md +0 -37
  13. package/docs/research/foundation-workflow-2026-10-09.md +0 -37
  14. package/src/apps/acp-server.ts +0 -41
  15. package/src/apps/agent-profiles.ts +0 -19
  16. package/src/apps/app-server.ts +0 -30
  17. package/src/apps/approve.ts +0 -49
  18. package/src/apps/cli.ts +0 -99
  19. package/src/apps/commands.ts +0 -319
  20. package/src/apps/completions.ts +0 -87
  21. package/src/apps/control-transport.ts +0 -90
  22. package/src/apps/export-cli.ts +0 -24
  23. package/src/apps/goal-flow.ts +0 -70
  24. package/src/apps/image-draft.ts +0 -46
  25. package/src/apps/input.ts +0 -312
  26. package/src/apps/model-catalog.ts +0 -69
  27. package/src/apps/preferences.ts +0 -20
  28. package/src/apps/project-trust.ts +0 -9
  29. package/src/apps/provider-connect.ts +0 -346
  30. package/src/apps/question-ui.ts +0 -27
  31. package/src/apps/render.ts +0 -181
  32. package/src/apps/resources.ts +0 -66
  33. package/src/apps/review.ts +0 -109
  34. package/src/apps/session-hub.ts +0 -96
  35. package/src/apps/session-lifecycle.ts +0 -64
  36. package/src/apps/shell.ts +0 -2071
  37. package/src/apps/startup-input.ts +0 -135
  38. package/src/apps/startup.ts +0 -1677
  39. package/src/apps/ui/app.ts +0 -1818
  40. package/src/apps/ui/control-model.ts +0 -43
  41. package/src/apps/ui/extension-host.ts +0 -194
  42. package/src/apps/ui/image-cache.ts +0 -121
  43. package/src/apps/ui/palette.ts +0 -88
  44. package/src/apps/ui/project.ts +0 -127
  45. package/src/apps/ui/standalone.ts +0 -148
  46. package/src/apps/ui/status-model.ts +0 -68
  47. package/src/apps/ui/surface.ts +0 -91
  48. package/src/apps/ui/theme.ts +0 -86
  49. package/src/apps/update-check.ts +0 -112
  50. package/src/apps/workflow.ts +0 -58
  51. package/src/core/acceptance.ts +0 -139
  52. package/src/core/action-defaults.ts +0 -58
  53. package/src/core/activity-status.ts +0 -52
  54. package/src/core/agent-profile.ts +0 -130
  55. package/src/core/attachment-input.ts +0 -17
  56. package/src/core/blind-checks.ts +0 -72
  57. package/src/core/boundary.ts +0 -174
  58. package/src/core/branch-receipt.ts +0 -26
  59. package/src/core/catalog-merge.ts +0 -19
  60. package/src/core/checkpoints.ts +0 -85
  61. package/src/core/churn.ts +0 -61
  62. package/src/core/command-artifacts.ts +0 -94
  63. package/src/core/command-contract.ts +0 -151
  64. package/src/core/completion-action.ts +0 -20
  65. package/src/core/completion-review.ts +0 -98
  66. package/src/core/context-detail.ts +0 -37
  67. package/src/core/context-policy.ts +0 -133
  68. package/src/core/context-window.ts +0 -54
  69. package/src/core/continuation.ts +0 -130
  70. package/src/core/control-protocol.ts +0 -69
  71. package/src/core/control-view.ts +0 -39
  72. package/src/core/cycle.ts +0 -17
  73. package/src/core/data-query.ts +0 -201
  74. package/src/core/deadline.ts +0 -40
  75. package/src/core/derive.ts +0 -516
  76. package/src/core/draft-images.ts +0 -56
  77. package/src/core/edit-plan.ts +0 -200
  78. package/src/core/environment.ts +0 -181
  79. package/src/core/event-validation.ts +0 -220
  80. package/src/core/events.ts +0 -670
  81. package/src/core/evidence-ladder.ts +0 -62
  82. package/src/core/evidence-links.ts +0 -68
  83. package/src/core/evidence.ts +0 -240
  84. package/src/core/execution.ts +0 -18
  85. package/src/core/exhaustive.ts +0 -18
  86. package/src/core/export.ts +0 -179
  87. package/src/core/extension-flags.ts +0 -14
  88. package/src/core/external-effects.ts +0 -488
  89. package/src/core/failure-recovery.ts +0 -173
  90. package/src/core/file-precondition.ts +0 -78
  91. package/src/core/file-references.ts +0 -35
  92. package/src/core/frontmatter.ts +0 -213
  93. package/src/core/git-status.ts +0 -29
  94. package/src/core/goal-view.ts +0 -64
  95. package/src/core/goal.ts +0 -200
  96. package/src/core/handoff.ts +0 -479
  97. package/src/core/history-query.ts +0 -139
  98. package/src/core/history.ts +0 -237
  99. package/src/core/image-limits.ts +0 -72
  100. package/src/core/images.ts +0 -46
  101. package/src/core/integration-health.ts +0 -34
  102. package/src/core/json-event.ts +0 -131
  103. package/src/core/json-pointer.ts +0 -46
  104. package/src/core/keymap.ts +0 -153
  105. package/src/core/lsp-framing.ts +0 -34
  106. package/src/core/mcp-config.ts +0 -68
  107. package/src/core/model-picker.ts +0 -64
  108. package/src/core/model-preferences.ts +0 -28
  109. package/src/core/model-replay.ts +0 -22
  110. package/src/core/model-scope.ts +0 -53
  111. package/src/core/note-guidance.ts +0 -34
  112. package/src/core/oauth-fields.ts +0 -44
  113. package/src/core/observation-view.ts +0 -152
  114. package/src/core/outcome.ts +0 -44
  115. package/src/core/overflow.ts +0 -59
  116. package/src/core/packages.ts +0 -47
  117. package/src/core/patch.ts +0 -130
  118. package/src/core/path-suggestions.ts +0 -59
  119. package/src/core/permissions.ts +0 -410
  120. package/src/core/process-receipt.ts +0 -105
  121. package/src/core/process-state.ts +0 -37
  122. package/src/core/program-status.ts +0 -40
  123. package/src/core/progress.ts +0 -150
  124. package/src/core/project.ts +0 -431
  125. package/src/core/prompt-policy.ts +0 -46
  126. package/src/core/prompts.ts +0 -111
  127. package/src/core/question.ts +0 -94
  128. package/src/core/queue.ts +0 -78
  129. package/src/core/read-bounds.ts +0 -24
  130. package/src/core/read-observation.ts +0 -106
  131. package/src/core/recovery-sources.ts +0 -74
  132. package/src/core/regex-dialect.ts +0 -25
  133. package/src/core/request-budget.ts +0 -23
  134. package/src/core/request-progress.ts +0 -87
  135. package/src/core/request-state.ts +0 -273
  136. package/src/core/resource-selection.ts +0 -36
  137. package/src/core/response-recovery.ts +0 -59
  138. package/src/core/resume.ts +0 -59
  139. package/src/core/review.ts +0 -68
  140. package/src/core/ripgrep-release.ts +0 -64
  141. package/src/core/runtime-target.ts +0 -94
  142. package/src/core/scheduling.ts +0 -8
  143. package/src/core/search-observation.ts +0 -97
  144. package/src/core/secrets.ts +0 -188
  145. package/src/core/session-archive.ts +0 -4
  146. package/src/core/session-browser.ts +0 -82
  147. package/src/core/session-entry.ts +0 -61
  148. package/src/core/session-exit.ts +0 -42
  149. package/src/core/session-format.ts +0 -169
  150. package/src/core/session-position.ts +0 -26
  151. package/src/core/session-tree.ts +0 -147
  152. package/src/core/settings.ts +0 -192
  153. package/src/core/skills.ts +0 -165
  154. package/src/core/sql-query.ts +0 -211
  155. package/src/core/stall.ts +0 -128
  156. package/src/core/stop-report.ts +0 -68
  157. package/src/core/text-normalize.ts +0 -81
  158. package/src/core/theme.ts +0 -166
  159. package/src/core/thinking.ts +0 -26
  160. package/src/core/tokens.ts +0 -151
  161. package/src/core/tool-contract.ts +0 -153
  162. package/src/core/tool-discovery.ts +0 -87
  163. package/src/core/tool-failure.ts +0 -81
  164. package/src/core/tool-input.ts +0 -44
  165. package/src/core/transcript.ts +0 -108
  166. package/src/core/tui-mode.ts +0 -29
  167. package/src/core/update-check.ts +0 -157
  168. package/src/core/version.ts +0 -55
  169. package/src/core/web-content.ts +0 -80
  170. package/src/core/work-state.ts +0 -154
  171. package/src/core/workflow-mode.ts +0 -34
  172. package/src/core/working-set.ts +0 -119
  173. package/src/core/yaml.ts +0 -282
  174. package/src/runtime/acp-agent.ts +0 -289
  175. package/src/runtime/agent-profiles.ts +0 -21
  176. package/src/runtime/auth.ts +0 -367
  177. package/src/runtime/blind-check.ts +0 -36
  178. package/src/runtime/branch-jobs.ts +0 -36
  179. package/src/runtime/catalog.ts +0 -285
  180. package/src/runtime/clipboard.ts +0 -125
  181. package/src/runtime/command-artifacts.ts +0 -64
  182. package/src/runtime/connection-reuse.ts +0 -40
  183. package/src/runtime/control-client.ts +0 -149
  184. package/src/runtime/control-connection.ts +0 -139
  185. package/src/runtime/counter-check.ts +0 -74
  186. package/src/runtime/environment-tools.ts +0 -60
  187. package/src/runtime/evidence-notes.ts +0 -35
  188. package/src/runtime/exec-sessions.ts +0 -456
  189. package/src/runtime/execution-control.ts +0 -40
  190. package/src/runtime/extension-loader.ts +0 -37
  191. package/src/runtime/extension-ui.ts +0 -47
  192. package/src/runtime/extensions.ts +0 -370
  193. package/src/runtime/formatter-tool.ts +0 -38
  194. package/src/runtime/formatter.ts +0 -50
  195. package/src/runtime/foundation.ts +0 -116
  196. package/src/runtime/history-cache.ts +0 -57
  197. package/src/runtime/history-tools.ts +0 -168
  198. package/src/runtime/images.ts +0 -80
  199. package/src/runtime/integrations.ts +0 -216
  200. package/src/runtime/loop.ts +0 -3108
  201. package/src/runtime/lsp-client.ts +0 -201
  202. package/src/runtime/mcp-client.ts +0 -264
  203. package/src/runtime/mcp-oauth.ts +0 -168
  204. package/src/runtime/mcp-schema.ts +0 -51
  205. package/src/runtime/model-config.ts +0 -232
  206. package/src/runtime/model-data.LICENSE +0 -27
  207. package/src/runtime/model-data.ts +0 -21
  208. package/src/runtime/model-discovery.ts +0 -208
  209. package/src/runtime/model-replay.ts +0 -10
  210. package/src/runtime/model-scope.ts +0 -20
  211. package/src/runtime/model-sources.ts +0 -151
  212. package/src/runtime/model.ts +0 -686
  213. package/src/runtime/models-fetch.ts +0 -57
  214. package/src/runtime/oauth-device.ts +0 -86
  215. package/src/runtime/oauth.ts +0 -853
  216. package/src/runtime/open-image.ts +0 -42
  217. package/src/runtime/openai-responses.ts +0 -304
  218. package/src/runtime/openai.ts +0 -697
  219. package/src/runtime/output-schema.ts +0 -26
  220. package/src/runtime/package-manager.ts +0 -125
  221. package/src/runtime/patch-tool.ts +0 -38
  222. package/src/runtime/permission-resources.ts +0 -18
  223. package/src/runtime/pi-provider.ts +0 -423
  224. package/src/runtime/program-status.ts +0 -38
  225. package/src/runtime/progress.ts +0 -26
  226. package/src/runtime/provider-access.ts +0 -21
  227. package/src/runtime/provider-connect.ts +0 -109
  228. package/src/runtime/provider-presets.ts +0 -98
  229. package/src/runtime/provider-protocols.ts +0 -29
  230. package/src/runtime/provider-request.ts +0 -87
  231. package/src/runtime/provider-tool-policy.ts +0 -33
  232. package/src/runtime/provider.ts +0 -580
  233. package/src/runtime/question-tool.ts +0 -62
  234. package/src/runtime/retry.ts +0 -447
  235. package/src/runtime/review-tool.ts +0 -48
  236. package/src/runtime/rich-text.ts +0 -36
  237. package/src/runtime/runtime-target.ts +0 -149
  238. package/src/runtime/secrets.ts +0 -79
  239. package/src/runtime/self-update.ts +0 -74
  240. package/src/runtime/session-control.ts +0 -265
  241. package/src/runtime/session-hub.ts +0 -85
  242. package/src/runtime/stream-idle.ts +0 -80
  243. package/src/runtime/tool-broker.ts +0 -137
  244. package/src/runtime/tool-discovery.ts +0 -58
  245. package/src/runtime/tool-queue.ts +0 -77
  246. package/src/runtime/tools.ts +0 -1820
  247. package/src/runtime/turn-effects.ts +0 -31
  248. package/src/runtime/update-check.ts +0 -48
  249. package/src/runtime/usage.ts +0 -253
  250. package/src/runtime/version.ts +0 -12
  251. package/src/runtime/web-http.ts +0 -56
  252. package/src/runtime/web-tools.ts +0 -88
  253. package/src/runtime/working-notes.ts +0 -60
  254. package/src/runtime/workspace-lifecycle.ts +0 -141
  255. package/src/store/acceptance.ts +0 -15
  256. package/src/store/agent-profile-files.ts +0 -13
  257. package/src/store/atomic.ts +0 -31
  258. package/src/store/auth-store.ts +0 -50
  259. package/src/store/blind-checks.ts +0 -32
  260. package/src/store/blobs.ts +0 -143
  261. package/src/store/checkpoint-history.ts +0 -32
  262. package/src/store/command-artifacts.ts +0 -96
  263. package/src/store/command.ts +0 -111
  264. package/src/store/config.ts +0 -69
  265. package/src/store/context-files.ts +0 -436
  266. package/src/store/file-checkpoints.ts +0 -108
  267. package/src/store/file-index.ts +0 -174
  268. package/src/store/foundation-config.ts +0 -24
  269. package/src/store/git-checkpoints.ts +0 -67
  270. package/src/store/git-status.ts +0 -15
  271. package/src/store/history-search.ts +0 -19
  272. package/src/store/log.ts +0 -725
  273. package/src/store/lsp-document.ts +0 -30
  274. package/src/store/mcp-credentials.ts +0 -85
  275. package/src/store/models.ts +0 -60
  276. package/src/store/observations.ts +0 -224
  277. package/src/store/packages.ts +0 -98
  278. package/src/store/patch.ts +0 -92
  279. package/src/store/permission-paths.ts +0 -34
  280. package/src/store/process-receipts.ts +0 -34
  281. package/src/store/query-source.ts +0 -49
  282. package/src/store/recovery.ts +0 -44
  283. package/src/store/replay.ts +0 -76
  284. package/src/store/resource-index.ts +0 -170
  285. package/src/store/resources.ts +0 -285
  286. package/src/store/restore.ts +0 -110
  287. package/src/store/review.ts +0 -109
  288. package/src/store/runtime-target.ts +0 -64
  289. package/src/store/search.ts +0 -728
  290. package/src/store/secrets.ts +0 -44
  291. package/src/store/serial.ts +0 -13
  292. package/src/store/session-archive.ts +0 -165
  293. package/src/store/session-browser.ts +0 -68
  294. package/src/store/session-position.ts +0 -27
  295. package/src/store/sessions.ts +0 -525
  296. package/src/store/source-cancellation.ts +0 -6
  297. package/src/store/tool-binaries.ts +0 -90
  298. package/src/store/trust.ts +0 -82
  299. package/src/store/update-cache.ts +0 -36
  300. package/src/store/user-settings.ts +0 -30
  301. package/src/store/workspace-clone.ts +0 -42
  302. package/src/store/workspace-observation.ts +0 -113
  303. package/src/store/workspace.ts +0 -115
  304. package/src/store/worktree.ts +0 -278
  305. package/src/tui/ansi-spans.ts +0 -126
  306. package/src/tui/diff.ts +0 -160
  307. package/src/tui/exhaustive.ts +0 -14
  308. package/src/tui/fuzzy.ts +0 -75
  309. package/src/tui/style.ts +0 -208
  310. package/src/tui/ui/atomic-draft.ts +0 -121
  311. package/src/tui/ui/branch-panel.ts +0 -47
  312. package/src/tui/ui/canvas.ts +0 -96
  313. package/src/tui/ui/colors.ts +0 -70
  314. package/src/tui/ui/composer.ts +0 -199
  315. package/src/tui/ui/control-panel.ts +0 -25
  316. package/src/tui/ui/control-sidebar.ts +0 -49
  317. package/src/tui/ui/dialog.ts +0 -398
  318. package/src/tui/ui/draft.ts +0 -23
  319. package/src/tui/ui/duration.ts +0 -16
  320. package/src/tui/ui/edge.ts +0 -186
  321. package/src/tui/ui/image-size.ts +0 -44
  322. package/src/tui/ui/ink.ts +0 -210
  323. package/src/tui/ui/items.ts +0 -42
  324. package/src/tui/ui/keys.ts +0 -40
  325. package/src/tui/ui/layout.ts +0 -74
  326. package/src/tui/ui/list-model.ts +0 -86
  327. package/src/tui/ui/list-view.ts +0 -172
  328. package/src/tui/ui/mascot.ts +0 -67
  329. package/src/tui/ui/messages.ts +0 -518
  330. package/src/tui/ui/motion.ts +0 -28
  331. package/src/tui/ui/paths.ts +0 -10
  332. package/src/tui/ui/pictures.ts +0 -108
  333. package/src/tui/ui/prompt-history.ts +0 -60
  334. package/src/tui/ui/screen.ts +0 -579
  335. package/src/tui/ui/status.ts +0 -403
  336. package/src/tui/ui/styled.ts +0 -85
  337. package/src/tui/ui/tool-card.ts +0 -150
  338. package/src/tui/width.ts +0 -567
  339. package/src/vendor/pi-rich/LICENSE.upstream +0 -21
  340. package/src/vendor/pi-rich/UPSTREAM.json +0 -17
  341. package/src/vendor/pi-rich/clipboard-command.ts +0 -44
  342. package/src/vendor/pi-rich/exif-orientation.ts +0 -182
  343. package/src/vendor/pi-rich/html.ts +0 -51
  344. package/src/vendor/pi-rich/image-convert.ts +0 -49
  345. package/src/vendor/pi-rich/image-resize-core.ts +0 -164
  346. package/src/vendor/pi-rich/image-resize-worker.ts +0 -42
  347. package/src/vendor/pi-rich/image-resize.ts +0 -123
  348. package/src/vendor/pi-rich/mime.ts +0 -116
  349. package/src/vendor/pi-rich/photon.ts +0 -139
  350. package/src/vendor/pi-rich/syntax-highlight.ts +0 -214
package/src/tui/ui/ink.ts DELETED
@@ -1,210 +0,0 @@
1
- /**
2
- * Terminal-owned colour (docs/tasks/refactor-tui-terminal-colors.md). Pure.
3
- *
4
- * The terminal already has a palette the user chose, often with transparency
5
- * and blur behind it. Painting our own background defeats both, and fixed RGB
6
- * accents clash with whatever palette is underneath. So the view asks the
7
- * terminal (OSC 4/10/11 via OpenTUI) and derives every role from the answer:
8
- *
9
- * text the terminal's own foreground (SGR 39), never a guess
10
- * background the terminal's own background (SGR 49): nothing is painted
11
- * accents one of the 16 slots, picked for contrast and hue
12
- * greys the foreground blended toward the background, as faint as a
13
- * contrast target allows (Codex styles.md does the same)
14
- *
15
- * When the terminal does not answer, a dark/light hint picks the slots and
16
- * fixed greys. Semantic colour still only marks glyphs and rules; it never
17
- * colours body text (D11 keeps green/red for logged outcomes).
18
- */
19
-
20
- /** How a colour is emitted. Indexed and default colours follow the terminal if its palette changes. */
21
- export type InkSpec =
22
- | { readonly kind: "default" }
23
- | { readonly kind: "index"; readonly index: number }
24
- | { readonly kind: "rgb"; readonly hex: string };
25
-
26
- export interface TerminalPalette {
27
- readonly foreground: string;
28
- readonly background: string;
29
- /** The 16 ANSI slots as #rrggbb. */
30
- readonly slots: readonly string[];
31
- /** The terminal's own selection colours (OSC 17 / 19), when it reports them. */
32
- readonly highlight?: { readonly background: string; readonly foreground: string };
33
- }
34
-
35
- export const INK_ROLES = ["text", "muted", "edge", "line", "accent", "success", "error", "warning", "reasoning", "code"] as const;
36
- export type InkRole = (typeof INK_ROLES)[number];
37
- export type InkSet = { readonly [K in InkRole]: InkSpec } & {
38
- readonly background: InkSpec;
39
- /** Selected text: the terminal's selection colours, else an inverse of its own ground (feat-tui-copy-selection DoD 5). */
40
- readonly selectionBg: InkSpec;
41
- readonly selectionFg: InkSpec;
42
- }
43
-
44
- /**
45
- * Contrast targets. 4.5:1 is WCAG AA for text and 3:1 for UI components;
46
- * 1.8:1 for decorative rules is an assumption (visible, never louder than text).
47
- * A terminal whose own foreground is weaker than these gets proportionally
48
- * lower targets, so muted text never becomes louder than body text.
49
- */
50
- const MUTED = { target: 4.5, share: 0.62 };
51
- const EDGE = { target: 3, share: 0.42 };
52
- const LINE = { target: 1.8, share: 0.3 };
53
- /** Minimum contrast for an accent glyph against the background (WCAG non-text). */
54
- const ACCENT_CONTRAST = 3;
55
- /** Below this saturation a slot is a grey, not a hue (Solarized's bright slots). Assumed. */
56
- const CHROMATIC = 0.15;
57
- /** A bright slot stands in for its normal slot only within this hue distance. Assumed. */
58
- const SAME_HUE_DEGREES = 40;
59
-
60
- /** [normal, bright] slot pairs per role, in preference order. */
61
- const SLOTS: Readonly<Record<Exclude<InkRole, "text" | "muted" | "edge" | "line">, readonly (readonly [number, number])[]>> = {
62
- accent: [[4, 12], [6, 14]],
63
- success: [[2, 10]],
64
- error: [[1, 9]],
65
- warning: [[3, 11]],
66
- reasoning: [[5, 13]],
67
- code: [[6, 14]],
68
- };
69
-
70
- /** Greys used when the terminal does not report its palette. Chosen for ≥ 4.5 / 3 / 1.6 on typical dark (#1e1e1e) and light (#ffffff) grounds. */
71
- const FALLBACK_GREYS = {
72
- dark: { muted: "#9a9a9a", edge: "#6e6e6e", line: "#4a4a4a" },
73
- light: { muted: "#6a6a6a", edge: "#949494", line: "#c8c8c8" },
74
- } as const;
75
-
76
- export function terminalInks(palette: TerminalPalette | null, hint: "dark" | "light"): InkSet {
77
- const valid = palette !== null && isHex(palette.foreground) && isHex(palette.background) && palette.slots.length >= 16 && palette.slots.slice(0, 16).every(isHex);
78
- if (!valid) return fallbackInks(hint);
79
- const { foreground: fg, background: bg } = palette;
80
- const dark = luminance(bg) < luminance(fg);
81
- const c = contrast(fg, bg);
82
- const pick = (pairs: readonly (readonly [number, number])[]): InkSpec => pickSlot(palette.slots, pairs, dark, bg);
83
- const highlight = palette.highlight && isHex(palette.highlight.background) && isHex(palette.highlight.foreground) ? palette.highlight : undefined;
84
- return {
85
- background: { kind: "default" },
86
- text: { kind: "default" },
87
- selectionBg: { kind: "rgb", hex: highlight?.background ?? fg },
88
- selectionFg: { kind: "rgb", hex: highlight?.foreground ?? bg },
89
- muted: { kind: "rgb", hex: faintest(fg, bg, Math.min(MUTED.target, MUTED.share * c)) },
90
- edge: { kind: "rgb", hex: faintest(fg, bg, Math.min(EDGE.target, EDGE.share * c)) },
91
- line: { kind: "rgb", hex: faintest(fg, bg, Math.min(LINE.target, LINE.share * c)) },
92
- accent: pick(SLOTS.accent), success: pick(SLOTS.success), error: pick(SLOTS.error),
93
- warning: pick(SLOTS.warning), reasoning: pick(SLOTS.reasoning), code: pick(SLOTS.code),
94
- };
95
- }
96
-
97
- function fallbackInks(hint: "dark" | "light"): InkSet {
98
- const greys = FALLBACK_GREYS[hint];
99
- // Without a palette, a bright slot is the safer guess on dark grounds and the normal slot on light ones.
100
- const slot = (pair: readonly [number, number]): InkSpec => ({ kind: "index", index: hint === "dark" ? pair[1] : pair[0] });
101
- return {
102
- background: { kind: "default" }, text: { kind: "default" },
103
- // Inverse through the terminal's own black and white slots: its ground colours are unknown here.
104
- selectionBg: { kind: "index", index: hint === "dark" ? 15 : 0 }, selectionFg: { kind: "index", index: hint === "dark" ? 0 : 15 },
105
- muted: { kind: "rgb", hex: greys.muted }, edge: { kind: "rgb", hex: greys.edge }, line: { kind: "rgb", hex: greys.line },
106
- accent: slot(SLOTS.accent[0]!), success: slot(SLOTS.success[0]!), error: slot(SLOTS.error[0]!),
107
- warning: slot(SLOTS.warning[0]!), reasoning: slot(SLOTS.reasoning[0]!), code: slot(SLOTS.code[0]!),
108
- };
109
- }
110
-
111
- function pickSlot(slots: readonly string[], pairs: readonly (readonly [number, number])[], dark: boolean, bg: string): InkSpec {
112
- const candidates: number[] = [];
113
- for (const [normal, bright] of pairs) {
114
- const normalOk = saturation(slots[normal]!) > CHROMATIC;
115
- const brightOk = saturation(slots[bright]!) > CHROMATIC && sameHue(slots[bright]!, slots[normal]!);
116
- for (const index of dark ? [bright, normal] : [normal, bright]) if (index === bright ? brightOk : normalOk) candidates.push(index);
117
- }
118
- const readable = candidates.find(index => contrast(slots[index]!, bg) >= ACCENT_CONTRAST);
119
- if (readable !== undefined) return { kind: "index", index: readable };
120
- // Every candidate is too faint on this ground: keep the first hue, pushed away from the background.
121
- if (candidates.length) return { kind: "rgb", hex: deepen(slots[candidates[0]!]!, bg, ACCENT_CONTRAST) };
122
- // No chromatic slot at all (a monochrome palette): the terminal's own slot is still the honest choice.
123
- return { kind: "index", index: pairs[0]![0] };
124
- }
125
-
126
- // ----------------------------------------------------------------- colour math
127
-
128
- export function isHex(value: string): boolean { return /^#[0-9a-fA-F]{6}$/.test(value); }
129
-
130
- function channels(hex: string): [number, number, number] {
131
- const n = Number.parseInt(hex.slice(1), 16);
132
- return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
133
- }
134
-
135
- function toHex(values: readonly number[]): string {
136
- return `#${values.map(v => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0")).join("")}`;
137
- }
138
-
139
- /** WCAG relative luminance. */
140
- export function luminance(hex: string): number {
141
- const linear = (v: number) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; };
142
- const [r, g, b] = channels(hex);
143
- return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
144
- }
145
-
146
- /** WCAG contrast ratio, 1–21. */
147
- export function contrast(a: string, b: string): number {
148
- const x = luminance(a), y = luminance(b);
149
- return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
150
- }
151
-
152
- /** `share` of `from` over `to` (1 = from, 0 = to). */
153
- export function mix(from: string, to: string, share: number): string {
154
- const a = channels(from), b = channels(to);
155
- return toHex(a.map((v, i) => b[i]! + (v - b[i]!) * share));
156
- }
157
-
158
- export function saturation(hex: string): number {
159
- const values = channels(hex).map(v => v / 255);
160
- const max = Math.max(...values), min = Math.min(...values);
161
- return max === 0 ? 0 : (max - min) / max;
162
- }
163
-
164
- export function hue(hex: string): number {
165
- const [r, g, b] = channels(hex).map(v => v / 255) as [number, number, number];
166
- const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
167
- if (d === 0) return 0;
168
- const sector = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
169
- return (sector * 60 + 360) % 360;
170
- }
171
-
172
- export function sameHue(a: string, b: string): boolean {
173
- const d = Math.abs(hue(a) - hue(b));
174
- return Math.min(d, 360 - d) <= SAME_HUE_DEGREES;
175
- }
176
-
177
- /**
178
- * The blend of fg into bg closest to bg that still reaches `target` against
179
- * bg, in 1% steps. Never louder than fg itself: a channel-wise blend is not
180
- * monotone in luminance when fg and bg differ in hue, so an intermediate mix
181
- * can out-contrast fg, and a grey must not compete with body text.
182
- */
183
- export function faintest(fg: string, bg: string, target: number): string {
184
- const ceiling = contrast(fg, bg);
185
- if (ceiling <= target) return fg;
186
- for (let step = 1; step <= 100; step++) {
187
- const color = mix(fg, bg, step / 100);
188
- const c = contrast(color, bg);
189
- if (c >= target) return c <= ceiling ? color : fg;
190
- }
191
- return fg;
192
- }
193
-
194
- /**
195
- * Push a colour toward white (dark ground) or black (light ground) until it
196
- * reaches `target` against bg, in 5% steps. Mixing with white or black
197
- * preserves hue exactly, and luminance moves monotonically away from bg, so
198
- * a too-faint yellow becomes a darker yellow rather than some other colour.
199
- * Either extreme reaches at least 4.58:1 against any ground, so any target up
200
- * to that is always met.
201
- */
202
- export function deepen(color: string, bg: string, target: number): string {
203
- if (contrast(color, bg) >= target) return color;
204
- const extreme = contrast("#ffffff", bg) >= contrast("#000000", bg) ? "#ffffff" : "#000000";
205
- for (let step = 1; step <= 20; step++) {
206
- const next = mix(extreme, color, step / 20);
207
- if (contrast(next, bg) >= target) return next;
208
- }
209
- return extreme;
210
- }
@@ -1,42 +0,0 @@
1
- /**
2
- * What the message stream shows, as plain data. The app projects its log
3
- * into these (apps/ui/project.ts); the view never sees log events.
4
- *
5
- * `rev` changes whenever an item's content changes; equal id + rev means the
6
- * renderable can be reused untouched, which keeps long sessions cheap.
7
- */
8
- export type ToolStatus = "pending" | "ok" | "error";
9
-
10
- /** A picture the view may draw: its blob address, name and pixel size (from the log's ImageRef). */
11
- export interface ImageView { readonly sha256: string; readonly name: string; readonly width: number; readonly height: number;
12
- /** The blob a click opens when it differs from `sha256`: what was attached, before any reduction for the model. */
13
- readonly full?: string }
14
-
15
- export type ViewItem =
16
- | { readonly kind: "user"; readonly id: string; readonly rev: string; readonly text: string; readonly images?: readonly ImageView[]; readonly interrupted?: boolean }
17
- | { readonly kind: "assistant"; readonly id: string; readonly rev: string; readonly text: string; readonly streaming?: boolean }
18
- | { readonly kind: "thinking"; readonly id: string; readonly rev: string; readonly text: string; readonly streaming?: boolean; readonly label?: string }
19
- | { readonly kind: "tool"; readonly id: string; readonly rev: string; readonly name: string; readonly summary: string; readonly status: ToolStatus;
20
- /** Evidence shown right-aligned: "exit 0", "+3 −1", "12 lines". Never the model's own claim (D11). */
21
- readonly evidence?: string; readonly preview?: readonly string[]; readonly images?: readonly ImageView[]; readonly detail: (width: number) => string[] }
22
- | { readonly kind: "notice"; readonly id: string; readonly rev: string; readonly text: string; readonly tone?: "info" | "success" | "warning" | "error" }
23
- | { readonly kind: "segment"; readonly id: string; readonly rev: string; readonly label: string; readonly detail?: string }
24
- | { readonly kind: "lines"; readonly id: string; readonly rev: string; readonly render: (width: number) => string[] };
25
-
26
- /**
27
- * Pure: what the reader missed between two projections. `added` counts
28
- * items that did not exist before (a streaming reply growing is one
29
- * message, not one per delta); `changed` says whether anything already
30
- * shown changed. The session-start rule is chrome and never counts.
31
- */
32
- export function unseen(previous: readonly ViewItem[], next: readonly ViewItem[]): { added: number; changed: boolean } {
33
- const before = new Map(previous.map(item => [item.id, item.rev]));
34
- let added = 0, changed = false;
35
- for (const item of next) {
36
- if (item.id === "session-start") continue;
37
- const rev = before.get(item.id);
38
- if (rev === undefined) added++;
39
- else if (rev !== item.rev) changed = true;
40
- }
41
- return { added, changed };
42
- }
@@ -1,40 +0,0 @@
1
- /**
2
- * One key shape for the whole view. OpenTUI's KeyEvent is normalised into a
3
- * chord string in core/keymap's canonical order (ctrl+alt+shift+super+key)
4
- * so bindings parsed from settings compare by string equality.
5
- */
6
- import type { KeyEvent } from "@opentui/core";
7
-
8
- export interface KeyPress {
9
- readonly chord: string;
10
- readonly name: string;
11
- readonly ctrl: boolean;
12
- readonly alt: boolean;
13
- readonly shift: boolean;
14
- /** Printable text this key inserts, or "" for a control key. */
15
- readonly text: string;
16
- }
17
-
18
- const RENAME: Readonly<Record<string, string>> = { return: "enter", esc: "escape", " ": "space" };
19
-
20
- export function keyPress(event: Pick<KeyEvent, "name" | "ctrl" | "meta" | "shift" | "sequence"> & { option?: boolean; super?: boolean }): KeyPress {
21
- const raw = (event.name || "").toLowerCase();
22
- const name = RENAME[raw] ?? raw;
23
- const alt = Boolean(event.meta || event.option);
24
- const sup = Boolean(event.super);
25
- const printable = !event.ctrl && !alt && !sup && typeof event.sequence === "string" && event.sequence.length > 0 && !/[\x00-\x1f\x7f]/.test(event.sequence) && !event.sequence.startsWith("\x1b");
26
- const parts: string[] = [];
27
- if (event.ctrl) parts.push("ctrl");
28
- if (alt) parts.push("alt");
29
- // Shift on a printable character is already in the character itself.
30
- if (event.shift && !(printable && name.length === 1)) parts.push("shift");
31
- if (sup) parts.push("super");
32
- parts.push(name);
33
- return { chord: parts.join("+"), name, ctrl: Boolean(event.ctrl), alt, shift: Boolean(event.shift), text: printable ? event.sequence : "" };
34
- }
35
-
36
- export type Bindings = Readonly<Record<string, readonly string[]>>;
37
-
38
- export function bound(bindings: Bindings, action: string, key: KeyPress): boolean {
39
- return (bindings[action] ?? []).includes(key.chord);
40
- }
@@ -1,74 +0,0 @@
1
- /**
2
- * Where status lives at a given terminal size (docs/tasks/refactor-tui-dock-layout.md). Pure.
3
- *
4
- * sidebar ≥ 110 cols and ≥ 20 rows right column plus compact provider header
5
- * topbar 80–109 cols, or a wide but short terminal (≥ 18 rows) one row on top
6
- * bare everything else the input box's bottom edge only;
7
- * session and branch go to the terminal title
8
- *
9
- * The input box's bottom edge carries model and thinking in every tier, and
10
- * the context share wherever there is no sidebar to hold it. Every status
11
- * field has exactly one place per tier, so nothing is shown twice.
12
- *
13
- * The 110-column threshold is the author's choice (2026-10-06), set so the
14
- * main column keeps the 75 columns OpenCode keeps beside its 42-column sidebar
15
- * at its 121-column threshold (packages/tui routes/session/index.tsx).
16
- */
17
- import { assertNever } from "../exhaustive.ts";
18
-
19
- export type Tier = "sidebar" | "topbar" | "bare";
20
-
21
- export interface ScreenLayout {
22
- readonly tier: Tier;
23
- /** Columns of the sidebar including its separator; 0 when hidden. */
24
- readonly sidebarWidth: number;
25
- readonly mainWidth: number;
26
- readonly topbar: boolean;
27
- }
28
-
29
- export const SIDEBAR_MIN_COLUMNS = 110;
30
- export const SIDEBAR_MIN_ROWS = 20;
31
- export const TOPBAR_MIN_COLUMNS = 80;
32
- export const TOPBAR_MIN_ROWS = 18;
33
- /** The main column never gets narrower than this beside the sidebar. */
34
- export const MAIN_MIN_COLUMNS = 75;
35
-
36
- /** Sidebar content width (without the separator column). Wider terminals give it more room; assumed steps. */
37
- export function sidebarContentWidth(cols: number): number {
38
- return cols >= 150 ? 42 : cols >= 130 ? 38 : 34;
39
- }
40
-
41
- export function screenLayout(cols: number, rows: number, sidebarWanted = true): ScreenLayout {
42
- const c = Math.max(0, Math.floor(cols)), r = Math.max(0, Math.floor(rows));
43
- if (sidebarWanted && c >= SIDEBAR_MIN_COLUMNS && r >= SIDEBAR_MIN_ROWS) {
44
- const sidebarWidth = sidebarContentWidth(c) + 1;
45
- return { tier: "sidebar", sidebarWidth, mainWidth: c - sidebarWidth, topbar: true };
46
- }
47
- if (c >= TOPBAR_MIN_COLUMNS && r >= TOPBAR_MIN_ROWS) return { tier: "topbar", sidebarWidth: 0, mainWidth: c, topbar: true };
48
- return { tier: "bare", sidebarWidth: 0, mainWidth: c, topbar: false };
49
- }
50
-
51
- export const STATUS_FIELDS = ["mode", "provider", "activity", "git", "model", "thinking", "context", "goal", "title", "workspace", "branch", "usage", "cost", "handoff", "tasks", "files", "background", "resources", "extension"] as const;
52
- export type StatusField = (typeof STATUS_FIELDS)[number];
53
- /** Screen surfaces only; the terminal title is outside the frame and listed separately. */
54
- export type Place = "sidebar" | "topbar" | "topEdge" | "bottomEdge" | "hidden";
55
-
56
- /**
57
- * The one place each field takes in a tier. Hidden fields stay one command
58
- * away (/usage, /state, /tasks, /session); the terminal title also carries
59
- * session and branch in every tier, outside the frame.
60
- */
61
- export function placement(tier: Tier): Readonly<Record<StatusField, Place>> {
62
- switch (tier) {
63
- case "sidebar":
64
- return { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "sidebar", model: "bottomEdge", thinking: "bottomEdge", context: "sidebar", goal: "sidebar", title: "sidebar", workspace: "sidebar", branch: "sidebar",
65
- usage: "sidebar", cost: "sidebar", handoff: "sidebar", tasks: "sidebar", files: "sidebar", background: "sidebar", resources: "sidebar", extension: "sidebar" };
66
- case "topbar":
67
- return { mode: "bottomEdge", provider: "topbar", activity: "topEdge", git: "hidden", model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", goal: "bottomEdge", title: "topbar", workspace: "topbar", branch: "topbar",
68
- usage: "hidden", cost: "topbar", handoff: "topbar", tasks: "hidden", files: "hidden", background: "topbar", resources: "hidden", extension: "topbar" };
69
- case "bare":
70
- return { mode: "bottomEdge", provider: "hidden", activity: "topEdge", git: "hidden", model: "bottomEdge", thinking: "bottomEdge", context: "bottomEdge", goal: "bottomEdge", title: "hidden", workspace: "hidden", branch: "hidden",
71
- usage: "hidden", cost: "hidden", handoff: "hidden", tasks: "hidden", files: "hidden", background: "hidden", resources: "hidden", extension: "hidden" };
72
- default: return assertNever(tier, "placement");
73
- }
74
- }
@@ -1,86 +0,0 @@
1
- /**
2
- * What a list dialog shows for a query. Pure.
3
- *
4
- * Every picker in the UI (palette, model, login, resume, tree, settings, …)
5
- * is the same shape: items grouped into sections, a fuzzy query, one
6
- * selected row, optional checkboxes. One model means one set of keys and
7
- * one place where "Enter confirms the row under the cursor" is proven.
8
- *
9
- * With an empty query, items keep their given order and each new section
10
- * gets a header row (OpenCode's dialog-model). With a query, sections
11
- * flatten into one ranked list: grouping a three-hit search only adds noise.
12
- */
13
- import { fuzzyMatch } from "../fuzzy.ts";
14
-
15
- export interface ListItem {
16
- readonly value: string;
17
- readonly label: string;
18
- readonly description?: string;
19
- readonly section?: string;
20
- /** Right-aligned hint: a key chord, "current", a count. */
21
- readonly hint?: string;
22
- /** Extra text matched by the query but not shown. */
23
- readonly keywords?: string;
24
- readonly disabled?: boolean;
25
- /** Indentation (tree rows). */
26
- readonly depth?: number;
27
- }
28
-
29
- export type ListRow =
30
- | { readonly kind: "header"; readonly text: string }
31
- | { readonly kind: "item"; readonly item: ListItem; readonly index: number };
32
-
33
- export function filterItems(items: readonly ListItem[], query: string): ListItem[] {
34
- const q = query.trim();
35
- if (!q) return [...items];
36
- const ranked: { item: ListItem; score: number; index: number }[] = [];
37
- items.forEach((item, index) => {
38
- const label = fuzzyMatch(item.label, q);
39
- const rest = fuzzyMatch(`${item.label} ${item.description ?? ""} ${item.section ?? ""} ${item.keywords ?? ""}`, q);
40
- // A hit in the label outranks a hit that needed the description.
41
- const score = label !== null ? label : rest !== null ? rest + 1000 : null;
42
- if (score !== null) ranked.push({ item, score, index });
43
- });
44
- ranked.sort((a, b) => a.score - b.score || a.index - b.index);
45
- return ranked.map(entry => entry.item);
46
- }
47
-
48
- export function listRows(items: readonly ListItem[], query: string): ListRow[] {
49
- const filtered = filterItems(items, query);
50
- const rows: ListRow[] = [];
51
- let section: string | undefined;
52
- const grouped = !query.trim();
53
- filtered.forEach((item, index) => {
54
- if (grouped && item.section && item.section !== section) rows.push({ kind: "header", text: item.section });
55
- section = item.section;
56
- rows.push({ kind: "item", item, index });
57
- });
58
- return rows;
59
- }
60
-
61
- /** Next selectable index (wrapping), skipping disabled items. -1 when none. */
62
- export function step(items: readonly ListItem[], from: number, delta: number): number {
63
- const n = items.length;
64
- if (n === 0) return -1;
65
- for (let k = 1; k <= n; k++) {
66
- const candidate = (((from + delta * k) % n) + n) % n;
67
- if (!items[candidate]!.disabled) return candidate;
68
- }
69
- return -1;
70
- }
71
-
72
- /** First enabled index at or after `from`, used when a query changes the list. */
73
- export function firstEnabled(items: readonly ListItem[], from = 0): number {
74
- for (let i = Math.max(0, from); i < items.length; i++) if (!items[i]!.disabled) return i;
75
- for (let i = 0; i < Math.min(from, items.length); i++) if (!items[i]!.disabled) return i;
76
- return -1;
77
- }
78
-
79
- /** Rows to draw so the selected item stays visible, keeping its header when it fits. */
80
- export function windowRows(rows: readonly ListRow[], selected: number, height: number): { start: number; end: number } {
81
- if (rows.length <= height) return { start: 0, end: rows.length };
82
- const at = Math.max(0, rows.findIndex(row => row.kind === "item" && row.index === selected));
83
- let start = Math.max(0, Math.min(at - Math.floor(height / 2), rows.length - height));
84
- if (height > 1 && start > 0 && rows[at - 1]?.kind === "header" && at - 1 < start) start = at - 1;
85
- return { start, end: Math.min(rows.length, start + height) };
86
- }
@@ -1,172 +0,0 @@
1
- /**
2
- * The one list component: grouped rows, selection, optional checkboxes,
3
- * right-aligned hints, and a search field on the bottom row — the row the
4
- * draft occupies when no picker is open, so the input box keeps its shape
5
- * (docs/tasks/refactor-tui-dock-layout.md). State lives in list-model.ts
6
- * (pure); this class draws it and exposes the moves the dialog host routes.
7
- *
8
- * Rows are a Canvas: moving the selection repaints cells, not nodes. The
9
- * search text is a focused Input so cursor, paste and word deletion stay
10
- * OpenTUI's.
11
- */
12
- import { BoxRenderable, InputRenderable, InputRenderableEvents, type RenderContext } from "@opentui/core";
13
- import { Canvas } from "./canvas.ts";
14
- import type { UiColors } from "./colors.ts";
15
- import { clipText, segsWidth, splitLine, type Seg } from "./edge.ts";
16
- import { filterItems, firstEnabled, listRows, step, windowRows, type ListItem, type ListRow } from "./list-model.ts";
17
- import type { Line } from "./status.ts";
18
-
19
- export interface ListViewOptions {
20
- readonly colors: UiColors;
21
- readonly rows?: number;
22
- readonly search?: boolean;
23
- readonly multi?: boolean;
24
- readonly placeholder?: string;
25
- readonly empty?: string;
26
- }
27
-
28
- /** Visible rows. Ten keeps a picker under half of a 30-row terminal. Assumed. */
29
- const DEFAULT_ROWS = 10;
30
- let serial = 0;
31
-
32
- export class ListView extends BoxRenderable {
33
- private colors: UiColors;
34
- private maxRows: number;
35
- private readonly multi: boolean;
36
- private readonly emptyText: string;
37
- readonly searchInput: InputRenderable | undefined;
38
- private readonly searchRow: BoxRenderable | undefined;
39
- private readonly searchGlyph: Canvas | undefined;
40
- private readonly body: Canvas;
41
- private items: ListItem[] = [];
42
- private filtered: ListItem[] = [];
43
- private rowsModel: ListRow[] = [];
44
- private selected = -1;
45
- readonly checked = new Set<string>();
46
- onChange: (() => void) | undefined;
47
-
48
- constructor(ctx: RenderContext, options: ListViewOptions) {
49
- const id = `list-${serial++}`;
50
- super(ctx, { id, flexDirection: "column", flexShrink: 0 });
51
- this.colors = options.colors;
52
- this.maxRows = options.rows ?? DEFAULT_ROWS;
53
- this.multi = options.multi ?? false;
54
- this.emptyText = options.empty ?? "No matches";
55
- this.body = new Canvas(ctx, { id: `${id}-rows`, height: 1, flexShrink: 0, colors: this.colors });
56
- this.add(this.body);
57
- if (options.search !== false) {
58
- this.searchRow = new BoxRenderable(ctx, { id: `${id}-search`, flexDirection: "row", height: 1, marginTop: 1, flexShrink: 0 });
59
- this.searchGlyph = new Canvas(ctx, { id: `${id}-glyph`, width: 2, height: 1, flexShrink: 0, colors: this.colors });
60
- this.searchGlyph.setRows([[{ text: "›", role: "accent", bold: true }]]);
61
- this.searchInput = new InputRenderable(ctx, { id: `${id}-input`, flexGrow: 1, placeholder: options.placeholder ?? "Search",
62
- textColor: this.colors.text, focusedTextColor: this.colors.text, placeholderColor: this.colors.muted, backgroundColor: "transparent", focusedBackgroundColor: "transparent" });
63
- this.searchInput.on(InputRenderableEvents.INPUT, () => this.refilter());
64
- this.searchRow.add(this.searchGlyph); this.searchRow.add(this.searchInput);
65
- this.add(this.searchRow);
66
- }
67
- this.onSizeChange = () => this.paint();
68
- }
69
-
70
- get query(): string { return this.searchInput?.value ?? ""; }
71
- get current(): ListItem | undefined { return this.selected >= 0 ? this.filtered[this.selected] : undefined; }
72
- get visibleItems(): readonly ListItem[] { return this.filtered; }
73
- /** "3/40" when the list scrolls, for the box's top edge; "" when everything is visible. */
74
- get scrollPosition(): string { return this.rowsModel.length > this.maxRows && this.selected >= 0 ? `${this.selected + 1}/${this.filtered.length}` : ""; }
75
- /** Plain text of the drawn rows (tests). */
76
- get text(): string { return this.body.text; }
77
-
78
- setColors(colors: UiColors): void {
79
- this.colors = colors;
80
- this.body.setColors(colors);
81
- this.searchGlyph?.setColors(colors);
82
- if (this.searchInput) {
83
- this.searchInput.textColor = colors.text;
84
- this.searchInput.focusedTextColor = colors.text;
85
- this.searchInput.placeholderColor = colors.muted;
86
- }
87
- }
88
-
89
- setRowLimit(rows: number): void {
90
- const limit = Math.max(1, rows);
91
- if (limit === this.maxRows) return;
92
- this.maxRows = limit; this.paint();
93
- }
94
-
95
- setItems(items: readonly ListItem[], selectedValue?: string): void {
96
- const keep = selectedValue ?? this.current?.value;
97
- this.items = [...items];
98
- this.refilter(keep);
99
- }
100
-
101
- setQuery(query: string): void {
102
- if (this.searchInput) this.searchInput.value = query;
103
- this.refilter();
104
- }
105
-
106
- move(delta: number): void {
107
- const next = step(this.filtered, this.selected, delta);
108
- if (next >= 0) this.selected = next;
109
- this.paint();
110
- }
111
-
112
- page(delta: number): void {
113
- const target = Math.max(0, Math.min(this.filtered.length - 1, this.selected + delta * this.maxRows));
114
- this.selected = firstEnabled(this.filtered, target);
115
- this.paint();
116
- }
117
-
118
- toggle(): void {
119
- const item = this.current;
120
- if (!this.multi || !item || item.disabled) return;
121
- if (this.checked.has(item.value)) this.checked.delete(item.value); else this.checked.add(item.value);
122
- this.paint();
123
- }
124
-
125
- toggleAllVisible(): void {
126
- if (!this.multi) return;
127
- const enabled = this.filtered.filter(item => !item.disabled);
128
- const all = enabled.every(item => this.checked.has(item.value));
129
- for (const item of enabled) { if (all) this.checked.delete(item.value); else this.checked.add(item.value); }
130
- this.paint();
131
- }
132
-
133
- private refilter(keep?: string): void {
134
- this.filtered = filterItems(this.items, this.query);
135
- this.rowsModel = listRows(this.items, this.query);
136
- const at = keep === undefined ? -1 : this.filtered.findIndex(item => item.value === keep);
137
- this.selected = at >= 0 && !this.filtered[at]!.disabled ? at : firstEnabled(this.filtered);
138
- this.paint();
139
- }
140
-
141
- private paint(): void {
142
- const width = Math.max(10, this.width || 60);
143
- const lines: Line[] = [];
144
- if (this.rowsModel.length === 0) lines.push([{ text: this.emptyText, role: "muted" }]);
145
- else {
146
- const { start, end } = windowRows(this.rowsModel, this.selected, this.maxRows);
147
- for (let r = start; r < end; r++) lines.push(this.line(this.rowsModel[r]!, width));
148
- }
149
- this.body.height = lines.length;
150
- this.body.setRows(lines);
151
- this.onChange?.();
152
- }
153
-
154
- private line(row: ListRow, width: number): Line {
155
- if (row.kind === "header") return [{ text: row.text.toUpperCase(), role: "muted", bold: true }];
156
- const item = row.item, selected = row.index === this.selected;
157
- const lead: Seg[] = [
158
- { text: selected ? "▸ " : " ", role: "accent", bold: true },
159
- ...(this.multi ? [{ text: this.checked.has(item.value) ? "◉ " : "○ ", role: this.checked.has(item.value) ? "success" as const : "muted" as const }] : []),
160
- ...(item.depth ? [{ text: " ".repeat(item.depth), role: "text" as const }] : []),
161
- ];
162
- const label: Seg = { text: item.label, role: item.disabled ? "muted" : "text", bold: selected };
163
- // Keep the actionable identity before optional detail on narrow boxes.
164
- const detail: Seg[] = item.description && width >= 60 ? [{ text: ` ${item.description}`, role: "muted" }] : [];
165
- const hint: Seg[] = item.hint ? [{ text: item.hint, role: "muted" }] : [];
166
- if (!hint.length) return [...lead, label, ...detail];
167
- const room = Math.max(4, width - segsWidth(hint) - 1);
168
- const left = [...lead, label, ...detail];
169
- const fitted = segsWidth(left) <= room ? left : [...lead, { ...label, text: clipText(label.text, Math.max(1, room - segsWidth(lead))) }];
170
- return splitLine(width, [{ segs: fitted, priority: 0 }], [{ segs: hint, priority: 1 }], 1);
171
- }
172
- }