@varo-ui/ai 1.2.0 → 2.0.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.
@@ -1,36 +1,36 @@
1
- import { isUnknownRecord } from './type-guards'
2
1
  import type { ParsedNode } from 'stream-markdown-parser'
2
+ import { isUnknownRecord } from './type-guards'
3
3
 
4
- export type AgentMarkdownNodeKind =
5
- | 'admonition'
6
- | 'blockquote'
7
- | 'checkbox'
8
- | 'code-block'
9
- | 'definition'
10
- | 'definition-list'
11
- | 'emphasis'
12
- | 'footnote'
13
- | 'hardbreak'
14
- | 'heading'
15
- | 'highlight'
16
- | 'image'
17
- | 'inline-code'
18
- | 'insert'
19
- | 'link'
20
- | 'list'
21
- | 'list-item'
22
- | 'math-block'
23
- | 'math-inline'
24
- | 'paragraph'
25
- | 'strikethrough'
26
- | 'strong'
27
- | 'subscript'
28
- | 'superscript'
29
- | 'table'
30
- | 'table-cell'
31
- | 'table-row'
32
- | 'text'
33
- | 'thematic-break'
4
+ export type AgentMarkdownNodeKind
5
+ = | 'admonition'
6
+ | 'blockquote'
7
+ | 'checkbox'
8
+ | 'code-block'
9
+ | 'definition'
10
+ | 'definition-list'
11
+ | 'emphasis'
12
+ | 'footnote'
13
+ | 'hardbreak'
14
+ | 'heading'
15
+ | 'highlight'
16
+ | 'image'
17
+ | 'inline-code'
18
+ | 'insert'
19
+ | 'link'
20
+ | 'list'
21
+ | 'list-item'
22
+ | 'math-block'
23
+ | 'math-inline'
24
+ | 'paragraph'
25
+ | 'strikethrough'
26
+ | 'strong'
27
+ | 'subscript'
28
+ | 'superscript'
29
+ | 'table'
30
+ | 'table-cell'
31
+ | 'table-row'
32
+ | 'text'
33
+ | 'thematic-break'
34
34
 
35
35
  export interface AgentMarkdownViewNode {
36
36
  align?: 'center' | 'left' | 'right'
@@ -49,6 +49,85 @@ export interface AgentMarkdownViewNode {
49
49
  title?: string
50
50
  }
51
51
 
52
+ export type AgentRichTextNode
53
+ = | {
54
+ text: string
55
+ type: 'text'
56
+ }
57
+ | {
58
+ attrs?: Record<string, string>
59
+ children?: AgentRichTextNode[]
60
+ name: 'br' | 'code' | 'del' | 'em' | 'ins' | 'mark' | 'span' | 'strong' | 'sub' | 'sup'
61
+ }
62
+ type AgentRichTextElementName = Extract<AgentRichTextNode, { name: string }>['name']
63
+
64
+ function richTextChildren(children: AgentMarkdownViewNode[] | undefined): AgentRichTextNode[] | undefined {
65
+ const result: AgentRichTextNode[] = []
66
+ for (const child of children ?? []) {
67
+ // Recursive inline nodes intentionally alternate between element and child normalization.
68
+ // eslint-disable-next-line ts/no-use-before-define
69
+ const normalized = richTextNode(child)
70
+ if (!normalized) { return undefined }
71
+ result.push(normalized)
72
+ }
73
+ return result
74
+ }
75
+
76
+ function richTextNode(node: AgentMarkdownViewNode): AgentRichTextNode | undefined {
77
+ if (node.kind === 'text') { return { text: node.text ?? '', type: 'text' } }
78
+ if (node.kind === 'hardbreak') { return { name: 'br' } }
79
+ if (node.kind === 'checkbox') { return { text: node.checked ? '☑ ' : '☐ ', type: 'text' } }
80
+
81
+ const names: Partial<Record<AgentMarkdownNodeKind, AgentRichTextElementName>> = {
82
+ emphasis: 'em',
83
+ strikethrough: 'del',
84
+ strong: 'strong',
85
+ subscript: 'sub',
86
+ superscript: 'sup',
87
+ }
88
+ const name = names[node.kind]
89
+ if (name) {
90
+ const children = richTextChildren(node.children)
91
+ return children ? { children, name } : undefined
92
+ }
93
+
94
+ if (node.kind === 'highlight' || node.kind === 'insert') {
95
+ const children = richTextChildren(node.children)
96
+ if (!children) { return undefined }
97
+ return {
98
+ attrs: {
99
+ style: node.kind === 'highlight'
100
+ ? 'background:rgba(250,146,0,.18);border-radius:2px;'
101
+ : 'text-decoration:underline;',
102
+ },
103
+ children,
104
+ name: 'span',
105
+ }
106
+ }
107
+
108
+ if (node.kind === 'inline-code') {
109
+ return {
110
+ attrs: {
111
+ style: 'font-family:monospace;padding:1px 4px;border-radius:4px;background:rgba(127,127,127,.12);',
112
+ },
113
+ children: [{ text: node.text ?? '', type: 'text' }],
114
+ name: 'code',
115
+ }
116
+ }
117
+ if (node.kind === 'math-inline') {
118
+ return {
119
+ attrs: { style: 'font-family:monospace;' },
120
+ children: [{ text: node.text ?? '', type: 'text' }],
121
+ name: 'span',
122
+ }
123
+ }
124
+ return undefined
125
+ }
126
+
127
+ export function toAgentRichTextNodes(nodes: AgentMarkdownViewNode[]): AgentRichTextNode[] | undefined {
128
+ return richTextChildren(nodes)
129
+ }
130
+
52
131
  function stringValue(value: unknown, fallback = '') {
53
132
  return typeof value === 'string' ? value : fallback
54
133
  }
@@ -70,18 +149,20 @@ function nodeField(node: ParsedNode, key: string): unknown {
70
149
  }
71
150
 
72
151
  function normalizeUnknownNode(value: unknown): AgentMarkdownViewNode | undefined {
73
- if (!isUnknownRecord(value) || typeof value.type !== 'string' || typeof value.raw !== 'string') return undefined
152
+ if (!isUnknownRecord(value) || typeof value.type !== 'string' || typeof value.raw !== 'string') { return undefined }
74
153
  // The required BaseNode fields are validated above; extension fields stay runtime-checked.
75
154
  const node = value as ParsedNode
155
+ // Recursive block normalization is declared below to keep the public switch centralized.
156
+ // eslint-disable-next-line ts/no-use-before-define
76
157
  return normalizeMarkdownNode(node)
77
158
  }
78
159
 
79
160
  function normalizeChildren(value: unknown): AgentMarkdownViewNode[] {
80
- if (!Array.isArray(value)) return []
161
+ if (!Array.isArray(value)) { return [] }
81
162
  const result: AgentMarkdownViewNode[] = []
82
163
  value.forEach((item) => {
83
164
  const normalized = normalizeUnknownNode(item)
84
- if (normalized) result.push(normalized)
165
+ if (normalized) { result.push(normalized) }
85
166
  })
86
167
  return result
87
168
  }
@@ -101,7 +182,7 @@ export function normalizeMarkdownNode(node: ParsedNode): AgentMarkdownViewNode {
101
182
  children: normalizeChildren(node.children),
102
183
  kind: 'heading',
103
184
  level: numberValue(nodeField(node, 'level')) ?? 2,
104
- text: stringValue(nodeField(node, 'text'))
185
+ text: stringValue(nodeField(node, 'text')),
105
186
  }
106
187
  case 'paragraph':
107
188
  case 'inline':
@@ -111,7 +192,7 @@ export function normalizeMarkdownNode(node: ParsedNode): AgentMarkdownViewNode {
111
192
  children: normalizeChildren(nodeField(node, 'items')),
112
193
  kind: 'list',
113
194
  ordered: booleanValue(nodeField(node, 'ordered')) ?? false,
114
- start: numberValue(nodeField(node, 'start'))
195
+ start: numberValue(nodeField(node, 'start')),
115
196
  }
116
197
  case 'list_item':
117
198
  return { children: normalizeChildren(node.children), kind: 'list-item' }
@@ -120,7 +201,7 @@ export function normalizeMarkdownNode(node: ParsedNode): AgentMarkdownViewNode {
120
201
  kind: 'code-block',
121
202
  language: stringValue(nodeField(node, 'language')),
122
203
  loading: booleanValue(node.loading),
123
- text: stringValue(node.code, fallback)
204
+ text: stringValue(node.code, fallback),
124
205
  }
125
206
  case 'inline_code':
126
207
  return { kind: 'inline-code', text: stringValue(node.code, fallback) }
@@ -130,14 +211,14 @@ export function normalizeMarkdownNode(node: ParsedNode): AgentMarkdownViewNode {
130
211
  href: stringValue(nodeField(node, 'href')),
131
212
  kind: 'link',
132
213
  text: stringValue(nodeField(node, 'text')),
133
- title: stringValue(nodeField(node, 'title'))
214
+ title: stringValue(nodeField(node, 'title')),
134
215
  }
135
216
  case 'image':
136
217
  return {
137
218
  alt: stringValue(nodeField(node, 'alt')),
138
219
  href: stringValue(nodeField(node, 'src')),
139
220
  kind: 'image',
140
- title: stringValue(nodeField(node, 'title'))
221
+ title: stringValue(nodeField(node, 'title')),
141
222
  }
142
223
  case 'thematic_break':
143
224
  return { kind: 'thematic-break' }
@@ -148,7 +229,7 @@ export function normalizeMarkdownNode(node: ParsedNode): AgentMarkdownViewNode {
148
229
  const rows = nodeField(node, 'rows')
149
230
  return {
150
231
  children: normalizeChildren(Array.isArray(rows) ? [header, ...rows] : [header]),
151
- kind: 'table'
232
+ kind: 'table',
152
233
  }
153
234
  }
154
235
  case 'table_row':
@@ -158,7 +239,7 @@ export function normalizeMarkdownNode(node: ParsedNode): AgentMarkdownViewNode {
158
239
  align: alignValue(nodeField(node, 'align')),
159
240
  children: normalizeChildren(node.children),
160
241
  header: booleanValue(nodeField(node, 'header')),
161
- kind: 'table-cell'
242
+ kind: 'table-cell',
162
243
  }
163
244
  case 'strong':
164
245
  return { children: normalizeChildren(node.children), kind: 'strong' }
@@ -185,9 +266,9 @@ export function normalizeMarkdownNode(node: ParsedNode): AgentMarkdownViewNode {
185
266
  return {
186
267
  children: [
187
268
  { children: normalizeChildren(nodeField(node, 'term')), header: true, kind: 'definition' },
188
- { children: normalizeChildren(nodeField(node, 'definition')), kind: 'definition' }
269
+ { children: normalizeChildren(nodeField(node, 'definition')), kind: 'definition' },
189
270
  ],
190
- kind: 'definition'
271
+ kind: 'definition',
191
272
  }
192
273
  case 'footnote':
193
274
  return { children: normalizeChildren(node.children), kind: 'footnote', title: stringValue(nodeField(node, 'id')) }
@@ -199,14 +280,14 @@ export function normalizeMarkdownNode(node: ParsedNode): AgentMarkdownViewNode {
199
280
  children: normalizeChildren(node.children),
200
281
  kind: 'admonition',
201
282
  text: stringValue(nodeField(node, 'kind')),
202
- title: stringValue(nodeField(node, 'title'))
283
+ title: stringValue(nodeField(node, 'title')),
203
284
  }
204
285
  case 'vmr_container':
205
286
  return {
206
287
  children: normalizeChildren(node.children),
207
288
  kind: 'admonition',
208
289
  loading: booleanValue(node.loading),
209
- title: stringValue(nodeField(node, 'name'))
290
+ title: stringValue(nodeField(node, 'name')),
210
291
  }
211
292
  case 'hardbreak':
212
293
  return { kind: 'hardbreak' }
package/src/sse.ts CHANGED
@@ -94,20 +94,45 @@ export function createAgentSseEventSource(): AgentSseEventSource {
94
94
  let textBuffer = ''
95
95
  let ended = false
96
96
 
97
+ function clearBufferedInput() {
98
+ dataLines.length = 0
99
+ textBuffer = ''
100
+ }
101
+
102
+ function closeSource() {
103
+ if (ended) return
104
+ ended = true
105
+ clearBufferedInput()
106
+ channel.end()
107
+ }
108
+
109
+ function failSource(error: unknown) {
110
+ if (ended) return
111
+ ended = true
112
+ clearBufferedInput()
113
+ channel.fail(error)
114
+ }
115
+
116
+ function finishWith(event: AgentStreamEvent) {
117
+ channel.push(event)
118
+ closeSource()
119
+ }
120
+
97
121
  function dispatch() {
98
122
  if (dataLines.length === 0 || ended) return
99
123
  const payload = dataLines.join('\n')
100
124
  dataLines.length = 0
101
125
  if (payload === '[DONE]') {
102
- channel.push({ type: 'done' })
126
+ finishWith({ type: 'done' })
103
127
  return
104
128
  }
105
129
 
106
130
  try {
107
- channel.push(parseAgentStreamEvent(JSON.parse(payload)))
131
+ const event = parseAgentStreamEvent(JSON.parse(payload))
132
+ if (event.type === 'done' || event.type === 'error') finishWith(event)
133
+ else channel.push(event)
108
134
  } catch (error) {
109
- ended = true
110
- channel.fail(error)
135
+ failSource(error)
111
136
  }
112
137
  }
113
138
 
@@ -129,10 +154,12 @@ export function createAgentSseEventSource(): AgentSseEventSource {
129
154
  }
130
155
 
131
156
  function appendText(value: string) {
157
+ if (ended) return
132
158
  textBuffer += value
133
159
  let lineEnd = textBuffer.indexOf('\n')
134
160
  while (lineEnd >= 0) {
135
161
  processLine(textBuffer.slice(0, lineEnd))
162
+ if (ended) return
136
163
  textBuffer = textBuffer.slice(lineEnd + 1)
137
164
  lineEnd = textBuffer.indexOf('\n')
138
165
  }
@@ -142,15 +169,13 @@ export function createAgentSseEventSource(): AgentSseEventSource {
142
169
  end() {
143
170
  if (ended) return
144
171
  appendText(decoder.decode(new Uint8Array(0), true))
172
+ if (ended) return
145
173
  if (textBuffer.length > 0) processLine(textBuffer)
146
174
  dispatch()
147
- ended = true
148
- channel.end()
175
+ closeSource()
149
176
  },
150
177
  fail(error) {
151
- if (ended) return
152
- ended = true
153
- channel.fail(error)
178
+ failSource(error)
154
179
  },
155
180
  feed(chunk) {
156
181
  if (ended) return
@@ -1,5 +1,5 @@
1
- import { createSmoothMarkdownStream } from 'markstream-core'
2
1
  import type { TextStreamController, TextStreamOptions, TextStreamSnapshot } from './types'
2
+ import { createSmoothMarkdownStream } from 'markstream-core'
3
3
 
4
4
  interface SegmenterLike {
5
5
  segment: (value: string) => Iterable<{ segment: string }>
@@ -21,20 +21,64 @@ function splitGraphemes(value: string): string[] {
21
21
  locale?: string,
22
22
  options?: { granularity: 'grapheme' }
23
23
  ) => SegmenterLike
24
- return Array.from(new Segmenter(undefined, { granularity: 'grapheme' }).segment(value), (item) => item.segment)
24
+ return Array.from(new Segmenter(undefined, { granularity: 'grapheme' }).segment(value), item => item.segment)
25
25
  }
26
26
 
27
27
  function clamp(value: number, min: number, max: number) {
28
28
  return Math.min(max, Math.max(min, value))
29
29
  }
30
30
 
31
+ function hasUnclosedFence(value: string) {
32
+ let marker: '`' | '~' | undefined
33
+ let markerLength = 0
34
+ for (const line of value.split(/\r?\n/)) {
35
+ const match = /^ {0,3}(`{3,}|~{3,})/.exec(line)
36
+ if (!match) { continue }
37
+ const nextMarker = match[1][0] as '`' | '~'
38
+ const nextLength = match[1].length
39
+ if (!marker) {
40
+ marker = nextMarker
41
+ markerLength = nextLength
42
+ }
43
+ else if (marker === nextMarker && nextLength >= markerLength) {
44
+ marker = undefined
45
+ markerLength = 0
46
+ }
47
+ }
48
+ return marker !== undefined
49
+ }
50
+
51
+ function fenceSafeCommitCount(visible: string, source: string, segments: string[]) {
52
+ if (segments.length === 0) { return 0 }
53
+ const candidate = visible + segments.join('')
54
+ const lineStart = candidate.lastIndexOf('\n') + 1
55
+ const candidateLine = candidate.slice(lineStart)
56
+ const sourceLineEnd = source.indexOf('\n', lineStart)
57
+ const sourceLine = source.slice(lineStart, sourceLineEnd < 0 ? source.length : sourceLineEnd)
58
+ const match = /^ {0,3}(`+|~+)/.exec(sourceLine)
59
+ if (!match) { return segments.length }
60
+
61
+ const marker = match[1]
62
+ const unresolvedCandidate = marker.length < 3 && sourceLine.trim() === marker
63
+ const completeFence = marker.length >= 3
64
+ if ((unresolvedCandidate || completeFence) && /^ {0,3}(?:`+|~+)/.test(candidateLine)) {
65
+ return 0
66
+ }
67
+ return segments.length
68
+ }
69
+
31
70
  function createTimerTextStream(options: TextStreamOptions = {}): TextStreamController {
32
71
  const minCharsPerSecond = Math.max(1, options.minCharsPerSecond ?? 40)
33
- const maxCharsPerSecond = Math.max(minCharsPerSecond, options.maxCharsPerSecond ?? 800)
34
- const targetLatencyMs = Math.max(50, options.targetLatencyMs ?? 700)
35
- const maxCommitFps = Math.max(1, Math.min(30, options.maxCommitFps ?? 20))
36
- const maxCharsPerCommit = Math.max(1, options.maxCharsPerCommit ?? 64)
37
- const catchUpThreshold = Math.max(1, options.catchUpThreshold ?? 320)
72
+ const maxCharsPerSecond = Math.max(minCharsPerSecond, options.maxCharsPerSecond ?? 1000)
73
+ const targetLatencyMs = Math.max(1, options.targetLatencyMs ?? 900)
74
+ const catchUpLatencyMs = Math.max(1, options.catchUpLatencyMs ?? 350)
75
+ const maxCommitFps = Math.max(1, Math.min(30, options.maxCommitFps ?? 30))
76
+ const maxCharsPerCommit = Math.max(1, options.maxCharsPerCommit ?? 80)
77
+ const catchUpThreshold = Math.max(0, options.catchUpThreshold ?? 600)
78
+ const startDelayMs = Math.max(0, options.startDelayMs ?? 80)
79
+ const flushOnFinish = options.flushOnFinish ?? false
80
+ const burstInitialContent = options.burstInitialContent ?? false
81
+ const burstRevealThresholdChars = Math.max(1, options.burstRevealThresholdChars ?? 2048)
38
82
  const listeners = new Set<() => void>()
39
83
  const pending: string[] = []
40
84
  let pendingOffset = 0
@@ -45,6 +89,7 @@ function createTimerTextStream(options: TextStreamOptions = {}): TextStreamContr
45
89
  let destroyed = false
46
90
  let timer: ReturnType<typeof setTimeout> | undefined
47
91
  let lastTick = Date.now()
92
+ let firstPendingAt = 0
48
93
  let budget = 0
49
94
 
50
95
  function pendingCount() {
@@ -52,38 +97,46 @@ function createTimerTextStream(options: TextStreamOptions = {}): TextStreamContr
52
97
  }
53
98
 
54
99
  function snapshot(): TextStreamSnapshot {
55
- const pendingChars = pendingCount()
100
+ const pendingChars = Math.max(0, source.length - visible.length)
56
101
  return {
57
- caughtUp: pendingChars === 0,
102
+ caughtUp: pendingCount() === 0,
58
103
  done,
59
- final: done && pendingChars === 0,
104
+ final: done && pendingCount() === 0,
60
105
  paused,
61
106
  pendingChars,
62
107
  source,
63
- visible
108
+ visible,
64
109
  }
65
110
  }
66
111
 
67
112
  function emit() {
68
- listeners.forEach((listener) => listener())
113
+ listeners.forEach(listener => listener())
69
114
  }
70
115
 
71
116
  function compactPending() {
72
- if (pendingOffset < 1024 || pendingOffset * 2 < pending.length) return
117
+ if (pendingOffset < 1024 || pendingOffset * 2 < pending.length) { return }
73
118
  pending.splice(0, pendingOffset)
74
119
  pendingOffset = 0
75
120
  }
76
121
 
77
122
  function schedule() {
78
- if (destroyed || paused || timer || pendingCount() === 0) return
123
+ if (destroyed || paused || timer || pendingCount() === 0) { return }
124
+ // The scheduler and tick callback are mutually recursive by design.
125
+ // eslint-disable-next-line ts/no-use-before-define
79
126
  timer = setTimeout(tick, Math.ceil(1000 / maxCommitFps))
80
127
  }
81
128
 
82
129
  function tick() {
83
130
  timer = undefined
84
- if (destroyed || paused) return
131
+ if (destroyed || paused) { return }
85
132
 
86
133
  const now = Date.now()
134
+ if (firstPendingAt && now - firstPendingAt < startDelayMs) {
135
+ schedule()
136
+ return
137
+ }
138
+ firstPendingAt = 0
139
+
87
140
  const deltaMs = Math.max(1, now - lastTick)
88
141
  lastTick = now
89
142
  const backlog = pendingCount()
@@ -92,14 +145,28 @@ function createTimerTextStream(options: TextStreamOptions = {}): TextStreamContr
92
145
  return
93
146
  }
94
147
 
95
- const latencyCps = (backlog * 1000) / targetLatencyMs
96
- const catchUpBoost = backlog >= catchUpThreshold ? 1.8 : 1
97
- const cps = clamp(latencyCps * catchUpBoost, minCharsPerSecond, maxCharsPerSecond)
148
+ const latencyMs = backlog >= catchUpThreshold ? catchUpLatencyMs : targetLatencyMs
149
+ const latencyCps = (backlog * 1000) / latencyMs
150
+ const cps = clamp(latencyCps, minCharsPerSecond, maxCharsPerSecond)
98
151
  budget += (cps * deltaMs) / 1000
99
152
  const commitCount = Math.min(backlog, maxCharsPerCommit, Math.max(1, Math.floor(budget)))
100
- budget = Math.max(0, budget - commitCount)
101
- visible += pending.slice(pendingOffset, pendingOffset + commitCount).join('')
102
- pendingOffset += commitCount
153
+ let proposedSegments = pending.slice(pendingOffset, pendingOffset + commitCount)
154
+ let safeCommitCount = fenceSafeCommitCount(visible, source, proposedSegments)
155
+ if (safeCommitCount === 0) {
156
+ const newlineOffset = pending.slice(pendingOffset).findIndex(segment => segment.includes('\n'))
157
+ if (newlineOffset >= 0) {
158
+ proposedSegments = pending.slice(pendingOffset, pendingOffset + newlineOffset + 1)
159
+ safeCommitCount = fenceSafeCommitCount(visible, source, proposedSegments)
160
+ }
161
+ }
162
+ if (safeCommitCount === 0) {
163
+ emit()
164
+ schedule()
165
+ return
166
+ }
167
+ budget = Math.max(0, budget - safeCommitCount)
168
+ visible += proposedSegments.slice(0, safeCommitCount).join('')
169
+ pendingOffset += safeCommitCount
103
170
  compactPending()
104
171
  emit()
105
172
  schedule()
@@ -110,27 +177,45 @@ function createTimerTextStream(options: TextStreamOptions = {}): TextStreamContr
110
177
  timer = undefined
111
178
  }
112
179
 
180
+ function destroy() {
181
+ if (destroyed) { return }
182
+ destroyed = true
183
+ cancelTimer()
184
+ pending.length = 0
185
+ listeners.clear()
186
+ }
187
+
113
188
  return {
114
- destroy() {
115
- if (destroyed) return
116
- destroyed = true
117
- cancelTimer()
118
- pending.length = 0
119
- listeners.clear()
120
- },
189
+ destroy,
190
+ dispose: destroy,
121
191
  enqueue(chunk) {
122
- if (destroyed || !chunk) return
123
- if (done) done = false
192
+ if (destroyed || !chunk) { return }
193
+ if (done) { done = false }
194
+ const initialChunk = source.length === 0
124
195
  source += chunk
125
196
  pending.push(...splitGraphemes(chunk))
197
+ if (initialChunk) { firstPendingAt = Date.now() }
126
198
  lastTick = Date.now()
199
+
200
+ if (
201
+ initialChunk
202
+ && burstInitialContent
203
+ && pendingCount() >= burstRevealThresholdChars
204
+ && !hasUnclosedFence(source)
205
+ ) {
206
+ visible = source
207
+ pending.length = 0
208
+ pendingOffset = 0
209
+ firstPendingAt = 0
210
+ }
211
+
127
212
  emit()
128
213
  schedule()
129
214
  },
130
215
  finish(finishOptions = {}) {
131
- if (destroyed) return
216
+ if (destroyed) { return }
132
217
  done = true
133
- if (finishOptions.flush) {
218
+ if (finishOptions.flush ?? flushOnFinish) {
134
219
  visible = source
135
220
  pending.length = 0
136
221
  pendingOffset = 0
@@ -140,7 +225,7 @@ function createTimerTextStream(options: TextStreamOptions = {}): TextStreamContr
140
225
  schedule()
141
226
  },
142
227
  flush() {
143
- if (destroyed) return
228
+ if (destroyed) { return }
144
229
  visible = source
145
230
  pending.length = 0
146
231
  pendingOffset = 0
@@ -150,12 +235,12 @@ function createTimerTextStream(options: TextStreamOptions = {}): TextStreamContr
150
235
  },
151
236
  getSnapshot: snapshot,
152
237
  pause() {
153
- if (destroyed || paused) return
238
+ if (destroyed || paused) { return }
154
239
  paused = true
155
240
  cancelTimer()
156
241
  emit()
157
242
  },
158
- reset(content = '') {
243
+ reset(content = '', _resetOptions = {}) {
159
244
  cancelTimer()
160
245
  source = content
161
246
  visible = content
@@ -164,21 +249,22 @@ function createTimerTextStream(options: TextStreamOptions = {}): TextStreamContr
164
249
  done = false
165
250
  paused = false
166
251
  budget = 0
252
+ firstPendingAt = 0
167
253
  lastTick = Date.now()
168
254
  emit()
169
255
  },
170
256
  resume() {
171
- if (destroyed || !paused) return
257
+ if (destroyed || !paused) { return }
172
258
  paused = false
173
259
  lastTick = Date.now()
174
260
  emit()
175
261
  schedule()
176
262
  },
177
263
  subscribe(listener) {
178
- if (destroyed) return () => {}
264
+ if (destroyed) { return () => {} }
179
265
  listeners.add(listener)
180
266
  return () => listeners.delete(listener)
181
- }
267
+ },
182
268
  }
183
269
  }
184
270
 
@@ -190,6 +276,7 @@ export function createTextStream(options: TextStreamOptions = {}): TextStreamCon
190
276
  const controller = createSmoothMarkdownStream(options)
191
277
  return {
192
278
  destroy: controller.destroy,
279
+ dispose: controller.dispose,
193
280
  enqueue: controller.enqueue,
194
281
  finish: controller.finish,
195
282
  flush: controller.flush,
@@ -197,6 +284,6 @@ export function createTextStream(options: TextStreamOptions = {}): TextStreamCon
197
284
  pause: controller.pause,
198
285
  reset: controller.reset,
199
286
  resume: controller.resume,
200
- subscribe: controller.subscribe
287
+ subscribe: controller.subscribe,
201
288
  }
202
289
  }