@linxiraos/pi-tui 1.2.1 → 1.3.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 (274) hide show
  1. package/CHANGELOG.md +188 -125
  2. package/README.md +36 -0
  3. package/THIRD-PARTY-NOTICES.txt +55 -5
  4. package/dist/types/app-keybindings.d.ts +18 -8
  5. package/dist/types/apps/git/diff-pane.d.ts +5 -0
  6. package/dist/types/apps/git/git-tui.d.ts +16 -2
  7. package/dist/types/apps/git/state.d.ts +11 -0
  8. package/dist/types/autocomplete.d.ts +2 -0
  9. package/dist/types/charts/chart-svg.d.ts +7 -13
  10. package/dist/types/chat/advisor-message.d.ts +4 -1
  11. package/dist/types/chat/assistant-message.d.ts +9 -4
  12. package/dist/types/chat/figure-markdown.d.ts +7 -2
  13. package/dist/types/chat/messages.d.ts +3 -1
  14. package/dist/types/chat/read-tool-group.d.ts +2 -2
  15. package/dist/types/chat/table-chart.d.ts +11 -15
  16. package/dist/types/chat/tool-execution.d.ts +32 -20
  17. package/dist/types/chat/user-message.d.ts +3 -2
  18. package/dist/types/chrome/countdown-timer.d.ts +3 -2
  19. package/dist/types/chrome/diff.d.ts +2 -3
  20. package/dist/types/chrome/transcript-container.d.ts +2 -0
  21. package/dist/types/components/image.d.ts +2 -0
  22. package/dist/types/components/markdown.d.ts +49 -3
  23. package/dist/types/components/text.d.ts +2 -0
  24. package/dist/types/debug-server.d.ts +2 -0
  25. package/dist/types/keybinding-matchers.d.ts +6 -0
  26. package/dist/types/multiplexers/tmux.d.ts +1 -0
  27. package/dist/types/multiplexers/types.d.ts +6 -0
  28. package/dist/types/native/backend.d.ts +5 -4
  29. package/dist/types/native/blobs.d.ts +24 -12
  30. package/dist/types/overlays/agent-hub-projection.d.ts +10 -5
  31. package/dist/types/overlays/annotation-overlay.d.ts +19 -0
  32. package/dist/types/overlays/approval-dialog.d.ts +79 -0
  33. package/dist/types/overlays/ask-dialog.d.ts +11 -6
  34. package/dist/types/overlays/btw-history-panel.d.ts +2 -6
  35. package/dist/types/overlays/hook-editor.d.ts +7 -2
  36. package/dist/types/overlays/hook-input.d.ts +12 -4
  37. package/dist/types/overlays/job-tree.d.ts +50 -0
  38. package/dist/types/overlays/jobs-panel.d.ts +25 -11
  39. package/dist/types/overlays/login-dialog.d.ts +8 -3
  40. package/dist/types/overlays/mcp-add-wizard.d.ts +9 -1
  41. package/dist/types/overlays/model-browser.d.ts +6 -1
  42. package/dist/types/overlays/model-picker.d.ts +2 -0
  43. package/dist/types/overlays/pause-screen.d.ts +5 -0
  44. package/dist/types/overlays/plan-review-overlay.d.ts +4 -0
  45. package/dist/types/overlays/report-panel.d.ts +5 -1
  46. package/dist/types/overlays/reset-usage-selector.d.ts +7 -5
  47. package/dist/types/overlays/session-account-selector.d.ts +4 -0
  48. package/dist/types/overlays/session-info-overlay.d.ts +7 -3
  49. package/dist/types/overlays/session-selector.d.ts +3 -1
  50. package/dist/types/overlays/usage-dashboard.d.ts +6 -6
  51. package/dist/types/prompt/composer.d.ts +21 -20
  52. package/dist/types/prompt/custom-editor.d.ts +20 -10
  53. package/dist/types/prompt/image-references.d.ts +22 -0
  54. package/dist/types/prompt/internal-url-autocomplete.d.ts +1 -0
  55. package/dist/types/prompt/macos-spelling.d.ts +2 -1
  56. package/dist/types/prompt/prose-gate.d.ts +3 -0
  57. package/dist/types/prompt/queued-messages.d.ts +1 -1
  58. package/dist/types/prompt/typing-word.d.ts +25 -0
  59. package/dist/types/prompt/welcome.d.ts +26 -6
  60. package/dist/types/render/hanging-wrap.d.ts +7 -0
  61. package/dist/types/render/hyperlink.d.ts +2 -0
  62. package/dist/types/render/render-utils.d.ts +9 -1
  63. package/dist/types/render/utils.d.ts +1 -1
  64. package/dist/types/setup/scenes/splash.d.ts +6 -5
  65. package/dist/types/status-line/context-usage.d.ts +4 -1
  66. package/dist/types/status-line/host.d.ts +6 -4
  67. package/dist/types/status-line/startup.d.ts +6 -6
  68. package/dist/types/status-line/types.d.ts +3 -0
  69. package/dist/types/stdin-buffer.d.ts +7 -0
  70. package/dist/types/terminal-capabilities.d.ts +16 -1
  71. package/dist/types/terminal-multiplexer.d.ts +7 -0
  72. package/dist/types/terminal.d.ts +8 -4
  73. package/dist/types/theme/symbols.d.ts +1 -1
  74. package/dist/types/theme/theme.d.ts +11 -0
  75. package/dist/types/thinking.d.ts +5 -0
  76. package/dist/types/tmux.d.ts +39 -4
  77. package/dist/types/tools/ast-edit.d.ts +2 -2
  78. package/dist/types/tools/ast-grep.d.ts +2 -2
  79. package/dist/types/tools/bash.d.ts +6 -1
  80. package/dist/types/tools/edit.d.ts +2 -0
  81. package/dist/types/tools/eval.d.ts +53 -4
  82. package/dist/types/tools/find.d.ts +14 -4
  83. package/dist/types/tools/glob.d.ts +3 -3
  84. package/dist/types/tools/grep.d.ts +2 -2
  85. package/dist/types/tools/grouped-file-output.d.ts +9 -4
  86. package/dist/types/tools/native-view.d.ts +1 -1
  87. package/dist/types/tools/read.d.ts +32 -10
  88. package/dist/types/tools/renderer.d.ts +2 -0
  89. package/dist/types/tools/task.d.ts +31 -0
  90. package/dist/types/tools/write.d.ts +2 -0
  91. package/dist/types/tui.d.ts +20 -3
  92. package/package.json +10 -10
  93. package/src/app-keybindings.d.ts +18 -8
  94. package/src/app-keybindings.ts +20 -8
  95. package/src/apps/autoresearch-dashboard.ts +444 -27
  96. package/src/apps/debug/raw-sse.ts +2 -2
  97. package/src/apps/git/diff-pane.ts +39 -41
  98. package/src/apps/git/git-tui.ts +80 -14
  99. package/src/apps/git/sidebar.ts +10 -1
  100. package/src/apps/git/state.ts +12 -0
  101. package/src/autocomplete.d.ts +2 -0
  102. package/src/autocomplete.ts +21 -19
  103. package/src/bracketed-paste.ts +9 -2
  104. package/src/charts/chart-svg.d.ts +7 -13
  105. package/src/charts/chart-svg.ts +19 -22
  106. package/src/chat/advisor-message.d.ts +4 -1
  107. package/src/chat/advisor-message.ts +77 -24
  108. package/src/chat/assistant-message.d.ts +9 -4
  109. package/src/chat/assistant-message.ts +127 -31
  110. package/src/chat/bash-execution.ts +7 -2
  111. package/src/chat/figure-markdown.d.ts +7 -2
  112. package/src/chat/figure-markdown.ts +10 -3
  113. package/src/chat/image-loading.ts +9 -6
  114. package/src/chat/messages.d.ts +3 -1
  115. package/src/chat/messages.ts +4 -1
  116. package/src/chat/read-tool-group.d.ts +2 -2
  117. package/src/chat/read-tool-group.ts +97 -42
  118. package/src/chat/svg-figure.ts +3 -1
  119. package/src/chat/svg-source.ts +6 -3
  120. package/src/chat/table-chart.d.ts +11 -15
  121. package/src/chat/table-chart.ts +24 -57
  122. package/src/chat/tool-execution.d.ts +32 -20
  123. package/src/chat/tool-execution.ts +103 -36
  124. package/src/chat/user-message.d.ts +3 -2
  125. package/src/chat/user-message.ts +54 -34
  126. package/src/chrome/countdown-timer.ts +3 -3
  127. package/src/chrome/diff.d.ts +2 -3
  128. package/src/chrome/diff.ts +4 -9
  129. package/src/chrome/transcript-container.d.ts +2 -0
  130. package/src/chrome/transcript-container.ts +212 -28
  131. package/src/components/editor.ts +12 -12
  132. package/src/components/image.d.ts +2 -0
  133. package/src/components/image.ts +54 -20
  134. package/src/components/markdown.d.ts +49 -3
  135. package/src/components/markdown.ts +461 -11
  136. package/src/components/settings-list.ts +2 -4
  137. package/src/components/text.d.ts +2 -0
  138. package/src/components/text.ts +11 -1
  139. package/src/debug-server.d.ts +2 -0
  140. package/src/debug-server.ts +51 -69
  141. package/src/hotkeys-markdown.ts +2 -0
  142. package/src/keybinding-matchers.d.ts +6 -0
  143. package/src/keybinding-matchers.ts +19 -0
  144. package/src/multiplexers/tmux.d.ts +1 -0
  145. package/src/multiplexers/tmux.ts +3 -0
  146. package/src/multiplexers/types.d.ts +6 -0
  147. package/src/multiplexers/types.ts +6 -0
  148. package/src/native/backend.d.ts +5 -4
  149. package/src/native/backend.ts +13 -5
  150. package/src/native/blobs.d.ts +24 -12
  151. package/src/native/blobs.ts +56 -7
  152. package/src/native/reconcile.ts +43 -21
  153. package/src/overlays/advisor-config.ts +259 -8
  154. package/src/overlays/agent-hub-projection.d.ts +10 -5
  155. package/src/overlays/agent-hub-projection.ts +63 -12
  156. package/src/overlays/annotation-overlay.ts +422 -168
  157. package/src/overlays/approval-dialog.ts +389 -0
  158. package/src/overlays/ask-dialog.ts +244 -105
  159. package/src/overlays/btw-history-panel.ts +208 -147
  160. package/src/overlays/extensions/extension-list.ts +4 -6
  161. package/src/overlays/hook-editor.ts +90 -32
  162. package/src/overlays/hook-input.ts +105 -19
  163. package/src/overlays/job-tree.ts +177 -0
  164. package/src/overlays/jobs-panel.ts +222 -142
  165. package/src/overlays/login-dialog.ts +201 -91
  166. package/src/overlays/mcp-add-wizard.ts +562 -279
  167. package/src/overlays/model-browser.ts +32 -7
  168. package/src/overlays/model-hub.ts +4 -10
  169. package/src/overlays/model-picker.ts +58 -21
  170. package/src/overlays/pause-screen.ts +81 -23
  171. package/src/overlays/plan-review-overlay.ts +16 -0
  172. package/src/overlays/plugin-selector.ts +17 -1
  173. package/src/overlays/queue-mode-selector.ts +10 -0
  174. package/src/overlays/report-panel.ts +6 -2
  175. package/src/overlays/reset-usage-selector.ts +130 -21
  176. package/src/overlays/session-account-selector.ts +36 -0
  177. package/src/overlays/session-info-overlay.ts +199 -76
  178. package/src/overlays/session-selector.ts +15 -10
  179. package/src/overlays/settings-selector.ts +3 -0
  180. package/src/overlays/show-images-selector.ts +1 -0
  181. package/src/overlays/snapcompact-shape-preview.ts +13 -6
  182. package/src/overlays/theme-selector.ts +6 -1
  183. package/src/overlays/thinking-selector.ts +8 -4
  184. package/src/overlays/tree-selector.ts +117 -2
  185. package/src/overlays/usage-dashboard.ts +498 -144
  186. package/src/prompt/composer-cache.ts +1 -1
  187. package/src/prompt/composer.ts +194 -74
  188. package/src/prompt/custom-editor.ts +168 -65
  189. package/src/prompt/image-references.d.ts +22 -0
  190. package/src/prompt/image-references.ts +79 -2
  191. package/src/prompt/internal-url-autocomplete.ts +1 -0
  192. package/src/prompt/macos-spelling.ts +28 -5
  193. package/src/prompt/prose-gate.ts +3 -0
  194. package/src/prompt/queued-messages.ts +9 -2
  195. package/src/prompt/tips.txt +1 -2
  196. package/src/prompt/typing-word.ts +59 -0
  197. package/src/prompt/welcome.ts +97 -45
  198. package/src/render/code-cell.ts +18 -5
  199. package/src/render/hanging-wrap.d.ts +7 -0
  200. package/src/render/hanging-wrap.ts +58 -0
  201. package/src/render/hyperlink.d.ts +2 -0
  202. package/src/render/hyperlink.ts +5 -0
  203. package/src/render/render-utils.d.ts +9 -1
  204. package/src/render/render-utils.ts +16 -2
  205. package/src/render/utils.d.ts +1 -1
  206. package/src/render/utils.ts +1 -1
  207. package/src/render/width-aware-text.ts +6 -5
  208. package/src/setup/scenes/splash.ts +22 -8
  209. package/src/status-line/component.ts +23 -10
  210. package/src/status-line/context-usage.ts +176 -73
  211. package/src/status-line/footer.ts +18 -35
  212. package/src/status-line/host.ts +6 -1
  213. package/src/status-line/segments.ts +16 -5
  214. package/src/status-line/startup.ts +6 -6
  215. package/src/status-line/types.ts +1 -0
  216. package/src/stdin-buffer.d.ts +7 -0
  217. package/src/stdin-buffer.ts +11 -1
  218. package/src/terminal-capabilities.d.ts +16 -1
  219. package/src/terminal-capabilities.ts +66 -28
  220. package/src/terminal-multiplexer.d.ts +7 -0
  221. package/src/terminal-multiplexer.ts +14 -0
  222. package/src/terminal.d.ts +8 -4
  223. package/src/terminal.ts +160 -46
  224. package/src/theme/symbols.d.ts +1 -1
  225. package/src/theme/symbols.ts +1 -0
  226. package/src/theme/theme-class.ts +1 -0
  227. package/src/theme/theme.d.ts +11 -0
  228. package/src/theme/theme.ts +46 -14
  229. package/src/thinking.d.ts +5 -0
  230. package/src/thinking.ts +9 -1
  231. package/src/tmux.d.ts +39 -4
  232. package/src/tmux.ts +110 -16
  233. package/src/tools/ask.ts +20 -9
  234. package/src/tools/ast-edit.d.ts +2 -2
  235. package/src/tools/ast-edit.ts +30 -7
  236. package/src/tools/ast-grep.d.ts +2 -2
  237. package/src/tools/ast-grep.ts +39 -35
  238. package/src/tools/bash.d.ts +6 -1
  239. package/src/tools/bash.ts +15 -9
  240. package/src/tools/debug.ts +2 -1
  241. package/src/tools/default-renderer.ts +3 -2
  242. package/src/tools/edit.d.ts +2 -0
  243. package/src/tools/edit.ts +100 -21
  244. package/src/tools/eval.d.ts +53 -4
  245. package/src/tools/eval.ts +173 -137
  246. package/src/tools/find.d.ts +14 -4
  247. package/src/tools/find.ts +71 -18
  248. package/src/tools/glob.d.ts +3 -3
  249. package/src/tools/glob.ts +51 -17
  250. package/src/tools/grep.d.ts +2 -2
  251. package/src/tools/grep.ts +99 -22
  252. package/src/tools/grouped-file-output.d.ts +9 -4
  253. package/src/tools/grouped-file-output.ts +21 -6
  254. package/src/tools/lsp.ts +30 -18
  255. package/src/tools/mcp.ts +2 -2
  256. package/src/tools/native-view.d.ts +1 -1
  257. package/src/tools/native-view.ts +2 -2
  258. package/src/tools/read.d.ts +32 -10
  259. package/src/tools/read.ts +89 -43
  260. package/src/tools/renderer.d.ts +2 -0
  261. package/src/tools/renderer.ts +2 -0
  262. package/src/tools/task.d.ts +31 -0
  263. package/src/tools/task.ts +40 -7
  264. package/src/tools/todo.ts +3 -2
  265. package/src/tools/wait.ts +16 -30
  266. package/src/tools/web-search.ts +2 -1
  267. package/src/tools/write.d.ts +2 -0
  268. package/src/tools/write.ts +36 -12
  269. package/src/tools/yield.ts +2 -2
  270. package/src/tui.d.ts +20 -3
  271. package/src/tui.ts +140 -36
  272. package/src/utils.ts +5 -3
  273. package/dist/types/tools/eval-format/code-mode-declarations.d.ts +0 -5
  274. package/src/tools/eval-format/code-mode-declarations.ts +0 -61
@@ -18,7 +18,7 @@ export interface QueuedMessageGroup {
18
18
  * Messages queued while the agent works, above the editor. ANSI: a heading
19
19
  * per group, numbered dim rows and the dequeue hint. Natively (§8.1): a
20
20
  * stack of pills, each `corner-down-right` · the text · `⌥↑ Edit`, the total
21
- * count in the first pill when more than one waits; the edit control sends
21
+ * (`N queued`, not a position) in the first pill when more than one waits; the edit control sends
22
22
  * `queue.edit`, answered like the dequeue key.
23
23
  */
24
24
  export class QueuedMessagesBand extends Container {
@@ -63,7 +63,14 @@ export class QueuedMessagesBand extends Container {
63
63
  ),
64
64
  ];
65
65
  if (pills.length === 0 && count > 1) {
66
- children.push(node("badge", { text: `${count}`, role: "zeta.queue.count" }, undefined, "count"));
66
+ children.push(
67
+ node(
68
+ "badge",
69
+ { text: `${count} queued`, title: `${count} messages queued`, role: "zeta.queue.count" },
70
+ undefined,
71
+ "count",
72
+ ),
73
+ );
67
74
  }
68
75
  children.push(
69
76
  node(
@@ -29,5 +29,4 @@ Turn on `/advisor` to attach a second model that reviews every turn and quietly
29
29
  Try starting your prompt with a ->, and writing a list (1. Do X, 2. Do Y)
30
30
  Press {key:shift+tab} to cycle through reasoning effort levels
31
31
  zeta can reconfigure and extend itself — ask any questions you have!
32
- Lint/type errors piling up? `zetacode cleanse` (or /cleanse right here) hunts project diagnostics and fixes them with parallel subagents — {key:escape} cancels
33
- [NO-TERN] Psst, zeta has a natural habitat 🐦 [tern](https://scl.so/tern)
32
+ Lint/type errors piling up? `zetacode cleanse` (or /cleanse right here) hunts project diagnostics and fixes them with parallel subagents — {key:escape} cancels
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The word being typed in the composer. Misspelling marks hold off on it until
3
+ * the caret leaves it (a space or punctuation, a caret move, losing focus), the
4
+ * way Slack and Chromium mark a typo only once its word is done; marks on other
5
+ * words stay while typing.
6
+ */
7
+
8
+ /** A word of one logical line, as UTF-16 columns `[start, end)`. */
9
+ export interface TypingWord {
10
+ line: number;
11
+ start: number;
12
+ end: number;
13
+ }
14
+
15
+ /** Letters, marks and digits, with apostrophes joining contractions. */
16
+ const WORD_CHAR = /[\p{L}\p{M}\p{N}'\u2019]/u;
17
+
18
+ /** The run of word characters around column `col` of `line` (`undefined` between non-word characters). */
19
+ function wordAround(line: string, col: number): { start: number; end: number } | undefined {
20
+ let start = col;
21
+ while (start > 0 && WORD_CHAR.test(line.charAt(start - 1))) start--;
22
+ let end = col;
23
+ while (end < line.length && WORD_CHAR.test(line.charAt(end))) end++;
24
+ return start < end ? { start, end } : undefined;
25
+ }
26
+
27
+ /** Follows an editor's text revision and caret to know which word is being typed. */
28
+ export class TypingWordTracker {
29
+ #revision: number | undefined;
30
+ #word: TypingWord | undefined;
31
+
32
+ /** The word being typed, if any. */
33
+ get word(): TypingWord | undefined {
34
+ return this.#word;
35
+ }
36
+
37
+ /**
38
+ * Catch up with the editor: an edit that leaves the caret in (or right after)
39
+ * a word starts typing it; a caret move out of that word, or losing focus,
40
+ * ends it. The first call only records the revision, so a restored draft is
41
+ * not held. Returns whether the held word changed.
42
+ */
43
+ update(revision: number, lines: readonly string[], line: number, col: number, focused: boolean): boolean {
44
+ const before = this.#word;
45
+ const edited = this.#revision !== undefined && this.#revision !== revision;
46
+ this.#revision = revision;
47
+ if (!focused) {
48
+ this.#word = undefined;
49
+ } else if (edited) {
50
+ const span = wordAround(lines[line] ?? "", col);
51
+ this.#word = span && { line, start: span.start, end: span.end };
52
+ } else if (before && (before.line !== line || col < before.start || col > before.end)) {
53
+ this.#word = undefined;
54
+ }
55
+ return (
56
+ before?.line !== this.#word?.line || before?.start !== this.#word?.start || before?.end !== this.#word?.end
57
+ );
58
+ }
59
+ }
@@ -15,27 +15,21 @@ import type { Component } from "../tui";
15
15
  import { padding, replaceTabs, visibleWidth, wrapTextWithAnsi } from "../utils";
16
16
  import tipsText from "./tips.txt" with { type: "text" };
17
17
 
18
- /** Leading marker for a tip that pitches Tern: shown only while no TSP surface
19
- * is live, since a live one means the user is already in Tern. */
20
- const NO_TERN_TIP_MARKER = /^\[NO-TERN\]\s*/;
21
-
22
18
  /** Trailing marker that flags a tip as a "what's new" callout. Stripped before
23
19
  * wrapping (with any preceding whitespace) and replaced by {@link NEW_TAG_TEXT}
24
20
  * painted as a shimmering rainbow. Non-global so `.test` stays stateless. */
25
21
  const NEW_TIP_MARKER = /\s*\[NEW\]\s*$/;
26
22
 
27
- /** A tips.txt line. The structural markers stay in the source line — they drive
28
- * pool selection and the "[NEW]" weighting — while the user-visible text is
23
+ /** A tips.txt line. The `[NEW]` marker stays in the source line — it drives
24
+ * pool selection and the weighting — while the user-visible text is
29
25
  * resolved from the host catalogue under `tipN`, N = the 1-based line number. */
30
26
  interface TipLine {
31
27
  /** 1-based tips.txt line number; the catalogue key is `tip${line}`. */
32
28
  readonly line: number;
33
- /** Raw line, markers intact — what the weighted picker selects from. */
29
+ /** Raw line, marker intact — what the weighted picker selects from. */
34
30
  readonly raw: string;
35
- /** English body with both markers stripped: the `tuiText` fallback. */
31
+ /** English body with the `[NEW]` marker stripped: the `tuiText` fallback. */
36
32
  readonly english: string;
37
- /** Pitches Tern; excluded from the TSP pool. */
38
- readonly noTern: boolean;
39
33
  /** "What's new" callout; carries the heavier selection weight. */
40
34
  readonly isNew: boolean;
41
35
  }
@@ -49,19 +43,47 @@ const TIP_LINES: readonly TipLine[] = tipsText.split("\n").flatMap((line, index)
49
43
  {
50
44
  line: index + 1,
51
45
  raw,
52
- english: raw.replace(NO_TERN_TIP_MARKER, "").replace(NEW_TIP_MARKER, ""),
53
- noTern: NO_TERN_TIP_MARKER.test(raw),
46
+ english: raw.replace(NEW_TIP_MARKER, ""),
54
47
  isNew: NEW_TIP_MARKER.test(raw),
55
48
  },
56
49
  ];
57
50
  });
58
51
 
59
- /** Classic pool: every tip's raw line, so {@link pickWeightedTip} can still read
60
- * the trailing {@link NEW_TIP_MARKER} for its selection weight. */
52
+ /** Every tip's raw line, so {@link pickWeightedTip} can still read the
53
+ * trailing {@link NEW_TIP_MARKER} for its selection weight. */
61
54
  const TIPS: readonly string[] = TIP_LINES.map(tip => tip.raw);
62
55
 
63
- /** TSP pool: those marked {@link NO_TERN_TIP_MARKER} dropped. */
64
- const TSP_TIPS: readonly string[] = TIP_LINES.filter(tip => !tip.noTern).map(tip => tip.raw);
56
+ /** Catalogue keys (`tip${line}`) of every tips.txt line, in file order. The
57
+ * host needs them to gate a tip by the product feature it advertises. */
58
+ export const TIP_CATALOGUE_KEYS: readonly string[] = TIP_LINES.map(tip => `tip${tip.line}`);
59
+
60
+ /**
61
+ * Host-installable tip gate: receives the tip's catalogue key and its raw
62
+ * tips.txt line; returning false removes it from the splash pool.
63
+ */
64
+ export type TuiTipFilter = (key: string, raw: string) => boolean;
65
+
66
+ let tipFilter: TuiTipFilter | undefined;
67
+
68
+ /**
69
+ * Install the host's tip gate (pass `undefined` to reset). Consulted for every
70
+ * candidate on every pick, so no roll can surface a rejected tip — used when a
71
+ * feature a tip advertises is switched off by configuration.
72
+ */
73
+ export function setTuiTipFilter(filter: TuiTipFilter | undefined): void {
74
+ tipFilter = filter;
75
+ }
76
+
77
+ /** The pool {@link WelcomeComponent} picks from, with the host gate applied. */
78
+ function tipPool(): readonly string[] {
79
+ // Captured so the narrowing survives into the filter callback.
80
+ const filter = tipFilter;
81
+ if (!filter) return TIPS;
82
+ return TIPS.filter(tip => {
83
+ const entry = TIP_LINES.find(candidate => candidate.raw === tip);
84
+ return entry === undefined || filter(`tip${entry.line}`, tip);
85
+ });
86
+ }
65
87
 
66
88
  /** Visible text rendered in place of {@link NEW_TIP_MARKER}. */
67
89
  const NEW_TAG_TEXT = "NEW!";
@@ -99,7 +121,7 @@ interface ResolvedTip {
99
121
  readonly isNew: boolean;
100
122
  }
101
123
 
102
- /** Localise a picked tip. `raw` is a {@link TIPS}/{@link TSP_TIPS} member (or the
124
+ /** Localise a picked tip. `raw` is a {@link TIPS} member (or the
103
125
  * nerdfont nudge); the catalogue is consulted live, so `/language` applies. */
104
126
  function resolveTip(raw: string): ResolvedTip {
105
127
  if (raw === NERDFONT_TIP) return { text: tuiText("welcomeNerdFontJoke", NERDFONT_TIP), isNew: false };
@@ -165,7 +187,7 @@ function expandTipKeys(tip: string): string {
165
187
  });
166
188
  }
167
189
 
168
- /** Markdown-style links in tips.txt: `[Tern](https://scl.so/tern)` shows the label, linked to the URL. */
190
+ /** Markdown-style links in tips.txt: `[docs](https://example.com)` shows the label, linked to the URL. */
169
191
  const TIP_LINK = /\[([^\]]+)\]\(([^\s)]+)\)/g;
170
192
 
171
193
  /** Tip links as underlined OSC 8 labels, or `label (url)` where the terminal cannot link. */
@@ -238,10 +260,11 @@ export function renderWelcomeTip(tip: string, width: number, phase = 0, isNew =
238
260
  }
239
261
 
240
262
  /**
241
- * The session's welcome banner. In a terminal: the gradient logo beside the
242
- * `zeta` wordmark with the version under it (the logo alone when the lockup does
263
+ * The session's welcome banner. In a terminal: the gradient mark beside the
264
+ * `Zeta` wordmark, with the version under the wordmark and the `Linxira-Zeta`
265
+ * identity subline centered below the pair (the mark alone when the lockup does
243
266
  * not fit) and the tip of the session (dropped below {@link TIP_MIN_COLUMNS}
244
- * columns). Natively: a card with the same logo, wordmark, version and tip
267
+ * columns). Natively: a card with the same mark, wordmark, version and tip
245
268
  * ({@link WelcomeComponent.describe}).
246
269
  */
247
270
  export class WelcomeComponent implements Component {
@@ -249,10 +272,8 @@ export class WelcomeComponent implements Component {
249
272
  #animTimer: Timer | null = null;
250
273
  #requestRender: (() => void) | null = null;
251
274
  // Tip randomness is latched once so the tip is stable across renders, but
252
- // the nerdfont-nag gate re-reads the live preset and the pool re-reads the
253
- // live TSP state: the startup prepaint can run under the default "unicode"
254
- // preset before settings resolve the real one, and an optimistic Tern start
255
- // falls back to the classic renderer when `hello` never confirms.
275
+ // the nerdfont-nag gate re-reads the live preset: the startup prepaint can
276
+ // run under the default "unicode" preset before settings resolve the real one.
256
277
  #nagRoll: number | undefined;
257
278
  #tipRoll: number | undefined;
258
279
  // Render cache: the welcome box is the first transcript-area component, so
@@ -272,7 +293,7 @@ export class WelcomeComponent implements Component {
272
293
  if (theme.getSymbolPreset() === "unicode" && this.#nagRoll < 0.1) {
273
294
  return NERDFONT_TIP;
274
295
  }
275
- return pickWeightedTip(isNativeRendering() ? TSP_TIPS : TIPS, this.#tipRoll) || undefined;
296
+ return pickWeightedTip(tipPool(), this.#tipRoll) || undefined;
276
297
  }
277
298
 
278
299
  invalidate(): void {
@@ -284,7 +305,8 @@ export class WelcomeComponent implements Component {
284
305
  /**
285
306
  * A `card` (`zeta.welcome`) mirroring the terminal banner: the lockup
286
307
  * (`zeta.welcome.lockup`: the terminal's builtin `zeta` mark, which it animates,
287
- * beside the wordmark with the version under it) and the tip of the session.
308
+ * beside the `Zeta` wordmark with the version and the `Linxira-Zeta`
309
+ * subline under it) and the tip of the session.
288
310
  * Roles carry the look (gradient logo, type scale); a "[NEW]" tip
289
311
  * carries a terminal-clocked shimmering tag.
290
312
  */
@@ -313,8 +335,9 @@ export class WelcomeComponent implements Component {
313
335
  keyed(
314
336
  col(
315
337
  [
316
- art([span("Z e t a", "strong")], "zeta.welcome.wordmark"),
338
+ art([span("Zeta", "strong")], "zeta.welcome.wordmark"),
317
339
  art([span(`v${this.version}`, "dim mono")], "zeta.welcome.version"),
340
+ art([span("Linxira-Zeta", "dim")], "zeta.welcome.subline"),
318
341
  ],
319
342
  { role: "zeta.welcome.mark" },
320
343
  ),
@@ -466,22 +489,39 @@ export class WelcomeComponent implements Component {
466
489
  }
467
490
  }
468
491
 
469
- /** Block-grid brand mark shared by the welcome and setup surfaces. */
470
- export const ZETA_LOGO = ["████████████", " ████████ ", " ████████ ", " ████████▒▒▒", "████████████"];
492
+ // The three arrays below are GENERATED by the standard FIGlet tool, never
493
+ // hand-drawn: `bun scripts/brand/generate-logo-art.ts` rewrites them from the
494
+ // FIGlet fonts vendored under `scripts/brand/fonts/` (mark + wordmark in the
495
+ // standard "ANSI Shadow" font, the `Linxira-Zeta` subline in "mini"), and
496
+ // `bun scripts/brand/brand-check.ts` fails on any hand-edited glyph. Standard
497
+ // FIGlet fonts carry no Greek block, so the mark is the Latin `Z` — the same
498
+ // glyph as `Ζ` (see `scripts/brand/logo-art.ts`).
499
+
500
+ /** Brand mark: FIGlet `ANSI Shadow` render of `Z` (generated). */
501
+ export const ZETA_LOGO = ["███████╗", "╚══███╔╝", " ███╔╝ ", " ███╔╝ ", "███████╗", "╚══════╝"];
502
+
503
+ /** Primary product name: FIGlet `ANSI Shadow` render of `Zeta` (generated). */
504
+ export const ZETA_WORDMARK = [
505
+ "███████╗███████╗████████╗ █████╗ ",
506
+ "╚══███╔╝██╔════╝╚══██╔══╝██╔══██╗",
507
+ " ███╔╝ █████╗ ██║ ███████║",
508
+ " ███╔╝ ██╔══╝ ██║ ██╔══██║",
509
+ "███████╗███████╗ ██║ ██║ ██║",
510
+ "╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝",
511
+ ];
471
512
 
472
- /** Columns of {@link ZETA_LOGO}. */
473
- const LOGO_WIDTH = Math.max(...ZETA_LOGO.map(row => row.length));
513
+ /** Secondary identity line: FIGlet `mini` render of `Linxira-Zeta` (generated). */
514
+ export const ZETA_SUBLINE = [
515
+ " __ ",
516
+ " | o ._ o ._ _. __ / _ _|_ _. ",
517
+ " |_ | | | >< | | (_| /_ (/_ |_ (_| ",
518
+ ];
474
519
 
475
- /**
476
- * The `Zeta` wordmark in half-blocks, set beside {@link ZETA_LOGO} from its
477
- * second row: the `Z` is a solid zig-zag closed by a light step, `e`/`a` sit on
478
- * the x-height, the `t` ascender rises above it, and the version takes the
479
- * fifth row.
480
- */
481
- const WORDMARK = ["███▀ ▄ ", " ██ ███▄ ███ ▄██▄", "▄▄██ █▄▄ █▄ █▄▄█"];
520
+ /** Columns of {@link ZETA_LOGO}. */
521
+ const LOGO_WIDTH = Math.max(...ZETA_LOGO.map(row => visibleWidth(row)));
482
522
 
483
- /** Columns of {@link WORDMARK}. */
484
- const WORDMARK_WIDTH = Math.max(...WORDMARK.map(row => row.length));
523
+ /** Columns of {@link ZETA_WORDMARK}. */
524
+ const WORDMARK_WIDTH = Math.max(...ZETA_WORDMARK.map(row => visibleWidth(row)));
485
525
 
486
526
  /** Columns between the logo and the wordmark. */
487
527
  const LOCKUP_GAP = 4;
@@ -492,10 +532,22 @@ const TIP_MEASURE = 72;
492
532
  /** Narrowest terminal that still shows the tip; below it the banner is the logo alone. */
493
533
  const TIP_MIN_COLUMNS = 50;
494
534
 
495
- /** Logo frame `logo` with the wordmark beside it and `version` (styled) under the wordmark. */
535
+ /**
536
+ * The lockup block: the mark beside the wordmark, `version` (styled) under the
537
+ * wordmark, and the `Linxira-Zeta` subline centered under the pair.
538
+ */
496
539
  function lockup(logo: readonly string[], version: string): string[] {
497
- const beside = ["", ...WORDMARK.map(row => theme.bold(theme.fg("text", row))), version];
498
- return logo.map((row, index) => `${row}${padding(LOCKUP_GAP)}${beside[index] ?? ""}`);
540
+ const beside = [...ZETA_WORDMARK.map(row => theme.bold(theme.fg("text", row))), version];
541
+ const rows = Math.max(logo.length, beside.length);
542
+ const art = Array.from({ length: rows }, (_, index) => {
543
+ const mark = logo[index] ?? "";
544
+ return `${mark}${padding(LOGO_WIDTH - visibleWidth(mark) + LOCKUP_GAP)}${beside[index] ?? ""}`;
545
+ });
546
+ const blockWidth = Math.max(...art.map(line => visibleWidth(line)));
547
+ for (const row of ZETA_SUBLINE) {
548
+ art.push(padding(Math.max(0, Math.floor((blockWidth - visibleWidth(row)) / 2))) + theme.fg("dim", row));
549
+ }
550
+ return art;
499
551
  }
500
552
 
501
553
  /**
@@ -508,7 +560,7 @@ function centerBlock(lines: readonly string[], width: number): string[] {
508
560
  return lines.map(line => indent + line);
509
561
  }
510
562
 
511
- /** The block-grid brand mark as accent lines; `shimmer` declares the terminal-clocked shine sweep. */
563
+ /** The generated brand mark as accent lines; `shimmer` declares the terminal-clocked shine sweep. */
512
564
  export function logoNode(lines: readonly string[], shimmer: boolean): NativeNode {
513
565
  return col(
514
566
  lines.map(line =>
@@ -8,6 +8,7 @@ import { code, md, span } from "../native/describe";
8
8
  import type { NativeNode } from "../native/node";
9
9
  import { getMarkdownTheme, highlightCode, type Theme } from "../theme/theme";
10
10
  import { formatDuration, formatExpandHint, formatMoreItems, formatStatusIcon, replaceTabs } from "./render-utils";
11
+ import { wrapPrefixedLine } from "./hanging-wrap";
11
12
  import {
12
13
  describeOutputBlock,
13
14
  type NativeOutputBlockSection,
@@ -167,6 +168,7 @@ export function renderCodeCell(options: CodeCellOptions, theme: Theme): string[]
167
168
  }
168
169
  }
169
170
 
171
+ const numberedCodeLines: string[] = [];
170
172
  if (lineNumberWidth > 0 && visibleLineNumbers) {
171
173
  for (let i = 0; i < codeLines.length; i++) {
172
174
  const lineNum = visibleLineNumbers[i];
@@ -174,10 +176,14 @@ export function renderCodeCell(options: CodeCellOptions, theme: Theme): string[]
174
176
  lineNum !== null && lineNum !== undefined
175
177
  ? String(lineNum).padStart(lineNumberWidth, " ")
176
178
  : " ".repeat(lineNumberWidth);
177
- codeLines[i] = theme.fg("dim", `${gutter} `) + codeLines[i];
179
+ const prefix = theme.fg("dim", `${gutter} `);
180
+ numberedCodeLines.push(
181
+ ...wrapPrefixedLine(prefix, " ".repeat(lineNumberWidth + 1), codeLines[i]!, outputBlockContentWidth(width)),
182
+ );
178
183
  }
179
184
  }
180
185
 
186
+ const displayCodeLines = lineNumberWidth > 0 ? numberedCodeLines : codeLines;
181
187
  if (hiddenCodeLines > 0) {
182
188
  const hint = formatExpandHint(theme, expanded, hiddenCodeLines > 0);
183
189
  const gutterPad = lineNumberWidth > 0 ? " ".repeat(lineNumberWidth + 1) : "";
@@ -185,16 +191,16 @@ export function renderCodeCell(options: CodeCellOptions, theme: Theme): string[]
185
191
  // Earlier rows scrolled above the live tail window — mark them on top so
186
192
  // the newest streamed line stays pinned to the bottom of the box.
187
193
  const earlier = `… ${hiddenCodeLines} earlier line${hiddenCodeLines === 1 ? "" : "s"}${hint ? ` ${hint}` : ""}`;
188
- codeLines.unshift(theme.fg("dim", gutterPad + earlier));
194
+ displayCodeLines.unshift(theme.fg("dim", gutterPad + earlier));
189
195
  } else {
190
196
  const moreLine = `${formatMoreItems(hiddenCodeLines, "line")}${hint ? ` ${hint}` : ""}`;
191
- codeLines.push(theme.fg("dim", gutterPad + moreLine));
197
+ displayCodeLines.push(theme.fg("dim", gutterPad + moreLine));
192
198
  }
193
199
  }
194
200
 
195
201
  const outputLines = renderCellOutput(output, expanded, outputMaxLines, theme);
196
202
 
197
- const sections: Array<{ label?: string; lines: readonly string[] }> = [{ lines: codeLines }];
203
+ const sections: Array<{ label?: string; lines: readonly string[] }> = [{ lines: displayCodeLines }];
198
204
  if (outputLines.length > 0) {
199
205
  sections.push({ label: theme.fg("toolTitle", "Output"), lines: outputLines });
200
206
  }
@@ -252,7 +258,14 @@ export function describeCodeCell(options: Omit<CodeCellOptions, "width">, theme:
252
258
  const sections: NativeOutputBlockSection[] = [
253
259
  {
254
260
  key: "code",
255
- body: [code(options.code ?? "", { lang: options.language, start, numbers: start !== undefined || undefined })],
261
+ body: [
262
+ code(options.code ?? "", {
263
+ lang: options.language,
264
+ start,
265
+ numbers: start !== undefined || undefined,
266
+ wrap: true,
267
+ }),
268
+ ],
256
269
  },
257
270
  ];
258
271
  const output = describeCellOutput(options.output, expanded, outputMaxLines);
@@ -0,0 +1,7 @@
1
+ import type { Theme } from "../theme/theme";
2
+ /** Wrap content under a distinct first-row prefix, preserving its column on continuation rows. */
3
+ export declare function wrapPrefixedLine(prefix: string, continuationPrefix: string, content: string, width: number): string[];
4
+ /** Preserve a tree connector's ancestor guides while hanging a long node beneath its label. */
5
+ export declare function wrapTreeLine(line: string, width: number, theme: Theme): string[] | undefined;
6
+ /** Hang a tree row beneath its connector, or a connector-less detail row beneath its guide rails. */
7
+ export declare function wrapGuidedLine(line: string, width: number, theme: Theme): string[];
@@ -0,0 +1,58 @@
1
+ import { visibleWidth, wrapTextWithAnsi } from "../utils";
2
+ import type { Theme } from "../theme/theme";
3
+
4
+ /** Wrap content under a distinct first-row prefix, preserving its column on continuation rows. */
5
+ export function wrapPrefixedLine(prefix: string, continuationPrefix: string, content: string, width: number): string[] {
6
+ const prefixWidth = visibleWidth(prefix);
7
+ const continuationWidth = visibleWidth(continuationPrefix);
8
+ if (width <= Math.max(prefixWidth, continuationWidth)) return wrapTextWithAnsi(prefix + content, width);
9
+ const rows = wrapTextWithAnsi(content, width - Math.max(prefixWidth, continuationWidth));
10
+ return rows.map((row, index) => `${index === 0 ? prefix : continuationPrefix}${row}`);
11
+ }
12
+
13
+ /** Preserve a tree connector's ancestor guides while hanging a long node beneath its label. */
14
+ export function wrapTreeLine(line: string, width: number, theme: Theme): string[] | undefined {
15
+ for (const connector of [theme.tree.branch, theme.tree.last]) {
16
+ const marker = theme.fg("dim", connector);
17
+ const index = line.indexOf(marker);
18
+ if (index < 0) continue;
19
+ const before = line.slice(0, index);
20
+ if (!/^ *$/.test(Bun.stripANSI(before).replaceAll(theme.tree.vertical, ""))) continue;
21
+ const end = index + marker.length + 1;
22
+ if (line[end - 1] !== " ") continue;
23
+ const connectorWidth = visibleWidth(connector);
24
+ const guide =
25
+ connector === theme.tree.last
26
+ ? " ".repeat(connectorWidth)
27
+ : `${theme.fg("dim", theme.tree.vertical)}${" ".repeat(Math.max(0, connectorWidth - visibleWidth(theme.tree.vertical)))}`;
28
+ return wrapPrefixedLine(line.slice(0, end), `${before}${guide} `, line.slice(end), width);
29
+ }
30
+ return undefined;
31
+ }
32
+
33
+ const SGR = /\x1b\[[0-9;]*m/y;
34
+
35
+ /** End of the leading run of spaces, `vertical` guides and SGR escapes that a tree detail row hangs beneath. */
36
+ function guideRunEnd(line: string, vertical: string): number {
37
+ let index = 0;
38
+ while (index < line.length) {
39
+ if (line[index] === " ") {
40
+ index++;
41
+ } else if (vertical && line.startsWith(vertical, index)) {
42
+ index += vertical.length;
43
+ } else {
44
+ SGR.lastIndex = index;
45
+ if (!SGR.test(line)) break;
46
+ index = SGR.lastIndex;
47
+ }
48
+ }
49
+ return index;
50
+ }
51
+
52
+ /** Hang a tree row beneath its connector, or a connector-less detail row beneath its guide rails. */
53
+ export function wrapGuidedLine(line: string, width: number, theme: Theme): string[] {
54
+ const tree = wrapTreeLine(line, width, theme);
55
+ if (tree) return tree;
56
+ const prefix = line.slice(0, guideRunEnd(line, theme.tree.vertical));
57
+ return wrapPrefixedLine(prefix, prefix, line.slice(prefix.length), width);
58
+ }
@@ -70,6 +70,8 @@ export declare function urlHyperlinkAlways(url: string, displayText: string): st
70
70
  * Non-HTTP(S) or unparsable URLs yield a plain span.
71
71
  */
72
72
  export declare function urlLinkSpan(target: string, displayText: string, s?: string): TspSpan;
73
+ /** Whether native link spans and nodes may carry an `href`: everything but an explicit `off` policy. */
74
+ export declare function isNativeLinkEnabled(): boolean;
73
75
  /**
74
76
  * A native link span to a filesystem path as a plain `file://` URI (the
75
77
  * terminal opens it through the system; the location stays in the visible
@@ -196,6 +196,11 @@ export function urlLinkSpan(target: string, displayText: string, s = "link"): Ts
196
196
  }
197
197
  }
198
198
 
199
+ /** Whether native link spans and nodes may carry an `href`: everything but an explicit `off` policy. */
200
+ export function isNativeLinkEnabled(): boolean {
201
+ return hyperlinkMode !== "off";
202
+ }
203
+
199
204
  /**
200
205
  * A native link span to a filesystem path as a plain `file://` URI (the
201
206
  * terminal opens it through the system; the location stays in the visible
@@ -105,14 +105,22 @@ export declare function cappedHeadLines(lines: readonly string[], max: number):
105
105
  };
106
106
  /** Get the first nonblank preview lines, trimmed and width-capped. */
107
107
  export declare function getPreviewLines(text: string, maxLines: number, maxLineLen: number, ellipsis?: Ellipsis): string[];
108
+ /** Collapse a possibly multi-line string into one line; whitespace runs (tabs included) become one space. */
109
+ export declare function flattenLine(text: string): string;
108
110
  /**
109
111
  * Collapse a possibly multi-line string into a single line, then truncate it to
110
112
  * `maxWidth` display cells. {@link truncateToWidth} alone caps width but
111
113
  * newlines are zero-width, so multi-line content (markdown briefs, tool args,
112
114
  * provider errors) would otherwise spill a single status row across several
113
- * visual lines. Whitespace runs collapse to one space, so tabs are handled too.
115
+ * visual lines.
114
116
  */
115
117
  export declare function previewLine(text: string, maxWidth: number, ellipsis?: Ellipsis): string;
118
+ /**
119
+ * Clip a result row to `maxWidth` only while collapsed. Expanded rows stay whole
120
+ * so the frame wraps them: the expand key ({@link formatExpandHint}) must reveal
121
+ * text, not re-clip it.
122
+ */
123
+ export declare function clipCollapsed(text: string, maxWidth: number, expanded: boolean | undefined): string;
116
124
  /**
117
125
  * Extract domain from URL, stripping www. prefix.
118
126
  */
@@ -233,15 +233,29 @@ export function getPreviewLines(text: string, maxLines: number, maxLineLen: numb
233
233
  return lines.slice(0, maxLines).map(l => truncateToWidth(l.trim(), maxLineLen, ellipsis));
234
234
  }
235
235
 
236
+ /** Collapse a possibly multi-line string into one line; whitespace runs (tabs included) become one space. */
237
+ export function flattenLine(text: string): string {
238
+ return text.replace(/\s+/g, " ").trim();
239
+ }
240
+
236
241
  /**
237
242
  * Collapse a possibly multi-line string into a single line, then truncate it to
238
243
  * `maxWidth` display cells. {@link truncateToWidth} alone caps width but
239
244
  * newlines are zero-width, so multi-line content (markdown briefs, tool args,
240
245
  * provider errors) would otherwise spill a single status row across several
241
- * visual lines. Whitespace runs collapse to one space, so tabs are handled too.
246
+ * visual lines.
242
247
  */
243
248
  export function previewLine(text: string, maxWidth: number, ellipsis?: Ellipsis): string {
244
- return truncateToWidth(text.replace(/\s+/g, " ").trim(), maxWidth, ellipsis);
249
+ return truncateToWidth(flattenLine(text), maxWidth, ellipsis);
250
+ }
251
+
252
+ /**
253
+ * Clip a result row to `maxWidth` only while collapsed. Expanded rows stay whole
254
+ * so the frame wraps them: the expand key ({@link formatExpandHint}) must reveal
255
+ * text, not re-clip it.
256
+ */
257
+ export function clipCollapsed(text: string, maxWidth: number, expanded: boolean | undefined): string {
258
+ return expanded ? text : truncateToWidth(text, maxWidth);
245
259
  }
246
260
 
247
261
  // =============================================================================
@@ -32,7 +32,7 @@ export interface RenderCache {
32
32
  lines: string[];
33
33
  }
34
34
  /** Build indentation and continuing branches for ancestor levels. */
35
- export declare function buildTreePrefix(ancestors: boolean[], theme: Theme): string;
35
+ export declare function buildTreePrefix(ancestors: readonly boolean[], theme: Theme): string;
36
36
  /** Return the branch glyph for a final or continuing tree item. */
37
37
  export declare function getTreeBranch(isLast: boolean, theme: Theme): string;
38
38
  /** Return the continuation prefix for subsequent lines of a tree item. */
@@ -76,7 +76,7 @@ export interface RenderCache {
76
76
  }
77
77
 
78
78
  /** Build indentation and continuing branches for ancestor levels. */
79
- export function buildTreePrefix(ancestors: boolean[], theme: Theme): string {
79
+ export function buildTreePrefix(ancestors: readonly boolean[], theme: Theme): string {
80
80
  return ancestors.map(hasNext => (hasNext ? `${theme.tree.vertical} ` : " ")).join("");
81
81
  }
82
82
 
@@ -25,7 +25,6 @@ export class WidthAwareText implements Component {
25
25
  #cachedContentWidth = -1;
26
26
  #cachedText: string | undefined;
27
27
  #ignoreTight = false;
28
- #nativeCols = -1;
29
28
  #native: NativeNode | undefined;
30
29
 
31
30
  constructor(format: (contentWidth: number) => string, paddingX = 1, paddingY = 1) {
@@ -71,10 +70,12 @@ export class WidthAwareText implements Component {
71
70
  * (the only width a describer knows) and again on `reformat()`.
72
71
  */
73
72
  describe(cx: DescribeContext): NativeNode {
74
- if (this.#cachedText === undefined || cx.cols !== this.#nativeCols) {
75
- this.#nativeCols = cx.cols;
76
- this.#cachedContentWidth = -1;
77
- this.#cachedText = this.#format(Math.max(1, cx.cols));
73
+ // Native describe emits no padding, so the formatter gets the full
74
+ // column budget — the same width a native Text.describe() would use.
75
+ const contentWidth = Math.max(1, cx.cols);
76
+ if (this.#cachedText === undefined || contentWidth !== this.#cachedContentWidth) {
77
+ this.#cachedContentWidth = contentWidth;
78
+ this.#cachedText = this.#format(contentWidth);
78
79
  this.#inner.setText(this.#cachedText);
79
80
  }
80
81
  this.#native ??= col([this.#inner]);