@visual-z/xeno 0.8.4 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (350) hide show
  1. package/CHANGELOG.md +19 -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,67 +0,0 @@
1
- /**
2
- * The empty-session greeting (docs/tasks/refactor-tui-dock-layout.md). Pure.
3
- *
4
- * ▝▖ ▗▘ xeno 0.2.1
5
- * ▐█▙█▟█▌ DeepSeek V4 Pro · high
6
- * ▀▘ ▝▀ ~/Projects/workspace/xeno-tui ⎇ main
7
- *
8
- * An original 7×3 glyph (xeno: the stranger) drawn with ordinary quadrant
9
- * blocks, so no Nerd Font is needed. Its entrance plays once: antennae rise,
10
- * wiggle, one blink, then rest. Every frame keeps the same cells, so the
11
- * terminal never reflows and a selection is never disturbed. Claude Code's
12
- * fullscreen welcome plays a 0.4–1.3 s entrance the same way; Codex and
13
- * OpenCode both dropped animations that kept running.
14
- */
15
- import type { Line } from "./status.ts";
16
-
17
- export const MASCOT_WIDTH = 7;
18
-
19
- const ANTENNAE_UP = "▝▖ ▗▘";
20
- const ANTENNAE_IN = "▗▘ ▝▖";
21
- const ANTENNAE_DOWN = " ▖ ▗ ";
22
- const EYES_OPEN = "▐█▙█▟█▌";
23
- const EYES_SHUT = "▐█████▌";
24
- const FEET = " ▀▘ ▝▀ ";
25
-
26
- /** The resting pose: what a still terminal and every frame after the entrance show. */
27
- export const MASCOT: readonly string[] = [ANTENNAE_UP, EYES_OPEN, FEET];
28
-
29
- /** 60 ms per frame: about 16 fps, smooth for blocky glyphs without a frame per paint. Assumed. */
30
- export const MASCOT_FRAME_MS = 60;
31
-
32
- /** Asleep, antennae rise, eyes open, wiggle, wiggle, blink, rest. 14 frames = 840 ms, under the 1 s the card allows. */
33
- const ENTRANCE: readonly (readonly [string, string])[] = [
34
- [ANTENNAE_DOWN, EYES_SHUT], [ANTENNAE_DOWN, EYES_SHUT], [ANTENNAE_UP, EYES_SHUT], [ANTENNAE_UP, EYES_OPEN],
35
- [ANTENNAE_IN, EYES_OPEN], [ANTENNAE_UP, EYES_OPEN], [ANTENNAE_IN, EYES_OPEN], [ANTENNAE_UP, EYES_OPEN],
36
- [ANTENNAE_UP, EYES_OPEN], [ANTENNAE_UP, EYES_SHUT], [ANTENNAE_UP, EYES_OPEN], [ANTENNAE_UP, EYES_OPEN],
37
- [ANTENNAE_UP, EYES_OPEN], [ANTENNAE_UP, EYES_OPEN],
38
- ];
39
- export const MASCOT_FRAMES = ENTRANCE.length;
40
-
41
- /** Frame `n` of the entrance; anything past the end is the resting pose. */
42
- export function mascotFrame(n: number): readonly string[] {
43
- const pose = ENTRANCE[Math.max(0, Math.floor(n))];
44
- return pose ? [pose[0], pose[1], FEET] : MASCOT;
45
- }
46
-
47
- export interface Greeting {
48
- readonly version: string;
49
- /** Model display name and thinking level, or null when no provider can authenticate. */
50
- readonly model: string | null;
51
- readonly thinking: string;
52
- readonly workspace: string;
53
- readonly branch: string | null;
54
- }
55
-
56
- /** Three rows: mascot frame, a two-cell gap, then version / model / location. */
57
- export function greetingLines(greeting: Greeting, frame: readonly string[] = MASCOT): Line[] {
58
- const art = (row: number) => ({ text: frame[row] ?? "", role: "accent" as const });
59
- const gap = { text: " ", role: "text" as const };
60
- return [
61
- [art(0), gap, { text: "xeno", role: "text", bold: true }, { text: ` ${greeting.version}`, role: "muted" }],
62
- [art(1), gap, ...(greeting.model
63
- ? [{ text: greeting.model, role: "text" as const }, { text: ` · ${greeting.thinking}`, role: "muted" as const }]
64
- : [{ text: "No provider connected yet · /provider", role: "warning" as const }])],
65
- [art(2), gap, { text: greeting.workspace, role: "muted" }, ...(greeting.branch ? [{ text: ` ⎇ ${greeting.branch}`, role: "muted" as const }] : [])],
66
- ];
67
- }
@@ -1,518 +0,0 @@
1
- /**
2
- * The message stream: a bottom-sticky ScrollBox holding a bounded window of
3
- * item renderables (docs/tasks/refactor-tui-chrome-cleanup.md).
4
- *
5
- * Every mounted renderable costs layout and a tree walk on every frame, the
6
- * spinner's included (OpenTUI 0.5.14 culls off-screen children from painting
7
- * only), so a long session must not mount its whole history. The window is
8
- * the last WINDOW items while following the bottom; reading upward pages
9
- * PAGE more in at a time, never more than CAP, with the first visible item
10
- * held in place. Renderables are caches of the projection: keyed by id,
11
- * reused while `rev` is unchanged, destroyed (native memory included) when
12
- * they leave the window.
13
- *
14
- * Reading position is the reader's: once they scroll up, stickiness drops
15
- * and new content raises "↓ N new" instead of yanking the view. Returning
16
- * to the bottom, by wheel or Ctrl+End, re-engages following.
17
- */
18
- import {
19
- BoxRenderable, MarkdownRenderable, ScrollBoxRenderable, SyntaxStyle, TextBufferRenderable, TextRenderable,
20
- type CliRenderer, type Renderable, type RGBA,
21
- } from "@opentui/core";
22
- import { Canvas } from "./canvas.ts";
23
- import type { UiColors } from "./colors.ts";
24
- import { edgeLine, type Seg } from "./edge.ts";
25
- import { assertNever } from "../exhaustive.ts";
26
- import { unseen, type ImageView, type ViewItem } from "./items.ts";
27
- import { DEFAULT_CELL, IMAGES_PER_ITEM, transcriptImageRows } from "./image-size.ts";
28
- import { Pictures, type PictureSource } from "./pictures.ts";
29
- import { ansiText, parts } from "./styled.ts";
30
-
31
- export interface MessageFeatures {
32
- /** Highlighted code block as ANSI lines, or undefined for the built-in renderer. */
33
- codeBlock?(code: string, language: string | undefined, width: number): string[] | undefined;
34
- }
35
-
36
- /** Without a source (tests, print mode) pictures are named, not drawn. */
37
- const NO_PICTURES: PictureSource = { mode: "chip", protocol: "blocks", cell: DEFAULT_CELL, widthCells: 60, load: async () => ({ kind: "error", reason: "closed" }) };
38
-
39
- interface Entry { rev: string; node: Renderable; item: ViewItem }
40
-
41
- /** Items kept mounted while following the bottom: about three screens of a typical session. Measured basis in the card. */
42
- export const WINDOW = 60;
43
- /** Never more than this mounted, however far the reader pages. */
44
- export const CAP = 80;
45
- /** Items mounted per page when the reader reaches either end of the window. */
46
- export const PAGE = 20;
47
- /** Rows from the window's edge at which the next page loads. Assumed. */
48
- const EDGE_ROWS = 2;
49
-
50
- let serial = 0;
51
-
52
- export class MessageList {
53
- readonly scroll: ScrollBoxRenderable;
54
- private readonly renderer: CliRenderer;
55
- private colors: UiColors;
56
- private syntax: SyntaxStyle;
57
- private readonly features: MessageFeatures;
58
- private readonly glyph: () => string;
59
- private entries = new Map<string, Entry>();
60
- private order: string[] = [];
61
- private items: readonly ViewItem[] = [];
62
- /** The mounted window over `items`: [start, end). */
63
- private start = 0;
64
- private end = 0;
65
- private readonly earlier: Canvas;
66
- private toolsExpanded = false;
67
- private thinkingExpanded = false;
68
- private waiting = false;
69
- private picturesSource: PictureSource = NO_PICTURES;
70
- private readonly toolOverrides = new Map<string, boolean>();
71
- private followingBottom = true;
72
- private paging = false;
73
- private unreadCount = 0;
74
- onUnread: ((count: number) => void) | undefined;
75
-
76
- constructor(renderer: CliRenderer, colors: UiColors, features: MessageFeatures = {}, glyph: () => string = () => "●") {
77
- this.renderer = renderer;
78
- this.colors = colors;
79
- this.features = features;
80
- this.glyph = glyph;
81
- this.syntax = syntaxStyle(colors);
82
- this.scroll = new ScrollBoxRenderable(renderer, {
83
- id: "messages", flexGrow: 1, flexShrink: 1, stickyScroll: true, stickyStart: "bottom",
84
- // Content starts in the input box's prompt column, so a sent line does not jump sideways.
85
- contentOptions: { flexDirection: "column", paddingLeft: 3, paddingRight: 3, paddingBottom: 1 },
86
- scrollbarOptions: { trackOptions: { foregroundColor: colors.line, backgroundColor: "transparent" } },
87
- });
88
- this.earlier = new Canvas(renderer, { id: "messages-earlier", height: 1, visible: false, flexShrink: 0, colors,
89
- onMouseDown: () => this.pageUp() });
90
- this.scroll.content.add(this.earlier);
91
- this.scroll.verticalScrollBar.on("change", () => this.trackPosition());
92
- this.selectAnywhere();
93
- // Hidden by default: the scroll position is already visible as "↓ N new" when it matters, and
94
- // OpenTUI draws the thumb as a full-cell block, a heavy bar beside a quiet transcript.
95
- this.setScrollbar("hidden");
96
- }
97
-
98
- /**
99
- * A drag may start anywhere in the transcript, as a terminal's own
100
- * selection does: on padding, a glyph or the empty end of a row, not only on
101
- * a word. OpenTUI starts a selection only on a selectable node under the
102
- * pointer; presses elsewhere bubble here and start it on the content box,
103
- * which owns no text, so the selection still collects only text nodes
104
- * (feat-tui-copy-selection DoD 1).
105
- */
106
- private selectAnywhere(): void {
107
- const content = this.scroll.content;
108
- content.selectable = true;
109
- content.onMouseDown = event => {
110
- if (event.button !== 0 || event.modifiers.ctrl || this.renderer.getSelection()?.isDragging) return;
111
- this.renderer.startSelection(content, event.x, event.y);
112
- // Otherwise the renderer clears any selection after an unhandled press.
113
- event.preventDefault();
114
- };
115
- }
116
-
117
- /** `auto` shows the bar only while the transcript overflows; `always` keeps it; `hidden` never draws it. */
118
- setScrollbar(mode: "auto" | "always" | "hidden"): void {
119
- const bar = this.scroll.verticalScrollBar;
120
- if (mode === "auto") bar.resetVisibilityControl(); else bar.visible = mode === "always";
121
- }
122
-
123
- get unread(): number { return this.unreadCount; }
124
- get atBottom(): boolean { return this.followingBottom; }
125
- /** How many items are mounted right now (tests, /debug). */
126
- get mounted(): number { return this.order.length; }
127
- get window(): { readonly start: number; readonly end: number; readonly total: number } { return { start: this.start, end: this.end, total: this.items.length }; }
128
- /** The "↑ N earlier" row at the top of the window, or "" when everything from the start is mounted. */
129
- get earlierLabel(): string { return this.earlier.visible ? this.earlier.text : ""; }
130
-
131
- /** A tool is waiting for the user's approval: pending tools show ◌ instead of the spinner. */
132
- setWaiting(waiting: boolean): void {
133
- if (waiting === this.waiting) return;
134
- this.waiting = waiting;
135
- for (const entry of this.entries.values()) if (entry.item.kind === "tool" && entry.item.status === "pending") this.replace(entry.item);
136
- }
137
-
138
- setColors(colors: UiColors): void {
139
- this.colors = colors;
140
- this.syntax = syntaxStyle(colors);
141
- this.earlier.setColors(colors);
142
- this.rebuild();
143
- }
144
-
145
- /** Folding changes only tool and thinking blocks; everything else, and the reading position, stays put. */
146
- setExpanded(tools: boolean, thinking: boolean): void {
147
- if (tools === this.toolsExpanded && thinking === this.thinkingExpanded) return;
148
- const toolsChanged = tools !== this.toolsExpanded, thinkingChanged = thinking !== this.thinkingExpanded;
149
- this.toolsExpanded = tools;
150
- this.thinkingExpanded = thinking;
151
- if (toolsChanged) this.toolOverrides.clear();
152
- const following = this.followingBottom;
153
- for (const entry of this.entries.values()) {
154
- if ((toolsChanged && entry.item.kind === "tool") || (thinkingChanged && entry.item.kind === "thinking")) this.replace(entry.item);
155
- }
156
- if (following) this.stickToBottom();
157
- }
158
-
159
- update(items: readonly ViewItem[]): void {
160
- // Only a reader who has scrolled away needs to know what arrived (an O(n) diff otherwise wasted).
161
- const missed = this.followingBottom ? undefined : unseen(this.items, items);
162
- const previous = this.items, firstMounted = this.order[0];
163
- this.items = items;
164
- if (this.followingBottom) { this.end = items.length; this.start = Math.max(0, this.end - WINDOW); }
165
- else {
166
- // Keep the reader's window anchored on the same first item; take new tail items only up to the cap.
167
- const anchor = firstMounted === undefined ? -1 : items.findIndex(item => item.id === firstMounted);
168
- const size = this.end - this.start;
169
- const tailWasMounted = this.end >= previous.length;
170
- this.start = anchor >= 0 ? anchor : Math.min(this.start, Math.max(0, items.length - 1));
171
- this.end = Math.min(items.length, this.start + Math.min(CAP, tailWasMounted ? size + Math.max(0, items.length - previous.length) : size));
172
- }
173
- this.reconcile();
174
- if (missed && (missed.added > 0 || missed.changed)) {
175
- // A live reply that only grew still means "there is more below".
176
- const next = Math.max(1, this.unreadCount + missed.added);
177
- if (next !== this.unreadCount) { this.unreadCount = next; this.onUnread?.(next); }
178
- }
179
- this.renderer.requestRender();
180
- }
181
-
182
- /** Make the mounted nodes match items[start, end), reusing what is unchanged. */
183
- private reconcile(): void {
184
- const wanted = this.items.slice(this.start, this.end);
185
- const keep = new Set(wanted.map(item => item.id));
186
- for (const [id, entry] of this.entries) if (!keep.has(id)) { this.scroll.content.remove(entry.node); entry.node.destroyRecursively(); this.entries.delete(id); }
187
- const content = this.scroll.content, base = 1; // child 0 is the "N earlier" row
188
- let reordered = this.order.length !== wanted.length;
189
- wanted.forEach((item, index) => {
190
- if (!reordered && this.order[index] !== item.id) reordered = true;
191
- const entry = this.entries.get(item.id);
192
- if (entry && entry.rev === item.rev) { entry.item = item; return; }
193
- if (entry && item.kind === "assistant" && entry.item.kind === "assistant" && entry.node instanceof AssistantBlock) {
194
- // Streaming: append into the same block so only its tail re-parses.
195
- entry.node.markdown.content = item.text;
196
- entry.node.markdown.streaming = item.streaming === true;
197
- entry.node.paintSelection();
198
- entry.rev = item.rev; entry.item = item;
199
- return;
200
- }
201
- const node = this.build(item);
202
- if (entry) { content.remove(entry.node); entry.node.destroyRecursively(); }
203
- this.entries.set(item.id, { rev: item.rev, node, item });
204
- content.add(node, base + index);
205
- });
206
- // Reorders are rare (a notice anchored before a reply); re-add in order when they happen.
207
- if (reordered) {
208
- for (const item of wanted) content.remove(this.entries.get(item.id)!.node);
209
- for (const item of wanted) content.add(this.entries.get(item.id)!.node);
210
- }
211
- this.order = wanted.map(item => item.id);
212
- this.paintEarlier();
213
- }
214
-
215
- private paintEarlier(): void {
216
- this.earlier.visible = this.start > 0;
217
- if (this.start > 0) this.earlier.setRows([[{ text: `↑ ${this.start} earlier`, role: "muted" }]]);
218
- }
219
-
220
- scrollToBottom(): void {
221
- this.followingBottom = true;
222
- if (this.unreadCount) { this.unreadCount = 0; this.onUnread?.(0); }
223
- if (this.end < this.items.length || this.end - this.start > WINDOW) { this.end = this.items.length; this.start = Math.max(0, this.end - WINDOW); this.reconcile(); }
224
- this.stickToBottom();
225
- }
226
-
227
- /** Scroll height is known only after layout; anchor now and again after the next frame. */
228
- private stickToBottom(): void {
229
- this.scroll.scrollTo(this.scroll.scrollHeight);
230
- this.renderer.requestRender();
231
- setTimeout(() => { if (this.followingBottom) { this.scroll.scrollTo(this.scroll.scrollHeight); this.renderer.requestRender(); } }, 0);
232
- }
233
-
234
- /** The very start of the session: the first WINDOW items, top of the view. */
235
- scrollToTop(): void {
236
- this.followingBottom = false;
237
- if (this.start > 0) { this.start = 0; this.end = Math.min(this.items.length, WINDOW); this.reconcile(); this.renderer.root.calculateLayout(); }
238
- this.scroll.scrollTo(0);
239
- this.trackPosition();
240
- }
241
- page(direction: number): void { this.scroll.scrollBy(direction * Math.max(1, Math.floor(this.viewHeight() * 0.8))); this.trackPosition(); }
242
-
243
- private viewHeight(): number { return this.scroll.viewport.height; }
244
-
245
- private trackPosition(): void {
246
- if (this.paging) return;
247
- const max = Math.max(0, this.scroll.scrollHeight - this.viewHeight());
248
- const top = this.scroll.scrollTop;
249
- // Bottom means the session's last item, not just the window's.
250
- const bottom = top >= max - 1 && this.end >= this.items.length;
251
- if (!bottom && top <= EDGE_ROWS && this.start > 0) { this.pageUp(); return; }
252
- if (!bottom && top >= max - EDGE_ROWS && this.end < this.items.length) { this.pageDown(); return; }
253
- if (bottom === this.followingBottom) return;
254
- this.followingBottom = bottom;
255
- if (bottom && this.unreadCount) { this.unreadCount = 0; this.onUnread?.(0); }
256
- }
257
-
258
- /** Mount PAGE earlier items, trimming the far end past CAP, with the first visible item held in place. */
259
- private pageUp(): void {
260
- if (this.start === 0) return;
261
- this.followingBottom = false;
262
- this.shifting(() => {
263
- this.start = Math.max(0, this.start - PAGE);
264
- if (this.end - this.start > CAP) this.end = this.start + CAP;
265
- this.reconcile();
266
- });
267
- }
268
-
269
- /** Mount PAGE later items, trimming the near end past CAP. */
270
- private pageDown(): void {
271
- if (this.end >= this.items.length) return;
272
- this.shifting(() => {
273
- this.end = Math.min(this.items.length, this.end + PAGE);
274
- if (this.end - this.start > CAP) this.start = this.end - CAP;
275
- this.reconcile();
276
- });
277
- }
278
-
279
- /**
280
- * Run a window change without moving what the reader sees: find the first
281
- * item at or below the viewport top, lay out again synchronously, and shift
282
- * the scroll position by however far that item moved.
283
- */
284
- private shifting(change: () => void): void {
285
- const top = this.scroll.scrollTop;
286
- const anchor = this.order.map(id => this.entries.get(id)!.node).find(node => node.getLayoutNode().getComputedTop() + node.getLayoutNode().getComputedHeight() > top);
287
- const before = anchor?.getLayoutNode().getComputedTop();
288
- this.paging = true;
289
- try {
290
- change();
291
- this.renderer.root.calculateLayout();
292
- const after = anchor && !anchor.isDestroyed ? anchor.getLayoutNode().getComputedTop() : undefined;
293
- const height = this.scroll.content.getLayoutNode().getComputedHeight();
294
- this.scroll.verticalScrollBar.scrollSize = height;
295
- if (before !== undefined && after !== undefined) this.scroll.scrollTop = Math.max(0, top + (after - before));
296
- } finally { this.paging = false; }
297
- this.renderer.requestRender();
298
- }
299
-
300
- private rebuild(): void {
301
- for (const entry of this.entries.values()) { this.scroll.content.remove(entry.node); entry.node.destroyRecursively(); }
302
- this.entries.clear(); this.order = [];
303
- const following = this.followingBottom;
304
- this.reconcile();
305
- if (following) this.stickToBottom();
306
- }
307
-
308
- private toolOpen(id: string): boolean { return this.toolOverrides.get(id) ?? this.toolsExpanded; }
309
-
310
- /** How pictures are drawn now. A change (settings, the terminal's reported capabilities) redraws the items that have any. */
311
- setPictures(source: PictureSource): void {
312
- const before = this.picturesSource;
313
- this.picturesSource = source;
314
- if (before.mode === source.mode && before.protocol === source.protocol && before.widthCells === source.widthCells
315
- && before.cell.width === source.cell.width && before.cell.height === source.cell.height) return;
316
- for (const entry of [...this.entries.values()]) if ((entry.item.kind === "user" || entry.item.kind === "tool") && entry.item.images?.length) this.replace(entry.item);
317
- }
318
-
319
- private pictures(images: readonly ImageView[], limit: number, marginLeft = 0): Pictures {
320
- const maxCols = Math.max(10, (this.scroll.width || this.renderer.width) - 10);
321
- return new Pictures(this.renderer, this.colors, images, this.picturesSource, { maxCols, maxRows: transcriptImageRows(this.renderer.height), limit, marginTop: 1, marginLeft });
322
- }
323
-
324
- private build(item: ViewItem): Renderable {
325
- const c = this.colors, r = this.renderer, id = `${item.kind}-${serial++}`;
326
- switch (item.kind) {
327
- case "user": {
328
- // `›` is the same mark the input box uses, so a sent line reads as what was typed.
329
- const row = new BoxRenderable(r, { id, flexDirection: "row", marginTop: 1 });
330
- // The mark is chrome: a copied message is its words, not its `›`.
331
- const mark = new Canvas(r, { id: `${id}-mark`, width: 2, height: 1, flexShrink: 0, colors: c });
332
- mark.setRows([[{ text: "›", role: item.interrupted ? "warning" : "accent", bold: true }]]);
333
- row.add(mark);
334
- const body = new BoxRenderable(r, { id: `${id}-body`, flexDirection: "column", flexGrow: 1, flexShrink: 1 });
335
- body.add(this.text(parts({ text: item.text, fg: c.text }), { wrapMode: "word" }));
336
- if (item.images?.length) body.add(this.pictures(item.images, IMAGES_PER_ITEM));
337
- if (item.interrupted) body.add(this.text(parts({ text: "interrupted", fg: c.warning })));
338
- row.add(body);
339
- return row;
340
- }
341
- case "assistant":
342
- return new AssistantBlock(r, id, item, c, this.syntax, this.features.codeBlock);
343
- case "thinking": {
344
- if (!this.thinkingExpanded) {
345
- const label = item.label ?? (item.streaming ? "Thinking…" : "Thought");
346
- return this.text(parts({ text: "∴ ", fg: c.muted }, { text: label, fg: c.muted, italic: true }), { marginTop: 1 });
347
- }
348
- const box = new BoxRenderable(r, { id, border: ["left"], borderColor: c.line, paddingLeft: 1, marginTop: 1, marginLeft: 2 });
349
- box.add(this.text(parts({ text: item.text, fg: c.muted, italic: true }), { wrapMode: "word" }));
350
- return box;
351
- }
352
- case "tool": return this.toolCard(id, item);
353
- case "notice": {
354
- const fg = item.tone === "error" ? c.error : item.tone === "warning" ? c.warning : item.tone === "success" ? c.success : c.muted;
355
- return this.text(parts({ text: item.text, fg }), { wrapMode: "word", marginTop: 1 });
356
- }
357
- case "segment": {
358
- // The log made visible: the session start and every context boundary are rules (refactor-tui-dock-layout DoD 7).
359
- const box = new BoxRenderable(r, { id, flexDirection: "column", marginTop: 1, flexShrink: 0 });
360
- const rule = new Canvas(r, { id: `${id}-rule`, height: 1, colors: c });
361
- const paint = () => rule.setRows([edgeLine({ width: Math.max(1, rule.width || 40), left: [{ segs: [{ text: item.label, role: "muted" }], priority: 0, min: 4 }], role: "line", lead: 2, trail: 0 })]);
362
- rule.onSizeChange = paint; paint();
363
- box.add(rule);
364
- if (item.detail) box.add(this.text(parts({ text: item.detail, fg: c.muted }), { wrapMode: "none", truncate: true }));
365
- return box;
366
- }
367
- case "lines":
368
- // Notices and extension blocks are not pre-wrapped to the column; long ones wrap instead of being cut.
369
- return new LinesBlock(r, id, item.render, { marginTop: 1, fg: c.text, wrapMode: "word", ...this.selection() });
370
- default:
371
- return assertNever(item, "message item");
372
- }
373
- }
374
-
375
- /**
376
- * `✓ read src/a.ts 215 lines`. The glyph is the logged outcome (D11):
377
- * ✓ ok, ✗ failed, the shared spinner while running, ◌ while waiting for
378
- * approval. Shell output and failures keep a two-line preview; everything
379
- * else is one row until expanded (Ctrl+O, or a click without a drag).
380
- *
381
- * The glyph and the preview's rule are chrome (Canvas / a Box border), the
382
- * words are text nodes, so a mouse selection copies `bash echo hi exit 0`
383
- * and never the ✓ or the │ (feat-tui-copy-selection).
384
- */
385
- private toolCard(id: string, item: Extract<ViewItem, { kind: "tool" }>): Renderable {
386
- const c = this.colors, r = this.renderer, open = this.toolOpen(item.id);
387
- const card = new BoxRenderable(r, { id, flexDirection: "column", marginTop: 1 });
388
- const shell = item.name === "!" || item.name === "!!";
389
- const glyph: Seg = item.status === "ok" ? { text: "✓", role: "success" } : item.status === "error" ? { text: "✗", role: "error" }
390
- : this.waiting ? { text: "◌", role: "warning" } : { text: "●", role: "accent", spin: true };
391
- // A click toggles the card; a drag is a selection and leaves it alone.
392
- let down: { x: number; y: number } | undefined;
393
- const head = new BoxRenderable(r, { id: `${id}-head`, flexDirection: "row", height: 1,
394
- onMouseDown: event => { down = { x: event.x, y: event.y }; },
395
- onMouseUp: event => { if (down && down.x === event.x && down.y === event.y) { this.toolOverrides.set(item.id, !this.toolOpen(item.id)); this.replace(item); } down = undefined; } });
396
- const mark = new Canvas(r, { id: `${id}-glyph`, width: 2, height: 1, flexShrink: 0, colors: c, glyph: this.glyph });
397
- mark.setRows([[glyph]]);
398
- head.add(mark);
399
- head.add(this.text(parts({ text: shell ? item.name : item.name.padEnd(6), fg: c.text, bold: true }, { text: ` ${item.summary}`, fg: c.text },
400
- item.evidence ? { text: ` ${item.evidence}`, fg: c.muted } : undefined), { flexGrow: 1, flexShrink: 1, wrapMode: "none", truncate: true }));
401
- card.add(head);
402
- const preview = !open && (shell || item.name === "bash" || item.status === "error") ? item.preview ?? [] : [];
403
- if (preview.length) {
404
- const body = new BoxRenderable(r, { id: `${id}-preview`, flexDirection: "column", border: ["left"], borderColor: c.line, paddingLeft: 1, marginLeft: 2 });
405
- for (const line of preview) body.add(this.text(parts({ text: line, fg: c.muted }), { height: 1, wrapMode: "none", truncate: true }));
406
- card.add(body);
407
- }
408
- // A tool that returned pictures shows the first, the rest as "+N more" (Grok shows one per tool block).
409
- if (item.images?.length) card.add(this.pictures(item.images, 1, 2));
410
- if (open) {
411
- const body = new BoxRenderable(r, { id: `${id}-body`, border: ["left"], borderColor: c.line, paddingLeft: 1, marginLeft: 2 });
412
- body.add(new LinesBlock(r, `${id}-lines`, item.detail, { fg: c.text, ...this.selection() }));
413
- card.add(body);
414
- }
415
- return card;
416
- }
417
-
418
- /** Colours for mouse-selected text: the terminal's own, or its inverse (feat-tui-copy-selection DoD 5). */
419
- private selection(): { selectionBg: RGBA; selectionFg: RGBA } { return { selectionBg: this.colors.selectionBg, selectionFg: this.colors.selectionFg }; }
420
-
421
- /** A selectable text node in the transcript. */
422
- private text(content: ReturnType<typeof parts>, extra: Record<string, unknown> = {}): TextRenderable {
423
- return new TextRenderable(this.renderer, { id: `t-${serial++}`, content, ...this.selection(), ...extra });
424
- }
425
-
426
- private replace(item: ViewItem): void {
427
- const entry = this.entries.get(item.id);
428
- if (!entry) return;
429
- const index = this.order.indexOf(item.id);
430
- this.scroll.content.remove(entry.node); entry.node.destroyRecursively();
431
- entry.node = this.build(item);
432
- this.scroll.content.add(entry.node, 1 + index);
433
- this.renderer.requestRender();
434
- }
435
- }
436
-
437
- /** Stable Markdown identity across streamed chunks: a delta re-parses only the tail. */
438
- class AssistantBlock extends BoxRenderable {
439
- readonly markdown: MarkdownRenderable;
440
- private readonly colors: UiColors;
441
- constructor(r: CliRenderer, id: string, item: Extract<ViewItem, { kind: "assistant" }>, c: UiColors, syntax: SyntaxStyle, highlight?: MessageFeatures["codeBlock"]) {
442
- // Indented under the user's line, as the reply to it; no name tag, no streaming label (the box edge shows activity).
443
- super(r, { id, flexDirection: "column", marginTop: 1, marginLeft: 2 });
444
- this.colors = c;
445
- this.markdown = new MarkdownRenderable(r, { id: `${id}-markdown`, content: item.text, syntaxStyle: syntax, fg: c.text, conceal: true, streaming: item.streaming === true,
446
- ...(highlight ? { renderNode: codeRenderer(r, c, highlight) } : {}) });
447
- this.add(this.markdown);
448
- this.paintSelection();
449
- }
450
-
451
- /**
452
- * Markdown builds its own text nodes, with OpenTUI's default selection
453
- * colours (opaque white, invisible on a light terminal). Give each the
454
- * view's; a streamed delta may have added nodes, so this runs after updates.
455
- */
456
- paintSelection(): void {
457
- const { selectionBg, selectionFg } = this.colors;
458
- const visit = (node: Renderable) => {
459
- if (node instanceof TextBufferRenderable && node.selectionBg !== selectionBg) { node.selectionBg = selectionBg; node.selectionFg = selectionFg; }
460
- for (const child of node.getChildren()) visit(child as Renderable);
461
- };
462
- visit(this.markdown);
463
- }
464
- }
465
-
466
-
467
- /** A width-dependent ANSI block (tool output, diffs, extension renderers). Re-renders when its width changes. */
468
- export class LinesBlock extends TextRenderable {
469
- private readonly paint: (width: number) => string[];
470
- private paintedWidth = -1;
471
- constructor(r: CliRenderer, id: string, paint: (width: number) => string[], extra: Record<string, unknown> = {}) {
472
- super(r, { id, content: "", wrapMode: "none", ...extra });
473
- this.paint = paint;
474
- this.onSizeChange = () => this.repaint();
475
- this.repaint(Math.max(20, r.width - 8));
476
- }
477
- private repaint(fallback?: number): void {
478
- const width = this.width > 0 ? this.width : fallback ?? 80;
479
- if (width === this.paintedWidth) return;
480
- this.paintedWidth = width;
481
- let lines: string[];
482
- try { lines = this.paint(width); } catch { lines = ["(this block failed to render)"]; }
483
- this.content = ansiText(lines);
484
- }
485
- }
486
-
487
- /** Code blocks of every language go through the app's highlighter; everything else is OpenTUI's. */
488
- function codeRenderer(r: CliRenderer, c: UiColors, highlight: NonNullable<MessageFeatures["codeBlock"]>): MarkdownRendererNode {
489
- return (token) => {
490
- if (token.type !== "code") return undefined;
491
- const code = token as { text: string; lang?: string };
492
- const lines = highlight(code.text, code.lang || undefined, Math.max(20, r.width - 10));
493
- if (!lines) return undefined;
494
- const box = new BoxRenderable(r, { id: `code-${serial++}`, border: ["left"], borderColor: c.line, paddingLeft: 1, marginTop: 1, marginBottom: 1 });
495
- box.add(new TextRenderable(r, { id: `code-t-${serial++}`, content: ansiText(lines), fg: c.text, wrapMode: "word" }));
496
- return box;
497
- };
498
- }
499
- type MarkdownRendererNode = NonNullable<ConstructorParameters<typeof MarkdownRenderable>[1]["renderNode"]>;
500
-
501
- function syntaxStyle(c: UiColors): SyntaxStyle {
502
- return SyntaxStyle.fromStyles({
503
- default: { fg: c.text },
504
- "markup.heading": { fg: c.accent, bold: true },
505
- "markup.strong": { bold: true },
506
- "markup.italic": { italic: true },
507
- "markup.strikethrough": { dim: true },
508
- "markup.raw": { fg: c.warning },
509
- "markup.raw.block": { fg: c.text },
510
- "markup.quote": { fg: c.muted, italic: true },
511
- "markup.list": { fg: c.accent },
512
- "markup.link": { fg: c.accent, underline: true },
513
- "markup.link.url": { fg: c.muted, underline: true },
514
- "markup.link.label": { fg: c.accent },
515
- conceal: { fg: c.muted },
516
- keyword: { fg: c.reasoning }, string: { fg: c.success }, comment: { fg: c.muted, italic: true }, number: { fg: c.warning }, function: { fg: c.accent },
517
- });
518
- }
@@ -1,28 +0,0 @@
1
- /**
2
- * A small, owned animation clock. Only the subscribing chrome is redrawn;
3
- * animations never trigger transcript projection (D20 view isolation), and
4
- * a clock that is not running holds no timer.
5
- */
6
- export class Motion {
7
- private timer: ReturnType<typeof setInterval> | undefined;
8
- private frame = 0;
9
- get running(): boolean { return this.timer !== undefined; }
10
-
11
- /**
12
- * Paint frame 0 now, then one frame per `intervalMs`. A finite animation
13
- * (`lastFrame`) stops itself after painting that frame.
14
- */
15
- start(paint: (frame: number) => void, lastFrame?: number, intervalMs = 120): void {
16
- this.stop();
17
- this.frame = 0;
18
- paint(this.frame);
19
- if (lastFrame !== undefined && lastFrame <= 0) return;
20
- this.timer = setInterval(() => {
21
- paint(++this.frame);
22
- if (lastFrame !== undefined && this.frame >= lastFrame) this.stop();
23
- }, intervalMs);
24
- this.timer.unref?.();
25
- }
26
-
27
- stop(): void { clearInterval(this.timer); this.timer = undefined; }
28
- }
@@ -1,10 +0,0 @@
1
- /** `~`-relative display of a directory; pure. */
2
- import { isAbsolute, relative, resolve, sep } from "node:path";
3
-
4
- export function displayPath(cwd: string, home: string | undefined): string {
5
- if (!home) return cwd;
6
- const relativeToHome = relative(resolve(home), resolve(cwd));
7
- const inside = relativeToHome === "" || (relativeToHome !== ".." && !relativeToHome.startsWith(`..${sep}`) && !isAbsolute(relativeToHome));
8
- if (!inside) return cwd;
9
- return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`;
10
- }