dsh-smooth-stream 0.6.1 → 0.7.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.
@@ -0,0 +1,173 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+
3
+ /**
4
+ * Detect whether the given markdown text ends inside an unclosed code block (fence).
5
+ * Supports both backtick (```) and tilde (~~~) fences per CommonMark rules.
6
+ *
7
+ * Rules:
8
+ * 1. An opening fence begins on a line with 0-3 leading spaces, followed by 3+ backticks or tildes.
9
+ * 2. An opening backtick fence info-string cannot contain backticks.
10
+ * 3. A closing fence must have at least as many characters as the opening fence, matching fence char,
11
+ * and cannot have non-whitespace content after the fence characters.
12
+ * 4. While inside a code block, all lines belong to the block until a matching closing fence is found.
13
+ */
14
+ export function hasUnclosedCodeFence(text: string): boolean {
15
+ if (!text.includes('```') && !text.includes('~~~')) {
16
+ return false
17
+ }
18
+
19
+ let inFence = false
20
+ let fenceChar = ''
21
+ let fenceLen = 0
22
+
23
+ let start = 0
24
+ const len = text.length
25
+
26
+ while (start < len) {
27
+ let end = text.indexOf('\n', start)
28
+ if (end === -1) end = len
29
+
30
+ let line = text.slice(start, end)
31
+ if (line.endsWith('\r')) line = line.slice(0, -1)
32
+
33
+ let indent = 0
34
+ while (indent < line.length && line[indent] === ' ' && indent < 4) {
35
+ indent++
36
+ }
37
+
38
+ if (indent < 4) {
39
+ const rest = line.slice(indent)
40
+ const firstChar = rest[0]
41
+
42
+ if (firstChar === '`' || firstChar === '~') {
43
+ let count = 0
44
+ while (count < rest.length && rest[count] === firstChar) {
45
+ count++
46
+ }
47
+
48
+ if (count >= 3) {
49
+ const afterFence = rest.slice(count)
50
+
51
+ if (!inFence) {
52
+ if (firstChar !== '`' || !afterFence.includes('`')) {
53
+ inFence = true
54
+ fenceChar = firstChar
55
+ fenceLen = count
56
+ }
57
+ } else if (firstChar === fenceChar && count >= fenceLen) {
58
+ if (afterFence.trim() === '') {
59
+ inFence = false
60
+ fenceChar = ''
61
+ fenceLen = 0
62
+ }
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+ start = end + 1
69
+ }
70
+
71
+ return inFence
72
+ }
73
+
74
+ export interface DecoupledMarkdownOptions {
75
+ /** Throttle interval in milliseconds during unclosed code blocks. Defaults to 33ms (~30Hz). */
76
+ throttleMs?: number
77
+ }
78
+
79
+ /**
80
+ * Decouple the smooth 60 FPS animation clock from the heavyweight Markdown AST compilation clock.
81
+ *
82
+ * Outside unclosed code blocks (or when settled), Markdown renders 1:1 with `shown`.
83
+ * Inside unclosed code blocks (where host incremental parsing degenerates to O(n) full-text compilation),
84
+ * Markdown re-renders are throttled to ~33ms (30Hz).
85
+ * Upon code block closure or stream end, updates are synchronized immediately without latency.
86
+ */
87
+ export function useDecoupledMarkdown(
88
+ shown: string,
89
+ live: boolean,
90
+ options?: DecoupledMarkdownOptions
91
+ ): string {
92
+ const throttleMs = options?.throttleMs ?? 33
93
+ const [markdownShown, setMarkdownShown] = useState(shown)
94
+ const lastCommitTimeRef = useRef(0)
95
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
96
+ const shownRef = useRef(shown)
97
+ shownRef.current = shown
98
+ const wasUnclosedRef = useRef(false)
99
+
100
+ useEffect(() => {
101
+ // If not live (settled / motion reduced / stream closed), sync immediately
102
+ if (!live) {
103
+ if (timerRef.current !== null) {
104
+ clearTimeout(timerRef.current)
105
+ timerRef.current = null
106
+ }
107
+ setMarkdownShown(shown)
108
+ lastCommitTimeRef.current = performance.now()
109
+ wasUnclosedRef.current = false
110
+ return
111
+ }
112
+
113
+ const unclosed = hasUnclosedCodeFence(shown)
114
+ const now = performance.now()
115
+
116
+ // Edge case: Closing edge - code block just closed right now!
117
+ // Immediately sync so completed code block styles render cleanly without any lag
118
+ if (wasUnclosedRef.current && !unclosed) {
119
+ if (timerRef.current !== null) {
120
+ clearTimeout(timerRef.current)
121
+ timerRef.current = null
122
+ }
123
+ wasUnclosedRef.current = false
124
+ setMarkdownShown(shown)
125
+ lastCommitTimeRef.current = now
126
+ return
127
+ }
128
+
129
+ wasUnclosedRef.current = unclosed
130
+
131
+ // Outside code blocks, keep instant 1:1 synchronization
132
+ if (!unclosed) {
133
+ if (timerRef.current !== null) {
134
+ clearTimeout(timerRef.current)
135
+ timerRef.current = null
136
+ }
137
+ setMarkdownShown(shown)
138
+ lastCommitTimeRef.current = now
139
+ return
140
+ }
141
+
142
+ // Inside unclosed code block: apply intelligent throttle
143
+ const elapsed = now - lastCommitTimeRef.current
144
+ if (elapsed >= throttleMs) {
145
+ if (timerRef.current !== null) {
146
+ clearTimeout(timerRef.current)
147
+ timerRef.current = null
148
+ }
149
+ setMarkdownShown(shown)
150
+ lastCommitTimeRef.current = now
151
+ } else {
152
+ if (timerRef.current === null) {
153
+ const delay = Math.max(16, throttleMs - elapsed)
154
+ timerRef.current = setTimeout(() => {
155
+ timerRef.current = null
156
+ setMarkdownShown(shownRef.current)
157
+ lastCommitTimeRef.current = performance.now()
158
+ }, delay)
159
+ }
160
+ }
161
+ }, [shown, live, throttleMs])
162
+
163
+ useEffect(() => {
164
+ return () => {
165
+ if (timerRef.current !== null) {
166
+ clearTimeout(timerRef.current)
167
+ timerRef.current = null
168
+ }
169
+ }
170
+ }, [])
171
+
172
+ return live ? markdownShown : shown
173
+ }
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { useCallback, useEffect, useRef } from 'react'
12
12
  import { debugRuntime } from './debugRuntime.ts'
13
+ import { FrameCoordinator } from './FrameCoordinator.ts'
13
14
 
14
15
  const FPS_THRESHOLD = 30
15
16
  const FPS_ALPHA = 0.12
@@ -33,14 +34,12 @@ export function useFpsGuard(active: boolean): {
33
34
 
34
35
  useEffect(() => {
35
36
  if (!active) return
36
- let rafId = 0
37
37
  let lastDebugReport = 0
38
- const frame = (now: number) => {
39
- rafId = requestAnimationFrame(frame)
38
+ const frame = (now: number): boolean => {
40
39
  const fps = fpsRef.current
41
40
  if (fps.lastMs === 0) {
42
41
  fps.lastMs = now
43
- return
42
+ return true
44
43
  }
45
44
  const delta = Math.min(MAX_FRAME_MS, Math.max(1, now - fps.lastMs))
46
45
  fps.lastMs = now
@@ -57,10 +56,14 @@ export function useFpsGuard(active: boolean): {
57
56
  debugRuntime.reportFps(currentFps, fps.emaMs, fps.degraded)
58
57
  lastDebugReport = now
59
58
  }
59
+ return true
60
60
  }
61
- rafId = requestAnimationFrame(frame)
61
+ // The frame-rate monitor no longer owns a rAF chain of its own: it rides
62
+ // the shared document clock and only re-arms while the reply is streaming.
63
+ const coordinator = FrameCoordinator.forDocument()
64
+ const taskId = coordinator.registerTask({ onSimulate: (_dtMs, now) => frame(now) })
62
65
  return () => {
63
- cancelAnimationFrame(rafId)
66
+ coordinator.unregisterTask(taskId)
64
67
  fpsRef.current = { emaMs: 0, lastMs: 0, healthyRun: 0, degraded: false }
65
68
  debugRuntime.clearFps()
66
69
  }