@linxiraos/pi-tui 1.2.0 → 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 (276) hide show
  1. package/CHANGELOG.md +188 -120
  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 +22 -26
  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 +35 -43
  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/agent-hub.ts +2 -2
  157. package/src/overlays/annotation-overlay.ts +422 -168
  158. package/src/overlays/approval-dialog.ts +389 -0
  159. package/src/overlays/ask-dialog.ts +244 -105
  160. package/src/overlays/btw-history-panel.ts +208 -147
  161. package/src/overlays/extensions/extension-list.ts +4 -6
  162. package/src/overlays/hook-editor.ts +90 -32
  163. package/src/overlays/hook-input.ts +105 -19
  164. package/src/overlays/job-tree.ts +177 -0
  165. package/src/overlays/jobs-panel.ts +222 -142
  166. package/src/overlays/login-dialog.ts +201 -91
  167. package/src/overlays/mcp-add-wizard.ts +562 -279
  168. package/src/overlays/model-browser.ts +32 -7
  169. package/src/overlays/model-hub.ts +4 -10
  170. package/src/overlays/model-picker.ts +58 -21
  171. package/src/overlays/pause-screen.ts +81 -23
  172. package/src/overlays/plan-review-overlay.ts +16 -0
  173. package/src/overlays/plugin-selector.ts +17 -1
  174. package/src/overlays/plugin-settings.ts +8 -2
  175. package/src/overlays/queue-mode-selector.ts +10 -0
  176. package/src/overlays/report-panel.ts +6 -2
  177. package/src/overlays/reset-usage-selector.ts +130 -21
  178. package/src/overlays/session-account-selector.ts +36 -0
  179. package/src/overlays/session-info-overlay.ts +199 -76
  180. package/src/overlays/session-selector.ts +15 -10
  181. package/src/overlays/settings-selector.ts +3 -0
  182. package/src/overlays/show-images-selector.ts +1 -0
  183. package/src/overlays/snapcompact-shape-preview.ts +13 -6
  184. package/src/overlays/theme-selector.ts +6 -1
  185. package/src/overlays/thinking-selector.ts +8 -4
  186. package/src/overlays/tree-selector.ts +117 -2
  187. package/src/overlays/usage-dashboard.ts +498 -144
  188. package/src/prompt/composer-cache.ts +4 -6
  189. package/src/prompt/composer.ts +196 -96
  190. package/src/prompt/custom-editor.ts +168 -65
  191. package/src/prompt/image-references.d.ts +22 -0
  192. package/src/prompt/image-references.ts +79 -2
  193. package/src/prompt/internal-url-autocomplete.ts +1 -0
  194. package/src/prompt/macos-spelling.ts +28 -5
  195. package/src/prompt/prose-gate.ts +3 -0
  196. package/src/prompt/queued-messages.ts +9 -2
  197. package/src/prompt/tips.txt +1 -2
  198. package/src/prompt/typing-word.ts +59 -0
  199. package/src/prompt/welcome.ts +256 -423
  200. package/src/render/code-cell.ts +18 -5
  201. package/src/render/hanging-wrap.d.ts +7 -0
  202. package/src/render/hanging-wrap.ts +58 -0
  203. package/src/render/hyperlink.d.ts +2 -0
  204. package/src/render/hyperlink.ts +5 -0
  205. package/src/render/render-utils.d.ts +9 -1
  206. package/src/render/render-utils.ts +16 -2
  207. package/src/render/utils.d.ts +1 -1
  208. package/src/render/utils.ts +1 -1
  209. package/src/render/width-aware-text.ts +6 -5
  210. package/src/setup/scenes/splash.ts +137 -60
  211. package/src/status-line/component.ts +23 -10
  212. package/src/status-line/context-usage.ts +176 -73
  213. package/src/status-line/footer.ts +18 -35
  214. package/src/status-line/host.ts +6 -1
  215. package/src/status-line/segments.ts +16 -5
  216. package/src/status-line/startup.ts +6 -6
  217. package/src/status-line/types.ts +1 -0
  218. package/src/stdin-buffer.d.ts +7 -0
  219. package/src/stdin-buffer.ts +11 -1
  220. package/src/terminal-capabilities.d.ts +16 -1
  221. package/src/terminal-capabilities.ts +66 -28
  222. package/src/terminal-multiplexer.d.ts +7 -0
  223. package/src/terminal-multiplexer.ts +14 -0
  224. package/src/terminal.d.ts +8 -4
  225. package/src/terminal.ts +160 -46
  226. package/src/theme/symbols.d.ts +1 -1
  227. package/src/theme/symbols.ts +1 -0
  228. package/src/theme/theme-class.ts +1 -0
  229. package/src/theme/theme.d.ts +11 -0
  230. package/src/theme/theme.ts +46 -14
  231. package/src/thinking.d.ts +5 -0
  232. package/src/thinking.ts +9 -1
  233. package/src/tmux.d.ts +39 -4
  234. package/src/tmux.ts +110 -16
  235. package/src/tools/ask.ts +20 -9
  236. package/src/tools/ast-edit.d.ts +2 -2
  237. package/src/tools/ast-edit.ts +30 -7
  238. package/src/tools/ast-grep.d.ts +2 -2
  239. package/src/tools/ast-grep.ts +39 -35
  240. package/src/tools/bash.d.ts +6 -1
  241. package/src/tools/bash.ts +15 -9
  242. package/src/tools/debug.ts +2 -1
  243. package/src/tools/default-renderer.ts +3 -2
  244. package/src/tools/edit.d.ts +2 -0
  245. package/src/tools/edit.ts +100 -21
  246. package/src/tools/eval.d.ts +53 -4
  247. package/src/tools/eval.ts +173 -137
  248. package/src/tools/find.d.ts +14 -4
  249. package/src/tools/find.ts +71 -18
  250. package/src/tools/glob.d.ts +3 -3
  251. package/src/tools/glob.ts +51 -17
  252. package/src/tools/grep.d.ts +2 -2
  253. package/src/tools/grep.ts +99 -22
  254. package/src/tools/grouped-file-output.d.ts +9 -4
  255. package/src/tools/grouped-file-output.ts +21 -6
  256. package/src/tools/lsp.ts +30 -18
  257. package/src/tools/mcp.ts +2 -2
  258. package/src/tools/native-view.d.ts +1 -1
  259. package/src/tools/native-view.ts +2 -2
  260. package/src/tools/read.d.ts +32 -10
  261. package/src/tools/read.ts +89 -43
  262. package/src/tools/renderer.d.ts +2 -0
  263. package/src/tools/renderer.ts +2 -0
  264. package/src/tools/task.d.ts +31 -0
  265. package/src/tools/task.ts +40 -7
  266. package/src/tools/todo.ts +3 -2
  267. package/src/tools/wait.ts +16 -30
  268. package/src/tools/web-search.ts +2 -1
  269. package/src/tools/write.d.ts +2 -0
  270. package/src/tools/write.ts +36 -12
  271. package/src/tools/yield.ts +2 -2
  272. package/src/tui.d.ts +20 -3
  273. package/src/tui.ts +140 -36
  274. package/src/utils.ts +5 -3
  275. package/dist/types/tools/eval-format/code-mode-declarations.d.ts +0 -5
  276. package/src/tools/eval-format/code-mode-declarations.ts +0 -61
@@ -2,57 +2,95 @@ import { APP_NAME } from "@linxiraos/pi-utils/dirs";
2
2
  import type { TspSpan } from "@linxiraos/pi-wire";
3
3
  import { formatDoubleTap, formatKeyHint, formatKeyHints, type KeyName } from "../app-keybindings";
4
4
  import { editorKey } from "../chrome/keybinding-hints";
5
+ import { tuiText } from "../i18n";
5
6
  import { getKeybindings, type Keybinding } from "../keybindings";
6
- import { card, col, kbd, keyed, node, row, span, text } from "../native/describe";
7
- import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "../native/node";
8
- import { runTranscriptAction } from "../chat/transcript-actions";
9
- import { compactText, plainLine, plainText } from "../native/spans";
10
-
7
+ import { card, col, keyed, node, row, span, text } from "../native/describe";
8
+ import type { DescribeContext, NativeChild, NativeNode } from "../native/node";
9
+ import { compactText, plainText } from "../native/spans";
11
10
  import { isNativeRendering } from "../native/state";
12
11
  import { isHyperlinkEnabled, urlHyperlink, urlLinkSpan } from "../render/hyperlink";
13
12
  import { TERMINAL } from "../terminal-capabilities";
14
13
  import { theme } from "../theme/theme";
15
14
  import type { Component } from "../tui";
16
- import { padding, replaceTabs, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
15
+ import { padding, replaceTabs, visibleWidth, wrapTextWithAnsi } from "../utils";
17
16
  import tipsText from "./tips.txt" with { type: "text" };
18
17
 
19
- /** Leading marker for a tip that pitches Tern: shown only while no TSP surface
20
- * is live, since a live one means the user is already in Tern. */
21
- const NO_TERN_TIP_MARKER = /^\[NO-TERN\]\s*/;
18
+ /** Trailing marker that flags a tip as a "what's new" callout. Stripped before
19
+ * wrapping (with any preceding whitespace) and replaced by {@link NEW_TAG_TEXT}
20
+ * painted as a shimmering rainbow. Non-global so `.test` stays stateless. */
21
+ const NEW_TIP_MARKER = /\s*\[NEW\]\s*$/;
22
+
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
25
+ * resolved from the host catalogue under `tipN`, N = the 1-based line number. */
26
+ interface TipLine {
27
+ /** 1-based tips.txt line number; the catalogue key is `tip${line}`. */
28
+ readonly line: number;
29
+ /** Raw line, marker intact — what the weighted picker selects from. */
30
+ readonly raw: string;
31
+ /** English body with the `[NEW]` marker stripped: the `tuiText` fallback. */
32
+ readonly english: string;
33
+ /** "What's new" callout; carries the heavier selection weight. */
34
+ readonly isNew: boolean;
35
+ }
22
36
 
23
37
  /** tips.txt lines embedded at build time; blanks dropped. Key placeholders
24
- * (see {@link expandTipKeys}) stay raw until render time. */
25
- const TIP_LINES: readonly string[] = tipsText
26
- .split("\n")
27
- .map(line => line.trim())
28
- .filter(line => line.length > 0);
38
+ * (see {@link expandTipKeys}) and markdown links stay raw until render time. */
39
+ const TIP_LINES: readonly TipLine[] = tipsText.split("\n").flatMap((line, index) => {
40
+ const raw = line.trim();
41
+ if (raw.length === 0) return [];
42
+ return [
43
+ {
44
+ line: index + 1,
45
+ raw,
46
+ english: raw.replace(NEW_TIP_MARKER, ""),
47
+ isNew: NEW_TIP_MARKER.test(raw),
48
+ },
49
+ ];
50
+ });
51
+
52
+ /** Every tip's raw line, so {@link pickWeightedTip} can still read the
53
+ * trailing {@link NEW_TIP_MARKER} for its selection weight. */
54
+ const TIPS: readonly string[] = TIP_LINES.map(tip => tip.raw);
55
+
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}`);
29
59
 
30
60
  /**
31
- * Fixed number of session rows in the welcome box so its height stays stable
32
- * across recent-session updates.
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.
33
63
  */
34
- export const WELCOME_SESSION_SLOTS = 4;
64
+ export type TuiTipFilter = (key: string, raw: string) => boolean;
65
+
66
+ let tipFilter: TuiTipFilter | undefined;
35
67
 
36
68
  /**
37
- * Fixed number of LSP-server rows, for the same reason. Overflow is sliced so
38
- * the box height is constant regardless of how many servers a project has.
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.
39
72
  */
40
- export const WELCOME_LSP_SLOTS = 4;
41
-
42
- /** Tips for the classic renderer: every tip, {@link NO_TERN_TIP_MARKER} stripped. */
43
- const TIPS: readonly string[] = TIP_LINES.map(line => line.replace(NO_TERN_TIP_MARKER, ""));
44
-
45
- /** Tips for a live TSP surface: those marked {@link NO_TERN_TIP_MARKER} dropped. */
46
- const TSP_TIPS: readonly string[] = TIP_LINES.filter(line => !NO_TERN_TIP_MARKER.test(line));
73
+ export function setTuiTipFilter(filter: TuiTipFilter | undefined): void {
74
+ tipFilter = filter;
75
+ }
47
76
 
48
- /** Trailing marker that flags a tip as a "what's new" callout. Stripped before
49
- * wrapping (with any preceding whitespace) and replaced by {@link NEW_TAG_TEXT}
50
- * painted as a shimmering rainbow. Non-global so `.test` stays stateless. */
51
- const NEW_TIP_MARKER = /\s*\[NEW\]\s*$/;
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
+ }
52
87
 
53
88
  /** Visible text rendered in place of {@link NEW_TIP_MARKER}. */
54
89
  const NEW_TAG_TEXT = "NEW!";
55
90
 
91
+ /** Inline English for the nerdfont nudge tip (`welcomeNerdFontJoke`). */
92
+ const NERDFONT_TIP = "Please use nerdfont 😭.";
93
+
56
94
  /** Milliseconds for one full hue rotation of the rainbow "NEW!" tag. */
57
95
  const NEW_GLOW_PERIOD_MS = 1500;
58
96
 
@@ -75,16 +113,33 @@ export function pickWeightedTip(tips: readonly string[], r: number): string {
75
113
  return tips[tips.length - 1] ?? "";
76
114
  }
77
115
 
116
+ /** A tip resolved through the host catalogue: the localised body plus the
117
+ * structural "[NEW]" flag, which lives in tips.txt rather than the translated
118
+ * text (so a translation never has to carry the marker). */
119
+ interface ResolvedTip {
120
+ readonly text: string;
121
+ readonly isNew: boolean;
122
+ }
123
+
124
+ /** Localise a picked tip. `raw` is a {@link TIPS} member (or the
125
+ * nerdfont nudge); the catalogue is consulted live, so `/language` applies. */
126
+ function resolveTip(raw: string): ResolvedTip {
127
+ if (raw === NERDFONT_TIP) return { text: tuiText("welcomeNerdFontJoke", NERDFONT_TIP), isNew: false };
128
+ const entry = TIP_LINES.find(tip => tip.raw === raw);
129
+ if (entry === undefined) return { text: raw, isNew: NEW_TIP_MARKER.test(raw) };
130
+ return { text: tuiText(`tip${entry.line}`, entry.english), isNew: entry.isNew };
131
+ }
132
+
78
133
  type ColorEncoding = "ansi-16m" | "ansi-256";
79
134
 
80
- /** Paint each glyph of {@link NEW_TAG_TEXT} on a moving HSL rainbow. `phase`
81
- * rotates the hue offset cyclically; successive renders with increasing phase
82
- * shimmer, while a fixed phase yields a still rainbow. */
83
- function renderNewTag(phase: number, encoding: ColorEncoding): string {
135
+ /** Paint each glyph of `tagText` on a moving HSL rainbow. `phase` rotates the
136
+ * hue offset cyclically; successive renders with increasing phase shimmer,
137
+ * while a fixed phase yields a still rainbow. */
138
+ function renderNewTag(tagText: string, phase: number, encoding: ColorEncoding): string {
84
139
  const bold = "\x1b[1m";
85
140
  const reset = "\x1b[0m";
86
141
  const wrapped = ((phase % 1) + 1) % 1;
87
- const chars = [...NEW_TAG_TEXT];
142
+ const chars = [...tagText];
88
143
  let out = bold;
89
144
  let prev = "";
90
145
  for (let i = 0; i < chars.length; i++) {
@@ -132,7 +187,7 @@ function expandTipKeys(tip: string): string {
132
187
  });
133
188
  }
134
189
 
135
- /** 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. */
136
191
  const TIP_LINK = /\[([^\]]+)\]\(([^\s)]+)\)/g;
137
192
 
138
193
  /** Tip links as underlined OSC 8 labels, or `label (url)` where the terminal cannot link. */
@@ -158,101 +213,87 @@ function tipSpans(tip: string): TspSpan[] {
158
213
  }
159
214
 
160
215
  /**
161
- * The welcome tip as lines of at most `width` columns: `Tip:` and the body
162
- * wrapped together, with no indent, so the banner can center each line.
163
- * `[]` when `width` leaves no room for a useful line.
216
+ * The welcome tip as lines of at most `width` columns: the `welcomeTipLabel`
217
+ * label and the body wrapped together, with no indent, so the banner can center
218
+ * each line. `isNew` drives the rainbow "welcomeNewTag" suffix; it is passed in
219
+ * because the marker lives in tips.txt, not in the resolved body. `[]` when
220
+ * `width` leaves no room for a useful line.
164
221
  */
165
- export function renderWelcomeTip(tip: string, width: number, phase = 0): string[] {
166
- const label = "Tip: ";
167
- const labelWidth = visibleWidth(label);
168
- const bodyBudget = width - labelWidth;
169
- if (bodyBudget < 8) return [];
222
+ export function renderWelcomeTip(tip: string, width: number, phase = 0, isNew = NEW_TIP_MARKER.test(tip)): string[] {
223
+ const label = tuiText("welcomeTipLabel", "Tip: ");
224
+ if (width - visibleWidth(label) < 8) return [];
170
225
 
171
- const isNew = NEW_TIP_MARKER.test(tip);
172
226
  const body = linkTipText(expandTipKeys(isNew ? tip.replace(NEW_TIP_MARKER, "") : tip));
173
227
 
174
- const wrappedBody = wrapTextWithAnsi(replaceTabs(body), bodyBudget);
175
- if (wrappedBody.length === 0) return [];
228
+ // Trailing spaces left by the wrap would count toward the width the banner centers.
229
+ const wrapped = wrapTextWithAnsi(replaceTabs(`${label}${body}`), width).map(line => line.trimEnd());
230
+ if (wrapped.length === 0) return [];
176
231
 
177
232
  // Pull both colors from the active theme so the line stays readable on light
178
233
  // themes; the previous hardcoded `#b48cff` / `#9ccfff` pastels (plus a manual
179
234
  // `\x1b[2m` dim on the body) dropped to ~1.5:1 contrast on a white background.
180
- const continuationIndent = padding(labelWidth);
181
235
  const styledLabel = theme.fg("customMessageLabel", label);
182
-
183
- const lines = wrappedBody.map((line, index) => {
184
- const styledBody = theme.fg("muted", line);
185
- const content = index === 0 ? `${styledLabel}${styledBody}` : `${styledBody}`;
186
- return theme.italic(content);
187
- });
236
+ const lines = wrapped.map((line, index) =>
237
+ theme.italic(
238
+ index === 0 && line.startsWith(label)
239
+ ? `${styledLabel}${theme.fg("muted", line.slice(label.length))}`
240
+ : theme.fg("muted", line),
241
+ ),
242
+ );
188
243
 
189
244
  if (isNew) {
190
- // Append the rainbow tag to the final body line when it fits within the
191
- // box; otherwise drop it onto its own indented continuation line so the
192
- // styled glyphs never overflow or reflow the wrapped body.
245
+ // Append the rainbow tag to the final line when it fits; otherwise give it
246
+ // a line of its own so the styled glyphs never overflow the width.
193
247
  const encoding: ColorEncoding = TERMINAL.trueColor ? "ansi-16m" : "ansi-256";
194
- const tag = renderNewTag(phase, encoding);
195
- const tagWidth = 1 + visibleWidth(NEW_TAG_TEXT); // 1 = space separator
248
+ const tagText = tuiText("welcomeNewTag", NEW_TAG_TEXT);
249
+ const tag = renderNewTag(tagText, phase, encoding);
250
+ const tagWidth = 1 + visibleWidth(tagText); // 1 = space separator
196
251
  const lastLine = lines[lines.length - 1];
197
252
  if (lastLine !== undefined && visibleWidth(lastLine) + tagWidth <= width) {
198
253
  lines[lines.length - 1] = `${lastLine} ${tag}`;
199
254
  } else {
200
- lines.push(continuationIndent + tag);
255
+ lines.push(tag);
201
256
  }
202
257
  }
203
258
 
204
259
  return lines;
205
260
  }
206
261
 
207
- export interface RecentSession {
208
- name: string;
209
- timeAgo: string;
210
- /** Session file; a native click on the row resumes it. */
211
- path?: string;
212
- }
213
-
214
- export interface LspServerInfo {
215
- name: string;
216
- status: "ready" | "error" | "connecting" | "available";
217
- fileTypes: string[];
218
- }
219
-
220
262
  /**
221
- * Premium welcome screen with block-based ZETA logo and two-column layout.
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
266
+ * not fit) and the tip of the session (dropped below {@link TIP_MIN_COLUMNS}
267
+ * columns). Natively: a card with the same mark, wordmark, version and tip
268
+ * ({@link WelcomeComponent.describe}).
222
269
  */
223
270
  export class WelcomeComponent implements Component {
224
271
  #animStart: number | null = null;
225
272
  #animTimer: Timer | null = null;
226
273
  #requestRender: (() => void) | null = null;
227
274
  // Tip randomness is latched once so the tip is stable across renders, but
228
- // the nerdfont-nag gate re-reads the live preset and the pool re-reads the
229
- // live TSP state: the startup prepaint can run under the default "unicode"
230
- // preset before settings resolve the real one, and an optimistic Tern start
231
- // 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.
232
277
  #nagRoll: number | undefined;
233
278
  #tipRoll: number | undefined;
234
279
  // Render cache: the welcome box is the first transcript-area component, so
235
280
  // returning a stable array reference keeps the whole frame prefix stable.
236
- // Bypassed while the intro animation runs (every frame differs).
281
+ // Bypassed while the intro animation runs (every frame differs). The
282
+ // `#cachedLang` sentinel retires the cache when `/language` swaps the
283
+ // catalogue, so a live switch repaints instead of serving stale text.
237
284
  #cachedWidth = -1;
238
285
  #cachedLines: string[] | undefined;
239
- #native: { tip: string | undefined; node: NativeNode } | undefined;
240
-
241
- constructor(
242
- private version: string,
243
- private modelName: string = "",
244
- private providerName: string = "",
245
- private recentSessions: RecentSession[] = [],
246
- /** Detected project servers; `null` means LSP is disabled and hides the section. */
247
- private lspServers: LspServerInfo[] | null = [],
248
- ) {}
286
+ #cachedLang = "";
287
+ #native: { lang: string; tip: string | undefined; node: NativeNode } | undefined;
288
+
289
+ constructor(private version: string) {}
249
290
  get tip(): string | undefined {
250
291
  this.#nagRoll ??= Math.random();
251
292
  this.#tipRoll ??= Math.random();
252
293
  if (theme.getSymbolPreset() === "unicode" && this.#nagRoll < 0.1) {
253
- return "Please use nerdfont 😭.";
294
+ return NERDFONT_TIP;
254
295
  }
255
- return pickWeightedTip(isNativeRendering() ? TSP_TIPS : TIPS, this.#tipRoll) || undefined;
296
+ return pickWeightedTip(tipPool(), this.#tipRoll) || undefined;
256
297
  }
257
298
 
258
299
  invalidate(): void {
@@ -262,25 +303,24 @@ export class WelcomeComponent implements Component {
262
303
  }
263
304
 
264
305
  /**
265
- * A `card` (`zeta.welcome`) titled with the app version. The brand column
266
- * (`zeta.welcome.brand`: greeting and the terminal's builtin `zeta` mark, which
267
- * it animates) sits beside the info column (`zeta.welcome.info`: prompt-sigil
268
- * keycaps, LSP servers, recent sessions); the tip of the session closes the card. Roles
269
- * carry the look (gradient logo, type scale, column hairline); a "[NEW]" tip
306
+ * A `card` (`zeta.welcome`) mirroring the terminal banner: the lockup
307
+ * (`zeta.welcome.lockup`: the terminal's builtin `zeta` mark, which it animates,
308
+ * beside the `Zeta` wordmark with the version and the `Linxira-Zeta`
309
+ * subline under it) and the tip of the session.
310
+ * Roles carry the look (gradient logo, type scale); a "[NEW]" tip
270
311
  * carries a terminal-clocked shimmering tag.
271
312
  */
272
313
  describe(_cx: DescribeContext): NativeNode {
273
- const tip = this.tip;
274
- if (this.#native && this.#native.tip === tip) return this.#native.node;
275
- const line = (spans: readonly TspSpan[], role?: string, key?: string): NativeNode =>
276
- keyed(text(spans, { wrap: "none", truncate: "end", role }), key ?? role ?? "");
277
- // Centered brand lines are short and fixed; the logo is multi-line art that must never truncate.
314
+ const lang = tuiText("welcomeBack", "Welcome back!");
315
+ const rawTip = this.tip;
316
+ const tip = rawTip === undefined ? undefined : resolveTip(rawTip);
317
+ if (this.#native && this.#native.lang === lang && this.#native.tip === tip?.text) return this.#native.node;
318
+ // Brand lines are short and fixed; never wrap or truncate them.
278
319
  const art = (spans: readonly TspSpan[], role: string): NativeNode =>
279
320
  keyed(text(spans, { wrap: "none", role }), role);
280
- const brand = keyed(
281
- col(
321
+ const lockupRow = keyed(
322
+ row(
282
323
  [
283
- art([span("Welcome back!", "strong")], "zeta.welcome.greeting"),
284
324
  node(
285
325
  "image",
286
326
  {
@@ -292,115 +332,39 @@ export class WelcomeComponent implements Component {
292
332
  undefined,
293
333
  "logo",
294
334
  ),
295
- ],
296
- { align: "center", role: "zeta.welcome.brand" },
297
- ),
298
- "brand",
299
- );
300
- const section = (key: string, label: string, rows: readonly NativeChild[]): NativeNode =>
301
- keyed(
302
- col([line([span(label, "dim")], "zeta.welcome.heading"), ...rows], {
303
- gap: "xs",
304
- role: `zeta.welcome.${key}`,
305
- }),
306
- key,
307
- );
308
- const shortcut = (key: string, label: string): NativeNode =>
309
- keyed(row([kbd(key), line([span(label, "muted")])], { gap: "sm" }), label);
310
- const info: NativeChild[] = [
311
- line([span(this.version, "dim mono")], "zeta.welcome.version"),
312
- section("tips", "Tips", [
313
- shortcut("#", "prompt actions"),
314
- shortcut("/", "commands"),
315
- shortcut("!", "run bash"),
316
- shortcut("$", "run python"),
317
- ]),
318
- ];
319
- if (this.lspServers !== null) {
320
- const lsp: NativeChild[] = [];
321
- if (this.lspServers.length === 0) lsp.push(line([span("No LSP servers", "dim")], undefined, "none"));
322
- for (const server of this.lspServers.slice(0, WELCOME_LSP_SLOTS)) {
323
- const [symbol, token] =
324
- server.status === "ready"
325
- ? (["status.enabled", "success"] as const)
326
- : server.status === "available"
327
- ? (["status.enabled", "dim"] as const)
328
- : server.status === "connecting"
329
- ? (["status.pending", "muted"] as const)
330
- : (["status.error", "error"] as const);
331
- lsp.push(
332
335
  keyed(
333
- row(
336
+ col(
334
337
  [
335
- text([span("●", token)], { role: "zeta.welcome.lsp-dot", title: server.status, aria: symbol }),
336
- line([span(server.name)]),
337
- ...server.fileTypes
338
- .slice(0, 3)
339
- .map(type => node("badge", { text: type, role: "zeta.welcome.lsp-type" }, undefined, type)),
338
+ art([span("Zeta", "strong")], "zeta.welcome.wordmark"),
339
+ art([span(`v${this.version}`, "dim mono")], "zeta.welcome.version"),
340
+ art([span("Linxira-Zeta", "dim")], "zeta.welcome.subline"),
340
341
  ],
341
- { gap: "sm", role: "zeta.welcome.lsp-row" },
342
+ { role: "zeta.welcome.mark" },
342
343
  ),
343
- server.name,
344
+ "mark",
344
345
  ),
345
- );
346
- }
347
- info.push(section("lsp", "LSP servers", lsp));
348
- }
349
- const recents: NativeChild[] = [];
350
- if (this.recentSessions.length === 0) recents.push(line([span("No recent sessions", "dim")], undefined, "none"));
351
- for (const [index, session] of this.recentSessions.slice(0, WELCOME_SESSION_SLOTS).entries()) {
352
- recents.push(
353
- keyed(
354
- row(
355
- [
356
- line([span(plainLine(session.name))], "zeta.welcome.session"),
357
- line([span(session.timeAgo, "dim")], "zeta.welcome.age"),
358
- ],
359
- {
360
- gap: "md",
361
- justify: "between",
362
- role: "zeta.welcome.recent",
363
- actions: session.path ? { click: "resume" } : undefined,
364
- title: session.path ? `Resume ${plainLine(session.name)}` : undefined,
365
- },
366
- ),
367
- `s${index}`,
368
- ),
369
- );
370
- }
371
- info.push(section("recents", "Recent sessions", recents));
372
- const body: NativeChild[] = [
373
- keyed(
374
- row([brand, keyed(col(info, { gap: "md", role: "zeta.welcome.info" }), "info")], {
375
- align: "start",
376
- wrap: true,
377
- role: "zeta.welcome.grid",
378
- }),
379
- "grid",
346
+ ],
347
+ { align: "center", gap: "md", role: "zeta.welcome.lockup" },
380
348
  ),
381
- ];
349
+ "lockup",
350
+ );
351
+ const body: NativeChild[] = [lockupRow];
382
352
  if (tip) {
383
- const isNew = NEW_TIP_MARKER.test(tip);
384
- const tipText = compactText(tipSpans(expandTipKeys(isNew ? tip.replace(NEW_TIP_MARKER, "") : tip)));
353
+ const tipText = compactText(tipSpans(expandTipKeys(tip.text)));
385
354
  const tipRow: NativeChild[] = [
386
355
  node("icon", { name: "lightbulb", role: "zeta.welcome.tip-icon" }),
387
356
  text(tipText, { wrap: "word", role: "zeta.welcome.tip-text" }),
388
357
  ];
389
- if (isNew) tipRow.push(node("shimmer", { text: "New", role: "zeta.welcome.new" }));
358
+ if (tip.isNew) {
359
+ tipRow.push(node("shimmer", { text: tuiText("welcomeNewTag", NEW_TAG_TEXT), role: "zeta.welcome.new" }));
360
+ }
390
361
  body.push(node("row", { gap: "sm", align: "start", role: "zeta.welcome.tip" }, tipRow, "tip"));
391
362
  }
392
- // No head row or chevron: the card is the hero; the version sits in the info column.
363
+ // No head row or chevron: the card is the hero; the version sits under the wordmark.
393
364
  const described = card({ role: "zeta.welcome" }, body);
394
- this.#native = { tip, node: described };
365
+ this.#native = { lang, tip: tip?.text, node: described };
395
366
  return described;
396
367
  }
397
- /** A click on a recent session resumes it. */
398
- handleNativeEvent(event: NativeUiEvent): void {
399
- if (event.type !== "action" || event.act !== "resume") return;
400
- const index = Number(/\/s(\d+)$/.exec(event.key)?.[1]);
401
- const path = this.recentSessions[index]?.path;
402
- if (path) runTranscriptAction({ act: "resume", path });
403
- }
404
368
 
405
369
  /** The intro keeps the welcome block mutable; settling lets it retire to history. */
406
370
  isTranscriptBlockFinalized(): boolean {
@@ -468,25 +432,10 @@ export class WelcomeComponent implements Component {
468
432
  this.invalidate();
469
433
  }
470
434
 
471
- setModel(modelName: string, providerName: string): void {
472
- this.modelName = modelName;
473
- this.providerName = providerName;
474
- this.invalidate();
475
- }
476
-
477
- setRecentSessions(sessions: RecentSession[]): void {
478
- this.recentSessions = sessions;
479
- this.invalidate();
480
- }
481
-
482
- setLspServers(servers: LspServerInfo[] | null): void {
483
- this.lspServers = servers;
484
- this.invalidate();
485
- }
486
-
487
435
  render(termWidth: number): readonly string[] {
488
436
  const animating = this.#animStart != null;
489
- if (!animating && this.#cachedLines && this.#cachedWidth === termWidth) {
437
+ const lang = tuiText("welcomeBack", "Welcome back!");
438
+ if (!animating && this.#cachedLines && this.#cachedWidth === termWidth && this.#cachedLang === lang) {
490
439
  return this.#cachedLines;
491
440
  }
492
441
  const lines = this.#renderLines(termWidth);
@@ -496,223 +445,39 @@ export class WelcomeComponent implements Component {
496
445
  } else {
497
446
  this.#cachedLines = lines;
498
447
  this.#cachedWidth = termWidth;
448
+ this.#cachedLang = lang;
499
449
  }
500
450
  return lines;
501
451
  }
502
452
 
503
453
  #renderLines(termWidth: number): string[] {
504
- // Box dimensions - responsive with max width and small-terminal support
505
- const maxWidth = 100;
506
- const boxWidth = Math.min(maxWidth, Math.max(0, termWidth - 2));
507
- if (boxWidth < 4) {
508
- return [];
509
- }
510
- const dualContentWidth = boxWidth - 3; // 3 = │ + │ + │
511
- const preferredLeftCol = 26;
512
- const minLeftCol = 12; // logo width
513
- const minRightCol = 20;
514
- // Dynamic model/provider labels are truncated inside the fixed column.
515
- // Letting them influence the responsive breakpoint changes the box height
516
- // when authoritative session data replaces the empty prepaint labels.
517
- const leftMinContentWidth = Math.max(minLeftCol, visibleWidth("Welcome back!"));
518
- const desiredLeftCol = Math.max(
519
- Math.min(preferredLeftCol, Math.max(minLeftCol, Math.floor(dualContentWidth * 0.35))),
520
- leftMinContentWidth,
521
- );
522
- const dualLeftCol =
523
- dualContentWidth >= minRightCol + 1
524
- ? Math.min(desiredLeftCol, dualContentWidth - minRightCol)
525
- : Math.max(1, dualContentWidth - 1);
526
- const dualRightCol = Math.max(1, dualContentWidth - dualLeftCol);
527
- const showRightColumn = dualLeftCol >= leftMinContentWidth && dualRightCol >= minRightCol;
528
- const leftCol = showRightColumn ? dualLeftCol : boxWidth - 2;
529
- const rightCol = showRightColumn ? dualRightCol : 0;
530
-
531
- // Logo: pick a frame from the intro animation if active, else the resting frame.
532
- const logoColored = this.#currentLogoFrame();
533
-
534
- // Left column - centered content
535
- const leftLines = [
536
- "",
537
- this.#centerText(theme.bold("Welcome back!"), leftCol),
538
- "",
539
- ...logoColored.map(l => this.#centerText(l, leftCol)),
540
- "",
541
- this.#centerText(theme.fg("muted", this.modelName), leftCol),
542
- this.#centerText(theme.fg("borderMuted", this.providerName), leftCol),
543
- ];
544
-
545
- // Right column separator
546
- const separatorWidth = Math.max(0, rightCol - 2); // padding on each side
547
- const separator = ` ${theme.fg("dim", theme.boxRound.horizontal.repeat(separatorWidth))}`;
548
-
549
- // Recent sessions content
550
- const sessionLines: string[] = [];
551
- if (this.recentSessions.length === 0) {
552
- sessionLines.push(` ${theme.fg("dim", "No recent sessions")}`);
553
- } else {
554
- // Reserve width for the bullet prefix (" • ") and the trailing " (timeAgo)"
555
- // so the relative time is never the part that gets truncated. The name
556
- // absorbs whatever space is left.
557
- const bulletPrefix = ` ${theme.md.bullet} `;
558
- const prefixWidth = visibleWidth(bulletPrefix);
559
- for (const session of this.recentSessions.slice(0, WELCOME_SESSION_SLOTS)) {
560
- const timeSuffixRaw = ` (${session.timeAgo})`;
561
- const timeWidth = visibleWidth(timeSuffixRaw);
562
- const nameBudget = Math.max(1, rightCol - prefixWidth - timeWidth);
563
- const nameVis = visibleWidth(session.name);
564
- const name = nameVis > nameBudget ? truncateToWidth(session.name, nameBudget) : session.name;
565
- sessionLines.push(
566
- `${theme.fg("dim", bulletPrefix)}${theme.fg("muted", name)}${theme.fg("dim", timeSuffixRaw)}`,
567
- );
568
- }
569
- }
570
- // Pad to the fixed slot count so the box height doesn't depend on session count.
571
- while (sessionLines.length < WELCOME_SESSION_SLOTS) {
572
- sessionLines.push("");
573
- }
574
-
575
- // Right column
576
- const rightLines = [
577
- ` ${theme.bold(theme.fg("accent", "Tips"))}`,
578
- ` ${theme.fg("dim", "#")}${theme.fg("muted", " for prompt actions")}`,
579
- ` ${theme.fg("dim", "/")}${theme.fg("muted", " for commands")}`,
580
- ` ${theme.fg("dim", "!")}${theme.fg("muted", " to run bash")}`,
581
- ` ${theme.fg("dim", "$")}${theme.fg("muted", " to run python")}`,
582
- ...this.#renderLspSection(separator),
583
- separator,
584
- ` ${theme.bold(theme.fg("accent", "Recent sessions"))}`,
585
- ...sessionLines,
586
- "",
587
- ];
588
-
589
- // Border characters (dim)
590
- const hChar = theme.boxRound.horizontal;
591
- const h = theme.fg("dim", hChar);
592
- const v = theme.fg("dim", theme.boxRound.vertical);
593
- const tl = theme.fg("dim", theme.boxRound.topLeft);
594
- const tr = theme.fg("dim", theme.boxRound.topRight);
595
- const bl = theme.fg("dim", theme.boxRound.bottomLeft);
596
- const br = theme.fg("dim", theme.boxRound.bottomRight);
597
-
598
- const lines: string[] = [];
599
-
600
- // Top border with embedded title
601
- const title = ` ${APP_NAME} v${this.version} `;
602
- const titlePrefixRaw = hChar.repeat(3);
603
- const titleStyled = theme.fg("dim", titlePrefixRaw) + theme.fg("muted", title);
604
- const titleVisLen = visibleWidth(titlePrefixRaw) + visibleWidth(title);
605
- const titleSpace = boxWidth - 2;
606
- if (titleVisLen >= titleSpace) {
607
- lines.push(tl + truncateToWidth(titleStyled, titleSpace) + tr);
608
- } else {
609
- const afterTitle = titleSpace - titleVisLen;
610
- lines.push(tl + titleStyled + theme.fg("dim", hChar.repeat(afterTitle)) + tr);
611
- }
612
-
613
- // Content rows
614
- const maxRows = showRightColumn ? Math.max(leftLines.length, rightLines.length) : leftLines.length;
615
- for (let i = 0; i < maxRows; i++) {
616
- const left = this.#fitToWidth(leftLines[i] ?? "", leftCol);
617
- if (showRightColumn) {
618
- const right = this.#fitToWidth(rightLines[i] ?? "", rightCol);
619
- lines.push(v + left + v + right + v);
620
- } else {
621
- lines.push(v + left + v);
622
- }
623
- }
624
- // Bottom border
625
- if (showRightColumn) {
626
- lines.push(bl + h.repeat(leftCol) + theme.fg("dim", theme.boxRound.teeUp) + h.repeat(rightCol) + br);
627
- } else {
628
- lines.push(bl + h.repeat(leftCol) + br);
629
- }
630
-
631
- // Randomly picked tip, rendered directly beneath the box.
632
- // The banner centers every tip line, so the tip drops out entirely on
633
- // narrow boxes rather than wrapping into unreadable slivers.
634
- if (termWidth >= 16) lines.push(...this.#renderTip(boxWidth));
635
-
454
+ // Content keeps a column clear on each side; everything centers in the full width.
455
+ const room = termWidth - 2;
456
+ if (room < 4) return [];
457
+ const logo = this.#currentLogoFrame();
458
+ const version = theme.fg("dim", `v${this.version}`);
459
+ const lockupWidth = LOGO_WIDTH + LOCKUP_GAP + Math.max(WORDMARK_WIDTH, visibleWidth(version));
460
+ const art = room >= lockupWidth ? lockup(logo, version) : room >= LOGO_WIDTH ? logo : [];
461
+ const lines = centerBlock(art, termWidth);
462
+ const tip = termWidth >= TIP_MIN_COLUMNS ? this.#renderTip(room) : [];
463
+ if (tip.length > 0) lines.push("", ...tip.flatMap(line => centerBlock([line], termWidth)));
636
464
  return lines;
637
465
  }
638
466
 
639
- /** Right-column LSP rows padded to a fixed height; empty when LSP is disabled. */
640
- #renderLspSection(separator: string): string[] {
641
- if (this.lspServers === null) return [];
642
- const lspLines: string[] = [];
643
- if (this.lspServers.length === 0) {
644
- lspLines.push(` ${theme.fg("dim", "No LSP servers")}`);
645
- } else {
646
- for (const server of this.lspServers.slice(0, WELCOME_LSP_SLOTS)) {
647
- const icon =
648
- server.status === "ready"
649
- ? theme.styledSymbol("status.enabled", "success")
650
- : server.status === "available"
651
- ? theme.styledSymbol("status.enabled", "dim")
652
- : server.status === "connecting"
653
- ? theme.styledSymbol("status.pending", "muted")
654
- : theme.styledSymbol("status.error", "error");
655
- const exts = server.fileTypes.slice(0, 3).join(" ");
656
- lspLines.push(` ${icon} ${theme.fg("muted", server.name)} ${theme.fg("dim", exts)}`);
657
- }
658
- }
659
- // Pad to the fixed slot count so the box height doesn't depend on server count.
660
- while (lspLines.length < WELCOME_LSP_SLOTS) {
661
- lspLines.push("");
662
- }
663
- return [separator, ` ${theme.bold(theme.fg("accent", "LSP Servers"))}`, ...lspLines];
664
- }
665
-
666
467
  /**
667
- * Render the per-instance tip line: the `customMessageLabel`-themed `Tip:`
668
- * label followed by a `muted` body, the whole line italicized. Returns `[]`
669
- * when no tip is available or the box is too narrow to be useful.
468
+ * The tip of the session wrapped to {@link TIP_MEASURE} columns (fewer when
469
+ * `room` is narrower); `[]` when there is none or no room for it.
670
470
  */
671
- #renderTip(boxWidth: number): string[] {
672
- const tip = this.tip;
673
- if (!tip) return [];
674
- // A trailing "[NEW]" marker paints an animated rainbow "NEW!" tag. Derive
675
- // its hue phase from wall-clock time so it shimmers across the welcome
676
- // intro's re-render frames, then settles into a still rainbow once the box
471
+ #renderTip(room: number): string[] {
472
+ const raw = this.tip;
473
+ if (!raw) return [];
474
+ // A trailing "[NEW]" marker paints an animated rainbow tag. Derive its
475
+ // hue phase from wall-clock time so it shimmers across the welcome intro's
476
+ // re-render frames, then settles into a still rainbow once the banner
677
477
  // caches its resting frame. Non-"[NEW]" tips ignore the phase entirely.
678
- const phase = NEW_TIP_MARKER.test(tip) ? performance.now() / NEW_GLOW_PERIOD_MS : 0;
679
- return renderWelcomeTip(tip, boxWidth, phase);
680
- }
681
-
682
- /** Center text within a given width */
683
- #centerText(text: string, width: number): string {
684
- const visLen = visibleWidth(text);
685
- if (visLen >= width) {
686
- return truncateToWidth(text, width);
687
- }
688
- const leftPad = Math.floor((width - visLen) / 2);
689
- const rightPad = width - visLen - leftPad;
690
- return padding(leftPad) + text + padding(rightPad);
691
- }
692
-
693
- /** Fit string to exact width with ANSI-aware truncation/padding */
694
- #fitToWidth(str: string, width: number): string {
695
- const visLen = visibleWidth(str);
696
- if (visLen > width) {
697
- const ellipsis = "…";
698
- const ellipsisWidth = visibleWidth(ellipsis);
699
- const maxWidth = Math.max(0, width - ellipsisWidth);
700
- let truncated = "";
701
- let currentWidth = 0;
702
- let inEscape = false;
703
- for (const char of str) {
704
- if (char === "\x1b") inEscape = true;
705
- if (inEscape) {
706
- truncated += char;
707
- if (char === "m") inEscape = false;
708
- } else if (currentWidth < maxWidth) {
709
- truncated += char;
710
- currentWidth++;
711
- }
712
- }
713
- return `${truncated}${ellipsis}`;
714
- }
715
- return str + padding(width - visLen);
478
+ const tip = resolveTip(raw);
479
+ const phase = tip.isNew ? performance.now() / NEW_GLOW_PERIOD_MS : 0;
480
+ return renderWelcomeTip(tip.text, Math.min(room, TIP_MEASURE), phase, tip.isNew);
716
481
  }
717
482
 
718
483
  /** Pick the logo frame for the current intro phase, or the resting frame. */
@@ -724,10 +489,78 @@ export class WelcomeComponent implements Component {
724
489
  }
725
490
  }
726
491
 
727
- /** Block-grid brand mark shared by the welcome and setup surfaces. */
728
- 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
+ ];
512
+
513
+ /** Secondary identity line: FIGlet `mini` render of `Linxira-Zeta` (generated). */
514
+ export const ZETA_SUBLINE = [
515
+ " __ ",
516
+ " | o ._ o ._ _. __ / _ _|_ _. ",
517
+ " |_ | | | >< | | (_| /_ (/_ |_ (_| ",
518
+ ];
519
+
520
+ /** Columns of {@link ZETA_LOGO}. */
521
+ const LOGO_WIDTH = Math.max(...ZETA_LOGO.map(row => visibleWidth(row)));
522
+
523
+ /** Columns of {@link ZETA_WORDMARK}. */
524
+ const WORDMARK_WIDTH = Math.max(...ZETA_WORDMARK.map(row => visibleWidth(row)));
525
+
526
+ /** Columns between the logo and the wordmark. */
527
+ const LOCKUP_GAP = 4;
528
+
529
+ /** Widest a welcome tip wraps, so a long one stays a centered paragraph. */
530
+ const TIP_MEASURE = 72;
531
+
532
+ /** Narrowest terminal that still shows the tip; below it the banner is the logo alone. */
533
+ const TIP_MIN_COLUMNS = 50;
534
+
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
+ */
539
+ function lockup(logo: readonly string[], version: string): string[] {
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;
551
+ }
552
+
553
+ /**
554
+ * `lines` indented as one block whose widest line is centered in `width`
555
+ * columns; pass a single line to center it on its own.
556
+ */
557
+ function centerBlock(lines: readonly string[], width: number): string[] {
558
+ const widest = lines.reduce((max, line) => Math.max(max, visibleWidth(line)), 0);
559
+ const indent = padding(Math.max(0, Math.floor((width - widest) / 2)));
560
+ return lines.map(line => indent + line);
561
+ }
729
562
 
730
- /** 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. */
731
564
  export function logoNode(lines: readonly string[], shimmer: boolean): NativeNode {
732
565
  return col(
733
566
  lines.map(line =>