@linxiraos/pi-tui 1.1.27 → 1.2.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 (373) hide show
  1. package/CHANGELOG.md +47 -463
  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 +1 -1
  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 +6 -1
  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/types.d.ts +1 -0
  95. package/dist/types/status-line/component.d.ts +3 -0
  96. package/dist/types/status-line/host.d.ts +4 -1
  97. package/dist/types/status-line/segments.d.ts +2 -0
  98. package/dist/types/status-line/types.d.ts +14 -3
  99. package/dist/types/terminal-capabilities.d.ts +27 -35
  100. package/dist/types/terminal-multiplexer.d.ts +127 -14
  101. package/dist/types/terminal.d.ts +22 -6
  102. package/dist/types/theme/active-symbols.d.ts +1 -1
  103. package/dist/types/theme/schema.d.ts +11 -2
  104. package/dist/types/theme/symbols.d.ts +1 -1
  105. package/dist/types/theme/theme-class.d.ts +7 -0
  106. package/dist/types/theme/theme.d.ts +1 -1
  107. package/dist/types/theme/tui-adapters.d.ts +3 -0
  108. package/dist/types/tmux.d.ts +11 -4
  109. package/dist/types/tools/daemon.d.ts +1 -1
  110. package/dist/types/tools/eval.d.ts +22 -3
  111. package/dist/types/tools/glob.d.ts +4 -0
  112. package/dist/types/tools/native-view.d.ts +47 -0
  113. package/dist/types/tools/output-meta.d.ts +11 -1
  114. package/dist/types/tools/renderer.d.ts +37 -2
  115. package/dist/types/tools/streaming-output.d.ts +20 -1
  116. package/dist/types/tools/task.d.ts +0 -4
  117. package/dist/types/tools/write.d.ts +9 -2
  118. package/dist/types/tools/yield.d.ts +22 -0
  119. package/dist/types/tui.d.ts +31 -0
  120. package/dist/types/utils.d.ts +6 -0
  121. package/dist/types/vim.d.ts +19 -5
  122. package/package.json +13 -9
  123. package/src/app-keybindings.d.ts +1 -1
  124. package/src/app-keybindings.ts +1 -1
  125. package/src/apps/autoresearch-dashboard.ts +7 -3
  126. package/src/apps/cleanse-board.ts +2 -2
  127. package/src/apps/cleanse-picker.ts +1 -1
  128. package/src/apps/debug/log-viewer.ts +188 -72
  129. package/src/apps/debug/raw-sse-buffer.ts +8 -0
  130. package/src/apps/debug/raw-sse.ts +39 -5
  131. package/src/apps/git/diff-pane.ts +26 -5
  132. package/src/apps/git/git-tui.ts +2 -2
  133. package/src/apps/git/help.ts +29 -20
  134. package/src/apps/if-bench-board.ts +2 -2
  135. package/src/apps/ps-top.ts +2 -2
  136. package/src/apps/setup-model-picker.ts +1 -1
  137. package/src/autocomplete.d.ts +5 -2
  138. package/src/autocomplete.ts +67 -43
  139. package/src/charts/chart-plan.d.ts +674 -0
  140. package/src/charts/chart-plan.ts +5081 -0
  141. package/src/charts/chart-svg.d.ts +59 -0
  142. package/src/charts/chart-svg.ts +4657 -0
  143. package/src/charts/table-data.d.ts +175 -0
  144. package/src/charts/table-data.ts +1478 -0
  145. package/src/chat/advisor-message.ts +11 -0
  146. package/src/chat/assistant-message.d.ts +5 -0
  147. package/src/chat/assistant-message.ts +219 -50
  148. package/src/chat/chat-transcript-builder.d.ts +2 -0
  149. package/src/chat/chat-transcript-builder.ts +5 -3
  150. package/src/chat/compaction-summary-message.d.ts +1 -0
  151. package/src/chat/compaction-summary-message.ts +8 -0
  152. package/src/chat/execution-shared.d.ts +6 -5
  153. package/src/chat/execution-shared.ts +42 -25
  154. package/src/chat/fence-figure.d.ts +42 -0
  155. package/src/chat/fence-figure.ts +80 -0
  156. package/src/chat/figure-markdown.d.ts +42 -0
  157. package/src/chat/figure-markdown.ts +174 -0
  158. package/src/chat/messages.d.ts +1 -1
  159. package/src/chat/messages.ts +1 -1
  160. package/src/chat/read-tool-group.ts +6 -4
  161. package/src/chat/skill-message.ts +6 -0
  162. package/src/chat/svg-figure.d.ts +33 -0
  163. package/src/chat/svg-figure.ts +399 -0
  164. package/src/chat/svg-source.d.ts +49 -0
  165. package/src/chat/svg-source.ts +188 -0
  166. package/src/chat/table-chart.d.ts +89 -0
  167. package/src/chat/table-chart.ts +294 -0
  168. package/src/chat/tool-execution.d.ts +2 -0
  169. package/src/chat/tool-execution.ts +80 -6
  170. package/src/chat/user-message.d.ts +15 -2
  171. package/src/chat/user-message.ts +55 -3
  172. package/src/chrome/shared.ts +53 -0
  173. package/src/chrome/transcript-container.d.ts +6 -0
  174. package/src/chrome/transcript-container.ts +93 -13
  175. package/src/components/box.d.ts +1 -0
  176. package/src/components/box.ts +5 -0
  177. package/src/components/disclosure.d.ts +1 -0
  178. package/src/components/disclosure.ts +8 -0
  179. package/src/components/editor.d.ts +15 -4
  180. package/src/components/editor.ts +294 -46
  181. package/src/components/form.d.ts +1 -0
  182. package/src/components/form.ts +8 -0
  183. package/src/components/image.d.ts +12 -44
  184. package/src/components/image.ts +47 -151
  185. package/src/components/key-value-list.d.ts +1 -0
  186. package/src/components/key-value-list.ts +4 -0
  187. package/src/components/layout/row.d.ts +1 -0
  188. package/src/components/layout/row.ts +7 -0
  189. package/src/components/layout/stack.d.ts +1 -0
  190. package/src/components/layout/stack.ts +7 -0
  191. package/src/components/markdown.d.ts +36 -3
  192. package/src/components/markdown.ts +433 -318
  193. package/src/components/scroll-view.d.ts +1 -0
  194. package/src/components/scroll-view.ts +5 -0
  195. package/src/components/section.d.ts +1 -0
  196. package/src/components/section.ts +5 -0
  197. package/src/components/settings-list.ts +2 -0
  198. package/src/components/table.d.ts +1 -0
  199. package/src/components/table.ts +4 -0
  200. package/src/components/text.d.ts +1 -0
  201. package/src/components/text.ts +7 -0
  202. package/src/components/tree-view.d.ts +7 -0
  203. package/src/components/tree-view.ts +38 -8
  204. package/src/components/truncated-text.d.ts +1 -0
  205. package/src/components/truncated-text.ts +5 -0
  206. package/src/desktop-notify.ts +1 -1
  207. package/src/fuzzy.d.ts +29 -1
  208. package/src/fuzzy.ts +115 -30
  209. package/src/key-hint-format.d.ts +1 -1
  210. package/src/key-hint-format.ts +1 -1
  211. package/src/keybinding-matchers.d.ts +3 -0
  212. package/src/keybinding-matchers.ts +6 -2
  213. package/src/keybindings.d.ts +1 -1
  214. package/src/keybindings.ts +1 -1
  215. package/src/kitty-graphics.d.ts +2 -2
  216. package/src/kitty-graphics.ts +4 -4
  217. package/src/loop-watchdog.d.ts +8 -7
  218. package/src/loop-watchdog.ts +18 -13
  219. package/src/multiplexers/cmux.d.ts +16 -0
  220. package/src/multiplexers/cmux.ts +29 -0
  221. package/src/multiplexers/herdr.d.ts +26 -0
  222. package/src/multiplexers/herdr.ts +64 -0
  223. package/src/multiplexers/notify.d.ts +13 -0
  224. package/src/multiplexers/notify.ts +24 -0
  225. package/src/multiplexers/orca.d.ts +12 -0
  226. package/src/multiplexers/orca.ts +16 -0
  227. package/src/multiplexers/screen.d.ts +9 -0
  228. package/src/multiplexers/screen.ts +15 -0
  229. package/src/multiplexers/tmux.d.ts +29 -0
  230. package/src/multiplexers/tmux.ts +40 -0
  231. package/src/multiplexers/types.d.ts +100 -0
  232. package/src/multiplexers/types.ts +110 -0
  233. package/src/multiplexers/wmux.d.ts +12 -0
  234. package/src/multiplexers/wmux.ts +16 -0
  235. package/src/multiplexers/zellij.d.ts +12 -0
  236. package/src/multiplexers/zellij.ts +24 -0
  237. package/src/native/backend.d.ts +3 -2
  238. package/src/native/backend.ts +174 -18
  239. package/src/native/blobs.d.ts +7 -3
  240. package/src/native/blobs.ts +47 -12
  241. package/src/native/encode.d.ts +3 -4
  242. package/src/native/encode.ts +58 -31
  243. package/src/native/overlay.d.ts +5 -0
  244. package/src/native/overlay.ts +13 -0
  245. package/src/native/reconcile.ts +4 -0
  246. package/src/overlays/agent-hub-projection.d.ts +6 -5
  247. package/src/overlays/agent-hub-projection.ts +124 -13
  248. package/src/overlays/agent-hub-types.d.ts +9 -0
  249. package/src/overlays/agent-hub-types.ts +6 -0
  250. package/src/overlays/agent-hub.ts +44 -29
  251. package/src/overlays/agent-transcript-viewer.ts +91 -23
  252. package/src/overlays/agents-hub.ts +28 -8
  253. package/src/overlays/annotation-overlay.ts +293 -90
  254. package/src/overlays/annotation-types.ts +2 -0
  255. package/src/overlays/ask-dialog.ts +7 -10
  256. package/src/overlays/btw-history-panel.ts +12 -1
  257. package/src/overlays/copy-selector.ts +30 -8
  258. package/src/overlays/extensions/state-manager.ts +52 -12
  259. package/src/overlays/history-search.ts +100 -7
  260. package/src/overlays/hook-selector.ts +12 -5
  261. package/src/overlays/jobs-panel.ts +28 -46
  262. package/src/overlays/model-browser.ts +341 -110
  263. package/src/overlays/model-hub.ts +300 -49
  264. package/src/overlays/move-overlay.ts +28 -14
  265. package/src/overlays/oauth-selector.ts +54 -70
  266. package/src/overlays/plan-review-overlay.ts +194 -70
  267. package/src/overlays/plugin-settings.ts +3 -3
  268. package/src/overlays/report-panel.ts +2 -2
  269. package/src/overlays/rewind-selector.ts +86 -17
  270. package/src/overlays/session-observer-registry.d.ts +2 -0
  271. package/src/overlays/session-observer-registry.ts +10 -2
  272. package/src/overlays/session-selector.ts +64 -26
  273. package/src/overlays/settings-defs.ts +4 -3
  274. package/src/overlays/settings-selector.ts +33 -8
  275. package/src/overlays/snapcompact-shape-preview.ts +2 -3
  276. package/src/overlays/stats-notice.ts +1 -1
  277. package/src/overlays/tree-selector.ts +41 -20
  278. package/src/overlays/usage-dashboard.ts +100 -50
  279. package/src/overlays/usage-display.ts +10 -4
  280. package/src/prompt/attachment-chips.ts +15 -11
  281. package/src/prompt/composer-attachments.ts +6 -4
  282. package/src/prompt/composer.ts +4 -15
  283. package/src/prompt/custom-editor.ts +54 -26
  284. package/src/prompt/image-source.ts +2 -1
  285. package/src/prompt/interactive-context-helpers.ts +30 -18
  286. package/src/prompt/macos-spelling.ts +7 -0
  287. package/src/prompt/magic-keywords.ts +11 -2
  288. package/src/prompt/model-mention-autocomplete.ts +14 -14
  289. package/src/prompt/tips.txt +6 -5
  290. package/src/prompt/welcome.ts +55 -12
  291. package/src/prompt/word-completion.ts +38 -5
  292. package/src/render/composite.d.ts +2 -0
  293. package/src/render/composite.ts +64 -0
  294. package/src/render/output-pane.d.ts +1 -0
  295. package/src/render/output-pane.ts +6 -0
  296. package/src/render/render-utils.d.ts +1 -0
  297. package/src/render/render-utils.ts +11 -0
  298. package/src/render/tool-card.d.ts +6 -0
  299. package/src/render/tool-card.ts +15 -0
  300. package/src/render/width-aware-text.d.ts +2 -0
  301. package/src/render/width-aware-text.ts +5 -0
  302. package/src/setup/scenes/model.ts +2 -1
  303. package/src/setup/scenes/types.ts +1 -0
  304. package/src/status-line/component.ts +292 -78
  305. package/src/status-line/host.ts +4 -1
  306. package/src/status-line/segments.ts +40 -22
  307. package/src/status-line/types.ts +15 -3
  308. package/src/terminal-capabilities.d.ts +27 -35
  309. package/src/terminal-capabilities.ts +75 -189
  310. package/src/terminal-multiplexer.d.ts +127 -14
  311. package/src/terminal-multiplexer.ts +112 -33
  312. package/src/terminal.d.ts +22 -6
  313. package/src/terminal.ts +139 -37
  314. package/src/theme/active-symbols.d.ts +1 -1
  315. package/src/theme/active-symbols.ts +1 -1
  316. package/src/theme/schema-validation.ts +1 -0
  317. package/src/theme/schema.d.ts +11 -2
  318. package/src/theme/schema.ts +14 -1
  319. package/src/theme/session-color.ts +1 -7
  320. package/src/theme/symbols.d.ts +1 -1
  321. package/src/theme/symbols.ts +6 -1
  322. package/src/theme/theme-class.d.ts +7 -0
  323. package/src/theme/theme-class.ts +26 -1
  324. package/src/theme/theme-schema.json +5 -1
  325. package/src/theme/theme.d.ts +1 -1
  326. package/src/theme/theme.ts +8 -4
  327. package/src/theme/tui-adapters.d.ts +3 -0
  328. package/src/theme/tui-adapters.ts +68 -0
  329. package/src/tmux.d.ts +11 -4
  330. package/src/tmux.ts +47 -11
  331. package/src/tools/ast-edit.ts +59 -35
  332. package/src/tools/ast-grep.ts +35 -16
  333. package/src/tools/bash.ts +92 -42
  334. package/src/tools/daemon.d.ts +1 -1
  335. package/src/tools/daemon.ts +1 -1
  336. package/src/tools/default-renderer.ts +35 -19
  337. package/src/tools/edit.ts +142 -120
  338. package/src/tools/eval-format/index.ts +28 -2
  339. package/src/tools/eval-format/javascript.ts +7 -5
  340. package/src/tools/eval.d.ts +22 -3
  341. package/src/tools/eval.ts +364 -208
  342. package/src/tools/fetch.ts +5 -6
  343. package/src/tools/glob.d.ts +4 -0
  344. package/src/tools/glob.ts +33 -65
  345. package/src/tools/grep.ts +81 -48
  346. package/src/tools/index.ts +2 -0
  347. package/src/tools/lsp.ts +81 -59
  348. package/src/tools/mcp.ts +111 -68
  349. package/src/tools/native-view.d.ts +47 -0
  350. package/src/tools/native-view.ts +102 -1
  351. package/src/tools/output-meta.d.ts +11 -1
  352. package/src/tools/output-meta.ts +23 -3
  353. package/src/tools/read.ts +7 -5
  354. package/src/tools/renderer.d.ts +37 -2
  355. package/src/tools/renderer.ts +42 -2
  356. package/src/tools/streaming-output.d.ts +20 -1
  357. package/src/tools/streaming-output.ts +149 -50
  358. package/src/tools/task.d.ts +0 -4
  359. package/src/tools/task.ts +112 -59
  360. package/src/tools/todo.ts +119 -30
  361. package/src/tools/wait.ts +34 -24
  362. package/src/tools/web-search.ts +1 -1
  363. package/src/tools/write.d.ts +9 -2
  364. package/src/tools/write.ts +147 -95
  365. package/src/tools/yield.d.ts +22 -0
  366. package/src/tools/yield.ts +225 -0
  367. package/src/tui.d.ts +31 -0
  368. package/src/tui.ts +309 -160
  369. package/src/utils.d.ts +6 -0
  370. package/src/utils.ts +19 -0
  371. package/src/vim.d.ts +19 -5
  372. package/src/vim.ts +333 -18
  373. package/src/windows-input-mode.ts +8 -0
@@ -179,9 +179,13 @@ function sessionPreview(session: SessionSelectorEntry, forkedFrom: string | unde
179
179
  ]);
180
180
  }
181
181
 
182
+ /** Absolute dates of week-old sessions; `toLocaleDateString` goes through Intl on every call. */
183
+ const localeDateCache = new WeakMap<Date, { time: number; text: string }>();
184
+
182
185
  /** Relative age of a session's last modification (`"3 hours ago"`), falling back to the date after a week. */
183
186
  function formatSessionDate(date: Date): string {
184
- const diffMs = Date.now() - date.getTime();
187
+ const time = date.getTime();
188
+ const diffMs = Date.now() - time;
185
189
  const diffMins = Math.floor(diffMs / 60000);
186
190
  const diffHours = Math.floor(diffMs / 3600000);
187
191
  const diffDays = Math.floor(diffMs / 86400000);
@@ -192,7 +196,11 @@ function formatSessionDate(date: Date): string {
192
196
  if (diffDays === 1) return "1 day ago";
193
197
  if (diffDays < 7) return `${diffDays} days ago`;
194
198
 
195
- return date.toLocaleDateString();
199
+ const cached = localeDateCache.get(date);
200
+ if (cached?.time === time) return cached.text;
201
+ const text = date.toLocaleDateString();
202
+ localeDateCache.set(date, { time, text });
203
+ return text;
196
204
  }
197
205
 
198
206
  /** A cached native session item and the inputs it was built from. */
@@ -517,12 +525,12 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
517
525
  readonly #getCurrentSessionPath: () => string | undefined;
518
526
  readonly #historyMatcher?: SessionHistoryMatcher;
519
527
  #historyMergeTimer: NodeJS.Timeout | undefined;
520
- /** Re-render hook for async list updates (fuzzy scan chunks, history merge). */
528
+ /** Re-render hook for async list updates (fuzzy scan completion, history merge). */
521
529
  onRequestRender?: () => void;
522
530
 
523
531
  // ── Incremental search state ──────────────────────────────────────────
524
532
  // The menu's visible list is always composed from these three inputs (see
525
- // #composeFiltered), so late-arriving fuzzy chunks and the debounced
533
+ // #composeFiltered), so a late-finishing fuzzy scan and the debounced
526
534
  // history merge can land in any order without clobbering each other.
527
535
  /** Recency-ranked sessions whose text contains every query token verbatim. */
528
536
  #literalRanked: RankedSessionMatch<T>[] = [];
@@ -535,8 +543,8 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
535
543
  #scanTimer: NodeJS.Timeout | undefined;
536
544
  /**
537
545
  * True once the user moved the selection for the current query; blocks the
538
- * history merge from reordering the list under their cursor. (Fuzzy chunks
539
- * only append below the literal group, which never shifts existing rows.)
546
+ * history merge from reordering the list under their cursor. (The fuzzy scan
547
+ * only appends below the literal group, which never shifts existing rows.)
540
548
  */
541
549
  #selectionMoved = false;
542
550
  /** True after a nonempty query; empty refilter restores current only then. */
@@ -545,6 +553,8 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
545
553
  #lastFilterQuery = "";
546
554
  /** Bumped whenever the visible session set may have changed (native memo key). */
547
555
  #itemsVersion = 0;
556
+ /** Per-row line heights of the visible list for the ANSI window, reused until the set changes. */
557
+ #rowHeights: { items: readonly T[]; length: number; version: number; heights: readonly number[] } | undefined;
548
558
  #itemsNative:
549
559
  | { version: number; showCwd: boolean; currentPath: string | undefined; items: NativeNode[] }
550
560
  | undefined;
@@ -677,38 +687,48 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
677
687
  // Fuzzy pass: building a fuzzy index per session is too expensive to run
678
688
  // across a huge listing inside one keystroke, so scan a bounded slice now
679
689
  // and spill the remainder into async chunks.
680
- this.#scanFuzzySlice(this.#scanGeneration, tokens, rest, 0, FUZZY_SCAN_INLINE_COUNT);
690
+ const inlineEnd = Math.min(rest.length, FUZZY_SCAN_INLINE_COUNT);
691
+ this.#scoreFuzzy(tokens, rest, 0, inlineEnd);
681
692
  this.#composeFiltered();
693
+ if (inlineEnd < rest.length) {
694
+ this.#scheduleFuzzyScan(this.#scanGeneration, tokens, rest, inlineEnd, this.#fuzzyRanked.length);
695
+ }
682
696
  // New query rebuilds ranking from scratch. Same-query refilter (delete)
683
- // and async compose (fuzzy chunks / history merge) only clamp so an
697
+ // and async compose (fuzzy scan / history merge) only clamp so an
684
698
  // arrow selection survives. A live-session index > 0 would otherwise
685
699
  // land on a lower-ranked match after the first keystroke.
686
700
  if (queryChanged) this.#menu.setSelectedIndex(0);
687
701
  this.#scheduleHistoryMerge(query);
688
702
  }
689
703
 
690
- /**
691
- * Score up to `budget` sessions from `rest[start..]` (indexes into the
692
- * unfiltered list), then schedule the remainder on a macrotask so pending
693
- * input events run first. Chunks that added matches recompose the visible
694
- * list and request a render; a stale generation aborts silently.
695
- */
696
- #scanFuzzySlice(generation: number, tokens: string[], rest: number[], start: number, budget: number): void {
704
+ /** Fuzzy-score `rest[start..end)` (indexes into the unfiltered list) into {@link #fuzzyRanked}. */
705
+ #scoreFuzzy(tokens: string[], rest: number[], start: number, end: number): void {
697
706
  const all = this.#allSessions;
698
- const end = Math.min(rest.length, start + budget);
699
707
  for (let i = start; i < end; i++) {
700
708
  const index = rest[i]!;
701
709
  const session = all[index]!;
702
710
  const match = scoreFuzzySession(session, index, tokens, new FuzzyText(sessionTextLower(session)));
703
711
  if (match) this.#fuzzyRanked.push(match);
704
712
  }
705
- if (end >= rest.length) return;
713
+ }
714
+
715
+ /**
716
+ * Score `rest[start..]` in {@link FUZZY_SCAN_CHUNK_COUNT}-session macrotask
717
+ * chunks so pending input runs between them, then recompose the visible
718
+ * list once if the scan matched more than the `shown` fuzzy hits already
719
+ * composed. Publishing per chunk reorders the list dozens of times per
720
+ * keystroke on a large listing, and the native picker replays its
721
+ * row-arrival animation on every reorder. A stale generation aborts silently.
722
+ */
723
+ #scheduleFuzzyScan(generation: number, tokens: string[], rest: number[], start: number, shown: number): void {
706
724
  this.#scanTimer = setTimeout(() => {
707
725
  this.#scanTimer = undefined;
708
726
  if (generation !== this.#scanGeneration) return;
709
- const before = this.#fuzzyRanked.length;
710
- this.#scanFuzzySlice(generation, tokens, rest, end, FUZZY_SCAN_CHUNK_COUNT);
711
- if (this.#fuzzyRanked.length > before) {
727
+ const end = Math.min(rest.length, start + FUZZY_SCAN_CHUNK_COUNT);
728
+ this.#scoreFuzzy(tokens, rest, start, end);
729
+ if (end < rest.length) {
730
+ this.#scheduleFuzzyScan(generation, tokens, rest, end, shown);
731
+ } else if (this.#fuzzyRanked.length > shown) {
712
732
  this.#composeFiltered();
713
733
  this.onRequestRender?.();
714
734
  }
@@ -730,7 +750,7 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
730
750
  tokenizeSessionQuery(this.#searchInput.getValue()),
731
751
  this.#literalRanked,
732
752
  );
733
- // Async chunks and the history merge only clamp the cursor so an arrow
753
+ // The fuzzy scan and the history merge only clamp the cursor so an arrow
734
754
  // selection survives recomposition; the query path resets explicitly.
735
755
  const keepIndex = Math.min(this.#menu.selectedIndex, Math.max(0, composed.length - 1));
736
756
  this.#menu.setItems(composed, keepIndex >= 0 ? composed[keepIndex]?.path : undefined);
@@ -1130,14 +1150,13 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
1130
1150
  // worst-case count-based window would leave (then padded by
1131
1151
  // fill-height).
1132
1152
  const filtered = this.#menu.visibleItems;
1133
- const itemHeight = (session: T): number => (session.title ? 4 : 3);
1134
1153
  const budget = this.#lineBudget();
1135
1154
  const {
1136
1155
  startIndex,
1137
1156
  endIndex,
1138
1157
  rowOffset: offsetRows,
1139
1158
  totalRows: rawTotalRows,
1140
- } = getMenuWindow(filtered.map(itemHeight), this.#menu.selectedIndex, budget);
1159
+ } = getMenuWindow(this.#visibleRowHeights(filtered), this.#menu.selectedIndex, budget);
1141
1160
 
1142
1161
  // Each session block is built into sessionLines, then wrapped by ScrollView
1143
1162
  // so the right-edge scrollbar is proportional at the physical-line level.
@@ -1232,6 +1251,17 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
1232
1251
  return lines;
1233
1252
  }
1234
1253
 
1254
+ /** Line height per visible session (3, or 4 when a title adds a preview line), memoized per visible set. */
1255
+ #visibleRowHeights(items: readonly T[]): readonly number[] {
1256
+ const cached = this.#rowHeights;
1257
+ if (cached?.items === items && cached.length === items.length && cached.version === this.#itemsVersion) {
1258
+ return cached.heights;
1259
+ }
1260
+ const heights = items.map(session => (session.title ? 4 : 3));
1261
+ this.#rowHeights = { items, length: items.length, version: this.#itemsVersion, heights };
1262
+ return heights;
1263
+ }
1264
+
1235
1265
  handleInput(keyData: string): void {
1236
1266
  // Delete key — or Backspace on an empty search query — request delete
1237
1267
  // confirmation from the parent. macOS laptops have no dedicated Forward
@@ -1310,7 +1340,11 @@ export interface SessionSelectorOptions<T extends SessionSelectorEntry = Session
1310
1340
  title?: string;
1311
1341
  /** Fixed scope label, or false to omit the scope suffix. */
1312
1342
  scopeLabel?: string | false;
1313
- /** Show each session's working directory in the list. */
1343
+ /**
1344
+ * Show each session's working directory in the list. Defaults to on when
1345
+ * the session files live in more than one directory (e.g. the folder scope
1346
+ * merging a repository's worktrees).
1347
+ */
1314
1348
  showCwd?: boolean;
1315
1349
  /**
1316
1350
  * Reads the live terminal height so the visible window fits the viewport.
@@ -1354,6 +1388,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1354
1388
  #onRequestRender?: () => void;
1355
1389
  readonly #loadAllSessions?: () => Promise<T[]>;
1356
1390
  #folderSessions: T[];
1391
+ readonly #folderShowCwd: boolean;
1357
1392
  #globalSessions: T[] | null = null;
1358
1393
  #scope: "folder" | "all" = "folder";
1359
1394
  #toggling = false;
@@ -1420,6 +1455,9 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1420
1455
  this.#onDelete = options.onDelete;
1421
1456
  this.#loadAllSessions = options.loadAllSessions;
1422
1457
  this.#folderSessions = sessions;
1458
+ // Storage directory, not recorded cwd: one folder's sessions may record
1459
+ // symlink aliases of the same path, which must not turn the column on.
1460
+ this.#folderShowCwd = options.showCwd ?? new Set(sessions.map(session => path.dirname(session.path))).size > 1;
1423
1461
  this.#globalSessions = options.allSessions ?? null;
1424
1462
  this.#getTerminalRows = options.getTerminalRows ?? (() => 24);
1425
1463
  this.#fillHeight = options.fillHeight ?? false;
@@ -1437,7 +1475,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1437
1475
  // projects' history (issue #3099).
1438
1476
  this.#sessionList = new SessionList(
1439
1477
  sessions,
1440
- options.showCwd ?? false,
1478
+ this.#folderShowCwd,
1441
1479
  options.historyMatcher,
1442
1480
  options.getTerminalRows,
1443
1481
  options.pinnedIds,
@@ -1510,7 +1548,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1510
1548
  this.#sessionList.setSessions(global, true);
1511
1549
  } else {
1512
1550
  this.#scope = "folder";
1513
- this.#sessionList.setSessions(this.#folderSessions, false);
1551
+ this.#sessionList.setSessions(this.#folderSessions, this.#folderShowCwd);
1514
1552
  }
1515
1553
  this.title = this.#headerLabel();
1516
1554
  this.#onRequestRender?.();
@@ -180,6 +180,7 @@ export type AnyUiMetadata = UiBase & {
180
180
  options?: ReadonlyArray<SubmenuOption> | "runtime";
181
181
  secret?: boolean;
182
182
  ordered?: boolean;
183
+ editor?: "json";
183
184
  };
184
185
 
185
186
  /** Structural schema entries supplied by the application host. */
@@ -330,9 +331,9 @@ function entryToSettingDef(entry: SettingsDisplayEntry): SettingDef | null {
330
331
  }
331
332
 
332
333
  if (schemaType === "array") {
333
- // Arrays without declared options stay config-file only (free-form lists
334
- // like extension paths have no finite choice set to toggle).
335
- if (!options || options === "runtime") return null;
334
+ if (ui.editor === "json") return { ...base, type: "text", secret: entry.credential === true };
335
+ if (!options) return null;
336
+ if (options === "runtime") return null;
336
337
  return { ...base, type: "multiselect", options, ordered: ui.ordered === true };
337
338
  }
338
339
 
@@ -1,10 +1,11 @@
1
1
  import type { ThinkingLevel } from "@linxiraos/pi-agent-core";
2
2
  import type { Effort } from "@linxiraos/pi-ai";
3
+ import { CLI_BIN_NAME } from "@linxiraos/pi-utils";
3
4
  import {
4
5
  type Component,
5
6
  Container,
6
7
  extractPrintableText,
7
- fuzzyRank,
8
+ FuzzyCorpus,
8
9
  getKeybindings,
9
10
  getSettingItemFilterText,
10
11
  type ImageBudget,
@@ -630,6 +631,12 @@ export class SettingsSelectorComponent implements Component {
630
631
  #searchMatchCount = 0;
631
632
  /** First matching item id per tab id, for Tab-key jumps while searching. */
632
633
  #searchFirstMatch = new Map<string, string>();
634
+ /**
635
+ * Per-tab search candidates prepared once per search session. Dropped on
636
+ * search start/end and whenever a value or condition may have changed
637
+ * (`#refreshItems`, `#onSearchSettingChange`); rebuilt lazily by `#setSearchQuery`.
638
+ */
639
+ #searchCorpora: { tab: SettingTab; corpus: FuzzyCorpus<SettingItem> }[] | null = null;
633
640
  #textInputActive = false;
634
641
  #hasSectionJump = false;
635
642
  // Frame geometry from the last render, for mouse hit-testing (the
@@ -872,7 +879,7 @@ export class SettingsSelectorComponent implements Component {
872
879
  }
873
880
 
874
881
  const props: TspPrefsProps = {
875
- title: "omp settings",
882
+ title: `${CLI_BIN_NAME} settings`,
876
883
  pages,
877
884
  page: searching ? this.#preSearchTabId : this.#currentTabId,
878
885
  lead: searching ? undefined : (pluginPage?.lead ?? (tab ? TAB_LEADS[tab] : undefined)),
@@ -1111,8 +1118,10 @@ export class SettingsSelectorComponent implements Component {
1111
1118
 
1112
1119
  /** Rebuilds the visible items after a change made outside a list's own `onChange` (changed flags, conditions). */
1113
1120
  #refreshItems(): void {
1114
- if (this.#searchList) this.#setSearchQuery(this.#searchQuery);
1115
- else if (this.#currentTabId !== "plugins")
1121
+ if (this.#searchList) {
1122
+ this.#searchCorpora = null;
1123
+ this.#setSearchQuery(this.#searchQuery);
1124
+ } else if (this.#currentTabId !== "plugins")
1116
1125
  this.#refreshCurrentTabItems(getSettingsForTab(this.#context.settings.entries, this.#currentTabId));
1117
1126
  }
1118
1127
 
@@ -1281,6 +1290,7 @@ export class SettingsSelectorComponent implements Component {
1281
1290
  /** Swap the tab content for the global search result list. */
1282
1291
  #startSearch(initialQuery: string): void {
1283
1292
  this.#preSearchTabId = this.#currentTabId;
1293
+ this.#searchCorpora = null;
1284
1294
  this.#searchInput = new Input();
1285
1295
  this.#searchInput.prompt = "";
1286
1296
  this.#searchInput.setValue(initialQuery);
@@ -1324,13 +1334,17 @@ export class SettingsSelectorComponent implements Component {
1324
1334
  const tabResults: { tab: SettingTab; matched: SettingItem[]; bestScore: number; order: number }[] = [];
1325
1335
  this.#searchFirstMatch.clear();
1326
1336
  let total = 0;
1327
- for (const tab of SETTING_TABS) {
1337
+ this.#searchCorpora ??= SETTING_TABS.map(tab => {
1328
1338
  const candidates: SettingItem[] = [];
1329
1339
  for (const def of getSettingsForTab(this.#context.settings.entries, tab)) {
1330
1340
  const item = this.#defToItem(def);
1331
1341
  if (item) candidates.push(item);
1332
1342
  }
1333
- const ranked = fuzzyRank(candidates, query, getSettingItemFilterText);
1343
+ return { tab, corpus: new FuzzyCorpus(candidates, getSettingItemFilterText) };
1344
+ });
1345
+ for (let order = 0; order < this.#searchCorpora.length; order++) {
1346
+ const { tab, corpus } = this.#searchCorpora[order]!;
1347
+ const ranked = corpus.rank(query);
1334
1348
  const matched = ranked.map(result => result.item);
1335
1349
  counts.set(tab, matched.length);
1336
1350
  if (matched.length === 0) continue;
@@ -1339,7 +1353,7 @@ export class SettingsSelectorComponent implements Component {
1339
1353
  tab,
1340
1354
  matched,
1341
1355
  bestScore: ranked[0]?.score ?? 0,
1342
- order: SETTING_TABS.indexOf(tab),
1356
+ order,
1343
1357
  });
1344
1358
  }
1345
1359
 
@@ -1378,6 +1392,7 @@ export class SettingsSelectorComponent implements Component {
1378
1392
  const targetTab: SettingTab | "plugins" = selectedDef?.tab ?? this.#preSearchTabId;
1379
1393
 
1380
1394
  this.#searchQuery = "";
1395
+ this.#searchCorpora = null;
1381
1396
  this.#searchFirstMatch.clear();
1382
1397
  this.#searchMatchCount = 0;
1383
1398
  this.#tabs = getSettingsTabs();
@@ -1441,6 +1456,7 @@ export class SettingsSelectorComponent implements Component {
1441
1456
  }
1442
1457
  // Values feed the searchable text and condition gates may have flipped:
1443
1458
  // recompute results in place (selection is preserved by item id).
1459
+ this.#searchCorpora = null;
1444
1460
  this.#setSearchQuery(this.#searchQuery);
1445
1461
  }
1446
1462
 
@@ -1648,7 +1664,7 @@ export class SettingsSelectorComponent implements Component {
1648
1664
  }
1649
1665
 
1650
1666
  /**
1651
- * Create a text input submenu for a plain string setting.
1667
+ * Create a text input submenu for a string or structured setting.
1652
1668
  */
1653
1669
  #createTextInput(
1654
1670
  def: SettingDef & { type: "text" },
@@ -1751,6 +1767,15 @@ export class SettingsSelectorComponent implements Component {
1751
1767
  this.#context.settings.set(path, -1);
1752
1768
  } else if (path === "compaction.thresholdTokens" && value === "default") {
1753
1769
  this.#context.settings.set(path, -1);
1770
+ } else if (schemaType === "array") {
1771
+ let parsed: unknown;
1772
+ try {
1773
+ parsed = JSON.parse(value);
1774
+ } catch {
1775
+ throw new Error(`Invalid array JSON for ${path}`);
1776
+ }
1777
+ if (!Array.isArray(parsed)) throw new Error(`Invalid array JSON for ${path}`);
1778
+ this.#context.settings.set(path, parsed);
1754
1779
  } else if (schemaType === "record") {
1755
1780
  let parsed: unknown;
1756
1781
  try {
@@ -31,7 +31,7 @@ import { theme } from "../theme/theme";
31
31
  import sampleDoc from "./snapcompact-shape-preview-doc.md" with { type: "text" };
32
32
  import type { DescribeContext, NativeNode } from "../native/node";
33
33
  import { col, node, span, text } from "../native/describe";
34
- import { registerNativeBlob } from "../native/blobs";
34
+ import { nativeImageNode } from "../native/blobs";
35
35
 
36
36
  /** Mini-frame edge in px — a small page from the real rasterizer ≈ a zoomed crop. */
37
37
  const SRC_FRAME_PX = 128;
@@ -100,8 +100,7 @@ export class SnapcompactShapePreview implements Component {
100
100
  else if (entry.state === "rendering") sample = node("spinner", { label: [span("rendering sample…", "dim")] });
101
101
  else if (entry.state === "failed") sample = text([span("(sample render failed)", "dim")]);
102
102
  else {
103
- sample = node("image", {
104
- blob: registerNativeBlob(entry.bytes, "image/png"),
103
+ sample = nativeImageNode(entry.bytes, "image/png", {
105
104
  alt: `snapcompact ${label} sample`,
106
105
  w: entry.edgePx,
107
106
  h: entry.edgePx,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `/stats` result: omp serves the stats dashboard in the browser. ANSI keeps
2
+ * `/stats` result: zetacode serves the stats dashboard in the browser. ANSI keeps
3
3
  * the dim status line; natively it is an inline notice with an
4
4
  * `Open dashboard ↗` button that opens the URL terminal-side.
5
5
  */
@@ -2,7 +2,8 @@ import { ThinkingLevel } from "@linxiraos/pi-agent-core";
2
2
  import {
3
3
  type Component,
4
4
  Container,
5
- fuzzyMatch,
5
+ FuzzyQuery,
6
+ FuzzyText,
6
7
  Input,
7
8
  matchesKey,
8
9
  Spacer,
@@ -53,7 +54,7 @@ import { shortenPath } from "../render/render-utils";
53
54
  import { canonicalizeMessage } from "../chat/thinking-display";
54
55
  import { resolveAssistantErrorPresentation } from "../chat/transcript-render-helpers";
55
56
  import { OverlayPanel, PanelDivider } from "../chrome/overlay-box";
56
- import { TreeView, type TreeRow } from "../components/tree-view";
57
+ import { TreeView, type TreeAncestor, type TreeRow } from "../components/tree-view";
57
58
  import { formatKeyHint, formatKeyHints } from "../app-keybindings";
58
59
  import { boundKeys, editorKeys, interruptKey } from "../chrome/keybinding-hints";
59
60
  import type { TspPickerItem, TspPickerProps, TspSpan, TspText } from "@linxiraos/pi-wire";
@@ -210,6 +211,8 @@ class TreeList implements Component {
210
211
  #multipleRoots = false;
211
212
  #activePathIds: Set<string> = new Set();
212
213
  #containsActive: Map<TreeSelectorNode, boolean> = new Map();
214
+ /** Prepared search text per node; rebuilt when the node's label changes. */
215
+ #searchTextCache = new WeakMap<TreeSelectorNode, { label: string | undefined; text: FuzzyText }>();
213
216
 
214
217
  onSelect?: (entryId: string, options: { summarize: boolean }) => void;
215
218
  onCancel?: () => void;
@@ -253,6 +256,7 @@ class TreeList implements Component {
253
256
  getChildren: node => this.#orderedChildren(node),
254
257
  getChildDepth: (_node, row, children) =>
255
258
  children.length > 1 || (this.#multipleRoots && row.parentKey === undefined) ? row.depth + 1 : row.depth,
259
+ compactSameDepthAncestors: true,
256
260
  theme,
257
261
  filter: this.#buildFilter(),
258
262
  maxRows: maxVisibleLines,
@@ -373,7 +377,11 @@ class TreeList implements Component {
373
377
  #buildFilter(): (node: TreeSelectorNode, row: TreeRow<TreeSelectorNode, string>) => boolean {
374
378
  const filterMode = this.#filterMode;
375
379
  const leafId = this.currentLeafId;
376
- const searchTokens = this.getSearchQuery().toLowerCase().split(/\s+/).filter(Boolean);
380
+ const searchTokens = this.getSearchQuery()
381
+ .toLowerCase()
382
+ .split(/\s+/)
383
+ .filter(Boolean)
384
+ .map(token => new FuzzyQuery(token));
377
385
  return node => {
378
386
  const entry = node.entry;
379
387
  const isCurrentLeaf = entry.id === leafId;
@@ -435,14 +443,23 @@ class TreeList implements Component {
435
443
 
436
444
  // Apply fuzzy search filter
437
445
  if (searchTokens.length > 0) {
438
- const nodeText = this.#getSearchableText(node);
439
- return searchTokens.every(token => fuzzyMatch(token, nodeText).matches);
446
+ const nodeText = this.#getFuzzyText(node);
447
+ return searchTokens.every(token => token.match(nodeText).matches);
440
448
  }
441
449
 
442
450
  return true;
443
451
  };
444
452
  }
445
453
 
454
+ /** Prepared fuzzy text for a node's searchable content, built once per label. */
455
+ #getFuzzyText(node: TreeSelectorNode): FuzzyText {
456
+ const cached = this.#searchTextCache.get(node);
457
+ if (cached !== undefined && cached.label === node.label) return cached.text;
458
+ const text = new FuzzyText(this.#getSearchableText(node));
459
+ this.#searchTextCache.set(node, { label: node.label, text });
460
+ return text;
461
+ }
462
+
446
463
  /** Get searchable text content from a node */
447
464
  #getSearchableText(node: TreeSelectorNode): string {
448
465
  const entry = node.entry;
@@ -877,23 +894,26 @@ class TreeList implements Component {
877
894
  // Linear rows reuse their branch head's depth. Existing sibling
878
895
  // gutters remain visible; terminal gutters remain terminated.
879
896
 
897
+ // Branch points that can draw a gutter, indexed by rendered level: the
898
+ // ancestor list is as deep as the conversation, so it is scanned once
899
+ // per row rather than once per prefix cell.
900
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
901
+ const gutterByLevel: (TreeAncestor<string> | undefined)[] = new Array(renderedIndent);
902
+ for (const ancestor of row.ancestors) {
903
+ if (ancestor.siblingCount <= 1) continue;
904
+ if (this.#multipleRoots && this.#rootIds.has(ancestor.key)) continue;
905
+ const level = ancestor.depth - 1 - scrollOffset;
906
+ if (level >= 0 && level < renderedIndent && gutterByLevel[level] === undefined) {
907
+ gutterByLevel[level] = ancestor;
908
+ }
909
+ }
880
910
  // Build prefix char by char, placing gutters and connector at their positions
881
911
  const totalChars = renderedIndent * 3;
882
912
  const prefixChars: string[] = [];
883
913
  for (let i = 0; i < totalChars; i++) {
884
914
  const level = Math.floor(i / 3);
885
- const originalDepth = level + scrollOffset;
886
915
  const posInLevel = i % 3;
887
-
888
- // An ancestor branch point draws its gutter at its own depth minus
889
- // one — the level its connector occupied. Session roots never emit
890
- // gutters; their connectors are suppressed.
891
- const gutterAncestor = row.ancestors.find(
892
- ancestor =>
893
- ancestor.depth - 1 === originalDepth &&
894
- ancestor.siblingCount > 1 &&
895
- !(this.#multipleRoots && this.#rootIds.has(ancestor.key)),
896
- );
916
+ const gutterAncestor = gutterByLevel[level];
897
917
  if (gutterAncestor) {
898
918
  // Gutters follow standard tree semantics: `│` only while more
899
919
  // siblings continue below, space below a `└─`.
@@ -1036,12 +1056,12 @@ class TreeList implements Component {
1036
1056
  }
1037
1057
 
1038
1058
  #extractContent(content: unknown): string {
1039
- return this.#joinTextContent(content).slice(0, SEARCH_TEXT_LIMIT);
1059
+ return this.#joinTextContent(content, SEARCH_TEXT_LIMIT);
1040
1060
  }
1041
1061
 
1042
- /** Concatenate every text block (or return a string as-is) with no length cap. */
1043
- #joinTextContent(content: unknown): string {
1044
- if (typeof content === "string") return content;
1062
+ /** Concatenate text blocks, stopping as soon as `limit` characters exist. */
1063
+ #joinTextContent(content: unknown, limit = Number.POSITIVE_INFINITY): string {
1064
+ if (typeof content === "string") return content.length > limit ? content.slice(0, limit) : content;
1045
1065
  if (Array.isArray(content)) {
1046
1066
  let result = "";
1047
1067
  for (const c of content) {
@@ -1054,6 +1074,7 @@ class TreeList implements Component {
1054
1074
  typeof c.text === "string"
1055
1075
  ) {
1056
1076
  result += c.text;
1077
+ if (result.length >= limit) return result.slice(0, limit);
1057
1078
  }
1058
1079
  }
1059
1080
  return result;