@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
@@ -1,398 +0,0 @@
1
- /**
2
- * Dialogs open inside the input box, stacked (docs/tasks/refactor-tui-dock-layout.md).
3
- * Escape pops one level (back); popping the last returns focus to the editor.
4
- *
5
- * The box keeps its place at the bottom and grows upward; the dialog's
6
- * title and any non-obvious keys go into the box's top edge (`title`,
7
- * `edgeHint`), so there is no separate frame or hint row. Every picker and
8
- * form in the app is one of these kinds:
9
- * list grouped rows (+ checkboxes), search on the input row, action keys
10
- * form one text field; `secret` keeps the value out of every renderable
11
- * confirm yes / no
12
- * message read-only lines (ANSI allowed)
13
- * busy the activity glyph while the app awaits IO
14
- * lines an app- or extension-rendered block with its own key handler
15
- */
16
- import { BoxRenderable, InputRenderable, TextRenderable, type CliRenderer, type Renderable } from "@opentui/core";
17
- import { Canvas } from "./canvas.ts";
18
- import type { UiColors } from "./colors.ts";
19
- import { bound, type Bindings, type KeyPress } from "./keys.ts";
20
- import type { ListItem } from "./list-model.ts";
21
- import { ListView } from "./list-view.ts";
22
- import { ansiText, parts } from "./styled.ts";
23
-
24
- export interface ListAction { readonly key: string; readonly label: string; run(item: ListItem | undefined, dialog: ListHandle): void | Promise<void> }
25
- export interface ListHandle { setItems(items: readonly ListItem[], selected?: string): void; readonly current: ListItem | undefined; readonly checked: ReadonlySet<string>; readonly query: string }
26
-
27
- export type DialogSpec =
28
- | { kind: "list"; title: string; items: readonly ListItem[]; selected?: string; multi?: boolean; checked?: readonly string[]; search?: boolean; placeholder?: string; empty?: string; rows?: number;
29
- /** Lines above the list (approval command, context). ANSI allowed. */
30
- intro?: readonly string[];
31
- /** Stay on the stack after a pick, so a drill-down's Esc returns here. */
32
- keep?: boolean;
33
- /** The box border colour: "warning" for decisions that run something (approval). */
34
- tone?: "warning";
35
- hint?: string; actions?: readonly ListAction[]; onPick?(item: ListItem, dialog: ListHandle): void | Promise<void>;
36
- onSubmitChecked?(values: string[]): void | Promise<void>; onMove?(item: ListItem | undefined): void; onCancel?(): void }
37
- | { kind: "form"; title: string; label: string; value?: string; secret?: boolean; hint?: string; intro?: readonly string[]; keep?: boolean; validate?(value: string): string | undefined; onSubmit(value: string): void | Promise<void>; onCancel?(): void }
38
- | { kind: "confirm"; title: string; message: string; yes?: string; no?: string; onAnswer(yes: boolean): void | Promise<void> }
39
- | { kind: "message"; title: string; lines: readonly string[]; onDone?(): void | Promise<void> }
40
- | { kind: "busy"; title: string; message: string; onCancel?(): void }
41
- | { kind: "lines"; title: string; render(width: number): string[]; handleKey?(key: KeyPress): boolean | Promise<boolean>; hint?: string; onCancel?(): void };
42
-
43
- /**
44
- * One stacked dialog. `list` and `row` (a plain form's label + Input) are
45
- * persistent: built once, they carry typed text and cursor across repaints,
46
- * and `show()` only detaches them. Everything else a repaint builds is
47
- * ephemeral and destroyed recursively on the next one, so a persistent node
48
- * must never sit inside an ephemeral parent -- that destroyed the form's
49
- * Input on the first validation error and every later key threw
50
- * (docs/tasks/fix-dialog-form-and-grok-version.md).
51
- */
52
- interface Open {
53
- spec: DialogSpec;
54
- list?: ListView;
55
- row?: BoxRenderable;
56
- label?: TextRenderable;
57
- input?: InputRenderable;
58
- secret?: string;
59
- error?: string | undefined;
60
- yes?: boolean;
61
- }
62
-
63
- /**
64
- * Hint clauses everyone already knows (Enter confirms, Esc goes back, arrows
65
- * move). Only the rest earns a place on the edge (refactor-tui-chrome-cleanup).
66
- */
67
- const OBVIOUS = /^(enter|esc|↑↓|←→|↑|↓)\b/;
68
- export function edgeHint(hint: string | undefined): string {
69
- return (hint ?? "").split(" · ").map(clause => clause.trim()).filter(clause => clause && !OBVIOUS.test(clause)).join(" · ");
70
- }
71
-
72
- let serial = 0;
73
-
74
- export class DialogHost {
75
- private readonly renderer: CliRenderer;
76
- private colors: UiColors;
77
- private readonly bindings: () => Bindings;
78
- private readonly slot: BoxRenderable;
79
- private readonly availableRows: () => number;
80
- private readonly glyph: () => string;
81
- private stack: Open[] = [];
82
- private readonly work = new Set<Promise<unknown>>();
83
- /** Nodes built for the current view; persistent ones (lists, inputs) live on their Open. */
84
- private ephemeral: Renderable[] = [];
85
- private shownTitle = "";
86
- private shownHint = "";
87
- onOpened: (() => void) | undefined;
88
- /** Called when the stack becomes empty (focus returns to the editor). */
89
- onClosed: (() => void) | undefined;
90
- /** Title, hint or tone changed: the box edge repaints. */
91
- onChrome: (() => void) | undefined;
92
- /** Errors thrown by dialog callbacks, surfaced by the app as notices. */
93
- onError: ((error: unknown) => void) | undefined;
94
-
95
- constructor(renderer: CliRenderer, colors: UiColors, bindings: () => Bindings, slot: BoxRenderable, availableRows: () => number, glyph: () => string = () => "●") {
96
- this.renderer = renderer;
97
- this.colors = colors;
98
- this.bindings = bindings;
99
- this.slot = slot;
100
- this.availableRows = availableRows;
101
- this.glyph = glyph;
102
- }
103
-
104
- get open(): boolean { return this.stack.length > 0; }
105
- get busy(): boolean { return this.work.size > 0; }
106
- /** Resolves when every callback started by a key has finished (tests, shutdown). */
107
- async idle(): Promise<void> { while (this.work.size) await Promise.all([...this.work]); }
108
- get depth(): number { return this.stack.length; }
109
- get top(): DialogSpec | undefined { return this.stack.at(-1)?.spec; }
110
- /** The list of the top dialog, for callers that update it after async loads. */
111
- get list(): ListHandle | undefined { return this.stack.at(-1)?.list; }
112
- /** The top list's view (tests read its drawn rows). */
113
- get listView(): ListView | undefined { return this.stack.at(-1)?.list; }
114
- /** What the box's top edge shows while a dialog is open. */
115
- get title(): string { return this.shownTitle; }
116
- get edgeHint(): string { return this.shownHint; }
117
- get tone(): "warning" | undefined { const spec = this.top; return spec?.kind === "list" ? spec.tone : undefined; }
118
- /** A busy dialog shows the activity glyph, which needs the clock running. */
119
- get animated(): boolean { return this.top?.kind === "busy"; }
120
-
121
- setColors(colors: UiColors): void {
122
- this.colors = colors;
123
- for (const open of this.stack) {
124
- open.list?.setColors(colors);
125
- if (open.input) { open.input.textColor = colors.text; open.input.focusedTextColor = colors.text; open.input.placeholderColor = colors.muted; }
126
- if (open.label && open.spec.kind === "form") open.label.content = this.formLabel(open.spec.label);
127
- }
128
- if (this.open) this.show();
129
- }
130
-
131
- dispose(): void { this.clearView(); }
132
-
133
- push(spec: DialogSpec, replace = false): void {
134
- if (replace) this.stack.pop();
135
- this.stack.push(this.build(spec));
136
- this.onOpened?.();
137
- this.show();
138
- }
139
-
140
- /** Replace the whole stack (e.g. after a save there is nothing to go back to). */
141
- reset(spec: DialogSpec): void { this.stack = []; this.push(spec); }
142
-
143
- pop(): void {
144
- const top = this.stack.pop();
145
- if (top) {
146
- const spec = top.spec;
147
- if ((spec.kind === "list" || spec.kind === "form" || spec.kind === "busy" || spec.kind === "lines") && spec.onCancel) this.guard(() => spec.onCancel!());
148
- if (spec.kind === "confirm") this.guard(() => spec.onAnswer(false));
149
- }
150
- this.show();
151
- }
152
-
153
- /** Remove one dialog by its spec without running its callbacks (an extension request that timed out). */
154
- remove(spec: DialogSpec): void {
155
- const at = this.stack.findIndex(open => open.spec === spec);
156
- if (at < 0) return;
157
- this.stack.splice(at, 1);
158
- this.show();
159
- }
160
-
161
- /**
162
- * Close everything (session switch, shutdown). Cancel callbacks run, top
163
- * first, so anything awaiting an answer resolves as "cancelled" instead of
164
- * hanging forever.
165
- */
166
- clear(): void {
167
- const open = this.stack.reverse();
168
- this.stack = [];
169
- for (const entry of open) {
170
- const spec = entry.spec;
171
- if ((spec.kind === "list" || spec.kind === "form" || spec.kind === "busy" || spec.kind === "lines") && spec.onCancel) void this.guard(() => spec.onCancel!());
172
- if (spec.kind === "confirm") void this.guard(() => spec.onAnswer(false));
173
- }
174
- this.show();
175
- }
176
-
177
- /** Rebuild the top dialog's view (after its spec's data changed). */
178
- refresh(): void { if (this.open) this.show(); }
179
-
180
- private build(spec: DialogSpec): Open {
181
- const open: Open = { spec };
182
- if (spec.kind === "list") {
183
- const list = new ListView(this.renderer, { colors: this.colors, ...(spec.rows ? { rows: spec.rows } : {}), search: spec.search !== false, multi: spec.multi ?? false,
184
- ...(spec.placeholder ? { placeholder: spec.placeholder } : {}), ...(spec.empty ? { empty: spec.empty } : {}) });
185
- for (const value of spec.checked ?? []) list.checked.add(value);
186
- list.setItems(spec.items, spec.selected);
187
- list.onChange = () => { spec.onMove?.(list.current); this.paintChrome(); };
188
- open.list = list;
189
- }
190
- if (spec.kind === "form" && !spec.secret) {
191
- open.input = new InputRenderable(this.renderer, { id: `dialog-input-${serial++}`, value: spec.value ?? "", textColor: this.colors.text,
192
- focusedTextColor: this.colors.text, backgroundColor: "transparent", focusedBackgroundColor: "transparent", flexGrow: 1 });
193
- // Prefilled values are edited from their end (a URL gets a path appended).
194
- open.input.gotoBufferEnd();
195
- open.row = new BoxRenderable(this.renderer, { id: `form-row-${serial++}`, flexDirection: "row", height: 1 });
196
- open.label = new TextRenderable(this.renderer, { id: `form-label-${serial++}`, content: this.formLabel(spec.label), flexShrink: 0 });
197
- open.row.add(open.label);
198
- open.row.add(open.input);
199
- }
200
- if (spec.kind === "form" && spec.secret) open.secret = "";
201
- if (spec.kind === "confirm") open.yes = true;
202
- return open;
203
- }
204
-
205
- /** Re-fit the top list to the rows the screen can spare. */
206
- layout(): void {
207
- const top = this.stack.at(-1);
208
- if (top?.spec.kind !== "list") return;
209
- const overhead = (top.spec.search === false ? 0 : 2) + (top.spec.intro?.length ? introRows(top.spec.intro) + 1 : 0);
210
- top.list?.setRowLimit(Math.min(top.spec.rows ?? 10, Math.max(1, this.availableRows() - overhead)));
211
- }
212
-
213
- private clearView(): void {
214
- for (const child of this.slot.getChildren()) this.slot.remove(child);
215
- for (const node of this.ephemeral) node.destroyRecursively();
216
- this.ephemeral = [];
217
- }
218
-
219
- private add(node: Renderable): void { this.ephemeral.push(node); this.slot.add(node); }
220
-
221
- private show(): void {
222
- this.clearView();
223
- const top = this.stack.at(-1);
224
- if (!top) {
225
- this.slot.visible = false;
226
- this.shownTitle = ""; this.shownHint = "";
227
- this.onClosed?.();
228
- this.onChrome?.();
229
- this.renderer.requestRender();
230
- return;
231
- }
232
- this.slot.visible = true;
233
- const c = this.colors, spec = top.spec;
234
- const text = (content: ReturnType<typeof parts>, extra: Record<string, unknown> = {}) => new TextRenderable(this.renderer, { id: `dialog-text-${serial++}`, content, wrapMode: "word", ...extra });
235
- const canvas = (rows: Parameters<Canvas["setRows"]>[0]) => { const node = new Canvas(this.renderer, { id: `dialog-canvas-${serial++}`, height: rows.length, colors: c, glyph: this.glyph }); node.setRows(rows); return node; };
236
- switch (spec.kind) {
237
- case "list": {
238
- if (spec.intro?.length) this.add(text(ansiText(spec.intro), { marginBottom: 1 }));
239
- this.slot.add(top.list!);
240
- this.layout();
241
- top.list!.searchInput?.focus();
242
- if (!top.list!.searchInput) this.renderer.currentFocusedRenderable?.blur();
243
- break;
244
- }
245
- case "form": {
246
- if (spec.intro?.length) this.add(text(ansiText(spec.intro), { marginBottom: 1 }));
247
- if (top.row) { this.slot.add(top.row); top.input!.focus(); }
248
- else {
249
- // The secret never reaches a renderable: only its length does. The
250
- // whole row is rebuilt per repaint, so it holds nothing persistent.
251
- const row = new BoxRenderable(this.renderer, { id: `form-row-${serial++}`, flexDirection: "row", height: 1 });
252
- row.add(new TextRenderable(this.renderer, { id: `form-label-${serial++}`, content: this.formLabel(spec.label), flexShrink: 0 }));
253
- const mask = canvas([[{ text: "•".repeat(Math.min(top.secret!.length, 48)), role: "text" }, { text: "▏", role: "accent" }]]);
254
- mask.flexGrow = 1;
255
- row.add(mask);
256
- this.add(row);
257
- this.renderer.currentFocusedRenderable?.blur();
258
- }
259
- if (top.error) this.add(text(parts({ text: top.error, fg: c.error })));
260
- break;
261
- }
262
- case "confirm":
263
- this.add(text(parts({ text: spec.message, fg: c.text })));
264
- // The choice is marked by shape and weight; nothing is painted behind it.
265
- this.add(canvas([[
266
- { text: top.yes ? "▸ " : " ", role: "accent", bold: true }, { text: spec.yes ?? "Yes", role: top.yes ? "accent" : "text", bold: top.yes === true },
267
- { text: " ", role: "text" },
268
- { text: !top.yes ? "▸ " : " ", role: "accent", bold: true }, { text: spec.no ?? "No", role: !top.yes ? "accent" : "text", bold: top.yes !== true },
269
- ]]));
270
- this.renderer.currentFocusedRenderable?.blur();
271
- break;
272
- case "message":
273
- this.add(text(ansiText(spec.lines)));
274
- this.renderer.currentFocusedRenderable?.blur();
275
- break;
276
- case "busy":
277
- this.add(canvas([[{ text: "●", role: "accent", spin: true }, { text: ` ${spec.message.split("\n")[0] ?? ""}`, role: "text" }]]));
278
- if (spec.message.includes("\n")) this.add(text(parts({ text: spec.message.split("\n").slice(1).join("\n"), fg: c.muted })));
279
- this.renderer.currentFocusedRenderable?.blur();
280
- break;
281
- case "lines": {
282
- const width = Math.max(10, this.renderer.width - 8);
283
- this.add(text(ansiText(this.guardRender(() => spec.render(width))), { wrapMode: "none" }));
284
- this.renderer.currentFocusedRenderable?.blur();
285
- break;
286
- }
287
- }
288
- this.paintChrome();
289
- this.renderer.requestRender();
290
- }
291
-
292
- private formLabel(label: string): ReturnType<typeof parts> { return parts({ text: `${label} › `, fg: this.colors.muted }); }
293
-
294
- private paintChrome(): void {
295
- const top = this.stack.at(-1);
296
- const spec = top?.spec;
297
- let hint = "";
298
- if (spec?.kind === "list") {
299
- const keys = (spec.actions ?? []).map(action => `${action.key} ${action.label}`);
300
- hint = [edgeHint(spec.hint), ...(spec.hint ? [] : [...(spec.multi ? ["space toggle"] : []), ...keys]), top!.list!.scrollPosition].filter(Boolean).join(" · ");
301
- } else if (spec?.kind === "form" || spec?.kind === "lines") hint = edgeHint(spec.hint);
302
- const title = spec?.title ?? "";
303
- if (title === this.shownTitle && hint === this.shownHint) return;
304
- this.shownTitle = title; this.shownHint = hint;
305
- this.onChrome?.();
306
- }
307
-
308
- /** Paste goes to the focused Input natively; a secret form takes it here. */
309
- paste(text: string): boolean {
310
- const top = this.stack.at(-1);
311
- if (!top) return false;
312
- if (top.secret !== undefined) { top.secret += text.replace(/[\r\n]+/g, ""); top.error = undefined; this.show(); return true; }
313
- return top.input !== undefined || top.list?.searchInput !== undefined;
314
- }
315
-
316
- /** Returns true when the key was consumed (the caller then stops propagation). Synchronous so
317
- * the caller can preventDefault inside the key event; callbacks run as tracked work. */
318
- key(key: KeyPress): boolean {
319
- const top = this.stack.at(-1);
320
- if (!top) return false;
321
- const b = this.bindings();
322
- const spec = top.spec;
323
- const run = (action: () => unknown) => { const job = this.guard(action).finally(() => { this.work.delete(job); this.refreshIfTop(top); }); this.work.add(job); };
324
- const leave = () => { const at = this.stack.indexOf(top); if (at >= 0) this.stack.splice(at, 1); this.show(); };
325
- if (bound(b, "list.cancel", key) || key.chord === "escape") { this.pop(); return true; }
326
- switch (spec.kind) {
327
- case "list": {
328
- const list = top.list!;
329
- if (bound(b, "list.up", key)) { list.move(-1); return true; }
330
- if (bound(b, "list.down", key)) { list.move(1); return true; }
331
- if (bound(b, "list.pageUp", key)) { list.page(-1); return true; }
332
- if (bound(b, "list.pageDown", key)) { list.page(1); return true; }
333
- if (spec.multi && bound(b, "list.toggle", key)) { list.toggle(); return true; }
334
- if (spec.multi && key.chord === "ctrl+a") { list.toggleAllVisible(); return true; }
335
- const action = spec.actions?.find(entry => entry.key === key.chord);
336
- if (action) { run(() => action.run(list.current, list)); return true; }
337
- if (bound(b, "list.confirm", key)) {
338
- if (spec.multi) { const values = [...list.checked]; if (!spec.keep) leave(); run(() => spec.onSubmitChecked?.(values)); return true; }
339
- const item = list.current;
340
- // The dialog leaves the stack before its callback runs, so a callback
341
- // that opens another dialog (or closes everything) wins.
342
- if (item && !item.disabled) { if (!spec.keep) leave(); run(() => spec.onPick?.(item, list)); }
343
- return true;
344
- }
345
- return false; // printable keys reach the focused search Input
346
- }
347
- case "form": {
348
- if (key.chord === "enter") {
349
- const value = (top.input ? top.input.value : top.secret!).trim();
350
- const error = spec.validate?.(value);
351
- if (error) { top.error = error; this.show(); return true; }
352
- if (!spec.keep) leave();
353
- run(() => spec.onSubmit(value));
354
- return true;
355
- }
356
- if (top.secret !== undefined) {
357
- if (key.chord === "backspace") top.secret = top.secret.slice(0, -1);
358
- else if (key.chord === "ctrl+u") top.secret = "";
359
- else if (key.text) top.secret += key.text;
360
- else return true;
361
- top.error = undefined;
362
- this.show();
363
- return true;
364
- }
365
- if (top.error) { top.error = undefined; this.show(); }
366
- return false;
367
- }
368
- case "confirm":
369
- if (["left", "right", "tab", "h", "l"].includes(key.chord)) { top.yes = !top.yes; this.show(); return true; }
370
- if (key.chord === "y" || key.chord === "n" || key.chord === "enter") {
371
- const yes = key.chord === "enter" ? top.yes! : key.chord === "y";
372
- leave();
373
- run(() => spec.onAnswer(yes));
374
- }
375
- return true;
376
- case "message":
377
- if (key.chord === "enter") { leave(); run(() => spec.onDone?.()); }
378
- return true;
379
- case "busy":
380
- return true;
381
- case "lines":
382
- if (spec.handleKey) run(() => spec.handleKey!(key));
383
- return true;
384
- }
385
- }
386
-
387
- private refreshIfTop(open: Open): void { if (this.stack.at(-1) === open) this.show(); }
388
-
389
- private async guard<T>(run: () => T | Promise<T>): Promise<T | undefined> {
390
- try { return await run(); } catch (error) { this.onError?.(error); return undefined; }
391
- }
392
- private guardRender(run: () => string[]): string[] {
393
- try { return run(); } catch (error) { this.onError?.(error); return ["(this block failed to render)"]; }
394
- }
395
- }
396
-
397
- /** Rows an intro block takes before wrapping (wrapping only adds rows; the list shrinks to fit). */
398
- function introRows(intro: readonly string[]): number { return intro.length; }
@@ -1,23 +0,0 @@
1
- /**
2
- * How tall the draft is and what scrolled out of it (docs/tasks/fix-tui-composer-growth.md). Pure.
3
- *
4
- * `total` is the draft's wrapped row count, `offset` the editor's first
5
- * visible row, `max` the tallest the box may grow. The box shows
6
- * min(total, max) rows; whatever does not fit is reported on the box's top
7
- * edge as "↑ N more" / "↓ N more", the way pi marks a scrolled editor.
8
- */
9
- export interface DraftRows {
10
- /** Rows the editor occupies. */
11
- readonly rows: number;
12
- /** Wrapped rows hidden above the visible part. */
13
- readonly above: number;
14
- /** Wrapped rows hidden below it. */
15
- readonly below: number;
16
- }
17
-
18
- export function draftRows(total: number, offset: number, max: number): DraftRows {
19
- const t = Math.max(1, Math.floor(total) || 1), m = Math.max(1, Math.floor(max) || 1);
20
- const rows = Math.min(t, m);
21
- const above = Math.max(0, Math.min(Math.floor(offset) || 0, t - rows));
22
- return { rows, above, below: t - rows - above };
23
- }
@@ -1,16 +0,0 @@
1
- /**
2
- * Elapsed time as people read it (feat-sidebar-goal-and-speed): `59s`, then
3
- * `1m 00s`, then `1h 01m 01s`. The steps are Codex's fmt_elapsed_compact; a
4
- * running turn used to read `3725s`.
5
- *
6
- * Every field after the first is two digits, so a running clock keeps its
7
- * width within a unit instead of jittering the labels beside it. Parts of a
8
- * second never round up: the clock shows seconds that have fully passed.
9
- */
10
- export function formatElapsed(ms: number): string {
11
- const total = Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0;
12
- if (total < 60) return `${total}s`;
13
- const seconds = String(total % 60).padStart(2, "0");
14
- if (total < 3600) return `${Math.floor(total / 60)}m ${seconds}s`;
15
- return `${Math.floor(total / 3600)}h ${String(Math.floor(total % 3600 / 60)).padStart(2, "0")}m ${seconds}s`;
16
- }
@@ -1,186 +0,0 @@
1
- /**
2
- * Labels set into a one-row rule (docs/tasks/refactor-tui-dock-layout.md). Pure.
3
- *
4
- * ╭─ left · left ───────────────── right ─╮ the input box edges
5
- * ── node 3 · model switch ─────────────── transcript dividers
6
- * xeno title right right the top bar (splitLine)
7
- *
8
- * A row never wraps and never squeezes what is next to it, so when labels do
9
- * not fit, the least important go first: each label has a priority (0 is
10
- * never dropped), and a label with `min` shrinks to that many cells with an
11
- * ellipsis before it is dropped. The output is always exactly `width` cells,
12
- * measured with the same display width the renderer uses (CJK counts 2).
13
- */
14
- import { displayWidth } from "../width.ts";
15
-
16
- /** Colour roles; the view maps them to the current palette. */
17
- export type Role = "text" | "muted" | "edge" | "line" | "accent" | "success" | "error" | "warning" | "reasoning" | "code";
18
-
19
- export interface Seg {
20
- readonly text: string;
21
- readonly role: Role;
22
- readonly bold?: boolean;
23
- readonly italic?: boolean;
24
- /** Drawn with the activity clock's current glyph instead of `text` (one cell). */
25
- readonly spin?: boolean;
26
- }
27
-
28
- export interface Label {
29
- readonly segs: readonly Seg[];
30
- /** 0 is never dropped; among the rest, higher numbers drop first. */
31
- readonly priority: number;
32
- /** Shrink the `shrink` seg (default 0) to this many cells before dropping. */
33
- readonly min?: number;
34
- readonly shrink?: number;
35
- /**
36
- * When to shrink, on the same scale as `priority` (default: the label's own
37
- * priority). A never-dropped title can still give up width early.
38
- */
39
- readonly shrinkPriority?: number;
40
- }
41
-
42
- export const SEPARATOR: readonly Seg[] = [{ text: " · ", role: "muted" }];
43
-
44
- function segWidth(seg: Seg): number { return seg.spin ? 1 : displayWidth(seg.text); }
45
-
46
- export function segsWidth(segs: readonly Seg[]): number {
47
- let width = 0;
48
- for (const seg of segs) width += segWidth(seg);
49
- return width;
50
- }
51
-
52
- /** Cut `text` to at most `width` cells, ending in "…" when anything was cut. */
53
- export function clipText(text: string, width: number): string {
54
- if (width <= 0) return "";
55
- if (displayWidth(text) <= width) return text;
56
- let out = "", used = 0;
57
- for (const ch of text) {
58
- const w = displayWidth(ch);
59
- if (used + w > width - 1) break;
60
- out += ch; used += w;
61
- }
62
- return `${out}…`;
63
- }
64
-
65
- /** Cut a run of segs to at most `width` cells; the cut seg ends in "…". */
66
- export function clipSegs(segs: readonly Seg[], width: number): Seg[] {
67
- if (segsWidth(segs) <= width) return [...segs];
68
- const out: Seg[] = [];
69
- let left = width;
70
- for (let i = 0; i < segs.length; i++) {
71
- const seg = segs[i]!, w = segWidth(seg);
72
- if (w < left) { out.push(seg); left -= w; continue; }
73
- if (left <= 0) break;
74
- if (!seg.spin) {
75
- // Something after this seg is being cut away, so this seg carries the ellipsis.
76
- const body = clipText(seg.text, left - 1).replace(/…$/, "");
77
- out.push({ ...seg, text: `${body}…` });
78
- }
79
- break;
80
- }
81
- return out;
82
- }
83
-
84
- interface Fitted { label: Label; segs: Seg[]; index: number; side: "left" | "right" }
85
-
86
- /**
87
- * Which labels survive in `avail` cells. Returns the joined segs per side
88
- * and the indices kept, so callers and tests can see exactly what dropped.
89
- * `gap` is the minimum space between the two sides when both survive.
90
- */
91
- export function fitLabels(avail: number, left: readonly Label[], right: readonly Label[], separator: readonly Seg[] = SEPARATOR, gap = 0):
92
- { left: Seg[]; right: Seg[]; keptLeft: number[]; keptRight: number[] } {
93
- let items: Fitted[] = [
94
- ...left.map((label, index) => ({ label, segs: [...label.segs], index, side: "left" as const })),
95
- ...right.map((label, index) => ({ label, segs: [...label.segs], index, side: "right" as const })),
96
- ];
97
- const sepWidth = segsWidth(separator);
98
- const width = () => {
99
- let l = 0, r = 0, nl = 0, nr = 0;
100
- for (const item of items) { const w = segsWidth(item.segs); if (item.side === "left") { l += w; nl++; } else { r += w; nr++; } }
101
- return l + r + Math.max(0, nl - 1) * sepWidth + Math.max(0, nr - 1) * sepWidth + (nl && nr ? gap : 0);
102
- };
103
- const shrinkable = (item: Fitted) => item.label.min !== undefined && segsWidth(item.segs) > item.label.min;
104
- // The step a label takes next and how unimportant that step is: shrinking happens at
105
- // `shrinkPriority`, dropping at `priority`. Least important step first; on a tie a
106
- // shrink comes before a drop, and the right side (the less central one) before the left.
107
- const level = (item: Fitted) => shrinkable(item) ? item.label.shrinkPriority ?? item.label.priority : item.label.priority;
108
- const before = (a: Fitted, b: Fitted) => level(a) !== level(b) ? level(a) > level(b)
109
- : shrinkable(a) !== shrinkable(b) ? shrinkable(a) : a.side === "right" && b.side === "left";
110
- for (let guard = 0; width() > avail && guard < 500; guard++) {
111
- const candidates = items.filter(item => item.label.priority > 0 || shrinkable(item));
112
- if (!candidates.length) break;
113
- const victim = candidates.reduce((a, b) => before(b, a) ? b : a);
114
- if (shrinkable(victim)) shrink(victim, Math.max(victim.label.min!, segsWidth(victim.segs) - (width() - avail)));
115
- else items = items.filter(item => item !== victim);
116
- }
117
- // Still too wide: only never-dropped labels at their minimum remain. Cut them, right side first.
118
- for (const item of [...items].reverse()) {
119
- const over = width() - avail;
120
- if (over <= 0) break;
121
- shrink(item, Math.max(0, segsWidth(item.segs) - over));
122
- }
123
- items = items.filter(item => segsWidth(item.segs) > 0);
124
- const join = (side: "left" | "right") => {
125
- const list = items.filter(item => item.side === side);
126
- return { segs: list.flatMap((item, i) => i ? [...separator, ...item.segs] : item.segs), kept: list.map(item => item.index) };
127
- };
128
- const l = join("left"), r = join("right");
129
- return { left: l.segs, right: r.segs, keptLeft: l.kept, keptRight: r.kept };
130
- }
131
-
132
- function shrink(item: Fitted, target: number): void {
133
- if (!item.segs.length) return;
134
- const at = Math.min(item.label.shrink ?? 0, item.segs.length - 1);
135
- const room = target - (segsWidth(item.segs) - segWidth(item.segs[at]!));
136
- if (room <= 0 || item.segs[at]!.spin) { item.segs = clipSegs(item.segs, target); return; }
137
- item.segs[at] = { ...item.segs[at]!, text: clipText(item.segs[at]!.text, room) };
138
- item.segs = item.segs.filter(seg => seg.spin || seg.text.length > 0);
139
- }
140
-
141
- export interface EdgeOptions {
142
- readonly width: number;
143
- readonly left?: readonly Label[];
144
- readonly right?: readonly Label[];
145
- /** Corner glyphs; omit for a rule with open ends (transcript dividers). */
146
- readonly corners?: readonly [string, string];
147
- readonly fill?: string;
148
- readonly role?: Role;
149
- /** Fill cells before the left labels and after the right ones. Default 1. */
150
- readonly lead?: number;
151
- readonly trail?: number;
152
- }
153
-
154
- /** One complete row, exactly `width` cells (a wide character cut at the end is filled). */
155
- export function edgeLine(options: EdgeOptions): Seg[] {
156
- const width = Math.max(0, Math.floor(options.width));
157
- const role = options.role ?? "edge", fill = options.fill ?? "─", corners = options.corners;
158
- if (width === 0) return [];
159
- if (corners && width < 2) return [{ text: corners[0], role }];
160
- const left = options.left ?? [], right = options.right ?? [];
161
- const lead = options.lead ?? 1, trail = options.trail ?? 1;
162
- const inner = width - (corners ? 2 : 0);
163
- const pads = (left.length ? 2 : 0) + (right.length ? 2 : 0);
164
- const fitted = fitLabels(Math.max(0, inner - lead - trail - pads), left, right, SEPARATOR, 1);
165
- const out: Seg[] = [];
166
- let used = 0;
167
- const push = (seg: Seg) => { if (seg.spin || seg.text) { out.push(seg); used += segWidth(seg); } };
168
- if (corners) out.push({ text: corners[0], role });
169
- push({ text: fill.repeat(Math.min(lead, inner)), role });
170
- if (fitted.left.length) { push({ text: " ", role }); fitted.left.forEach(push); push({ text: " ", role }); }
171
- const rightBlock = fitted.right.length ? segsWidth(fitted.right) + 2 : 0;
172
- const gap = inner - used - rightBlock - trail;
173
- if (gap > 0) push({ text: fill.repeat(gap), role });
174
- if (fitted.right.length) { push({ text: " ", role }); fitted.right.forEach(push); push({ text: " ", role }); }
175
- if (inner - used > 0) push({ text: fill.repeat(inner - used), role });
176
- if (corners) out.push({ text: corners[1], role });
177
- return out;
178
- }
179
-
180
- /** A row with labels at both ends and blank space between (the top bar). Exactly `width` cells. */
181
- export function splitLine(width: number, left: readonly Label[], right: readonly Label[], gap = 3, separator: readonly Seg[] = [{ text: " ", role: "muted" }]): Seg[] {
182
- const w = Math.max(0, Math.floor(width));
183
- const fitted = fitLabels(w, left, right, separator, gap);
184
- const space = w - segsWidth(fitted.left) - segsWidth(fitted.right);
185
- return [...fitted.left, ...(space > 0 ? [{ text: " ".repeat(space), role: "text" as const }] : []), ...fitted.right];
186
- }
@@ -1,44 +0,0 @@
1
- /**
2
- * How many terminal cells a picture takes (docs/tasks/feat-tui-inline-images.md). Pure.
3
- *
4
- * A cell is taller than it is wide, so keeping a picture's shape means
5
- * columns / rows = (width / height) × (cell height / cell width). The result
6
- * fits the given box, is at least one cell, and never asks for more cells
7
- * than the picture has pixels for: a 16-pixel icon stays small instead of
8
- * being blown up into a blurry tile.
9
- */
10
-
11
- export interface CellSize { readonly cols: number; readonly rows: number }
12
-
13
- /** Pixels per cell when the terminal does not report them: 9×18, the common default. Assumed. */
14
- export const DEFAULT_CELL = { width: 9, height: 18 } as const;
15
-
16
- export function imageCells(pixels: { readonly width: number; readonly height: number }, maxCols: number, maxRows: number,
17
- cell: { readonly width: number; readonly height: number } = DEFAULT_CELL): CellSize {
18
- const w = Math.max(1, pixels.width), h = Math.max(1, pixels.height);
19
- const cw = cell.width > 0 ? cell.width : DEFAULT_CELL.width, ch = cell.height > 0 ? cell.height : DEFAULT_CELL.height;
20
- // The picture's own size in cells caps the box: no upscaling.
21
- const capCols = Math.max(1, Math.min(Math.floor(maxCols), Math.ceil(w / cw)));
22
- const capRows = Math.max(1, Math.min(Math.floor(maxRows), Math.ceil(h / ch)));
23
- const shape = (w / h) * (ch / cw); // columns per row that keep the picture's proportions
24
- let rows = capRows, cols = Math.round(rows * shape);
25
- if (cols > capCols) { cols = capCols; rows = Math.round(cols / shape); }
26
- return { cols: Math.max(1, Math.min(capCols, cols)), rows: Math.max(1, Math.min(capRows, rows)) };
27
- }
28
-
29
- /**
30
- * Rows for pictures in the transcript: half the terminal, 4–20 (feat-tui-image-full-size).
31
- * A quarter left a screenshot's text unreadable; 20 is Grok's ceiling, past which one picture
32
- * pushes the conversation off screen. Assumed, not measured. Full size is a click away.
33
- */
34
- export function transcriptImageRows(terminalRows: number): number {
35
- return Math.max(4, Math.min(20, Math.floor(terminalRows / 2)));
36
- }
37
-
38
- /** Rows for the attachment strip above the input box: a sixth of the terminal, 3–6. Assumed. */
39
- export function stripImageRows(terminalRows: number): number {
40
- return Math.max(3, Math.min(6, Math.floor(terminalRows / 6)));
41
- }
42
-
43
- /** Pictures shown per message before "+N more" (OpenCode shows 3). */
44
- export const IMAGES_PER_ITEM = 3;