@xp266/dshtui 0.1.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 (159) hide show
  1. package/bin/dshtui.js +65 -0
  2. package/cordis.patch.yml +81 -0
  3. package/lib/contract/index.d.mts +2 -0
  4. package/lib/contract/index.mjs +5 -0
  5. package/lib/dialog.d.mts +156 -0
  6. package/lib/dialog.mjs +3 -0
  7. package/lib/index-DqnaSXqP.d.mts +823 -0
  8. package/lib/index.d.mts +17 -0
  9. package/lib/index.mjs +11404 -0
  10. package/lib/prod-react-Dzof4qJx.mjs +4 -0
  11. package/lib/surface-DdxzIgIY.mjs +3126 -0
  12. package/lib/vendor.d.mts +3 -0
  13. package/lib/vendor.mjs +4 -0
  14. package/package.json +128 -0
  15. package/src/apply-theme.ts +8 -0
  16. package/src/boot-log.ts +35 -0
  17. package/src/chat/blocks.ts +41 -0
  18. package/src/chat/bridge.ts +1076 -0
  19. package/src/chat/builtin-tool-views.ts +53 -0
  20. package/src/chat/chat-face.ts +16 -0
  21. package/src/chat/chat-nodes.ts +53 -0
  22. package/src/chat/efforts.ts +4 -0
  23. package/src/chat/interactions.ts +329 -0
  24. package/src/chat/models.ts +263 -0
  25. package/src/chat/partial-json.ts +125 -0
  26. package/src/chat/presets.ts +26 -0
  27. package/src/chat/question-view.ts +106 -0
  28. package/src/chat/retry-status.ts +33 -0
  29. package/src/chat/session-groups.ts +34 -0
  30. package/src/chat/session-list.ts +311 -0
  31. package/src/chat/store.ts +636 -0
  32. package/src/chat/todo-view.ts +66 -0
  33. package/src/chat/tool-view.ts +311 -0
  34. package/src/chat/tool-views.ts +114 -0
  35. package/src/contract/index.ts +704 -0
  36. package/src/contract/upstream.ts +165 -0
  37. package/src/core/caret-nonce.ts +19 -0
  38. package/src/core/composer-layout.ts +65 -0
  39. package/src/core/edit.ts +152 -0
  40. package/src/core/field-view.ts +52 -0
  41. package/src/core/fields.ts +185 -0
  42. package/src/core/metrics.ts +26 -0
  43. package/src/core/paste.ts +149 -0
  44. package/src/core/segments.ts +112 -0
  45. package/src/core/text.ts +290 -0
  46. package/src/dialog.ts +11 -0
  47. package/src/env.ts +38 -0
  48. package/src/harness-home.ts +22 -0
  49. package/src/hot-theme.ts +56 -0
  50. package/src/index.tsx +185 -0
  51. package/src/kernel/registry.ts +102 -0
  52. package/src/kernel/surface.ts +33 -0
  53. package/src/log.ts +249 -0
  54. package/src/model/message.ts +89 -0
  55. package/src/model/selection.ts +63 -0
  56. package/src/performance-guard.ts +28 -0
  57. package/src/runtime/prod-react.ts +10 -0
  58. package/src/terminal/background.ts +66 -0
  59. package/src/terminal/capabilities.ts +54 -0
  60. package/src/terminal/clipboard-backends.ts +69 -0
  61. package/src/terminal/clipboard.ts +161 -0
  62. package/src/terminal/cursor-shape.ts +13 -0
  63. package/src/terminal/glyphs.ts +44 -0
  64. package/src/terminal/mouse.ts +154 -0
  65. package/src/terminal/probe.ts +61 -0
  66. package/src/terminal/screen.ts +349 -0
  67. package/src/theme-settings.ts +31 -0
  68. package/src/theme.ts +319 -0
  69. package/src/ui/app.tsx +454 -0
  70. package/src/ui/chrome/caps.tsx +9 -0
  71. package/src/ui/chrome/hint-service.ts +87 -0
  72. package/src/ui/chrome/input-status.ts +27 -0
  73. package/src/ui/chrome/key-gate.tsx +29 -0
  74. package/src/ui/chrome/status-bar.tsx +147 -0
  75. package/src/ui/contributions.ts +47 -0
  76. package/src/ui/dialog/defaults-dialog.tsx +152 -0
  77. package/src/ui/dialog/dialog-item.tsx +38 -0
  78. package/src/ui/dialog/dialog.tsx +386 -0
  79. package/src/ui/dialog/effort-dialog.tsx +45 -0
  80. package/src/ui/dialog/geometry.ts +131 -0
  81. package/src/ui/dialog/items.ts +124 -0
  82. package/src/ui/dialog/list-dialog.tsx +63 -0
  83. package/src/ui/dialog/models-dialog.tsx +203 -0
  84. package/src/ui/dialog/presets-dialog.tsx +46 -0
  85. package/src/ui/dialog/providers-dialog.tsx +313 -0
  86. package/src/ui/dialog/sessions-dialog.tsx +163 -0
  87. package/src/ui/dialog/sizes.ts +7 -0
  88. package/src/ui/dialog/status-lines.ts +11 -0
  89. package/src/ui/dialog/todo-dialog.tsx +29 -0
  90. package/src/ui/dialog/use-dialog-input.ts +186 -0
  91. package/src/ui/extension-point.ts +188 -0
  92. package/src/ui/home-logo.ts +132 -0
  93. package/src/ui/hooks/use-async-action.ts +25 -0
  94. package/src/ui/hooks/use-async-list.ts +71 -0
  95. package/src/ui/hooks/use-caret.ts +24 -0
  96. package/src/ui/hooks/use-chat-events.ts +153 -0
  97. package/src/ui/hooks/use-mouse-selection.ts +352 -0
  98. package/src/ui/hooks/use-scroll.ts +49 -0
  99. package/src/ui/hooks/use-terminal-size.ts +20 -0
  100. package/src/ui/input/commands.ts +118 -0
  101. package/src/ui/input/composer-bus.ts +14 -0
  102. package/src/ui/input/composer-fields.ts +111 -0
  103. package/src/ui/input/composer-keys.ts +21 -0
  104. package/src/ui/input/composer-paste.ts +20 -0
  105. package/src/ui/input/input-bar.tsx +279 -0
  106. package/src/ui/input/use-composer.ts +495 -0
  107. package/src/ui/key-arbiter.ts +37 -0
  108. package/src/ui/keymap.ts +15 -0
  109. package/src/ui/layout-service.ts +77 -0
  110. package/src/ui/message/layout.ts +763 -0
  111. package/src/ui/message/md/block.ts +247 -0
  112. package/src/ui/message/md/engine.ts +430 -0
  113. package/src/ui/message/md/extensions.ts +58 -0
  114. package/src/ui/message/md/highlight.ts +626 -0
  115. package/src/ui/message/md/index.ts +14 -0
  116. package/src/ui/message/md/inline.ts +97 -0
  117. package/src/ui/message/md/palette.ts +87 -0
  118. package/src/ui/message/message-list.tsx +69 -0
  119. package/src/ui/message/message-row.tsx +137 -0
  120. package/src/ui/message/message-views.ts +16 -0
  121. package/src/ui/message/renderers.ts +39 -0
  122. package/src/ui/message/tool-diff.ts +188 -0
  123. package/src/ui/message/warmup.ts +41 -0
  124. package/src/ui/message/wave.ts +98 -0
  125. package/src/ui/overlay.ts +22 -0
  126. package/src/ui/panels/approval-panel.tsx +193 -0
  127. package/src/ui/panels/exports.ts +3 -0
  128. package/src/ui/panels/question-model.ts +424 -0
  129. package/src/ui/panels/question-panel.tsx +154 -0
  130. package/src/ui/panels/surface.tsx +106 -0
  131. package/src/ui/panels-builtin.tsx +86 -0
  132. package/src/ui/pointer/builtins.ts +281 -0
  133. package/src/ui/pointer/registry.ts +25 -0
  134. package/src/ui/region.tsx +31 -0
  135. package/src/ui/selection/service.ts +92 -0
  136. package/src/ui/selection-registry.ts +88 -0
  137. package/src/ui/selection.tsx +148 -0
  138. package/src/ui/spinner-tick.tsx +24 -0
  139. package/src/ui/use-available-windows.ts +31 -0
  140. package/src/ui/widgets/actions.tsx +84 -0
  141. package/src/ui/widgets/button.tsx +57 -0
  142. package/src/ui/widgets/checkbox.tsx +58 -0
  143. package/src/ui/widgets/header.tsx +36 -0
  144. package/src/ui/widgets/index.ts +8 -0
  145. package/src/ui/widgets/input.tsx +76 -0
  146. package/src/ui/widgets/registry.ts +32 -0
  147. package/src/ui/widgets/search.tsx +59 -0
  148. package/src/ui/widgets/select.tsx +97 -0
  149. package/src/ui/widgets/static.tsx +37 -0
  150. package/src/ui/widgets/types.ts +1 -0
  151. package/src/ui/window-services-bridge.ts +71 -0
  152. package/src/ui/window-services.ts +20 -0
  153. package/src/ui/windows/models-window.tsx +15 -0
  154. package/src/ui/windows/providers-window.tsx +14 -0
  155. package/src/ui/windows/service-window.tsx +18 -0
  156. package/src/ui/windows/sessions-window.tsx +25 -0
  157. package/src/ui/windows-builtin.tsx +94 -0
  158. package/src/ui/windows.ts +18 -0
  159. package/src/vendor.ts +12 -0
@@ -0,0 +1,247 @@
1
+ import type { Token, Tokens } from 'marked'
2
+ import { mergeRuns, wrapSegments } from '../../../core/segments.ts'
3
+ import type { Segment } from '../../../core/segments.ts'
4
+ import { charWidth, segmentGraphemes, textWidth } from '../../../core/text.ts'
5
+ import { glyphs } from '../../../terminal/glyphs.ts'
6
+ import { highlightCodeBlock } from './highlight.ts'
7
+ import { renderInline } from './inline.ts'
8
+ import { markdownExtensions } from './extensions.ts'
9
+ import type { MdPalette } from './palette.ts'
10
+
11
+ export interface BlockContext {
12
+ palette: MdPalette
13
+ thinking: boolean
14
+ width: number
15
+ streamId: string
16
+ }
17
+
18
+ function plainOf(segments: Segment[]): string {
19
+ return segments.map(segment => segment.text).join('')
20
+ }
21
+
22
+ function renderHeading(token: Tokens.Heading, ctx: BlockContext): Segment[][] {
23
+ const style = ctx.palette.heading(token.depth)
24
+ const segments = renderInline(token.tokens, ctx.palette, style)
25
+ return wrapSegments(segments, ctx.width)
26
+ }
27
+
28
+ function renderCode(token: Tokens.Code, ctx: BlockContext): Segment[][] {
29
+ if (token.text === '') return []
30
+ const lang = token.lang?.trim().split(/\s+/)[0]?.toLowerCase() ?? ''
31
+ const highlighted = highlightCodeBlock(token.text, lang, ctx.thinking, ctx.streamId)
32
+ const segments = highlighted ?? [{ text: token.text, style: lang === '' ? ctx.palette.codeFallback : ctx.palette.codePlain }]
33
+ return wrapSegments(segments, ctx.width)
34
+ }
35
+
36
+ function renderParagraphish(tokens: Token[], ctx: BlockContext): Segment[][] {
37
+ return wrapSegments(renderInline(tokens, ctx.palette), ctx.width)
38
+ }
39
+
40
+ function renderHr(ctx: BlockContext): Segment[][] {
41
+ return [[{ text: glyphs.horizontal.repeat(Math.max(ctx.width, 8)), style: ctx.palette.hr }]]
42
+ }
43
+
44
+ const TABLE_SAFETY_COLS = 2
45
+
46
+ interface TableCell {
47
+ text: string
48
+ segments: Segment[]
49
+ }
50
+
51
+ function renderTable(token: Tokens.Table, ctx: BlockContext): Segment[][] {
52
+ const headerCells: TableCell[] = token.header.map(cell => ({
53
+ text: cell.text,
54
+ segments: renderInline(cell.tokens, ctx.palette),
55
+ }))
56
+ const bodyRows = token.rows.map(row => row.map<TableCell>(cell => ({
57
+ text: cell.text,
58
+ segments: renderInline(cell.tokens, ctx.palette),
59
+ })))
60
+ const allRows = [headerCells, ...bodyRows]
61
+ const cols = Math.max(1, ...allRows.map(cells => cells.length))
62
+ const widths: number[] = []
63
+ const mins: number[] = []
64
+ for (let c = 0; c < cols; c++) {
65
+ let natural = 1
66
+ let min = 1
67
+ for (const cells of allRows) {
68
+ const text = (cells[c]?.text ?? '').trim()
69
+ natural = Math.max(natural, textWidth(text))
70
+ for (const { segment } of segmentGraphemes(text)) min = Math.max(min, charWidth(segment))
71
+ }
72
+ widths.push(natural)
73
+ mins.push(min)
74
+ }
75
+ const budget = Math.max(cols, ctx.width - (cols * 3 + 1) - TABLE_SAFETY_COLS)
76
+ const sum = (): number => widths.reduce((a, b) => a + b, 0)
77
+ while (sum() > budget) {
78
+ let maxIdx = -1
79
+ for (let c = 0; c < cols; c++) {
80
+ if (widths[c]! <= mins[c]!) continue
81
+ if (maxIdx === -1 || widths[c]! > widths[maxIdx]!) maxIdx = c
82
+ }
83
+ if (maxIdx === -1) break
84
+ widths[maxIdx] -= 1
85
+ }
86
+ const blocksByRow = allRows.map(cells => {
87
+ const blocks: Segment[][][] = []
88
+ for (let c = 0; c < cols; c++) {
89
+ const segs = cells[c]?.segments ?? []
90
+ blocks.push(segs.length === 0 ? [[]] : wrapSegments(segs, widths[c]!))
91
+ }
92
+ return blocks
93
+ })
94
+ const effective = widths.slice()
95
+ for (const blocks of blocksByRow) {
96
+ for (let c = 0; c < cols; c++) {
97
+ for (const rowSegs of blocks[c]!) {
98
+ effective[c] = Math.max(effective[c]!, textWidth(plainOf(rowSegs)))
99
+ }
100
+ }
101
+ }
102
+ const borderStyle = ctx.palette.hr
103
+ const borderRow = (left: string, mid: string, right: string): Segment[] =>
104
+ [{ text: left + widths.map(w => glyphs.horizontal.repeat(w + 2)).join(mid) + right, style: borderStyle }]
105
+ const renderRowBlock = (blocks: Segment[][][]): Segment[][] => {
106
+ let height = 1
107
+ for (let c = 0; c < cols; c++) height = Math.max(height, blocks[c]!.length)
108
+ const out: Segment[][] = []
109
+ for (let h = 0; h < height; h++) {
110
+ const line: Segment[] = [{ text: glyphs.tableVertical, style: borderStyle }]
111
+ for (let c = 0; c < cols; c++) {
112
+ const rowSegs = blocks[c]![h] ?? []
113
+ const used = textWidth(plainOf(rowSegs))
114
+ line.push({ text: ' ', style: ctx.palette.plain })
115
+ line.push(...rowSegs)
116
+ line.push(
117
+ { text: ' '.repeat(Math.max(0, effective[c]! - used + 1)), style: ctx.palette.plain },
118
+ { text: glyphs.tableVertical, style: borderStyle },
119
+ )
120
+ }
121
+ out.push(mergeRuns(line))
122
+ }
123
+ return out
124
+ }
125
+ return [
126
+ borderRow(...glyphs.tableBorders.top),
127
+ ...renderRowBlock(blocksByRow[0]!),
128
+ borderRow(...glyphs.tableBorders.middle),
129
+ ...blocksByRow.slice(1).flatMap(blocks => renderRowBlock(blocks)),
130
+ borderRow(...glyphs.tableBorders.bottom),
131
+ ]
132
+ }
133
+
134
+ interface ItemParts {
135
+ headTokens: Token[]
136
+ childTokens: Token[]
137
+ }
138
+
139
+ function splitItemTokens(item: Tokens.ListItem): ItemParts {
140
+ const headTokens: Token[] = []
141
+ const childTokens: Token[] = []
142
+ let scanningHead = true
143
+ for (const token of item.tokens ?? []) {
144
+ if (scanningHead && (token.type === 'text' || token.type === 'paragraph' || token.type === 'checkbox')) {
145
+ if (token.type !== 'checkbox') headTokens.push(token)
146
+ continue
147
+ }
148
+ scanningHead = false
149
+ childTokens.push(token)
150
+ }
151
+ return { headTokens, childTokens }
152
+ }
153
+
154
+ function markerSegments(item: Tokens.ListItem, ordered: boolean, label: string, depth: number, palette: MdPalette): Segment[] {
155
+ if (item.task === true) {
156
+ return [{ text: item.checked ? glyphs.taskChecked : glyphs.taskUnchecked, style: item.checked ? palette.taskDone : palette.taskTodo }]
157
+ }
158
+ if (ordered) return [{ text: label, style: palette.listMarker }]
159
+ return [{ text: glyphs.bullets[Math.min(depth, glyphs.bullets.length - 1)]!, style: palette.listMarker }]
160
+ }
161
+
162
+ function indentRows(rows: Segment[][], indent: string, palette: MdPalette): Segment[][] {
163
+ const pad: Segment = { text: indent, style: palette.plain }
164
+ return rows.map(row => [pad, ...row])
165
+ }
166
+
167
+ export function renderList(token: Tokens.List, depth: number, ctx: BlockContext): Segment[][] {
168
+ const rows: Segment[][] = []
169
+ const ordered = token.ordered === true
170
+ const start = typeof token.start === 'number' && Number.isFinite(token.start) ? Math.trunc(token.start) : 1
171
+ for (let index = 0; index < token.items.length; index++) {
172
+ const item = token.items[index]!
173
+ const { headTokens, childTokens } = splitItemTokens(item)
174
+ const markerSegs = markerSegments(item, ordered, `${start + index}.`, depth, ctx.palette)
175
+ const markerText = plainOf(markerSegs)
176
+ const hang = Math.min(textWidth(markerText) + 1, Math.max(2, Math.floor(ctx.width / 2)))
177
+ const innerCtx: BlockContext = { ...ctx, width: Math.max(4, ctx.width - hang) }
178
+ const pad = ' '.repeat(hang)
179
+ const itemRows: Segment[][] = []
180
+ const inlineRows = renderParagraphish(headTokens.flatMap(token => (token as Tokens.Text).tokens ?? []), innerCtx)
181
+ if (inlineRows.length === 0) {
182
+ itemRows.push(mergeRuns(markerSegs))
183
+ } else {
184
+ itemRows.push(mergeRuns([...markerSegs, { text: ' ', style: ctx.palette.plain }, ...inlineRows[0]!]))
185
+ for (let r = 1; r < inlineRows.length; r++) {
186
+ itemRows.push([{ text: pad, style: ctx.palette.plain }, ...inlineRows[r]!])
187
+ }
188
+ }
189
+ for (const child of childTokens) {
190
+ if (child.type === 'list') {
191
+ itemRows.push(...indentRows(renderList(child as Tokens.List, depth + 1, innerCtx), pad, ctx.palette))
192
+ } else {
193
+ itemRows.push(...indentRows(renderBlockRows(child, innerCtx), pad, ctx.palette))
194
+ }
195
+ }
196
+ rows.push(...itemRows)
197
+ }
198
+ return rows
199
+ }
200
+
201
+ export function renderBlockquote(token: Tokens.Blockquote, ctx: BlockContext): Segment[][] {
202
+ const innerCtx: BlockContext = { ...ctx, width: Math.max(4, ctx.width - 2) }
203
+ const inner: Segment[][] = []
204
+ for (const child of token.tokens ?? []) {
205
+ inner.push(...renderBlockRows(child, innerCtx))
206
+ }
207
+ const bar: Segment = { text: glyphs.quoteBar, style: ctx.palette.quoteBar }
208
+ return inner.map(row => mergeRuns([bar, { text: ' ', style: ctx.palette.plain }, ...row]))
209
+ }
210
+
211
+ export function renderBlockRows(token: Token, ctx: BlockContext): Segment[][] {
212
+ const contributed = markdownExtensions.markdownBlockOf(token)
213
+ if (contributed !== undefined) return contributed.render(token, ctx)
214
+ switch (token.type) {
215
+ case 'heading':
216
+ return renderHeading(token as Tokens.Heading, ctx)
217
+ case 'paragraph':
218
+ return renderParagraphish((token as Tokens.Paragraph).tokens, ctx)
219
+ case 'text':
220
+ return renderParagraphish((token as Tokens.Text).tokens ?? [], ctx)
221
+ case 'code':
222
+ return renderCode(token as Tokens.Code, ctx)
223
+ case 'hr':
224
+ return renderHr(ctx)
225
+ case 'table':
226
+ return renderTable(token as Tokens.Table, ctx)
227
+ case 'blockquote':
228
+ return renderBlockquote(token as Tokens.Blockquote, ctx)
229
+ case 'list':
230
+ return renderList(token as Tokens.List, 0, ctx)
231
+ case 'html': {
232
+ const raw = (token as Tokens.HTML).raw.replace(/\n$/, '')
233
+ return wrapSegments([{ text: raw, style: ctx.palette.codeFallback }], ctx.width)
234
+ }
235
+ default: {
236
+ // Unknown block tokens (from marked extensions) render their raw text
237
+ // instead of silently disappearing. Structural no-op tokens (link
238
+ // reference definitions, blank runs) stay invisible.
239
+ if (token.type === 'def' || token.type === 'space') return []
240
+ const raw = (token as { raw?: string }).raw ?? ''
241
+ if (raw.trim() === '') return []
242
+ return raw.replace(/\n$/, '')
243
+ .split('\n')
244
+ .map(line => [{ text: line, style: ctx.palette.plain }])
245
+ }
246
+ }
247
+ }
@@ -0,0 +1,430 @@
1
+ import { marked } from 'marked'
2
+ import type { Token } from 'marked'
3
+ import type { Segment } from '../../../core/segments.ts'
4
+ import { renderBlockRows } from './block.ts'
5
+ import type { BlockContext } from './block.ts'
6
+ import { createMdPalette } from './palette.ts'
7
+
8
+ export interface MarkdownRenderResult {
9
+ rows: Segment[][]
10
+ lines: string[]
11
+ }
12
+
13
+ export interface MarkdownRenderer {
14
+ update(content: string): MarkdownRenderResult
15
+ }
16
+
17
+ function normalizeEol(content: string): string {
18
+ return content.includes('\r') ? content.replace(/\r\n?/g, '\n') : content
19
+ }
20
+
21
+ interface FenceScan {
22
+ insideFence: boolean
23
+ }
24
+
25
+ interface FenceOpen {
26
+ char: string
27
+ len: number
28
+ }
29
+
30
+ /**
31
+ * CommonMark fence rules: an opening fence's info string may not contain the
32
+ * fence character (so "``` ```ts" is a paragraph, not a fence), and a closing
33
+ * fence may carry only trailing whitespace. marked follows the same rules, so
34
+ * the streaming scanner must too or its fence state diverges from the lexer.
35
+ */
36
+ function fenceOpenOf(line: string): FenceOpen | null {
37
+ const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line)
38
+ if (match === null) return null
39
+ const marker = match[1]!
40
+ const info = match[2] ?? ''
41
+ if (marker[0] === '`' && info.includes('`')) return null
42
+ if (marker[0] === '~' && info.includes('~')) return null
43
+ return { char: marker[0]!, len: marker.length }
44
+ }
45
+
46
+ function fenceCloseOf(line: string, open: FenceOpen): boolean {
47
+ const match = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line)
48
+ if (match === null) return false
49
+ const marker = match[1]!
50
+ return marker[0] === open.char && marker.length >= open.len
51
+ }
52
+
53
+ function scanFences(content: string): FenceScan {
54
+ let open: FenceOpen | null = null
55
+ for (const line of content.split('\n')) {
56
+ if (open !== null) {
57
+ if (fenceCloseOf(line, open)) open = null
58
+ continue
59
+ }
60
+ open = fenceOpenOf(line)
61
+ }
62
+ return { insideFence: open !== null }
63
+ }
64
+
65
+ function healInlineTail(content: string): string {
66
+ if (content === '' || scanFences(content).insideFence) return content
67
+ const breakAt = content.lastIndexOf('\n\n')
68
+ const head = breakAt === -1 ? '' : content.slice(0, breakAt + 2)
69
+ const tail = content.slice(head.length)
70
+ let suffix = ''
71
+ const matches = [...tail.matchAll(/(`+|~~|\*\*|__)/g)]
72
+ if (matches.length > 0) {
73
+ const last = matches[matches.length - 1]![1]!
74
+ const count = matches.filter(match => match[1] === last).length
75
+ if (count % 2 === 1) suffix = last
76
+ }
77
+ return suffix === '' ? content : head + tail + suffix
78
+ }
79
+
80
+ interface BlockEntry {
81
+ rows: Segment[][]
82
+ lines: string[]
83
+ bytes: number
84
+ }
85
+
86
+ const BLOCK_CACHE_MAX = 512
87
+ // Rows retain one segment per styled run plus the joined plain line, so an
88
+ // entry for a large code block can reach megabytes; the byte cap bounds the
89
+ // cache independent of how many blocks it holds.
90
+ const BLOCK_CACHE_MAX_BYTES = 16 * 1024 * 1024
91
+
92
+ const blockCache = new Map<string, BlockEntry>()
93
+ let blockCacheBytes = 0
94
+
95
+ export function clearMarkdownBlockCache(): void {
96
+ blockCache.clear()
97
+ blockCacheBytes = 0
98
+ }
99
+
100
+ function evictBlockCacheIfNeeded(): void {
101
+ while (blockCache.size > 0 && (blockCache.size >= BLOCK_CACHE_MAX || blockCacheBytes >= BLOCK_CACHE_MAX_BYTES)) {
102
+ const oldest = blockCache.keys().next()
103
+ if (oldest.done) return
104
+ const entry = blockCache.get(oldest.value)
105
+ if (entry !== undefined) blockCacheBytes -= entry.bytes
106
+ blockCache.delete(oldest.value)
107
+ }
108
+ }
109
+
110
+ // Segment objects cost far more heap than their text; the constant keeps the
111
+ // byte accounting within ~2x of actual heap usage.
112
+ const SEGMENT_HEAP_BYTES = 128
113
+
114
+ function rowsHeapBytes(rows: Segment[][]): number {
115
+ let bytes = 0
116
+ for (const row of rows) {
117
+ for (const segment of row) bytes += SEGMENT_HEAP_BYTES + segment.text.length
118
+ }
119
+ return bytes
120
+ }
121
+
122
+ function cachedBlockRows(token: Token, ctx: BlockContext, streamId: string): BlockEntry {
123
+ const key = `${ctx.thinking ? 't' : 'm'}\u0000${ctx.width}\u0000${streamId}\u0000${token.raw}`
124
+ const hit = blockCache.get(key)
125
+ if (hit !== undefined) return hit
126
+ const rows = renderBlockRows(token, { ...ctx, streamId })
127
+ const lines = rows.map(row => {
128
+ let line = ''
129
+ for (const segment of row) line += segment.text
130
+ return line
131
+ })
132
+ let bytes = key.length
133
+ for (const line of lines) bytes += line.length
134
+ bytes += rowsHeapBytes(rows)
135
+ const entry: BlockEntry = { rows, lines, bytes }
136
+ evictBlockCacheIfNeeded()
137
+ blockCache.set(key, entry)
138
+ blockCacheBytes += bytes
139
+ return entry
140
+ }
141
+
142
+ export function renderMarkdown(content: string, width: number, thinking = false, streamId = 'static'): MarkdownRenderResult {
143
+ if (streamId !== 'static') dropStreamState(streamId)
144
+ const palette = createMdPalette(thinking)
145
+ const ctx: BlockContext = { palette, thinking, width: Math.max(4, width), streamId }
146
+ const tokens = marked.lexer(healInlineTail(normalizeEol(content))) as Token[]
147
+ const rows: Segment[][] = []
148
+ const lines: string[] = []
149
+ let pendingSeparator = false
150
+ for (const [index, token] of tokens.entries()) {
151
+ const entry = cachedBlockRows(token, ctx, `${streamId}:${index}`)
152
+ if (entry.rows.length === 0) continue
153
+ if (pendingSeparator && rows.length > 0) {
154
+ rows.push([])
155
+ lines.push('')
156
+ }
157
+ pendingSeparator = true
158
+ rows.push(...entry.rows)
159
+ lines.push(...entry.lines)
160
+ }
161
+ while (rows.length > 0 && rows[rows.length - 1]!.length === 0) {
162
+ rows.pop()
163
+ lines.pop()
164
+ }
165
+ return { rows, lines }
166
+ }
167
+
168
+ export function createMarkdownRenderer(width: number, thinking = false): MarkdownRenderer {
169
+ return {
170
+ update(content: string) {
171
+ return renderMarkdown(content, width, thinking)
172
+ },
173
+ }
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Streaming render
178
+ //
179
+ // Re-lexing the full content on every streamed chunk makes per-frame cost
180
+ // O(total length); a 128KB answer spends tens of milliseconds per frame in
181
+ // marked's lexer alone. The streaming path finalizes blocks at blank-line
182
+ // boundaries outside fences and only lexes the unfinished tail each frame;
183
+ // the final settled render always goes through renderMarkdown, which is
184
+ // exact by construction. Adversarial mid-stream content (unclosed inline
185
+ // spans reaching across a finalized boundary) can render one transient frame
186
+ // differently before the next frame or the settled render corrects it.
187
+ //
188
+ // The boundary rule mirrors marked's block tokenizer: a blank run ends the
189
+ // previous block unless the next content line continues an open list —
190
+ // marked merges same-marker list items across blank lines (loose lists), so
191
+ // those runs must not cut. Fence semantics match marked's fence regex
192
+ // exactly (fenceOpenOf/fenceCloseOf), otherwise fence state diverges from
193
+ // the lexer.
194
+ // ---------------------------------------------------------------------------
195
+
196
+ type ListKind = 'bullet' | 'ordered'
197
+
198
+ function listMarkerOf(line: string): ListKind | undefined {
199
+ const match = /^ {0,3}(?:([-+*])|(\d{1,9})[.)])(?:[ \t]+|$)/.exec(line)
200
+ if (match === null) return undefined
201
+ return match[1] !== undefined ? 'bullet' : 'ordered'
202
+ }
203
+
204
+ interface StreamRenderState {
205
+ epoch: number
206
+ width: number
207
+ thinking: boolean
208
+ /** Offset: content[:cut] is finalized into finalRows. */
209
+ cut: number
210
+ /** Offset: fence/boundary scan has consumed content[:scanPos]. */
211
+ scanPos: number
212
+ fence: FenceOpen | null
213
+ blankRun: boolean
214
+ /** Marker kind of the open list, when the current region is a list. */
215
+ listKind: ListKind | undefined
216
+ finalRows: Segment[][]
217
+ finalLines: string[]
218
+ /** Ordinal of the next block token; keeps blockCache keys stable as the tail finalizes. */
219
+ tokenCount: number
220
+ pendingSeparator: boolean
221
+ prevContent: string
222
+ }
223
+
224
+ const STREAM_STATE_MAX = 32
225
+ // A state retains the content prefix plus the finalized row references, so a
226
+ // byte cap (content length as the proxy) prevents a handful of very large
227
+ // streams from pinning unbounded memory.
228
+ const STREAM_STATE_MAX_BYTES = 4 * 1024 * 1024
229
+
230
+ const streamRenderStates = new Map<string, StreamRenderState>()
231
+ let streamStateBytes = 0
232
+ let streamEpoch = 0
233
+
234
+ export function clearMarkdownStreamStates(): void {
235
+ streamEpoch += 1
236
+ streamRenderStates.clear()
237
+ streamStateBytes = 0
238
+ }
239
+
240
+ function dropStreamState(streamId: string): void {
241
+ const dropped = streamRenderStates.get(streamId)
242
+ if (dropped !== undefined) streamStateBytes -= dropped.prevContent.length
243
+ streamRenderStates.delete(streamId)
244
+ }
245
+
246
+ function touchStreamState(streamId: string, state: StreamRenderState): void {
247
+ dropStreamState(streamId)
248
+ streamRenderStates.set(streamId, state)
249
+ streamStateBytes += state.prevContent.length
250
+ while (streamRenderStates.size > 1 && (streamRenderStates.size > STREAM_STATE_MAX || streamStateBytes > STREAM_STATE_MAX_BYTES)) {
251
+ const oldest = streamRenderStates.keys().next()
252
+ if (oldest.done) break
253
+ dropStreamState(oldest.value)
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Advances the block-boundary scan over complete lines. A cut is recorded
259
+ * where a blank run is followed by content that cannot continue the previous
260
+ * block (lists continue across blank lines until a different block or a
261
+ * different marker kind appears). Only complete lines (terminated by '\n')
262
+ * are consumed: a partial trailing line still grows and may reclassify
263
+ * (e.g. "```" completing into "``` para"), so it is re-scanned every frame.
264
+ */
265
+ function scanStreamTail(
266
+ content: string,
267
+ from: number,
268
+ fence: FenceOpen | null,
269
+ blankRun: boolean,
270
+ listKind: ListKind | undefined,
271
+ ): { fence: FenceOpen | null; blankRun: boolean; listKind: ListKind | undefined; scanPos: number; boundary: number } {
272
+ let open = fence
273
+ let run = blankRun
274
+ let kind = listKind
275
+ let pos = from
276
+ let boundary = -1
277
+ const cut = (at: number): void => {
278
+ if (boundary === -1) boundary = at
279
+ }
280
+ while (pos < content.length) {
281
+ const nl = content.indexOf('\n', pos)
282
+ if (nl === -1) break
283
+ const line = content.slice(pos, nl)
284
+ if (open !== null) {
285
+ if (fenceCloseOf(line, open)) open = null
286
+ } else {
287
+ const opened = fenceOpenOf(line)
288
+ if (opened !== null) {
289
+ open = opened
290
+ kind = undefined
291
+ run = false
292
+ } else if (/^\s*$/.test(line)) {
293
+ run = true
294
+ } else {
295
+ const marker = listMarkerOf(line)
296
+ if (marker !== undefined) {
297
+ // Same marker kind across a blank run keeps one loose list.
298
+ if (run && marker !== kind) cut(pos)
299
+ kind = marker
300
+ run = false
301
+ } else if (/^ {2,}/.test(line) && kind !== undefined) {
302
+ // Indented continuation of an open list item.
303
+ run = false
304
+ } else if (run) {
305
+ // A blank run plus a non-list line always ends the open list.
306
+ cut(pos)
307
+ kind = undefined
308
+ run = false
309
+ }
310
+ // A non-list line attached without a blank is a lazy continuation
311
+ // (marked keeps it inside the item's paragraph), so kind persists.
312
+ }
313
+ }
314
+ pos = nl + 1
315
+ }
316
+ if (boundary === -1 && run && open === null && kind === undefined && content.endsWith('\n')) {
317
+ boundary = content.length
318
+ }
319
+ return { fence: open, blankRun: run, listKind: kind, scanPos: pos, boundary }
320
+ }
321
+
322
+ export function renderMarkdownStreaming(content: string, width: number, thinking: boolean, streamId: string): MarkdownRenderResult {
323
+ if (content.includes('\r')) return renderMarkdown(content, width, thinking, streamId)
324
+ let state = streamRenderStates.get(streamId)
325
+ const valid = state !== undefined
326
+ && state.epoch === streamEpoch
327
+ && state.width === width
328
+ && state.thinking === thinking
329
+ && content.length >= state.prevContent.length
330
+ && content.startsWith(state.prevContent)
331
+ if (!valid) {
332
+ state = {
333
+ epoch: streamEpoch,
334
+ width,
335
+ thinking,
336
+ cut: 0,
337
+ scanPos: 0,
338
+ fence: null,
339
+ blankRun: false,
340
+ listKind: undefined,
341
+ finalRows: [],
342
+ finalLines: [],
343
+ tokenCount: 0,
344
+ pendingSeparator: false,
345
+ prevContent: '',
346
+ }
347
+ }
348
+ state = state!
349
+ const palette = createMdPalette(thinking)
350
+ const ctx: BlockContext = { palette, thinking, width: Math.max(4, width), streamId }
351
+ const scan = scanStreamTail(content, state.scanPos, state.fence, state.blankRun, state.listKind)
352
+ state.fence = scan.fence
353
+ state.blankRun = scan.blankRun
354
+ state.listKind = scan.listKind
355
+ state.scanPos = scan.scanPos
356
+ // The per-frame accumulator copies the finalized prefix; tail rows are
357
+ // transient (the same region is re-lexed next frame), so the state snapshot
358
+ // is taken after the finalize pass and must not include them.
359
+ const rows = state.finalRows.slice()
360
+ const lines = state.finalLines.slice()
361
+ let pendingSeparator = state.pendingSeparator
362
+ if (scan.boundary > state.cut) {
363
+ const tokens = marked.lexer(content.slice(state.cut, scan.boundary)) as Token[]
364
+ for (const token of tokens) {
365
+ const entry = cachedBlockRows(token, ctx, `${streamId}:${state.tokenCount}`)
366
+ state.tokenCount += 1
367
+ if (entry.rows.length === 0) continue
368
+ if (pendingSeparator && rows.length > 0) {
369
+ rows.push([])
370
+ lines.push('')
371
+ }
372
+ pendingSeparator = true
373
+ rows.push(...entry.rows)
374
+ lines.push(...entry.lines)
375
+ }
376
+ state.cut = scan.boundary
377
+ }
378
+ state.finalRows = rows.slice()
379
+ state.finalLines = lines.slice()
380
+ state.pendingSeparator = pendingSeparator
381
+ const tail = healStreamingTail(content.slice(state.cut), state.fence)
382
+ const tokens = marked.lexer(tail) as Token[]
383
+ const tailRows: Segment[][] = []
384
+ const tailLines: string[] = []
385
+ let tailIndex = 0
386
+ for (const token of tokens) {
387
+ const entry = cachedBlockRows(token, ctx, `${streamId}:${state.tokenCount + tailIndex}`)
388
+ tailIndex += 1
389
+ if (entry.rows.length === 0) continue
390
+ if (pendingSeparator && rows.length + tailRows.length > 0) {
391
+ tailRows.push([])
392
+ tailLines.push('')
393
+ }
394
+ pendingSeparator = true
395
+ tailRows.push(...entry.rows)
396
+ tailLines.push(...entry.lines)
397
+ }
398
+ state.prevContent = content
399
+ touchStreamState(streamId, state)
400
+ const resultRows = rows.concat(tailRows)
401
+ const resultLines = lines.concat(tailLines)
402
+ while (resultRows.length > 0 && resultRows[resultRows.length - 1]!.length === 0) {
403
+ resultRows.pop()
404
+ resultLines.pop()
405
+ }
406
+ return { rows: resultRows, lines: resultLines }
407
+ }
408
+
409
+ function healStreamingTail(tail: string, fence: FenceOpen | null): string {
410
+ if (tail === '' || fence !== null) return tail
411
+ // An incomplete trailing line that starts with a fence marker may be a
412
+ // fence opening (the scanner holds incomplete lines back), so inline
413
+ // balancing must not treat its marker as an unclosed span.
414
+ const nl = tail.lastIndexOf('\n')
415
+ const lastLine = nl === -1 ? tail : tail.slice(nl + 1)
416
+ if (/^ {0,3}(`{3,}|~{3,})/.test(lastLine)) return tail
417
+ // Inline markers cannot span blank lines, so only the last paragraph-shaped
418
+ // segment of the tail can leave a marker open at the streaming cut; the
419
+ // suffix heals that segment and lands at the end of the tail.
420
+ const breakAt = tail.lastIndexOf('\n\n')
421
+ const last = breakAt === -1 ? tail : tail.slice(breakAt + 2)
422
+ let suffix = ''
423
+ const matches = [...last.matchAll(/(`+|~~|\*\*|__)/g)]
424
+ if (matches.length > 0) {
425
+ const marker = matches[matches.length - 1]![1]!
426
+ const count = matches.filter(match => match[1] === marker).length
427
+ if (count % 2 === 1) suffix = marker
428
+ }
429
+ return suffix === '' ? tail : tail + suffix
430
+ }