@linxiraos/pi-tui 1.1.27 → 1.2.1

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