@linxiraos/pi-tui 1.2.1 → 1.3.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 (280) hide show
  1. package/CHANGELOG.md +245 -224
  2. package/README.md +36 -0
  3. package/THIRD-PARTY-NOTICES.txt +55 -5
  4. package/dist/types/app-keybindings.d.ts +18 -8
  5. package/dist/types/apps/git/diff-pane.d.ts +5 -0
  6. package/dist/types/apps/git/git-tui.d.ts +16 -2
  7. package/dist/types/apps/git/state.d.ts +11 -0
  8. package/dist/types/autocomplete.d.ts +2 -0
  9. package/dist/types/charts/chart-svg.d.ts +7 -13
  10. package/dist/types/chat/advisor-message.d.ts +4 -1
  11. package/dist/types/chat/assistant-message.d.ts +9 -4
  12. package/dist/types/chat/figure-markdown.d.ts +7 -2
  13. package/dist/types/chat/messages.d.ts +3 -1
  14. package/dist/types/chat/read-tool-group.d.ts +2 -2
  15. package/dist/types/chat/table-chart.d.ts +11 -15
  16. package/dist/types/chat/tool-execution.d.ts +32 -20
  17. package/dist/types/chat/user-message.d.ts +3 -2
  18. package/dist/types/chrome/countdown-timer.d.ts +3 -2
  19. package/dist/types/chrome/diff.d.ts +2 -3
  20. package/dist/types/chrome/status-notice.d.ts +2 -0
  21. package/dist/types/chrome/transcript-container.d.ts +2 -0
  22. package/dist/types/components/image.d.ts +2 -0
  23. package/dist/types/components/markdown.d.ts +49 -3
  24. package/dist/types/components/text.d.ts +2 -0
  25. package/dist/types/debug-server.d.ts +2 -0
  26. package/dist/types/keybinding-matchers.d.ts +6 -0
  27. package/dist/types/multiplexers/tmux.d.ts +1 -0
  28. package/dist/types/multiplexers/types.d.ts +6 -0
  29. package/dist/types/native/backend.d.ts +5 -4
  30. package/dist/types/native/blobs.d.ts +24 -12
  31. package/dist/types/overlays/agent-hub-projection.d.ts +10 -5
  32. package/dist/types/overlays/annotation-overlay.d.ts +19 -0
  33. package/dist/types/overlays/approval-dialog.d.ts +79 -0
  34. package/dist/types/overlays/ask-dialog.d.ts +11 -6
  35. package/dist/types/overlays/btw-history-panel.d.ts +2 -6
  36. package/dist/types/overlays/hook-editor.d.ts +7 -2
  37. package/dist/types/overlays/hook-input.d.ts +12 -4
  38. package/dist/types/overlays/job-tree.d.ts +50 -0
  39. package/dist/types/overlays/jobs-panel.d.ts +25 -11
  40. package/dist/types/overlays/login-dialog.d.ts +8 -3
  41. package/dist/types/overlays/mcp-add-wizard.d.ts +9 -1
  42. package/dist/types/overlays/model-browser.d.ts +6 -1
  43. package/dist/types/overlays/model-picker.d.ts +2 -0
  44. package/dist/types/overlays/pause-screen.d.ts +5 -0
  45. package/dist/types/overlays/plan-review-overlay.d.ts +4 -0
  46. package/dist/types/overlays/report-panel.d.ts +5 -1
  47. package/dist/types/overlays/reset-usage-selector.d.ts +7 -5
  48. package/dist/types/overlays/session-account-selector.d.ts +4 -0
  49. package/dist/types/overlays/session-info-overlay.d.ts +7 -3
  50. package/dist/types/overlays/session-selector.d.ts +3 -1
  51. package/dist/types/overlays/usage-dashboard.d.ts +6 -6
  52. package/dist/types/prompt/composer.d.ts +23 -20
  53. package/dist/types/prompt/custom-editor.d.ts +20 -10
  54. package/dist/types/prompt/image-references.d.ts +22 -0
  55. package/dist/types/prompt/internal-url-autocomplete.d.ts +1 -0
  56. package/dist/types/prompt/macos-spelling.d.ts +2 -1
  57. package/dist/types/prompt/prose-gate.d.ts +3 -0
  58. package/dist/types/prompt/queued-messages.d.ts +1 -1
  59. package/dist/types/prompt/typing-word.d.ts +25 -0
  60. package/dist/types/prompt/welcome.d.ts +26 -6
  61. package/dist/types/render/hanging-wrap.d.ts +7 -0
  62. package/dist/types/render/hyperlink.d.ts +2 -0
  63. package/dist/types/render/render-utils.d.ts +9 -1
  64. package/dist/types/render/utils.d.ts +1 -1
  65. package/dist/types/setup/scenes/splash.d.ts +6 -5
  66. package/dist/types/status-line/context-usage.d.ts +4 -1
  67. package/dist/types/status-line/host.d.ts +6 -4
  68. package/dist/types/status-line/startup.d.ts +6 -6
  69. package/dist/types/status-line/types.d.ts +3 -0
  70. package/dist/types/stdin-buffer.d.ts +7 -0
  71. package/dist/types/terminal-capabilities.d.ts +16 -1
  72. package/dist/types/terminal-multiplexer.d.ts +7 -0
  73. package/dist/types/terminal.d.ts +8 -4
  74. package/dist/types/theme/symbols.d.ts +1 -1
  75. package/dist/types/theme/theme.d.ts +11 -0
  76. package/dist/types/thinking.d.ts +5 -0
  77. package/dist/types/tmux.d.ts +39 -4
  78. package/dist/types/tools/ast-edit.d.ts +2 -2
  79. package/dist/types/tools/ast-grep.d.ts +2 -2
  80. package/dist/types/tools/bash.d.ts +6 -1
  81. package/dist/types/tools/edit.d.ts +2 -0
  82. package/dist/types/tools/eval.d.ts +53 -4
  83. package/dist/types/tools/find.d.ts +14 -4
  84. package/dist/types/tools/glob.d.ts +3 -3
  85. package/dist/types/tools/grep.d.ts +2 -2
  86. package/dist/types/tools/grouped-file-output.d.ts +9 -4
  87. package/dist/types/tools/native-view.d.ts +1 -1
  88. package/dist/types/tools/read.d.ts +32 -10
  89. package/dist/types/tools/renderer.d.ts +2 -0
  90. package/dist/types/tools/task.d.ts +31 -0
  91. package/dist/types/tools/web-search-types.d.ts +1 -0
  92. package/dist/types/tools/write.d.ts +2 -0
  93. package/dist/types/tui.d.ts +20 -3
  94. package/package.json +10 -10
  95. package/src/app-keybindings.d.ts +18 -8
  96. package/src/app-keybindings.ts +20 -8
  97. package/src/apps/autoresearch-dashboard.ts +444 -27
  98. package/src/apps/debug/raw-sse.ts +2 -2
  99. package/src/apps/git/diff-pane.ts +39 -41
  100. package/src/apps/git/git-tui.ts +80 -14
  101. package/src/apps/git/sidebar.ts +10 -1
  102. package/src/apps/git/state.ts +12 -0
  103. package/src/autocomplete.d.ts +2 -0
  104. package/src/autocomplete.ts +21 -19
  105. package/src/bracketed-paste.ts +9 -2
  106. package/src/charts/chart-svg.d.ts +7 -13
  107. package/src/charts/chart-svg.ts +19 -22
  108. package/src/charts/table-data.ts +17 -6
  109. package/src/chat/advisor-message.d.ts +4 -1
  110. package/src/chat/advisor-message.ts +77 -24
  111. package/src/chat/assistant-message.d.ts +9 -4
  112. package/src/chat/assistant-message.ts +127 -31
  113. package/src/chat/bash-execution.ts +7 -2
  114. package/src/chat/figure-markdown.d.ts +7 -2
  115. package/src/chat/figure-markdown.ts +10 -3
  116. package/src/chat/image-loading.ts +9 -6
  117. package/src/chat/messages.d.ts +3 -1
  118. package/src/chat/messages.ts +4 -1
  119. package/src/chat/read-tool-group.d.ts +2 -2
  120. package/src/chat/read-tool-group.ts +97 -42
  121. package/src/chat/svg-figure.ts +3 -1
  122. package/src/chat/svg-source.ts +6 -3
  123. package/src/chat/table-chart.d.ts +11 -15
  124. package/src/chat/table-chart.ts +24 -57
  125. package/src/chat/tool-execution.d.ts +32 -20
  126. package/src/chat/tool-execution.ts +103 -36
  127. package/src/chat/user-message.d.ts +3 -2
  128. package/src/chat/user-message.ts +54 -34
  129. package/src/chrome/countdown-timer.ts +3 -3
  130. package/src/chrome/diff.d.ts +2 -3
  131. package/src/chrome/diff.ts +4 -9
  132. package/src/chrome/status-notice.ts +5 -0
  133. package/src/chrome/transcript-container.d.ts +2 -0
  134. package/src/chrome/transcript-container.ts +212 -28
  135. package/src/components/editor.ts +12 -12
  136. package/src/components/image.d.ts +2 -0
  137. package/src/components/image.ts +54 -20
  138. package/src/components/markdown.d.ts +49 -3
  139. package/src/components/markdown.ts +482 -12
  140. package/src/components/settings-list.ts +2 -4
  141. package/src/components/text.d.ts +2 -0
  142. package/src/components/text.ts +11 -1
  143. package/src/debug-server.d.ts +2 -0
  144. package/src/debug-server.ts +51 -69
  145. package/src/hotkeys-markdown.ts +2 -0
  146. package/src/keybinding-matchers.d.ts +6 -0
  147. package/src/keybinding-matchers.ts +19 -0
  148. package/src/multiplexers/tmux.d.ts +1 -0
  149. package/src/multiplexers/tmux.ts +3 -0
  150. package/src/multiplexers/types.d.ts +6 -0
  151. package/src/multiplexers/types.ts +6 -0
  152. package/src/native/backend.d.ts +5 -4
  153. package/src/native/backend.ts +13 -5
  154. package/src/native/blobs.d.ts +24 -12
  155. package/src/native/blobs.ts +56 -7
  156. package/src/native/reconcile.ts +43 -21
  157. package/src/overlays/advisor-config.ts +259 -8
  158. package/src/overlays/agent-hub-projection.d.ts +10 -5
  159. package/src/overlays/agent-hub-projection.ts +63 -12
  160. package/src/overlays/annotation-overlay.ts +422 -168
  161. package/src/overlays/approval-dialog.ts +389 -0
  162. package/src/overlays/ask-dialog.ts +244 -105
  163. package/src/overlays/btw-history-panel.ts +208 -147
  164. package/src/overlays/extensions/extension-list.ts +4 -6
  165. package/src/overlays/hook-editor.ts +90 -32
  166. package/src/overlays/hook-input.ts +105 -19
  167. package/src/overlays/job-tree.ts +177 -0
  168. package/src/overlays/jobs-panel.ts +222 -142
  169. package/src/overlays/login-dialog.ts +201 -91
  170. package/src/overlays/mcp-add-wizard.ts +562 -279
  171. package/src/overlays/model-browser.ts +32 -7
  172. package/src/overlays/model-hub.ts +4 -10
  173. package/src/overlays/model-picker.ts +58 -21
  174. package/src/overlays/pause-screen.ts +81 -23
  175. package/src/overlays/plan-review-overlay.ts +16 -0
  176. package/src/overlays/plugin-selector.ts +17 -1
  177. package/src/overlays/queue-mode-selector.ts +10 -0
  178. package/src/overlays/report-panel.ts +6 -2
  179. package/src/overlays/reset-usage-selector.ts +130 -21
  180. package/src/overlays/session-account-selector.ts +36 -0
  181. package/src/overlays/session-info-overlay.ts +199 -76
  182. package/src/overlays/session-selector.ts +15 -10
  183. package/src/overlays/settings-selector.ts +3 -0
  184. package/src/overlays/show-images-selector.ts +1 -0
  185. package/src/overlays/snapcompact-shape-preview.ts +13 -6
  186. package/src/overlays/theme-selector.ts +6 -1
  187. package/src/overlays/thinking-selector.ts +8 -4
  188. package/src/overlays/tree-selector.ts +117 -2
  189. package/src/overlays/usage-dashboard.ts +498 -144
  190. package/src/prompt/composer-cache.ts +4 -1
  191. package/src/prompt/composer.ts +200 -74
  192. package/src/prompt/custom-editor.ts +168 -65
  193. package/src/prompt/image-references.d.ts +22 -0
  194. package/src/prompt/image-references.ts +79 -2
  195. package/src/prompt/internal-url-autocomplete.ts +1 -0
  196. package/src/prompt/macos-spelling.ts +28 -5
  197. package/src/prompt/prose-gate.ts +3 -0
  198. package/src/prompt/queued-messages.ts +9 -2
  199. package/src/prompt/tips.txt +1 -2
  200. package/src/prompt/typing-word.ts +59 -0
  201. package/src/prompt/welcome.ts +97 -45
  202. package/src/render/code-cell.ts +18 -5
  203. package/src/render/hanging-wrap.d.ts +7 -0
  204. package/src/render/hanging-wrap.ts +58 -0
  205. package/src/render/hyperlink.d.ts +2 -0
  206. package/src/render/hyperlink.ts +5 -0
  207. package/src/render/render-utils.d.ts +9 -1
  208. package/src/render/render-utils.ts +16 -2
  209. package/src/render/utils.d.ts +1 -1
  210. package/src/render/utils.ts +1 -1
  211. package/src/render/width-aware-text.ts +6 -5
  212. package/src/setup/scenes/splash.ts +22 -8
  213. package/src/status-line/component.ts +79 -11
  214. package/src/status-line/context-usage.ts +176 -73
  215. package/src/status-line/footer.ts +18 -35
  216. package/src/status-line/host.ts +6 -1
  217. package/src/status-line/segments.ts +16 -5
  218. package/src/status-line/startup.ts +6 -6
  219. package/src/status-line/types.ts +1 -0
  220. package/src/stdin-buffer.d.ts +7 -0
  221. package/src/stdin-buffer.ts +300 -62
  222. package/src/terminal-capabilities.d.ts +16 -1
  223. package/src/terminal-capabilities.ts +66 -28
  224. package/src/terminal-multiplexer.d.ts +7 -0
  225. package/src/terminal-multiplexer.ts +14 -0
  226. package/src/terminal.d.ts +8 -4
  227. package/src/terminal.ts +190 -63
  228. package/src/theme/symbols.d.ts +1 -1
  229. package/src/theme/symbols.ts +1 -0
  230. package/src/theme/theme-class.ts +1 -0
  231. package/src/theme/theme.d.ts +11 -0
  232. package/src/theme/theme.ts +46 -14
  233. package/src/thinking.d.ts +5 -0
  234. package/src/thinking.ts +9 -1
  235. package/src/tmux.d.ts +39 -4
  236. package/src/tmux.ts +110 -16
  237. package/src/tools/ask.ts +20 -9
  238. package/src/tools/ast-edit.d.ts +2 -2
  239. package/src/tools/ast-edit.ts +30 -7
  240. package/src/tools/ast-grep.d.ts +2 -2
  241. package/src/tools/ast-grep.ts +39 -35
  242. package/src/tools/bash.d.ts +6 -1
  243. package/src/tools/bash.ts +15 -9
  244. package/src/tools/debug.ts +2 -1
  245. package/src/tools/default-renderer.ts +3 -2
  246. package/src/tools/edit.d.ts +2 -0
  247. package/src/tools/edit.ts +100 -21
  248. package/src/tools/eval.d.ts +53 -4
  249. package/src/tools/eval.ts +173 -137
  250. package/src/tools/find.d.ts +14 -4
  251. package/src/tools/find.ts +71 -18
  252. package/src/tools/glob.d.ts +3 -3
  253. package/src/tools/glob.ts +51 -17
  254. package/src/tools/grep.d.ts +2 -2
  255. package/src/tools/grep.ts +99 -22
  256. package/src/tools/grouped-file-output.d.ts +9 -4
  257. package/src/tools/grouped-file-output.ts +21 -6
  258. package/src/tools/lsp.ts +30 -18
  259. package/src/tools/mcp.ts +2 -2
  260. package/src/tools/native-view.d.ts +1 -1
  261. package/src/tools/native-view.ts +2 -2
  262. package/src/tools/read.d.ts +32 -10
  263. package/src/tools/read.ts +89 -43
  264. package/src/tools/renderer.d.ts +2 -0
  265. package/src/tools/renderer.ts +2 -0
  266. package/src/tools/task.d.ts +31 -0
  267. package/src/tools/task.ts +40 -7
  268. package/src/tools/todo.ts +3 -2
  269. package/src/tools/wait.ts +16 -30
  270. package/src/tools/web-search-types.d.ts +1 -0
  271. package/src/tools/web-search-types.ts +1 -0
  272. package/src/tools/web-search.ts +2 -1
  273. package/src/tools/write.d.ts +2 -0
  274. package/src/tools/write.ts +36 -12
  275. package/src/tools/yield.ts +2 -2
  276. package/src/tui.d.ts +20 -3
  277. package/src/tui.ts +160 -43
  278. package/src/utils.ts +5 -3
  279. package/dist/types/tools/eval-format/code-mode-declarations.d.ts +0 -5
  280. package/src/tools/eval-format/code-mode-declarations.ts +0 -61
@@ -10,10 +10,13 @@ import {
10
10
  } from "@linxiraos/pi-utils/marked";
11
11
  import {
12
12
  type MathBlockOpener,
13
+ type MathOpener,
13
14
  mathBlockCloserIndex,
14
15
  mathBlockInContext,
15
16
  mathBlockMayCloseAt,
16
17
  mathBlockOpenerAt,
18
+ mathOpenerAt,
19
+ mathSpanAt,
17
20
  mathSpanInContext,
18
21
  mathStartIndex,
19
22
  } from "@linxiraos/pi-utils/math-delimiters";
@@ -52,6 +55,361 @@ function normalizeOsc8Terminators(text: string): string {
52
55
  return text.replace(OSC8_ST_PREFIX_REGEX, "$1\x07");
53
56
  }
54
57
 
58
+ /**
59
+ * `text` as the streaming renderer lexes it: OSC 8 terminators unified and
60
+ * tabs expanded. {@link Markdown.getLastRenderStableText} is a prefix of this
61
+ * form, not of the raw source, so append-only publishers that mix settled
62
+ * prefixes with whole blocks must compare both in this form. Rendering it is
63
+ * byte-identical to rendering `text`.
64
+ */
65
+ export function streamingMarkdownSource(text: string): string {
66
+ if (!text.includes("\t") && !text.includes("\x1b")) return text;
67
+ return replaceTabs(normalizeOsc8Terminators(text));
68
+ }
69
+
70
+ // A leading-pipe table line: optional indent / blockquote markers, then `|`.
71
+ const TABLE_LINE_AT = /[ \t>]*\|/y;
72
+ // A line holding an unescaped `|`: a GFM table line needs no outer pipes.
73
+ const PIPE_IN_LINE_AT = /[^\n]*?(?<!\\)\|/y;
74
+ // A whole GFM delimiter row (`| --- | :-: |`, `--- | ---`). Without a leading pipe it needs an inner one; a lone
75
+ // `---` is a rule or setext underline.
76
+ const DELIMITER_ROW_AT =
77
+ /[ \t>]*(?:\|[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?|[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)+\|?)[ \t]*\r?(?=\n|$)/y;
78
+ const UNESCAPED_PIPE = /(?<!\\)\|/g;
79
+ const UNESCAPED_PIPE_END = /(?<!\\)\|$/;
80
+ // A fence line (opener or closer) after any blockquote markers: indentation, marker, info string.
81
+ const FENCE_LINE = /^(?:[ \t]*>)*([ \t]*)(`{3,}|~{3,})([^\n]*)$/gm;
82
+ // A list item's marker line after any blockquote markers: indentation, marker, spacing.
83
+ const LIST_ITEM_LINE = /^(?:[ \t]*>)*([ \t]*)([-*+]|\d{1,9}[.)])([ \t]+)\S/;
84
+
85
+ function matchesAt(pattern: RegExp, text: string, lineStart: number): boolean {
86
+ pattern.lastIndex = lineStart;
87
+ return pattern.test(text);
88
+ }
89
+
90
+ /** Column width of leading whitespace, tabs to the next multiple of four. */
91
+ function indentWidth(whitespace: string): number {
92
+ let width = 0;
93
+ for (const ch of whitespace) width = ch === "\t" ? width + 4 - (width % 4) : width + 1;
94
+ return width;
95
+ }
96
+
97
+ /**
98
+ * Whether the fence line at `lineStart`, indented `indent` columns, is a real
99
+ * fence: at most three columns in, or inside a list item whose content column
100
+ * it sits within three columns of. Deeper lines elsewhere are indented code.
101
+ */
102
+ function fenceIndentValid(text: string, lineStart: number, indent: number): boolean {
103
+ if (indent <= 3) return true;
104
+ for (let end = lineStart - 1; end > 0;) {
105
+ const start = text.lastIndexOf("\n", end - 1) + 1;
106
+ const line = text.slice(start, end);
107
+ end = start - 1;
108
+ if (!line.trim()) continue;
109
+ const lineIndent = indentWidth(/^(?:[ \t]*>)*([ \t]*)/.exec(line)![1]);
110
+ if (lineIndent >= indent) continue;
111
+ const item = LIST_ITEM_LINE.exec(line);
112
+ if (item) {
113
+ const content = indentWidth(item[1]) + item[2].length + indentWidth(item[3]);
114
+ return indent >= content && indent <= content + 3;
115
+ }
116
+ // A less indented line that is no list item: only a list item's continuation text keeps looking.
117
+ if (lineIndent === 0) return false;
118
+ }
119
+ return false;
120
+ }
121
+
122
+ /** Whether a code fence is still open at `end` of `text`. */
123
+ function fenceOpenAt(text: string, end: number): boolean {
124
+ let open: string | undefined;
125
+ FENCE_LINE.lastIndex = 0;
126
+ for (let match = FENCE_LINE.exec(text); match !== null && match.index < end; match = FENCE_LINE.exec(text)) {
127
+ const [, indent, marker, rest] = match;
128
+ if (!fenceIndentValid(text, match.index, indentWidth(indent))) continue;
129
+ if (open === undefined) {
130
+ // A backtick fence's info string cannot hold a backtick (that line is inline code).
131
+ if (marker[0] !== "`" || !rest.includes("`")) open = marker;
132
+ } else if (marker[0] === open[0] && marker.length >= open.length && !rest.trim()) {
133
+ open = undefined;
134
+ }
135
+ }
136
+ return open !== undefined;
137
+ }
138
+
139
+ /**
140
+ * Streaming `md` source for a native terminal, minus a still-growing table row:
141
+ * the unterminated last line when a GFM table (a run of pipe lines holding a
142
+ * header and its delimiter row, outer pipes optional) sits right above it,
143
+ * outside any open code fence. GFM continues a table on any non-blank line, so
144
+ * that line is a row whatever it holds so far. The terminal sizes columns by
145
+ * every row it gets, so a half-streamed row (an unclosed link shows its whole
146
+ * URL) would rewrap the table each frame, and TSP cannot ask it to skip one.
147
+ * The result keeps the newline before the held line, so it is a prefix of
148
+ * `text` and of the result for every append to it: streaming stays
149
+ * append-only. Pass untrimmed source: trimming first drops the newline that
150
+ * marks the last row finished, so that row would be held too.
151
+ */
152
+ export function holdPartialTableRow(text: string): string {
153
+ const lastBreak = text.lastIndexOf("\n");
154
+ if (lastBreak <= 0 || lastBreak === text.length - 1) return text;
155
+ // Walk up the run of pipe lines above the growing row to a delimiter row with a header above it.
156
+ let lineStart = text.lastIndexOf("\n", lastBreak - 1) + 1;
157
+ let table = false;
158
+ while (matchesAt(PIPE_IN_LINE_AT, text, lineStart)) {
159
+ const above = lineStart > 0 ? text.lastIndexOf("\n", lineStart - 2) + 1 : -1;
160
+ if (matchesAt(DELIMITER_ROW_AT, text, lineStart)) {
161
+ table = above >= 0 && matchesAt(PIPE_IN_LINE_AT, text, above);
162
+ lineStart = above;
163
+ break;
164
+ }
165
+ if (above < 0) break;
166
+ lineStart = above;
167
+ }
168
+ if (!table || fenceOpenAt(text, lineStart)) return text;
169
+ return text.slice(0, lastBreak + 1);
170
+ }
171
+
172
+ /**
173
+ * Index of the cell a streaming table's last row is still writing: the cell
174
+ * after its last `|`, or -1 when a newline or trailing `|` closed the row.
175
+ * `raw` is the table token's source; GFM splits cells on every unescaped pipe.
176
+ */
177
+ function growingTableCell(raw: string): number {
178
+ if (raw.endsWith("\n")) return -1;
179
+ const line = raw.slice(raw.lastIndexOf("\n") + 1).trimEnd();
180
+ if (UNESCAPED_PIPE_END.test(line)) return -1;
181
+ const pipes = line.match(UNESCAPED_PIPE)?.length ?? 0;
182
+ return matchesAt(TABLE_LINE_AT, line, 0) ? pipes - 1 : pipes;
183
+ }
184
+
185
+ const MATH_CLOSERS: Record<MathOpener, string> = { $: "$", $$: "$$", "\\(": "\\)", "\\[": "\\]" };
186
+ // The end of a math body that is not yet valid LaTeX: a half-typed command, a lone `\`, or a dangling `^`/`_`.
187
+ const PARTIAL_LATEX_TAIL = /(?:\\[A-Za-z]*|[\^_])$/;
188
+ // A body opened by anything but `$$` needs one of these: `$5` is a price and `\[docs]` an escaped bracket until
189
+ // LaTeX shows up, so they stay literal until they close.
190
+ const LATEX_HINT = /[\\^_{}]/;
191
+ // Math openers tried from the end of the run; the one being written is almost always the last.
192
+ const MATH_OPENER_TRIES = 3;
193
+
194
+ /** Whether the character at `at` follows an odd run of backslashes. */
195
+ function escapedAt(text: string, at: number): boolean {
196
+ let slashes = 0;
197
+ while (at - slashes - 1 >= 0 && text[at - slashes - 1] === "\\") slashes++;
198
+ return slashes % 2 === 1;
199
+ }
200
+
201
+ /**
202
+ * Where `text` is inline prose rather than code: the closed code spans, and
203
+ * the start of a backtick run no later run closes. While streaming, that run
204
+ * is a code span still being written, so nothing after it is prose.
205
+ */
206
+ class InlineCode {
207
+ readonly #spans: number[] = [];
208
+ readonly #openFrom: number;
209
+
210
+ constructor(text: string) {
211
+ let at = text.indexOf("`");
212
+ let openFrom = text.length;
213
+ while (at >= 0) {
214
+ let runEnd = at;
215
+ while (text[runEnd] === "`") runEnd++;
216
+ if (escapedAt(text, at) && runEnd - at === 1) {
217
+ at = text.indexOf("`", runEnd);
218
+ continue;
219
+ }
220
+ const length = runEnd - at;
221
+ let close = text.indexOf("`", runEnd);
222
+ let closeEnd = close;
223
+ while (close >= 0) {
224
+ closeEnd = close;
225
+ while (text[closeEnd] === "`") closeEnd++;
226
+ if (closeEnd - close === length) break;
227
+ close = text.indexOf("`", closeEnd);
228
+ }
229
+ if (close < 0) {
230
+ openFrom = at;
231
+ break;
232
+ }
233
+ this.#spans.push(at, closeEnd);
234
+ at = text.indexOf("`", closeEnd);
235
+ }
236
+ this.#openFrom = openFrom;
237
+ }
238
+
239
+ /** Whether `at` is prose: outside every code span, and before any span still open. */
240
+ prose(at: number): boolean {
241
+ if (at >= this.#openFrom) return false;
242
+ for (let i = 0; i < this.#spans.length; i += 2) {
243
+ if (at >= this.#spans[i]! && at < this.#spans[i + 1]!) return false;
244
+ }
245
+ return true;
246
+ }
247
+ }
248
+
249
+ /** `text` with its trailing open link closed, or `undefined` when its end is not inside a link destination. */
250
+ function closeOpenLink(text: string, code: () => InlineCode): string | undefined {
251
+ const destination = text.lastIndexOf("](");
252
+ if (destination < 0 || escapedAt(text, destination)) return undefined;
253
+ // A destination may hold balanced parentheses; `<…>` destinations and titles are not guessed.
254
+ if (text[destination + 2] === "<") return undefined;
255
+ let depth = 1;
256
+ for (let i = destination + 2; i < text.length; i++) {
257
+ const ch = text[i];
258
+ if (ch === "\\") i++;
259
+ else if (ch === "(") depth++;
260
+ else if (ch === ")" && --depth === 0) return undefined;
261
+ else if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") return undefined;
262
+ }
263
+ let nested = 0;
264
+ let label = -1;
265
+ for (let i = destination - 1; i >= 0; i--) {
266
+ const ch = text[i];
267
+ if ((ch !== "[" && ch !== "]") || escapedAt(text, i)) continue;
268
+ if (ch === "]") nested++;
269
+ else if (nested > 0) nested--;
270
+ else {
271
+ label = i;
272
+ break;
273
+ }
274
+ }
275
+ // An image keeps its literal form: its alt text and source are not a link.
276
+ if (label < 0 || (text[label - 1] === "!" && !escapedAt(text, label - 1))) return undefined;
277
+ const spans = code();
278
+ if (!spans.prose(label) || !spans.prose(destination)) return undefined;
279
+ return text + ")".repeat(depth);
280
+ }
281
+
282
+ /** `text` with its trailing open math span closed in its last valid form, or `undefined`. */
283
+ function closeOpenMath(text: string, code: () => InlineCode): string | undefined {
284
+ let from = text.length;
285
+ for (let tries = 0; tries < MATH_OPENER_TRIES && from > 0;) {
286
+ const at = Math.max(
287
+ text.lastIndexOf("$", from - 1),
288
+ text.lastIndexOf("\\(", from - 1),
289
+ text.lastIndexOf("\\[", from - 1),
290
+ );
291
+ if (at < 0) return undefined;
292
+ from = at;
293
+ const opener = mathOpenerAt(text, at);
294
+ // The second `$` of a `$$` opener is not an opener of its own.
295
+ if (opener === undefined || (opener === "$" && text[at - 1] === "$")) continue;
296
+ tries++;
297
+ if (!code().prose(at)) continue;
298
+ const bodyStart = at + opener.length;
299
+ let written = text.slice(bodyStart);
300
+ // A `$$` span whose closer is half written ends in that closer's first `$`.
301
+ if (opener === "$$" && written.endsWith("$") && !escapedAt(text, text.length - 1)) written = written.slice(0, -1);
302
+ const body = written.replace(PARTIAL_LATEX_TAIL, "");
303
+ if (!body.trim() || (opener !== "$$" && !LATEX_HINT.test(written))) continue;
304
+ let depth = 0;
305
+ for (let i = 0; i < body.length; i++) {
306
+ if (body[i] === "\\") i++;
307
+ else if (body[i] === "{") depth++;
308
+ else if (body[i] === "}" && depth > 0) depth--;
309
+ }
310
+ const closed = `${text.slice(0, bodyStart)}${body}${"}".repeat(depth)}${MATH_CLOSERS[opener]}`;
311
+ // The grammar decides: escaped, currency-like, or space-padded openers stay literal.
312
+ if (mathSpanAt(closed, at)?.end === closed.length) return closed;
313
+ }
314
+ return undefined;
315
+ }
316
+
317
+ /**
318
+ * `text`, one inline run whose end is still streaming, with a trailing open
319
+ * link or math span closed the way the finished text most likely closes it,
320
+ * or `undefined` when nothing at its end is open. A link gains its missing
321
+ * `)` (one per open parenthesis in its destination), so it renders as the
322
+ * link it becomes instead of literal brackets and URL. A math body is cut back
323
+ * to its last valid form (a half-typed command, dangling `^`/`_`, or the first
324
+ * `$` of a `$$` closer dropped, open braces closed) and closed, so the
325
+ * rendered math grows in place instead of raw LaTeX turning into symbols at
326
+ * the closer. A `$` body needs a LaTeX character, so a price (`$5 per`) is
327
+ * never speculated. Only prose is: escaped openers and anything inside a code
328
+ * span, closed or still being written, stay literal.
329
+ */
330
+ export function speculativeInlineClose(text: string): string | undefined {
331
+ let spans: InlineCode | undefined;
332
+ const code = () => (spans ??= new InlineCode(text));
333
+ return closeOpenLink(text, code) ?? closeOpenMath(text, code);
334
+ }
335
+
336
+ /**
337
+ * Streaming `md` source for a native terminal with the open link or math span
338
+ * at its end speculatively closed ({@link speculativeInlineClose}), as the TUI
339
+ * renders it. Only the last block is read (a span cannot cross a blank line),
340
+ * and source inside an open code fence is left as it is. Each frame rewrites
341
+ * only the guessed closers at the end, which the reconciler sends as a small
342
+ * `splice`.
343
+ */
344
+ export function speculativeStreamingSource(text: string): string {
345
+ const blockBreak = text.lastIndexOf("\n\n");
346
+ const blockStart = blockBreak < 0 ? 0 : blockBreak + 2;
347
+ const closed = speculativeInlineClose(text.slice(blockStart));
348
+ if (closed === undefined || fenceOpenAt(text, text.length)) return text;
349
+ return text.slice(0, blockStart) + closed;
350
+ }
351
+
352
+ /** `raw` with its last `from` replaced by `to`, or `undefined` when `from` is not in it. */
353
+ function replaceLast(raw: string, from: string, to: string): string | undefined {
354
+ const at = raw.lastIndexOf(from);
355
+ return at < 0 ? undefined : raw.slice(0, at) + to + raw.slice(at + from.length);
356
+ }
357
+
358
+ /**
359
+ * A copy of `token`, the last block of a streaming render, whose still-written
360
+ * inline run is speculatively closed ({@link speculativeInlineClose}), or
361
+ * `undefined` when nothing in it is open. Follows the run into the last list
362
+ * item, blockquote child, or the open cell of a table's last row. `raw` is
363
+ * rewritten to match, so row caches keyed on it never mistake the copy for
364
+ * the source as it stands.
365
+ */
366
+ function speculateStreamingToken(token: Token): Token | undefined {
367
+ switch (token.type) {
368
+ case "paragraph":
369
+ case "heading":
370
+ case "text": {
371
+ const closed = speculativeInlineClose(token.text);
372
+ const raw = closed === undefined ? undefined : replaceLast(token.raw, token.text, closed);
373
+ if (closed === undefined || raw === undefined) return undefined;
374
+ return { ...token, raw, text: closed, tokens: lexInlineTokens(closed) };
375
+ }
376
+ case "blockquote": {
377
+ const children: Token[] = token.tokens ?? [];
378
+ const inner = children.at(-1);
379
+ const patched = inner && speculateStreamingToken(inner);
380
+ const raw = inner && patched && replaceLast(token.raw, inner.raw, patched.raw);
381
+ if (!patched || raw === undefined) return undefined;
382
+ return { ...token, raw, tokens: [...children.slice(0, -1), patched] };
383
+ }
384
+ case "list": {
385
+ const items: Tokens.ListItem[] = token.items;
386
+ const item = items.at(-1);
387
+ const inner = item?.tokens.at(-1);
388
+ if (!item || !inner) return undefined;
389
+ const patched = speculateStreamingToken(inner);
390
+ const itemRaw = patched && replaceLast(item.raw, inner.raw, patched.raw);
391
+ const raw = itemRaw === undefined ? undefined : replaceLast(token.raw, item.raw, itemRaw);
392
+ if (!patched || itemRaw === undefined || raw === undefined) return undefined;
393
+ const grown = { ...item, raw: itemRaw, tokens: [...item.tokens.slice(0, -1), patched] };
394
+ return { ...token, raw, items: [...items.slice(0, -1), grown] };
395
+ }
396
+ case "table": {
397
+ const rows: Tokens.TableCell[][] = token.rows;
398
+ const open = growingTableCell(token.raw);
399
+ const row = rows.at(-1);
400
+ const cell = open >= 0 ? row?.[open] : undefined;
401
+ if (!row || !cell) return undefined;
402
+ const closed = speculativeInlineClose(cell.text);
403
+ const raw = closed === undefined ? undefined : replaceLast(token.raw, cell.text, closed);
404
+ if (closed === undefined || raw === undefined) return undefined;
405
+ const cells = row.map((c, i) => (i === open ? { ...c, text: closed, tokens: lexInlineTokens(closed) } : c));
406
+ return { ...token, raw, rows: [...rows.slice(0, -1), cells] };
407
+ }
408
+ default:
409
+ return undefined;
410
+ }
411
+ }
412
+
55
413
  /** The longest suffix of `text` a future append could still complete into a
56
414
  * full `\x1b]8;[^\x07\x1b]*\x1b\\` match: the last `\x1b]8;` plus clean
57
415
  * body (or that plus the pending ST-ESC `\x1b`), or a strict prefix of the
@@ -1421,6 +1779,33 @@ function loosenListItem(item: Tokens.ListItem): Tokens.ListItem {
1421
1779
  };
1422
1780
  }
1423
1781
 
1782
+ const REFERENCE_LABEL_RE = /\[[^[\]]*[^\s[\]][^[\]]*\]/;
1783
+ const LABEL_FREE_TOKENS = new Set(["checkbox", "code", "codespan", "escape", "html", "image", "math"]);
1784
+
1785
+ function labelSource(tokens: readonly Token[]): string {
1786
+ let source = "";
1787
+ for (const token of tokens) {
1788
+ if (token.type === "list") {
1789
+ for (const item of (token as Tokens.List).items) source += `${labelSource(item.tokens)}\n\n`;
1790
+ } else if (token.type === "table") {
1791
+ const table = token as TableToken;
1792
+ for (const cell of [...table.header, ...table.rows.flat()]) source += `${labelSource(cell.tokens ?? [])}\n\n`;
1793
+ } else if ("tokens" in token && token.tokens) {
1794
+ source += labelSource(token.tokens);
1795
+ } else {
1796
+ source += LABEL_FREE_TOKENS.has(token.type) ? " " : token.raw;
1797
+ }
1798
+ }
1799
+ return source;
1800
+ }
1801
+
1802
+ /** Whether a reference definition arriving later could still turn part of `item` into a link. */
1803
+ function hasUnresolvedReferenceLabel(item: Tokens.ListItem): boolean {
1804
+ return labelSource(item.tokens)
1805
+ .split(BLANK_LINE_RE)
1806
+ .some(block => REFERENCE_LABEL_RE.test(block));
1807
+ }
1808
+
1424
1809
  function formatHyperlink(text: string, target: string): string {
1425
1810
  if (!TERMINAL.hyperlinks || !target) {
1426
1811
  return text;
@@ -1787,6 +2172,7 @@ interface StreamingListCache {
1787
2172
  start: number;
1788
2173
  tailStart: number;
1789
2174
  stableCount: number;
2175
+ referenceStop?: number;
1790
2176
  list: Tokens.List;
1791
2177
  }
1792
2178
  /**
@@ -1874,6 +2260,13 @@ export class Markdown implements Component {
1874
2260
  // The frozen prefix's leading run in front of the first of those openers:
1875
2261
  // no append can change its tokens, so getLastRenderStableText publishes it.
1876
2262
  #streamSettledText?: string;
2263
+ // The text in front of the newest top-level item of a list that is still
2264
+ // streaming as the last block. Once an item's marker line has ended, no
2265
+ // append can add to the items in front of it, and #renderList draws each
2266
+ // item from its own tokens alone, so their rows are final. Kept while later
2267
+ // texts extend it, so the published prefix never shrinks when the list
2268
+ // stops being the last block.
2269
+ #streamListSettledText?: string;
1877
2270
  // The prefix before a rewind that only a closer on the still-growing last
1878
2271
  // line caused. Once a later text holds no closer line after it, that
1879
2272
  // prefix is right again, so #lexTokens puts it back.
@@ -2044,21 +2437,27 @@ export class Markdown implements Component {
2044
2437
  const cached = this.#native;
2045
2438
  if (cached?.text === this.#text && cached.stream === stream) return cached.node;
2046
2439
  // Sources are model text; OSC 8 / SGR bytes a caller spliced in are the terminal's to style, not ours.
2047
- const source = this.#text.includes("\x1b") ? plainText(this.#text) : this.#text;
2048
- const described = md(source, stream ? { stream: true } : undefined);
2440
+ const plain = this.#text.includes("\x1b") ? plainText(this.#text) : this.#text;
2441
+ const described = stream
2442
+ ? md(speculativeStreamingSource(holdPartialTableRow(plain)), { stream: true })
2443
+ : md(plain);
2049
2444
  this.#native = { text: this.#text, stream, node: described };
2050
2445
  return described;
2051
2446
  }
2052
2447
 
2053
2448
  /**
2054
- * Width-independent source prefix of the last render ending at a frozen
2055
- * Markdown block boundary that no append can move: it stops in front of a
2056
- * display-math opener whose block an append could still close. Only
2449
+ * Width-independent source prefix of the last render that no append can
2450
+ * change: up to a frozen Markdown block boundary, stopping in front of a
2451
+ * display-math opener whose block an append could still close, or, while a
2452
+ * list streams as the last block, up to its newest top-level item. Only
2057
2453
  * meaningful while streaming (transient render cache on); grows
2058
2454
  * monotonically under append-only `setText`.
2059
2455
  */
2060
2456
  getLastRenderStableText(): string {
2061
- return this.#transientRenderCache ? (this.#streamSettledText ?? "") : "";
2457
+ if (!this.#transientRenderCache) return "";
2458
+ const settled = this.#streamSettledText ?? "";
2459
+ const list = this.#streamListSettledText;
2460
+ return list !== undefined && list.length > settled.length ? list : settled;
2062
2461
  }
2063
2462
 
2064
2463
  get transientRenderCache(): boolean {
@@ -2229,6 +2628,9 @@ export class Markdown implements Component {
2229
2628
  #checkpointStreamingList(text: string, tokens: Token[]): void {
2230
2629
  const previous = this.#streamingListCache;
2231
2630
  this.#streamingListCache = undefined;
2631
+ if (this.#streamListSettledText !== undefined && !text.startsWith(this.#streamListSettledText)) {
2632
+ this.#streamListSettledText = undefined;
2633
+ }
2232
2634
  let index = tokens.length - 1;
2233
2635
  while (tokens[index]?.type === "space") index--;
2234
2636
  const token = tokens[index];
@@ -2243,6 +2645,7 @@ export class Markdown implements Component {
2243
2645
  let cursor = canResume ? previous.tailStart : start;
2244
2646
  let tailStart = start;
2245
2647
  let stableCount = 0;
2648
+ let referenceStop = canResume ? previous.referenceStop : undefined;
2246
2649
  const indent = /^ */.exec(list.raw)![0].length;
2247
2650
  for (let i = canResume ? previous.stableCount : 0; i < list.items.length; i++) {
2248
2651
  const item = list.items[i]!;
@@ -2254,9 +2657,34 @@ export class Markdown implements Component {
2254
2657
  tailStart = position;
2255
2658
  stableCount = i;
2256
2659
  }
2660
+ if (referenceStop === undefined && hasUnresolvedReferenceLabel(item)) referenceStop = tailStart;
2257
2661
  cursor = position + item.raw.length;
2258
2662
  }
2259
- this.#streamingListCache = { text, before: tokens.slice(0, index), start, tailStart, stableCount, list };
2663
+ // Items from stableCount on are scanned again next time and may still
2664
+ // resolve their own labels, so only a finished item's label is kept.
2665
+ if (referenceStop !== undefined && referenceStop >= tailStart) referenceStop = undefined;
2666
+ this.#streamingListCache = {
2667
+ text,
2668
+ before: tokens.slice(0, index),
2669
+ start,
2670
+ tailStart,
2671
+ stableCount,
2672
+ referenceStop,
2673
+ list,
2674
+ };
2675
+ // No append can add to an item once a later one has begun, but a later
2676
+ // reference definition can still restyle a bracketed label in it. The
2677
+ // list must also start right at the settled boundary: in front of it may
2678
+ // sit a display-math opener whose closer, arriving after the list, would
2679
+ // turn the whole run, list included, into one math block.
2680
+ const settledEnd = referenceStop ?? tailStart;
2681
+ if (
2682
+ settledEnd > start &&
2683
+ (this.#streamSettledText?.length ?? 0) === start &&
2684
+ settledEnd > (this.#streamListSettledText?.length ?? 0)
2685
+ ) {
2686
+ this.#streamListSettledText = text.slice(0, settledEnd);
2687
+ }
2260
2688
  }
2261
2689
 
2262
2690
  /**
@@ -2269,6 +2697,7 @@ export class Markdown implements Component {
2269
2697
  this.#streamPrefixTokens = undefined;
2270
2698
  this.#streamPrefixOpeners = NO_OPENERS;
2271
2699
  this.#streamSettledText = undefined;
2700
+ this.#streamListSettledText = undefined;
2272
2701
  this.#streamPrefixLineCache = undefined;
2273
2702
  this.#tailRowCache = undefined;
2274
2703
  this.#listItemRowsCache = undefined;
@@ -2520,7 +2949,9 @@ export class Markdown implements Component {
2520
2949
  // into a table on a marker-free delta (`| --` + `--- | -`).
2521
2950
  !FAST_TABLE_DELIM_ROW_RE.test(grownLastLine) &&
2522
2951
  (!markerDelta || (!this.#lastTailCapture?.open && !pairSeamHazard && !inlineHasOpen(deltaTokens!))) &&
2523
- !FAST_URL_ANYWHERE_RE.test(seamWindow)
2952
+ !FAST_URL_ANYWHERE_RE.test(seamWindow) &&
2953
+ // A link or math span the grown row leaves open renders speculatively closed: re-lex.
2954
+ speculativeInlineClose(grownLastLine) === undefined
2524
2955
  ) {
2525
2956
  // Same text paths a full re-lex applies: real pipeline for marker
2526
2957
  // deltas, plain swatch/entity render for inert deltas.
@@ -2624,8 +3055,12 @@ export class Markdown implements Component {
2624
3055
  }
2625
3056
  }
2626
3057
 
2627
- // Parse markdown to HTML-like tokens
2628
- const tokens = this.#lexTokens(normalizedText);
3058
+ // Parse markdown to HTML-like tokens. While streaming, the block still
3059
+ // being written renders its open link or math span as already closed.
3060
+ const lexed = this.#lexTokens(normalizedText);
3061
+ const tail = lexed.at(-1);
3062
+ const speculated = this.transientRenderCache && tail ? speculateStreamingToken(tail) : undefined;
3063
+ const tokens = speculated ? [...lexed.slice(0, -1), speculated] : lexed;
2629
3064
  let contentLines: string[];
2630
3065
  this.#activeRenderSignature = signature;
2631
3066
  try {
@@ -2652,6 +3087,8 @@ export class Markdown implements Component {
2652
3087
 
2653
3088
  const fastEligible =
2654
3089
  this.transientRenderCache &&
3090
+ // A speculated frame's rows come from source the stream does not hold yet.
3091
+ speculated === undefined &&
2655
3092
  contentLines.length > 0 &&
2656
3093
  // B+ invariant: only the FINAL #renderContentLines call captures;
2657
3094
  // the all-cache-hit prefix path clears #lastTailCapture at its top.
@@ -4042,8 +4479,21 @@ export class Markdown implements Component {
4042
4479
  naturalWidths[i] = Math.max(...headerLineWidths, 0);
4043
4480
  minWordWidths[i] = Math.max(1, this.#getLongestWordWidth(headerText, maxUnbrokenWordWidth));
4044
4481
  }
4045
- for (const row of token.rows) {
4482
+ // While streaming, the last row's open cell is still growing (an unclosed
4483
+ // link even shows its whole URL): sizing columns by it would rewrap every
4484
+ // row above it on each frame. Lay that cell out in the measured widths
4485
+ // instead; it counts once a `|` or newline closes it. The row's closed
4486
+ // cells are final and count already. The first data row has no finished
4487
+ // rows to borrow from, so it counts whole.
4488
+ const openCell =
4489
+ this.#transientRenderCache && !this.#renderingStablePrefix && token.rows.length > 1
4490
+ ? growingTableCell(token.raw)
4491
+ : -1;
4492
+ const lastRow = token.rows.length - 1;
4493
+ for (let rowIndex = 0; rowIndex <= lastRow; rowIndex++) {
4494
+ const row = token.rows[rowIndex];
4046
4495
  for (let i = 0; i < row.length; i++) {
4496
+ if (rowIndex === lastRow && i === openCell) continue;
4047
4497
  const cellText = this.#renderInlineTokens(row[i].tokens || [], styleContext);
4048
4498
  const cellLineWidths = this.#terminalLineWidths(cellText);
4049
4499
  naturalWidths[i] = Math.max(naturalWidths[i] || 0, ...cellLineWidths);
@@ -4161,7 +4611,27 @@ export class Markdown implements Component {
4161
4611
  const text = this.#renderInlineTokens(cell.tokens || [], styleContext);
4162
4612
  return this.#wrapCellText(text, columnWidths[i]);
4163
4613
  });
4164
- const rowLineCount = Math.max(...rowCellLines.map(c => c.length));
4614
+ let rowLineCount = Math.max(...rowCellLines.map(c => c.length));
4615
+ // The open cell is laid out in widths measured without it, so it can
4616
+ // wrap past the lines its row's finished cells need. Those extra lines
4617
+ // come and go as the cell grows and the column widens, moving every row
4618
+ // below the table each frame; hold the row at its finished cells' height
4619
+ // until the cell closes and counts toward the widths. While lines are
4620
+ // held back, only the last kept line makes room for a `…`: it is
4621
+ // re-wrapped one column narrower, since the wrapper closes the line's
4622
+ // links and styles where truncation would cut them off, and dropped when
4623
+ // even that overflows (a wide glyph in a two-cell column).
4624
+ if (rowIndex === lastRow && rowCellLines[openCell]) {
4625
+ const settledLineCount = Math.max(1, ...rowCellLines.filter((_, i) => i !== openCell).map(c => c.length));
4626
+ if (rowCellLines[openCell].length > settledLineCount) {
4627
+ const width = columnWidths[openCell];
4628
+ const kept = rowCellLines[openCell].slice(0, settledLineCount);
4629
+ const last = width > 1 ? (this.#wrapCellText(kept[settledLineCount - 1], width - 1)[0] ?? "") : "";
4630
+ kept[settledLineCount - 1] = `${visibleWidth(last) < width ? last : ""}…`;
4631
+ rowCellLines[openCell] = kept;
4632
+ }
4633
+ rowLineCount = settledLineCount;
4634
+ }
4165
4635
 
4166
4636
  for (let lineIdx = 0; lineIdx < rowLineCount; lineIdx++) {
4167
4637
  const rowParts = rowCellLines.map((cellLines, colIdx) => {
@@ -4,6 +4,7 @@ import { fuzzyFilter } from "../fuzzy";
4
4
  import { getKeybindings } from "../keybindings";
5
5
  import { extractPrintableText } from "../keys";
6
6
  import type { MouseRoutable, SgrMouseEvent } from "../mouse";
7
+ import { slugify } from "@linxiraos/pi-utils";
7
8
  import type { TspPrefsControl, TspPrefsRow, TspPrefsSection, TspProps, TspSpan } from "@linxiraos/pi-wire";
8
9
  import { col, node, span } from "../native/describe";
9
10
  import { sameItems, sameProps } from "../native/memo";
@@ -78,10 +79,7 @@ export interface PrefsSectionRef {
78
79
 
79
80
  /** The section id for a section title (`"Status Line"` → `"status-line"`). */
80
81
  export function prefsSectionId(title: string): string {
81
- return title
82
- .toLowerCase()
83
- .replace(/[^a-z0-9]+/g, "-")
84
- .replace(/^-|-$/g, "");
82
+ return slugify(title);
85
83
  }
86
84
 
87
85
  /** A setting as a native settings row, given its typed control; `undefined` for headings. */
@@ -12,6 +12,8 @@ import type { Component } from "../tui";
12
12
  export declare class Text implements Component {
13
13
  #private;
14
14
  setIgnoreTight(ignore: boolean): this;
15
+ /** Override row wrapping when the source carries structural prefixes. */
16
+ setWrapLine(wrapLine: (line: string, width: number) => string[]): this;
15
17
  constructor(text?: string, paddingX?: number, paddingY?: number, customBgFn?: (text: string) => string);
16
18
  /** Return bounded text and layout state for debug inspection. */
17
19
  debugState(): Record<string, unknown>;
@@ -30,6 +30,7 @@ export class Text implements Component {
30
30
  #customBgFn?: (text: string) => string;
31
31
  #styleFn?: (text: string) => string;
32
32
  #ignoreTight = false;
33
+ #wrapLine?: (line: string, width: number) => string[];
33
34
 
34
35
  setIgnoreTight(ignore: boolean): this {
35
36
  if (this.#ignoreTight === ignore) return this;
@@ -37,6 +38,12 @@ export class Text implements Component {
37
38
  this.invalidate();
38
39
  return this;
39
40
  }
41
+ /** Override row wrapping when the source carries structural prefixes. */
42
+ setWrapLine(wrapLine: (line: string, width: number) => string[]): this {
43
+ this.#wrapLine = wrapLine;
44
+ this.invalidate();
45
+ return this;
46
+ }
40
47
 
41
48
  // Cache for rendered output
42
49
  #cachedText?: string;
@@ -162,7 +169,10 @@ export class Text implements Component {
162
169
  const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
163
170
  const contentWidth = Math.max(1, width - paddingX * 2);
164
171
  // Wrap text (this preserves ANSI codes but does NOT pad)
165
- const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth);
172
+ const wrapLine = this.#wrapLine;
173
+ const wrappedLines = wrapLine
174
+ ? normalizedText.split("\n").flatMap(line => wrapLine(line, contentWidth))
175
+ : wrapTextWithAnsi(normalizedText, contentWidth);
166
176
 
167
177
  // Add margins and background to each line
168
178
  const leftMargin = padding(paddingX);