@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,132 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { useMemo, useSyncExternalStore } from 'react'
4
+ import { installedUpstreamLines } from '../contract/upstream.ts'
5
+ import { BUBBLE_WIDTH_OFFSET } from '../core/metrics.ts'
6
+ import type { HomeLogoMessage } from '../model/message.ts'
7
+ import { keyedRegistry } from '../kernel/registry.ts'
8
+ import { textWidth } from '../core/text.ts'
9
+ import { COLORS } from '../theme.ts'
10
+
11
+ export interface HomeLogoContribution {
12
+ id: string
13
+ order?: number
14
+ /** Logo artwork, one string per terminal row. */
15
+ lines: readonly string[]
16
+ }
17
+
18
+ const BUILTIN_LOGO_LINES = [
19
+ '▀█▀▀▄ ▄▀▀▀▄ ▀█',
20
+ ' █ █ ▄▀▀▀▄ ▄▀▀▀▄ ▀█▀▀▄ ▀▄▄▄ ▄▀▀▀▄ ▄▀▀▀▄ █ ▄▀',
21
+ ' █ █ █▀▀▀▀ █▀▀▀▀ █▄▄▀ ▄ █ █▀▀▀▀ █▀▀▀▀ █▀▄',
22
+ '▀▀▀▀ ▀▀▀ ▀▀▀ █ ▀▀▀ ▀▀▀ ▀▀▀ ▀▀ ▀',
23
+ '█ █ ▀▀▀',
24
+ '█▄▄▄█ ▀▀▀▄ ▀█▄▀▄ █▄▀▀▄ ▄▀▀▀▄ ▄▀▀▀ ▄▀▀▀',
25
+ '█ █ ▄▀▀█ █ ▀ █ █ █▀▀▀▀ ▀▀▄ ▀▀▄',
26
+ '▀ ▀ ▀▀ ▀ ▀▀▀ ▀ ▀ ▀▀▀ ▀▀▀ ▀▀▀',
27
+ ]
28
+
29
+ const SLOT = 'home-logo'
30
+ const HOME_LOGO_BUILTIN_ORDER = 1000
31
+ const registry = keyedRegistry<HomeLogoContribution>()
32
+
33
+ registry.register(SLOT, { id: 'builtin', order: HOME_LOGO_BUILTIN_ORDER, lines: BUILTIN_LOGO_LINES }, { order: HOME_LOGO_BUILTIN_ORDER })
34
+
35
+ export function registerHomeLogo(contribution: HomeLogoContribution): () => void {
36
+ return registry.register(SLOT, contribution, { order: contribution.order })
37
+ }
38
+
39
+ export function currentHomeLogo(): HomeLogoContribution | undefined {
40
+ return registry.get(SLOT)
41
+ }
42
+
43
+ export function subscribeHomeLogo(listener: () => void): () => void {
44
+ return registry.subscribe(listener)
45
+ }
46
+
47
+ export function useHomeLogo(): HomeLogoContribution | undefined {
48
+ return useSyncExternalStore(subscribeHomeLogo, currentHomeLogo, currentHomeLogo)
49
+ }
50
+
51
+ /** Mix two hex colors linearly; t=0 keeps `from`, t=1 keeps `to`. */
52
+ export function mixHex(from: string, to: string, t: number): string {
53
+ const clamp = Math.max(0, Math.min(1, t))
54
+ const mix = (at: number): string => {
55
+ const a = parseInt(from.slice(at, at + 2), 16)
56
+ const b = parseInt(to.slice(at, at + 2), 16)
57
+ return Math.round(a + (b - a) * clamp).toString(16).padStart(2, '0')
58
+ }
59
+ return `#${mix(1)}${mix(3)}${mix(5)}`
60
+ }
61
+
62
+ export interface HomeLogoMetrics {
63
+ rows: number
64
+ columns: number
65
+ }
66
+
67
+ /** Trimmed display extent of the artwork; used for the small-terminal guard. */
68
+ export function homeLogoMetrics(lines: readonly string[]): HomeLogoMetrics {
69
+ let columns = 0
70
+ for (const raw of lines) {
71
+ const width = textWidth(raw.trimEnd())
72
+ if (width > columns) columns = width
73
+ }
74
+ return { rows: lines.length, columns }
75
+ }
76
+
77
+ /** One color per artwork row, graded from `top` to `bottom`. */
78
+ export function homeLogoLineColors(lines: readonly string[], top: string, bottom: string): string[] {
79
+ const count = lines.length
80
+ if (count === 0) return []
81
+ if (count === 1) return [top]
82
+ const step = 1 / (count - 1)
83
+ return lines.map((_, i) => mixHex(top, bottom, i * step))
84
+ }
85
+
86
+ const HOME_LOGO_ID = 'home-logo'
87
+
88
+ /**
89
+ * The home page presents its content in chat-page style: the logo artwork is
90
+ * the first bubble, left-aligned with a transparent background, followed by
91
+ * version banner lines inside the same bubble. `width` is the full list
92
+ * width and `height` the visible viewport; when either cannot hold the
93
+ * artwork, only the artwork lines are culled and the bubble stays.
94
+ */
95
+ export function useHomeLogoBubble(width: number, height: number): HomeLogoMessage {
96
+ const logo = useHomeLogo()
97
+ return useMemo(() => homeLogoBubble(logo, width, height), [logo, width, height])
98
+ }
99
+
100
+ function homeLogoBubble(logo: HomeLogoContribution | undefined, width: number, height: number): HomeLogoMessage {
101
+ const clean = logo?.lines.map(line => line.trimEnd()) ?? []
102
+ const metrics = homeLogoMetrics(clean)
103
+ const fits = clean.length > 0 && width - BUBBLE_WIDTH_OFFSET >= metrics.columns && height >= metrics.rows
104
+ return {
105
+ kind: 'home-logo',
106
+ id: HOME_LOGO_ID,
107
+ lines: fits ? clean : [],
108
+ colors: fits ? homeLogoLineColors(clean, COLORS.homeLogoTop, COLORS.homeLogoBottom) : [],
109
+ info: [dshVersionLine(), `dshtui ${tuiVersion()}`],
110
+ }
111
+ }
112
+
113
+ function dshVersionLine(): string {
114
+ // dsh-agent itself does not export ./package.json, so the version comes
115
+ // from the lockstepped harness line any sibling package exposes.
116
+ return `dsh ${installedUpstreamLines()[0] ?? 'unknown'}`
117
+ }
118
+
119
+ let cachedTuiVersion: string | undefined
120
+
121
+ function tuiVersion(): string {
122
+ if (cachedTuiVersion !== undefined) return cachedTuiVersion
123
+ try {
124
+ const path = fileURLToPath(import.meta.resolve('@xp266/dshtui/package.json'))
125
+ const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: string }
126
+ cachedTuiVersion = manifest.version ?? 'unknown'
127
+ } catch {
128
+ // The manifest may be unreadable in unusual install layouts; the bubble still renders.
129
+ cachedTuiVersion = 'unknown'
130
+ }
131
+ return cachedTuiVersion
132
+ }
@@ -0,0 +1,25 @@
1
+ import { useCallback, useState } from 'react'
2
+ import { errorText } from '../../core/text.ts'
3
+
4
+ export type AsyncActionResult<T> = { ok: true; value: T } | { ok: false }
5
+
6
+ export interface AsyncActionState {
7
+ error: string | null
8
+ setError(error: string | null): void
9
+ clearError(): void
10
+ run<T>(action: () => Promise<T>): Promise<AsyncActionResult<T>>
11
+ }
12
+
13
+ export function useAsyncAction(): AsyncActionState {
14
+ const [error, setError] = useState<string | null>(null)
15
+ const clearError = useCallback(() => setError(null), [])
16
+ const run = useCallback(async <T,>(action: () => Promise<T>): Promise<AsyncActionResult<T>> => {
17
+ try {
18
+ return { ok: true, value: await action() }
19
+ } catch (cause) {
20
+ setError(errorText(cause))
21
+ return { ok: false }
22
+ }
23
+ }, [])
24
+ return { error, setError, clearError, run }
25
+ }
@@ -0,0 +1,71 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import { errorText } from '../../core/text.ts'
3
+
4
+ export interface AsyncListState<T> {
5
+ items: T[]
6
+ loading: boolean
7
+ error: string | null
8
+ reload(): void
9
+ remove(predicate: (item: T) => boolean): void
10
+ }
11
+
12
+ const LOADING_HINT_DELAY_MS = 120
13
+
14
+ export function useAsyncList<T>(load: () => Promise<T[]>): AsyncListState<T> {
15
+ const [items, setItems] = useState<T[]>([])
16
+ const itemsRef = useRef(items)
17
+ itemsRef.current = items
18
+ const [loading, setLoading] = useState(false)
19
+ const [error, setError] = useState<string | null>(null)
20
+ const loadRef = useRef(load)
21
+ loadRef.current = load
22
+ const cancelRef = useRef<(() => void) | null>(null)
23
+ const hintTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
24
+ const clearHintTimer = (): void => {
25
+ if (hintTimerRef.current !== null) {
26
+ clearTimeout(hintTimerRef.current)
27
+ hintTimerRef.current = null
28
+ }
29
+ }
30
+ const reload = useCallback(() => {
31
+ cancelRef.current?.()
32
+ clearHintTimer()
33
+ const cancelled = { current: false }
34
+ cancelRef.current = () => {
35
+ cancelled.current = true
36
+ }
37
+ setLoading(false)
38
+ hintTimerRef.current = setTimeout(() => {
39
+ if (!cancelled.current && itemsRef.current.length === 0) setLoading(true)
40
+ }, LOADING_HINT_DELAY_MS)
41
+ loadRef.current()
42
+ .then(next => {
43
+ if (cancelled.current) return
44
+ itemsRef.current = next
45
+ setItems(next)
46
+ setError(null)
47
+ })
48
+ .catch(cause => {
49
+ if (cancelled.current) return
50
+ setError(errorText(cause))
51
+ })
52
+ .finally(() => {
53
+ if (cancelled.current) return
54
+ clearHintTimer()
55
+ setLoading(false)
56
+ })
57
+ }, [])
58
+ const remove = useCallback((predicate: (item: T) => boolean) => {
59
+ const next = itemsRef.current.filter(item => !predicate(item))
60
+ itemsRef.current = next
61
+ setItems(next)
62
+ }, [])
63
+ useEffect(() => {
64
+ reload()
65
+ return () => {
66
+ cancelRef.current?.()
67
+ clearHintTimer()
68
+ }
69
+ }, [reload])
70
+ return { items, loading, error, reload, remove }
71
+ }
@@ -0,0 +1,24 @@
1
+ import { useCursor } from 'ink'
2
+ import { useCallback } from 'react'
3
+
4
+ export interface CaretPosition {
5
+ x: number
6
+ y: number
7
+ }
8
+
9
+ /**
10
+ * Ink 7.1.1 rests the hardware cursor one row above where frames without a
11
+ * trailing newline end; this layout fills the viewport, so request one row
12
+ * lower.
13
+ */
14
+ export function useCaret(): { setCursorPosition(position: CaretPosition | undefined): void } {
15
+ const { setCursorPosition } = useCursor()
16
+ const adjusted = useCallback((position: CaretPosition | undefined) => {
17
+ if (position === undefined) {
18
+ setCursorPosition(undefined)
19
+ return
20
+ }
21
+ setCursorPosition({ x: position.x, y: position.y + 1 })
22
+ }, [setCursorPosition])
23
+ return { setCursorPosition: adjusted }
24
+ }
@@ -0,0 +1,153 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import type { SessionEvent } from '@deepseek-ai/dsh-session'
3
+ import type { ChatBridge, ChatToolPresenter, RegistryCommand } from '../../chat/bridge.ts'
4
+ import { initialTurnState, reduceChatEvent } from '../../chat/store.ts'
5
+ import { nextRetryStatus } from '../../chat/retry-status.ts'
6
+ import type { RetryStatus } from '../../chat/retry-status.ts'
7
+ import type { AgentActivity } from '../../chat/store.ts'
8
+ import { normalizeTodos } from '../../chat/todo-view.ts'
9
+ import type { TodoItemLike } from '../../chat/todo-view.ts'
10
+ import type { Message } from '../../model/message.ts'
11
+ import { releaseFields, hasFieldSlots, releaseUnreferenced } from '../../core/fields.ts'
12
+
13
+ const FRAME_MS = 33
14
+
15
+ const IDLE_ACTIVITY: AgentActivity = { running: false, phase: 'awaiting-request', compacting: false }
16
+ const NO_TODOS: TodoItemLike[] = []
17
+
18
+ function todosKey(todos: readonly TodoItemLike[]): string {
19
+ return todos.map(item => `${item.status}:${item.content}`).join('\n')
20
+ }
21
+
22
+ export interface ChatEvents {
23
+ messages: Message[]
24
+ modelName: string
25
+ setModelName(name: string): void
26
+ updateMessages(fn: (messages: Message[]) => Message[]): void
27
+ resetChat(): void
28
+ activity: AgentActivity
29
+ todos: TodoItemLike[]
30
+ retryStatus?: RetryStatus
31
+ streamedChars: number
32
+ registryCommands: readonly RegistryCommand[]
33
+ }
34
+
35
+ export function useChatEvents(bridge: ChatBridge | undefined, dialogOpen: boolean): ChatEvents {
36
+ const [messages, setMessages] = useState<Message[]>([])
37
+ const [modelName, setModelName] = useState('')
38
+ const [activity, setActivity] = useState<AgentActivity>(IDLE_ACTIVITY)
39
+ const [todos, setTodos] = useState<TodoItemLike[]>(NO_TODOS)
40
+ const [retryStatus, setRetryStatus] = useState<RetryStatus | undefined>(undefined)
41
+ const [streamedChars, setStreamedChars] = useState(0)
42
+ const [registryCommands, setRegistryCommands] = useState<readonly RegistryCommand[]>([])
43
+ const streamedCharsRef = useRef(0)
44
+ const chatStateRef = useRef<{ messages: Message[]; turn: ReturnType<typeof initialTurnState> }>({
45
+ messages: [],
46
+ turn: initialTurnState(),
47
+ })
48
+ const pendingEventsRef = useRef<SessionEvent[]>([])
49
+ const retryStatusRef = useRef<RetryStatus | undefined>(undefined)
50
+ const presenterRef = useRef<ChatToolPresenter | undefined>(undefined)
51
+ const dialogOpenRef = useRef(false)
52
+ dialogOpenRef.current = dialogOpen
53
+ useEffect(() => {
54
+ presenterRef.current = bridge?.toolPresenter
55
+ if (bridge === undefined) {
56
+ setRegistryCommands([])
57
+ return
58
+ }
59
+ setRegistryCommands(bridge.listRegistryCommands())
60
+ return bridge.onRegistryChanged(() => setRegistryCommands(bridge.listRegistryCommands()))
61
+ }, [bridge])
62
+ useEffect(() => {
63
+ if (!bridge) return
64
+ return bridge.subscribe(event => {
65
+ pendingEventsRef.current.push(event)
66
+ })
67
+ }, [bridge])
68
+ useEffect(() => {
69
+ const timer = setInterval(() => {
70
+ if (dialogOpenRef.current) return
71
+ const events = pendingEventsRef.current
72
+ if (events.length === 0) return
73
+ pendingEventsRef.current = []
74
+ let state = chatStateRef.current
75
+ let dirty = false
76
+ let latestTodos: TodoItemLike[] | undefined
77
+ let retry: RetryStatus | undefined = retryStatusRef.current
78
+ let retryDirty = false
79
+ for (const event of events) {
80
+ if (event.type === 'assistant/chunk') {
81
+ const chunk = (event.data as { chunk?: { type?: string; text?: string } }).chunk
82
+ if ((chunk?.type === 'text-delta' || chunk?.type === 'reasoning-delta') && typeof chunk.text === 'string') {
83
+ streamedCharsRef.current += chunk.text.length
84
+ }
85
+ } else if (event.type === 'assistant/message' || event.type === 'turn/end') {
86
+ streamedCharsRef.current = 0
87
+ }
88
+ if (event.type === 'todo/write') {
89
+ latestTodos = normalizeTodos((event.data as { todos?: unknown }).todos)
90
+ }
91
+ const nextRetry = nextRetryStatus(retry, event)
92
+ if (nextRetry !== retry) {
93
+ retry = nextRetry
94
+ retryDirty = true
95
+ }
96
+ const next = reduceChatEvent(state.messages, event, state.turn, presenterRef.current)
97
+ dirty = dirty || next.changed
98
+ state = { messages: next.messages, turn: next.turn }
99
+ }
100
+ chatStateRef.current = state
101
+ if (dirty && hasFieldSlots('message')) {
102
+ let keep = ''
103
+ for (const message of state.messages) {
104
+ if (message.kind === 'bubble') keep += message.content
105
+ }
106
+ releaseUnreferenced('message', keep)
107
+ }
108
+ retryStatusRef.current = retry
109
+ setStreamedChars(previous => previous === streamedCharsRef.current ? previous : streamedCharsRef.current)
110
+ if (dirty) setMessages([...state.messages])
111
+ if (retryDirty) setRetryStatus(retry)
112
+ const turn = state.turn
113
+ setActivity(previous => previous.running === turn.running && previous.phase === turn.phase && previous.compacting === turn.compacting
114
+ ? previous
115
+ : { running: turn.running, phase: turn.phase, compacting: turn.compacting })
116
+ if (latestTodos !== undefined) {
117
+ setTodos(previous => todosKey(previous) === todosKey(latestTodos!) ? previous : latestTodos!)
118
+ }
119
+ }, FRAME_MS)
120
+ return () => clearInterval(timer)
121
+ }, [])
122
+ useEffect(() => {
123
+ if (!bridge) return
124
+ setModelName(bridge.modelName())
125
+ }, [bridge])
126
+ return {
127
+ messages,
128
+ modelName,
129
+ setModelName,
130
+ updateMessages(fn) {
131
+ const next = fn(chatStateRef.current.messages)
132
+ chatStateRef.current = { ...chatStateRef.current, messages: next }
133
+ setMessages(next)
134
+ },
135
+ resetChat() {
136
+ setMessages([])
137
+ chatStateRef.current = { messages: [], turn: initialTurnState() }
138
+ pendingEventsRef.current = []
139
+ retryStatusRef.current = undefined
140
+ streamedCharsRef.current = 0
141
+ releaseFields('message')
142
+ setStreamedChars(0)
143
+ setRetryStatus(undefined)
144
+ setActivity(IDLE_ACTIVITY)
145
+ setTodos(NO_TODOS)
146
+ },
147
+ activity,
148
+ todos,
149
+ retryStatus,
150
+ streamedChars,
151
+ registryCommands,
152
+ }
153
+ }