@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.
- package/bin/dshtui.js +65 -0
- package/cordis.patch.yml +81 -0
- package/lib/contract/index.d.mts +2 -0
- package/lib/contract/index.mjs +5 -0
- package/lib/dialog.d.mts +156 -0
- package/lib/dialog.mjs +3 -0
- package/lib/index-DqnaSXqP.d.mts +823 -0
- package/lib/index.d.mts +17 -0
- package/lib/index.mjs +11404 -0
- package/lib/prod-react-Dzof4qJx.mjs +4 -0
- package/lib/surface-DdxzIgIY.mjs +3126 -0
- package/lib/vendor.d.mts +3 -0
- package/lib/vendor.mjs +4 -0
- package/package.json +128 -0
- package/src/apply-theme.ts +8 -0
- package/src/boot-log.ts +35 -0
- package/src/chat/blocks.ts +41 -0
- package/src/chat/bridge.ts +1076 -0
- package/src/chat/builtin-tool-views.ts +53 -0
- package/src/chat/chat-face.ts +16 -0
- package/src/chat/chat-nodes.ts +53 -0
- package/src/chat/efforts.ts +4 -0
- package/src/chat/interactions.ts +329 -0
- package/src/chat/models.ts +263 -0
- package/src/chat/partial-json.ts +125 -0
- package/src/chat/presets.ts +26 -0
- package/src/chat/question-view.ts +106 -0
- package/src/chat/retry-status.ts +33 -0
- package/src/chat/session-groups.ts +34 -0
- package/src/chat/session-list.ts +311 -0
- package/src/chat/store.ts +636 -0
- package/src/chat/todo-view.ts +66 -0
- package/src/chat/tool-view.ts +311 -0
- package/src/chat/tool-views.ts +114 -0
- package/src/contract/index.ts +704 -0
- package/src/contract/upstream.ts +165 -0
- package/src/core/caret-nonce.ts +19 -0
- package/src/core/composer-layout.ts +65 -0
- package/src/core/edit.ts +152 -0
- package/src/core/field-view.ts +52 -0
- package/src/core/fields.ts +185 -0
- package/src/core/metrics.ts +26 -0
- package/src/core/paste.ts +149 -0
- package/src/core/segments.ts +112 -0
- package/src/core/text.ts +290 -0
- package/src/dialog.ts +11 -0
- package/src/env.ts +38 -0
- package/src/harness-home.ts +22 -0
- package/src/hot-theme.ts +56 -0
- package/src/index.tsx +185 -0
- package/src/kernel/registry.ts +102 -0
- package/src/kernel/surface.ts +33 -0
- package/src/log.ts +249 -0
- package/src/model/message.ts +89 -0
- package/src/model/selection.ts +63 -0
- package/src/performance-guard.ts +28 -0
- package/src/runtime/prod-react.ts +10 -0
- package/src/terminal/background.ts +66 -0
- package/src/terminal/capabilities.ts +54 -0
- package/src/terminal/clipboard-backends.ts +69 -0
- package/src/terminal/clipboard.ts +161 -0
- package/src/terminal/cursor-shape.ts +13 -0
- package/src/terminal/glyphs.ts +44 -0
- package/src/terminal/mouse.ts +154 -0
- package/src/terminal/probe.ts +61 -0
- package/src/terminal/screen.ts +349 -0
- package/src/theme-settings.ts +31 -0
- package/src/theme.ts +319 -0
- package/src/ui/app.tsx +454 -0
- package/src/ui/chrome/caps.tsx +9 -0
- package/src/ui/chrome/hint-service.ts +87 -0
- package/src/ui/chrome/input-status.ts +27 -0
- package/src/ui/chrome/key-gate.tsx +29 -0
- package/src/ui/chrome/status-bar.tsx +147 -0
- package/src/ui/contributions.ts +47 -0
- package/src/ui/dialog/defaults-dialog.tsx +152 -0
- package/src/ui/dialog/dialog-item.tsx +38 -0
- package/src/ui/dialog/dialog.tsx +386 -0
- package/src/ui/dialog/effort-dialog.tsx +45 -0
- package/src/ui/dialog/geometry.ts +131 -0
- package/src/ui/dialog/items.ts +124 -0
- package/src/ui/dialog/list-dialog.tsx +63 -0
- package/src/ui/dialog/models-dialog.tsx +203 -0
- package/src/ui/dialog/presets-dialog.tsx +46 -0
- package/src/ui/dialog/providers-dialog.tsx +313 -0
- package/src/ui/dialog/sessions-dialog.tsx +163 -0
- package/src/ui/dialog/sizes.ts +7 -0
- package/src/ui/dialog/status-lines.ts +11 -0
- package/src/ui/dialog/todo-dialog.tsx +29 -0
- package/src/ui/dialog/use-dialog-input.ts +186 -0
- package/src/ui/extension-point.ts +188 -0
- package/src/ui/home-logo.ts +132 -0
- package/src/ui/hooks/use-async-action.ts +25 -0
- package/src/ui/hooks/use-async-list.ts +71 -0
- package/src/ui/hooks/use-caret.ts +24 -0
- package/src/ui/hooks/use-chat-events.ts +153 -0
- package/src/ui/hooks/use-mouse-selection.ts +352 -0
- package/src/ui/hooks/use-scroll.ts +49 -0
- package/src/ui/hooks/use-terminal-size.ts +20 -0
- package/src/ui/input/commands.ts +118 -0
- package/src/ui/input/composer-bus.ts +14 -0
- package/src/ui/input/composer-fields.ts +111 -0
- package/src/ui/input/composer-keys.ts +21 -0
- package/src/ui/input/composer-paste.ts +20 -0
- package/src/ui/input/input-bar.tsx +279 -0
- package/src/ui/input/use-composer.ts +495 -0
- package/src/ui/key-arbiter.ts +37 -0
- package/src/ui/keymap.ts +15 -0
- package/src/ui/layout-service.ts +77 -0
- package/src/ui/message/layout.ts +763 -0
- package/src/ui/message/md/block.ts +247 -0
- package/src/ui/message/md/engine.ts +430 -0
- package/src/ui/message/md/extensions.ts +58 -0
- package/src/ui/message/md/highlight.ts +626 -0
- package/src/ui/message/md/index.ts +14 -0
- package/src/ui/message/md/inline.ts +97 -0
- package/src/ui/message/md/palette.ts +87 -0
- package/src/ui/message/message-list.tsx +69 -0
- package/src/ui/message/message-row.tsx +137 -0
- package/src/ui/message/message-views.ts +16 -0
- package/src/ui/message/renderers.ts +39 -0
- package/src/ui/message/tool-diff.ts +188 -0
- package/src/ui/message/warmup.ts +41 -0
- package/src/ui/message/wave.ts +98 -0
- package/src/ui/overlay.ts +22 -0
- package/src/ui/panels/approval-panel.tsx +193 -0
- package/src/ui/panels/exports.ts +3 -0
- package/src/ui/panels/question-model.ts +424 -0
- package/src/ui/panels/question-panel.tsx +154 -0
- package/src/ui/panels/surface.tsx +106 -0
- package/src/ui/panels-builtin.tsx +86 -0
- package/src/ui/pointer/builtins.ts +281 -0
- package/src/ui/pointer/registry.ts +25 -0
- package/src/ui/region.tsx +31 -0
- package/src/ui/selection/service.ts +92 -0
- package/src/ui/selection-registry.ts +88 -0
- package/src/ui/selection.tsx +148 -0
- package/src/ui/spinner-tick.tsx +24 -0
- package/src/ui/use-available-windows.ts +31 -0
- package/src/ui/widgets/actions.tsx +84 -0
- package/src/ui/widgets/button.tsx +57 -0
- package/src/ui/widgets/checkbox.tsx +58 -0
- package/src/ui/widgets/header.tsx +36 -0
- package/src/ui/widgets/index.ts +8 -0
- package/src/ui/widgets/input.tsx +76 -0
- package/src/ui/widgets/registry.ts +32 -0
- package/src/ui/widgets/search.tsx +59 -0
- package/src/ui/widgets/select.tsx +97 -0
- package/src/ui/widgets/static.tsx +37 -0
- package/src/ui/widgets/types.ts +1 -0
- package/src/ui/window-services-bridge.ts +71 -0
- package/src/ui/window-services.ts +20 -0
- package/src/ui/windows/models-window.tsx +15 -0
- package/src/ui/windows/providers-window.tsx +14 -0
- package/src/ui/windows/service-window.tsx +18 -0
- package/src/ui/windows/sessions-window.tsx +25 -0
- package/src/ui/windows-builtin.tsx +94 -0
- package/src/ui/windows.ts +18 -0
- 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
|
+
}
|