@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,636 @@
1
+ import type { SessionEvent } from '@deepseek-ai/dsh-session'
2
+ import type { Message, ToolCardMessage } from '../model/message.ts'
3
+ import { reasoningFromBlocks, textFromBlocks, userDisplayText } from './blocks.ts'
4
+ import type { ChatToolPresenter, ToolResultLike } from './bridge.ts'
5
+ import type { CollapsibleMessage } from '../model/message.ts'
6
+ import { glyphs } from '../terminal/glyphs.ts'
7
+ import { diffsFromResultMeta, diffLineGroups } from './tool-view.ts'
8
+ import { applyChatNodes } from './chat-nodes.ts'
9
+
10
+ export type AgentPhase = 'awaiting-request' | 'thinking' | 'working'
11
+
12
+ function errorSummary(cause: { name?: string; code?: string } | undefined): string {
13
+ return `error: ${cause?.name ?? cause?.code ?? 'unknown'}`
14
+ }
15
+
16
+ export function formatThinkingDuration(durationMs: number): string {
17
+ const total = Math.max(0, Math.floor(durationMs))
18
+ if (total < 1000) return `${total}ms`
19
+ const hours = Math.floor(total / 3_600_000)
20
+ const minutes = Math.floor(total / 60_000) % 60
21
+ const seconds = Math.floor(total / 1000) % 60
22
+ const tenth = Math.floor((total % 1000) / 100)
23
+ const tail = `${seconds}.${tenth}s`
24
+ if (hours > 0) return `${hours}h ${minutes}m ${tail}`
25
+ if (minutes > 0) return `${minutes}m ${tail}`
26
+ return tail
27
+ }
28
+
29
+ export interface AgentActivity {
30
+ running: boolean
31
+ phase: AgentPhase
32
+ compacting: boolean
33
+ }
34
+
35
+ export interface TurnState {
36
+ thinkingIds: Map<number, string>
37
+ assistantIds: Map<number, string>
38
+ toolIds: Map<string, string>
39
+ pendingText: Map<number, string>
40
+ pendingTools: Map<string, string>
41
+ commandNames: Map<string, string>
42
+ compactions: Map<string, string>
43
+ chatNodes: Map<string, string>
44
+ compacting: boolean
45
+ running: boolean
46
+ phase: AgentPhase
47
+ }
48
+
49
+ export function initialTurnState(): TurnState {
50
+ return {
51
+ thinkingIds: new Map(),
52
+ assistantIds: new Map(),
53
+ toolIds: new Map(),
54
+ pendingText: new Map(),
55
+ pendingTools: new Map(),
56
+ commandNames: new Map(),
57
+ compactions: new Map(),
58
+ chatNodes: new Map(),
59
+ compacting: false,
60
+ running: false,
61
+ phase: 'awaiting-request',
62
+ }
63
+ }
64
+
65
+ function markRunning(turn: TurnState, running: boolean): void {
66
+ turn.running = running
67
+ }
68
+
69
+ function markPhase(turn: TurnState, phase: AgentPhase): void {
70
+ turn.phase = phase
71
+ }
72
+
73
+ let messageCounter = 0
74
+
75
+ function nextId(prefix: string): string {
76
+ messageCounter += 1
77
+ return `${prefix}-${messageCounter}`
78
+ }
79
+
80
+ const CHROME_EVENTS = new Set(['permission/preset', 'sandbox/mode', 'approval/policy'])
81
+
82
+ function dropStepMessages(messages: Message[], turn: TurnState, step: number): boolean {
83
+ let removed = false
84
+ for (const map of [turn.assistantIds, turn.thinkingIds]) {
85
+ const id = map.get(step)
86
+ if (id === undefined) continue
87
+ map.delete(step)
88
+ const at = messages.findIndex(m => m.id === id)
89
+ if (at >= 0) {
90
+ messages.splice(at, 1)
91
+ removed = true
92
+ }
93
+ }
94
+ turn.pendingText.delete(step)
95
+ return removed
96
+ }
97
+
98
+ export function reduceChatEvent(
99
+ messages: Message[],
100
+ event: SessionEvent,
101
+ turn: TurnState,
102
+ presenter?: ChatToolPresenter,
103
+ ): { messages: Message[]; turn: TurnState; changed: boolean } {
104
+ if (applyChatNodes(messages, event, turn)) return { messages, turn, changed: true }
105
+ switch (event.type) {
106
+ case 'user/message': {
107
+ if (event.data.source.kind !== 'user') return { messages, turn, changed: false }
108
+ messages.push({ kind: 'bubble', id: nextId('user'), role: 'user', content: userDisplayText(event.data.content) })
109
+ return { messages, turn, changed: true }
110
+ }
111
+ case 'assistant/chunk': {
112
+ const chunk = event.data.chunk
113
+ if (chunk.type === 'reasoning-delta') return appendChunk(messages, turn, chunk.text, event.data.step, 'thinking', event.time)
114
+ if (chunk.type === 'text-delta') return appendChunk(messages, turn, chunk.text, event.data.step, 'assistant', event.time)
115
+ if (chunk.type === 'tool-call-delta') {
116
+ return appendToolDelta(messages, turn, event.data.step, chunk.id, chunk.name, chunk.argumentsDelta, event.time)
117
+ }
118
+ return { messages, turn, changed: false }
119
+ }
120
+ case 'turn/start': {
121
+ markRunning(turn, true)
122
+ markPhase(turn, 'awaiting-request')
123
+ turn.thinkingIds.clear()
124
+ turn.assistantIds.clear()
125
+ turn.pendingText.clear()
126
+ return { messages, turn, changed: true }
127
+ }
128
+ case 'tool/call': {
129
+ const view = presenter?.call(event.data.name, event.data.callId, event.data.arguments)
130
+ const argsBody = view?.body ?? ''
131
+ const id = commitToolPlaceholder(messages, turn, event.data.callId, {
132
+ kind: 'tool-card',
133
+ id: nextId('tool'),
134
+ tool: event.data.name,
135
+ label: view?.label ?? event.data.name,
136
+ argsBody,
137
+ running: true,
138
+ ...view?.bodyCol === undefined ? {} : { bodyCol: view.bodyCol },
139
+ ...view?.diff === undefined ? {} : { diff: view.diff },
140
+ })
141
+ turn.toolIds.set(event.data.callId, id)
142
+ markRunning(turn, true)
143
+ markPhase(turn, 'working')
144
+ return { messages, turn, changed: true }
145
+ }
146
+ case 'tool/result': {
147
+ const callId = event.data.message.content[0]?.toolCallId
148
+ if (callId === undefined) return { messages, turn, changed: false }
149
+ const id = turn.toolIds.get(callId)
150
+ if (id === undefined) return { messages, turn, changed: false }
151
+ const card = toolCardById(messages, id)
152
+ if (card === undefined) return { messages, turn, changed: false }
153
+ const error = event.data.error
154
+ const block = event.data.message.content[0]
155
+ const result: ToolResultLike = {
156
+ content: block?.content ?? [],
157
+ isError: block?.isError === true,
158
+ ...event.data.meta === undefined ? {} : { meta: event.data.meta },
159
+ }
160
+ const presentation = presenter?.result(callId, result)
161
+ let text = presentation?.text
162
+ if (text === undefined) text = textFromBlocks(event.data.message.content)
163
+ // The args JSON is a last-resort body: it only helps when the header
164
+ // carries no summary of its own (a bare tool name), otherwise it just
165
+ // repeats what the header already says.
166
+ if (
167
+ text.trim() === '' && card.argsBody === '' && presentation?.read === undefined
168
+ && card.label === card.tool && presenter !== undefined
169
+ ) {
170
+ const fallback = presenter.argsJson(callId)
171
+ if (fallback !== undefined && fallback.trim() !== '') text = fallback
172
+ }
173
+ const metaDiffs = presentation?.diff === undefined ? diffsFromResultMeta(event.data.meta) : undefined
174
+ const diff = presentation?.diff ?? (metaDiffs === undefined
175
+ ? undefined
176
+ : {
177
+ path: card.diff?.path ?? metaDiffs[0]!.path,
178
+ hunks: diffLineGroups(metaDiffs),
179
+ })
180
+ turn.toolIds.delete(callId)
181
+ const label = presentation?.label
182
+ const read = presentation?.read
183
+ const failed = result.isError === true && error === undefined
184
+ updateById(messages, id, message => {
185
+ if (message.kind !== 'tool-card') return message
186
+ const nextLabel = label === undefined || label === '' || label === message.tool ? message.label : label
187
+ const nextArgs = presentation?.argsBody
188
+ return {
189
+ ...message,
190
+ ...(text === '' ? {} : { resultBody: text }),
191
+ ...(diff === undefined ? {} : { diff }),
192
+ ...(read === undefined ? {} : { read }),
193
+ ...(presentation?.exitCode === undefined ? {} : { exitCode: presentation.exitCode }),
194
+ ...(presentation?.signal === undefined ? {} : { signal: presentation.signal }),
195
+ ...(nextArgs === undefined || nextArgs === message.argsBody ? {} : { argsBody: nextArgs }),
196
+ ...(nextLabel === message.label ? {} : { label: nextLabel }),
197
+ ...(failed ? { failed } : {}),
198
+ running: false,
199
+ ...(error === undefined
200
+ ? (presentation?.bodyCol === undefined ? {} : { bodyCol: presentation.bodyCol })
201
+ : { error: errorSummary(error), bodyCol: undefined }),
202
+ }
203
+ })
204
+ markPhase(turn, 'awaiting-request')
205
+ return { messages, turn, changed: true }
206
+ }
207
+ case 'assistant/message': {
208
+ const step = event.data.step
209
+ const blocks = event.data.message.content
210
+ const text = textFromBlocks(blocks)
211
+ const reasoning = reasoningFromBlocks(blocks)
212
+
213
+ const aiId = turn.assistantIds.get(step)
214
+ if (aiId !== undefined) {
215
+ if (text !== '') {
216
+ updateById(messages, aiId, message =>
217
+ message.kind === 'bubble' ? { ...message, content: text, streaming: false } : message,
218
+ )
219
+ } else {
220
+ clearStreamingById(messages, aiId)
221
+ }
222
+ } else if (text !== '') {
223
+ const fresh = nextId('ai')
224
+ turn.assistantIds.set(step, fresh)
225
+ messages.push({ kind: 'bubble', id: fresh, role: 'assistant', content: text })
226
+ }
227
+
228
+ const thinkId = turn.thinkingIds.get(step)
229
+ if (thinkId !== undefined) {
230
+ updateById(messages, thinkId, message => {
231
+ if (message.kind !== 'collapsible') return message
232
+ const updates = reasoning === '' ? {} : { body: reasoning }
233
+ if (!message.running) return { ...message, ...updates, streaming: false }
234
+ const duration = message.startedAt === undefined ? Number.NaN : event.time - message.startedAt
235
+ return {
236
+ ...message,
237
+ ...updates,
238
+ running: false,
239
+ streaming: false,
240
+ ...(Number.isFinite(duration) && duration >= 0
241
+ ? { label: `Thought: ${formatThinkingDuration(duration)}` }
242
+ : {}),
243
+ }
244
+ })
245
+ } else if (reasoning !== '') {
246
+ const freshThink = nextId('think')
247
+ turn.thinkingIds.set(step, freshThink)
248
+ const entry: CollapsibleMessage = {
249
+ kind: 'collapsible',
250
+ id: freshThink,
251
+ label: 'Thinking',
252
+ body: reasoning,
253
+ running: false,
254
+ collapsed: true,
255
+ thinking: true,
256
+ }
257
+ const anchor = turn.assistantIds.get(step)
258
+ const at = anchor === undefined ? -1 : messages.findIndex(m => m.id === anchor)
259
+ if (at >= 0) messages.splice(at, 0, entry)
260
+ else messages.push(entry)
261
+ }
262
+ return { messages, turn, changed: true }
263
+ }
264
+ case 'turn/end': {
265
+ let changed = false
266
+ for (const id of turn.thinkingIds.values()) {
267
+ const thinking = collapsibleById(messages, id)
268
+ if (thinking === undefined) continue
269
+ changed = true
270
+ if (thinking.body === '') messages.splice(messages.indexOf(thinking), 1)
271
+ else updateById(messages, id, message => ({ ...message, running: false }))
272
+ }
273
+ for (const message of messages) {
274
+ if (message.kind === 'collapsible' && message.running) {
275
+ changed = true
276
+ message.running = false
277
+ message.body = message.body === '' ? '(no result)' : `${message.body}\n(no result)`
278
+ continue
279
+ }
280
+ if (message.kind === 'tool-card' && message.running) {
281
+ changed = true
282
+ message.running = false
283
+ if (message.resultBody === undefined && message.argsBody.trim() === '') message.argsBody = '(no result)'
284
+ continue
285
+ }
286
+ if (message.kind === 'tool-card' && message.nested !== undefined && message.nested.some(child => child.running)) {
287
+ changed = true
288
+ message.nested = message.nested.map(child => !child.running
289
+ ? child
290
+ : {
291
+ ...child,
292
+ running: false,
293
+ ...(child.resultBody === undefined && child.argsBody.trim() === '' ? { argsBody: '(no result)' } : {}),
294
+ })
295
+ continue
296
+ }
297
+ if ((message.kind === 'custom') && message.running) {
298
+ changed = true
299
+ message.running = false
300
+ }
301
+ }
302
+ for (const [, pendingId] of turn.pendingTools) {
303
+ const index = messages.findIndex(message => message.id === pendingId)
304
+ if (index >= 0) {
305
+ messages.splice(index, 1)
306
+ changed = true
307
+ }
308
+ }
309
+ turn.pendingTools.clear()
310
+ for (const message of messages) {
311
+ if (message.kind === 'compaction' || !('streaming' in message) || !message.streaming) continue
312
+ changed = true
313
+ message.streaming = false
314
+ }
315
+ const reason = event.data.reason
316
+ if (reason.kind === 'error') {
317
+ changed = true
318
+ messages.push({ kind: 'bubble', id: nextId('error'), role: 'error', content: `error: ${reason.error.message}` })
319
+ }
320
+ return { messages, turn: { ...initialTurnState(), compactions: turn.compactions, compacting: turn.compacting }, changed }
321
+ }
322
+ default: {
323
+ if ((event.type as string) === 'tool/code-dispatch-start') {
324
+ return reduceCodeDispatch(messages, turn, event, false, presenter)
325
+ }
326
+ if ((event.type as string) === 'tool/code-dispatch') {
327
+ return reduceCodeDispatch(messages, turn, event, true, presenter)
328
+ }
329
+ if ((event.type as string) === 'command/run') {
330
+ const data = (event.data as unknown as { commandId: string; name: string })
331
+ turn.commandNames.set(data.commandId, data.name)
332
+ return { messages, turn, changed: false }
333
+ }
334
+ if ((event.type as string) === 'command/done') {
335
+ const data = (event.data as unknown as { commandId: string; kind: "success" | "error"; text?: string })
336
+ const name = turn.commandNames.get(data.commandId) ?? ''
337
+ turn.commandNames.delete(data.commandId)
338
+ if (name === 'compact' && data.kind !== 'error') return { messages, turn, changed: false }
339
+ const text = data.text ?? ''
340
+ if (data.kind !== 'error' && text.trim() === '') return { messages, turn, changed: false }
341
+ const label = name === '' ? '' : `${name} ${glyphs.separator} `
342
+ messages.push({
343
+ kind: 'bubble',
344
+ id: nextId('cmd'),
345
+ role: data.kind === 'error' ? 'error' : 'assistant',
346
+ content: data.kind === 'error' && text.trim() === '' ? `${name} failed` : `${label}${text}`,
347
+ origin: 'command',
348
+ })
349
+ return { messages, turn, changed: true }
350
+ }
351
+ if ((event.type as string) === 'llm/retry-started') {
352
+ const changed = dropStepMessages(messages, turn, (event.data as { step: number }).step)
353
+ markRunning(turn, true)
354
+ return { messages, turn, changed }
355
+ }
356
+ if ((event.type as string) === 'compaction/start') {
357
+ const data = (event.data as unknown as { compactionId: string })
358
+ const id = nextId('cmp')
359
+ turn.compactions.set(data.compactionId, id)
360
+ turn.compacting = true
361
+ messages.push({ kind: 'compaction', id, compactionId: data.compactionId, running: true, summary: '' })
362
+ return { messages, turn, changed: true }
363
+ }
364
+ if ((event.type as string) === 'compaction/summary') {
365
+ const data = (event.data as unknown as {
366
+ compactionId: string
367
+ summary: Array<{ type: string; text?: string }>
368
+ })
369
+ const id = turn.compactions.get(data.compactionId)
370
+ if (id === undefined) return { messages, turn, changed: false }
371
+ updateById(messages, id, message => message.kind === 'compaction'
372
+ ? { ...message, summary: textFromBlocks(data.summary as Parameters<typeof textFromBlocks>[0]) }
373
+ : message)
374
+ return { messages, turn, changed: true }
375
+ }
376
+ if ((event.type as string) === 'compaction/end') {
377
+ const data = (event.data as unknown as { compactionId: string; error?: string })
378
+ const id = turn.compactions.get(data.compactionId)
379
+ if (id === undefined) return { messages, turn, changed: false }
380
+ turn.compactions.delete(data.compactionId)
381
+ turn.compacting = false
382
+ updateById(messages, id, message => message.kind === 'compaction'
383
+ ? { ...message, running: false, ...(data.error === undefined ? {} : { error: data.error }) }
384
+ : message)
385
+ return { messages, turn, changed: true }
386
+ }
387
+ return { messages, turn, changed: CHROME_EVENTS.has(event.type) }
388
+ }
389
+ }
390
+ }
391
+
392
+ function appendChunk(
393
+ messages: Message[],
394
+ turn: TurnState,
395
+ text: string,
396
+ step: number,
397
+ kind: 'thinking' | 'assistant',
398
+ time: number,
399
+ ): { messages: Message[]; turn: TurnState; changed: boolean } {
400
+ markRunning(turn, true)
401
+ if (kind === 'thinking') {
402
+ markPhase(turn, 'thinking')
403
+ } else {
404
+ markPhase(turn, 'working')
405
+ finalizeThinking(messages, turn, step, time)
406
+ }
407
+ const ids = kind === 'thinking' ? turn.thinkingIds : turn.assistantIds
408
+ const id = ids.get(step)
409
+ if (id === undefined) {
410
+ if (kind === 'assistant') {
411
+ const pending = (turn.pendingText.get(step) ?? '') + text
412
+ if (pending.trim() === '') {
413
+ turn.pendingText.set(step, pending)
414
+ return { messages, turn, changed: false }
415
+ }
416
+ turn.pendingText.delete(step)
417
+ const fresh = nextId('ai')
418
+ ids.set(step, fresh)
419
+ messages.push({ kind: 'bubble', id: fresh, role: 'assistant', content: pending, streaming: true })
420
+ return { messages, turn, changed: true }
421
+ }
422
+ const fresh = nextId('think')
423
+ ids.set(step, fresh)
424
+ messages.push({ kind: 'collapsible', id: fresh, label: 'Thinking', body: text, running: true, collapsed: true, thinking: true, startedAt: time })
425
+ return { messages, turn, changed: true }
426
+ }
427
+ let changed = false
428
+ const index = messages.findIndex(m => m.id === id)
429
+ const target = index >= 0 ? messages[index] : undefined
430
+ if (target !== undefined && text !== '') {
431
+ if (kind === 'thinking' && target.kind === 'collapsible') {
432
+ if (target.running) {
433
+ target.body += text
434
+ } else {
435
+ updateById(messages, id, message => message.kind === 'collapsible'
436
+ ? { ...message, running: true, label: 'Thinking', body: message.body + text }
437
+ : message)
438
+ }
439
+ changed = true
440
+ } else if (kind === 'assistant' && target.kind === 'bubble') {
441
+ target.content += text
442
+ // The streaming flag routes the render through the incremental markdown
443
+ // path; it is cleared by the assistant/message and turn/end handlers.
444
+ target.streaming = true
445
+ changed = true
446
+ }
447
+ }
448
+ return { messages, turn, changed }
449
+ }
450
+
451
+ function commitToolPlaceholder(messages: Message[], turn: TurnState, callId: string, real: Message): string {
452
+ const committed = { ...real, streaming: false }
453
+ const pendingId = turn.pendingTools.get(callId)
454
+ turn.pendingTools.delete(callId)
455
+ if (pendingId === undefined) {
456
+ messages.push(committed)
457
+ return committed.id
458
+ }
459
+ const index = messages.findIndex(message => message.id === pendingId)
460
+ if (index >= 0) {
461
+ messages[index] = { ...committed, id: pendingId }
462
+ return pendingId
463
+ }
464
+ messages.push(committed)
465
+ return committed.id
466
+ }
467
+
468
+ function appendToolDelta(
469
+ messages: Message[],
470
+ turn: TurnState,
471
+ step: number,
472
+ callId: string,
473
+ name: string | undefined,
474
+ delta: string,
475
+ time: number,
476
+ ): { messages: Message[]; turn: TurnState; changed: boolean } {
477
+ markRunning(turn, true)
478
+ markPhase(turn, 'working')
479
+ finalizeThinking(messages, turn, step, time)
480
+ if (name === undefined || name === '') return { messages, turn, changed: false }
481
+ const known = turn.pendingTools.has(callId) || turn.toolIds.has(callId)
482
+ if (known) return { messages, turn, changed: false }
483
+ const placeholder: Message = {
484
+ kind: 'tool-card',
485
+ id: nextId('tool'),
486
+ tool: name,
487
+ label: name,
488
+ argsBody: '',
489
+ running: true,
490
+ streaming: true,
491
+ }
492
+ messages.push(placeholder)
493
+ turn.pendingTools.set(callId, placeholder.id)
494
+ return { messages, turn, changed: true }
495
+ }
496
+
497
+ function collapsibleById(messages: Message[], id: string): CollapsibleMessage | undefined {
498
+ const message = messages.find(m => m.id === id)
499
+ return message?.kind === 'collapsible' ? message : undefined
500
+ }
501
+
502
+ function toolCardById(messages: Message[], id: string): ToolCardMessage | undefined {
503
+ const message = messages.find(m => m.id === id)
504
+ return message?.kind === 'tool-card' ? message : undefined
505
+ }
506
+
507
+ function finalizeThinking(messages: Message[], turn: TurnState, step: number, endedAt: number): boolean {
508
+ const id = turn.thinkingIds.get(step)
509
+ if (id === undefined) return false
510
+ const target = collapsibleById(messages, id)
511
+ if (target === undefined || !target.running) return false
512
+ const duration = target.startedAt === undefined ? Number.NaN : endedAt - target.startedAt
513
+ updateById(messages, id, message => message.kind === 'collapsible'
514
+ ? {
515
+ ...message,
516
+ running: false,
517
+ ...(Number.isFinite(duration) && duration >= 0
518
+ ? { label: `Thought: ${formatThinkingDuration(duration)}` }
519
+ : {}),
520
+ }
521
+ : message)
522
+ return true
523
+ }
524
+
525
+ function updateById(messages: Message[], id: string, update: (message: Message) => Message): void {
526
+ const index = messages.findIndex(m => m.id === id)
527
+ if (index >= 0) messages[index] = update(messages[index]!)
528
+ }
529
+
530
+ function clearStreamingById(messages: Message[], id: string): void {
531
+ const index = messages.findIndex(m => m.id === id)
532
+ if (index >= 0) {
533
+ const message = messages[index]!
534
+ if ('streaming' in message && message.streaming === true) messages[index] = { ...message, streaming: false }
535
+ }
536
+ }
537
+
538
+ interface CodeDispatchData {
539
+ rootCallId: string
540
+ parentCallId: string
541
+ subCallId: string
542
+ name: string
543
+ arguments: unknown
544
+ isError?: boolean
545
+ content?: Array<{ type: string; text?: string }>
546
+ }
547
+
548
+ /**
549
+ * Code-mode sub-dispatches render as child cards under the root `run_code`
550
+ * bubble: the start event commits a running placeholder, the settle event
551
+ * pairs with it by `subCallId`. The root card is the render anchor, so a
552
+ * settle whose start fell outside the turn still attaches to it.
553
+ */
554
+ function reduceCodeDispatch(
555
+ messages: Message[],
556
+ turn: TurnState,
557
+ event: SessionEvent,
558
+ settled: boolean,
559
+ presenter?: ChatToolPresenter,
560
+ ): { messages: Message[]; turn: TurnState; changed: boolean } {
561
+ const data = (event.data as unknown as CodeDispatchData)
562
+ const rootId = turn.toolIds.get(String(data.rootCallId))
563
+ const argsText = stringifyDispatchArgs(data.arguments)
564
+ if (!settled) {
565
+ const view = presenter?.call(data.name, String(data.subCallId), argsText)
566
+ const child: ToolCardMessage = {
567
+ kind: 'tool-card',
568
+ id: nextId('tool'),
569
+ tool: data.name,
570
+ label: view?.label ?? data.name,
571
+ argsBody: view?.body ?? '',
572
+ running: true,
573
+ ...view?.bodyCol === undefined ? {} : { bodyCol: view.bodyCol },
574
+ ...view?.diff === undefined ? {} : { diff: view.diff },
575
+ }
576
+ if (rootId !== undefined) {
577
+ updateById(messages, rootId, message => message.kind === 'tool-card'
578
+ ? { ...message, nested: [...message.nested ?? [], child] }
579
+ : message)
580
+ } else {
581
+ messages.push(child)
582
+ }
583
+ turn.toolIds.set(String(data.subCallId), child.id)
584
+ markRunning(turn, true)
585
+ return { messages, turn, changed: true }
586
+ }
587
+ const childId = turn.toolIds.get(String(data.subCallId))
588
+ turn.toolIds.delete(String(data.subCallId))
589
+ const childResult = textFromBlocks((data.content ?? []) as Parameters<typeof textFromBlocks>[0])
590
+ const settle = (card: ToolCardMessage): ToolCardMessage => ({
591
+ ...card,
592
+ running: false,
593
+ ...(childResult === '' ? {} : { resultBody: childResult }),
594
+ ...(data.isError === true ? { failed: true } : {}),
595
+ })
596
+ let changed = false
597
+ if (childId !== undefined) {
598
+ const at = messages.findIndex(message => message.id === childId)
599
+ if (at >= 0 && messages[at]!.kind === 'tool-card') {
600
+ messages[at] = settle(messages[at] as ToolCardMessage)
601
+ } else if (rootId !== undefined) {
602
+ updateById(messages, rootId, message => message.kind === 'tool-card'
603
+ ? { ...message, nested: (message.nested ?? []).map(child => child.id === childId ? settle(child) : child) }
604
+ : message)
605
+ }
606
+ changed = true
607
+ } else {
608
+ const child = settle({
609
+ kind: 'tool-card',
610
+ id: nextId('tool'),
611
+ tool: data.name,
612
+ label: data.name,
613
+ argsBody: '',
614
+ running: false,
615
+ })
616
+ if (rootId !== undefined) {
617
+ updateById(messages, rootId, message => message.kind === 'tool-card'
618
+ ? { ...message, nested: [...message.nested ?? [], child] }
619
+ : message)
620
+ } else {
621
+ messages.push(child)
622
+ }
623
+ changed = true
624
+ }
625
+ return { messages, turn, changed }
626
+ }
627
+
628
+ function stringifyDispatchArgs(args: unknown): string {
629
+ if (args === undefined || args === null) return ''
630
+ try {
631
+ return JSON.stringify(args) ?? ''
632
+ } catch {
633
+ // Cyclic or unserializable dispatch args degrade to an empty body.
634
+ return ''
635
+ }
636
+ }
@@ -0,0 +1,66 @@
1
+ import { glyphs } from '../terminal/glyphs.ts'
2
+
3
+ export const TODO_TOOL_NAME = 'todo_write'
4
+
5
+ export type TodoStatus = 'pending' | 'in_progress' | 'completed'
6
+
7
+ export interface TodoItemLike {
8
+ content: string
9
+ status: TodoStatus
10
+ }
11
+
12
+ const TODO_SYMBOLS: Record<TodoStatus, string> = {
13
+ pending: glyphs.todoPending,
14
+ in_progress: glyphs.todoInProgress,
15
+ completed: glyphs.todoDone,
16
+ }
17
+
18
+ export function todoSymbol(status: TodoStatus | undefined): string {
19
+ return status === undefined ? TODO_SYMBOLS.pending : TODO_SYMBOLS[status] ?? TODO_SYMBOLS.pending
20
+ }
21
+
22
+ export function normalizeTodos(raw: unknown): TodoItemLike[] {
23
+ if (!Array.isArray(raw)) return []
24
+ const out: TodoItemLike[] = []
25
+ for (const entry of raw) {
26
+ if (typeof entry !== 'object' || entry === null) continue
27
+ const record = entry as Record<string, unknown>
28
+ if (typeof record.content !== 'string' || record.content.trim() === '') continue
29
+ const status = record.status === 'in_progress' || record.status === 'completed' ? record.status : 'pending'
30
+ out.push({ content: record.content.trim(), status })
31
+ }
32
+ return out
33
+ }
34
+
35
+ export function parseTodoArgs(raw: unknown): TodoItemLike[] {
36
+ if (typeof raw !== 'object' || raw === null) return []
37
+ return normalizeTodos((raw as { todos?: unknown }).todos)
38
+ }
39
+
40
+ export function parseTodoResult(resultText: string): TodoItemLike[] {
41
+ let parsed: unknown
42
+ try {
43
+ parsed = JSON.parse(resultText)
44
+ } catch {
45
+ // A non-JSON result text simply carries no todos.
46
+ return []
47
+ }
48
+ if (typeof parsed !== 'object' || parsed === null) return []
49
+ return normalizeTodos((parsed as { todos?: unknown }).todos)
50
+ }
51
+
52
+ export function formatTodoBubble(items: readonly TodoItemLike[]): string {
53
+ return items.map(item => `${todoSymbol(item.status)} ${item.content}`).join('\n')
54
+ }
55
+
56
+ export function isTodoActive(items: readonly TodoItemLike[]): boolean {
57
+ return items.length > 0 && items.some(item => item.status !== 'completed')
58
+ }
59
+
60
+ export function todoProgress(items: readonly TodoItemLike[]): { current: number; total: number } | undefined {
61
+ if (!isTodoActive(items)) return undefined
62
+ const active = items.findIndex(item => item.status === 'in_progress')
63
+ const fallback = items.findIndex(item => item.status !== 'completed')
64
+ const index = active >= 0 ? active : fallback
65
+ return index < 0 ? undefined : { current: index + 1, total: items.length }
66
+ }