dsh-live-trace 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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +920 -0
  3. package/README.zh.md +790 -0
  4. package/assets/rain.ogg +0 -0
  5. package/bin/dsh-glyph-probe.js +51 -0
  6. package/bin/dsh-live-trace.js +29 -0
  7. package/bin/dsh-live-working.js +14 -0
  8. package/cordis.patch.yml +31 -0
  9. package/icon.svg +12 -0
  10. package/index.js +328 -0
  11. package/lib/client.js +178 -0
  12. package/lib/instance.js +68 -0
  13. package/lib/normalize.js +850 -0
  14. package/lib/paths.js +66 -0
  15. package/lib/protocol.js +115 -0
  16. package/lib/registry.js +232 -0
  17. package/lib/tools.js +257 -0
  18. package/lib/tracker.js +648 -0
  19. package/lib/transport.js +231 -0
  20. package/locale/en.json +6 -0
  21. package/locale/zh.json +6 -0
  22. package/package.json +94 -0
  23. package/picture/call1.png +0 -0
  24. package/picture/call2.png +0 -0
  25. package/picture/sleep1.png +0 -0
  26. package/picture/sleep2.png +0 -0
  27. package/picture/tui1.png +0 -0
  28. package/picture/tui2.png +0 -0
  29. package/picture/type1.png +0 -0
  30. package/picture/type2.png +0 -0
  31. package/scripts/bench-render.mjs +69 -0
  32. package/scripts/demo-working.mjs +130 -0
  33. package/scripts/demo.mjs +284 -0
  34. package/scripts/install-profile.mjs +174 -0
  35. package/scripts/mock-provider.mjs +211 -0
  36. package/src/cli/cellsize.js +120 -0
  37. package/src/cli/format.js +73 -0
  38. package/src/cli/highlight.js +932 -0
  39. package/src/cli/i18n.js +457 -0
  40. package/src/cli/main.js +630 -0
  41. package/src/cli/markdown.js +753 -0
  42. package/src/cli/renderer.js +1044 -0
  43. package/src/cli/screen.js +270 -0
  44. package/src/cli/theme.js +221 -0
  45. package/src/cli/view-state.js +396 -0
  46. package/src/cli/views.js +406 -0
  47. package/src/cli/width.js +337 -0
  48. package/src/cli/working/art.js +413 -0
  49. package/src/cli/working/main.js +569 -0
  50. package/src/cli/working/packing.js +159 -0
  51. package/src/cli/working/picker.js +75 -0
  52. package/src/cli/working/props.js +385 -0
  53. package/src/cli/working/scene.js +837 -0
  54. package/src/cli/working/sky.js +641 -0
  55. package/src/cli/working/sound.js +400 -0
  56. package/src/cli/working/state.js +528 -0
@@ -0,0 +1,528 @@
1
+ /**
2
+ * What is the orca doing?
3
+ *
4
+ * The animation answers to the session's real activity, so this module turns
5
+ * the same record stream the trace dashboard consumes into one of a handful of
6
+ * work states. It is deliberately pure: give it records and a clock, read back
7
+ * a state.
8
+ *
9
+ * @module dsh-live-working/state
10
+ */
11
+
12
+ /**
13
+ * Every state the scene can draw.
14
+ *
15
+ * `thinking`, `typing` and `writing` are the subdivisions of "the model is
16
+ * working": reasoning, prose/code streaming, and a file being written. They
17
+ * look slightly different because they are different kinds of work.
18
+ */
19
+ export const WORK_STATES = [
20
+ 'sleep',
21
+ 'thinking',
22
+ 'typing',
23
+ 'writing',
24
+ 'waiting',
25
+ 'reading',
26
+ 'searching',
27
+ 'calling',
28
+ // A subagent has finished and is calling back: the telephone rings and the
29
+ // orca answers it with the subagent's own reply.
30
+ 'ringing'
31
+ ]
32
+
33
+ /**
34
+ * How long a callback keeps ringing.
35
+ *
36
+ * Long enough to read the reply off the bubble, short enough that it settles
37
+ * back to what the session is actually doing.
38
+ */
39
+ export const CALL_MS = 2500
40
+ /** How long a callback rings before it settles. */
41
+ export const RING_MS = 14_000
42
+
43
+ /**
44
+ * How long a running shell command may stay silent before the orca dozes off.
45
+ * A build that prints nothing for this long looks exactly like no work at all.
46
+ */
47
+ export const SILENT_MS = 8000
48
+
49
+ /** Tool names grouped by what they make the orca do. */
50
+ export const TOOL_KINDS = {
51
+ shell: ['bash', 'shell', 'sh', 'exec', 'run', 'terminal', 'command'],
52
+ read: ['read', 'read_file', 'readfile', 'view', 'open', 'cat', 'list', 'ls', 'glob', 'grep', 'search_files'],
53
+ search: ['web_search', 'websearch', 'web_fetch', 'webfetch', 'fetch', 'browse', 'search_web'],
54
+ subagent: ['task', 'agent', 'subagent', 'delegate', 'spawn_agent', 'task_tool'],
55
+ write: ['write', 'write_file', 'writefile', 'edit', 'edit_file', 'apply_patch', 'patch', 'multiedit', 'notebook_edit']
56
+ }
57
+
58
+ /**
59
+ * What kind of work a tool name implies.
60
+ *
61
+ * The lookup is loose on purpose: tool names arrive in several styles
62
+ * (`read_file`, `ReadFile`, `read-file`) and a miss should degrade to "the
63
+ * model is working", never to a crash.
64
+ *
65
+ * @param {unknown} name
66
+ * @returns {'shell' | 'read' | 'search' | 'subagent' | 'write' | null}
67
+ */
68
+ export function classifyTool(name) {
69
+ if (typeof name !== 'string' || name.length === 0) return null
70
+ const needle = name.toLowerCase().replace(/[^a-z]/g, '')
71
+ for (const [kind, names] of Object.entries(TOOL_KINDS)) {
72
+ for (const candidate of names) {
73
+ const target = candidate.replace(/[^a-z]/g, '')
74
+ if (needle === target || needle.endsWith(target) || needle.startsWith(target)) return kind
75
+ }
76
+ }
77
+ return null
78
+ }
79
+
80
+
81
+ /* ------------------------------------------------------------------ *
82
+ * Subagents: the telephone queue
83
+ * ------------------------------------------------------------------ */
84
+
85
+ /** One argument value as it appears in a call, clipped to something readable. */
86
+ function quoteValue(value, limit = 44) {
87
+ if (value === null) return 'null'
88
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
89
+ const text = typeof value === 'string' ? value : JSON.stringify(value)
90
+ if (typeof text !== 'string') return '…'
91
+ const flat = text.replace(/\s+/g, ' ').trim()
92
+ return `"${flat.length > limit ? `${flat.slice(0, limit - 1)}…` : flat}"`
93
+ }
94
+
95
+ /**
96
+ * The call as it was written, not just the instruction inside it.
97
+ *
98
+ * A subagent invocation is more than its prompt: the bubble shows the actual
99
+ * call — `task(description="…", prompt="…")` — so it is clear what was asked
100
+ * and how, and how many arguments came with it.
101
+ *
102
+ * @param {unknown} tool
103
+ * @param {unknown} rawArgs the plugin's `arguments`, usually a JSON string
104
+ * @returns {string}
105
+ */
106
+ export function callFormat(tool, rawArgs) {
107
+ const name = typeof tool === 'string' && tool.trim().length > 0 ? tool.trim() : 'subagent'
108
+ if (rawArgs === undefined || rawArgs === null) return `${name}()`
109
+ let args = rawArgs
110
+ if (typeof args === 'string') {
111
+ const text = args.trim()
112
+ if (text.length === 0) return `${name}()`
113
+ try {
114
+ args = JSON.parse(text)
115
+ } catch {
116
+ return `${name}(${text.replace(/\s+/g, ' ')})`
117
+ }
118
+ }
119
+ if (typeof args !== 'object' || args === null || Array.isArray(args)) return `${name}(${quoteValue(args)})`
120
+ const keys = Object.keys(args)
121
+ if (keys.length === 0) return `${name}()`
122
+ return `${name}(${keys.map((key) => `${key}=${quoteValue(args[key])}`).join(', ')})`
123
+ }
124
+
125
+ /**
126
+ * When a record actually happened.
127
+ *
128
+ * The plugin timestamps every entry, and a viewer replaying the backlog gets
129
+ * hours of history at once. Using the wall clock instead made every replayed
130
+ * event look like it had just happened — so starting the command rang the
131
+ * telephone for a subagent that finished five minutes ago.
132
+ *
133
+ * @param {any} record
134
+ * @param {number} fallback
135
+ * @returns {number}
136
+ */
137
+ export function eventTime(record, fallback) {
138
+ const raw = record?.entry?.time ?? record?.time ?? record?.at
139
+ return Number.isFinite(raw) ? raw : fallback
140
+ }
141
+
142
+ /** The text a finished subagent came back with. */
143
+ export function replyTextOf(entry) {
144
+ const candidates = [entry?.output?.text, entry?.result, entry?.rawText, entry?.text]
145
+ for (const candidate of candidates) {
146
+ if (typeof candidate === 'string' && candidate.trim().length > 0) return candidate.trim()
147
+ }
148
+ return ''
149
+ }
150
+
151
+ /** A subagent as the scene needs it. */
152
+ function subagentView(state, record) {
153
+ if (record === null || record === undefined) return null
154
+ const pending = state.subagentOrder
155
+ .map((callId) => state.subagents.get(callId))
156
+ .filter((item) => item !== undefined && item.status === 'pending')
157
+ return {
158
+ index: record.index,
159
+ total: state.subagentCount,
160
+ // Everything dispatched before this one that has not finished yet.
161
+ queued: pending.filter((item) => item.callId !== record.callId).length,
162
+ tool: record.tool,
163
+ format: record.format,
164
+ input: record.input,
165
+ reply: record.status === 'done' ? record.result : null
166
+ }
167
+ }
168
+
169
+ /** The number of subagents dispatched but not yet finished. */
170
+ function pendingSubagents(state) {
171
+ let count = 0
172
+ for (const record of state.subagents.values()) if (record.status === 'pending') count += 1
173
+ return count
174
+ }
175
+
176
+
177
+ /**
178
+ * A background subagent replies in two steps, and only the second one is an
179
+ * answer.
180
+ *
181
+ * Dispatching one returns immediately with `started subagent <id>` — an
182
+ * acknowledgement that the work was handed out, not the work. The subagent's
183
+ * actual answer arrives later, as a message from the agent. Treating the
184
+ * acknowledgement as the reply made every background subagent look finished the
185
+ * instant it started, which emptied the queue and rang the telephone
186
+ * immediately.
187
+ */
188
+ const DISPATCH_ACK = /^started\s+subagent\s+(\S+)/i
189
+ const AGENT_MESSAGE = /^Agent\s+(\S+)\s+sent a message:\s*([\s\S]*)$/i
190
+ const AGENT_FINISHED = /^Background\s+subagent\s+(\S+)\s+finished/i
191
+ const CLOSING_MESSAGE = /Its closing message:\s*([\s\S]*)$/i
192
+
193
+ /**
194
+ * Read a subagent notice.
195
+ *
196
+ * @param {unknown} text
197
+ * @returns {{ handle: string, reply: string | null } | null}
198
+ */
199
+ export function parseSubagentNotice(text) {
200
+ if (typeof text !== 'string' || text.length === 0) return null
201
+ const trimmed = text.trim()
202
+ const sent = AGENT_MESSAGE.exec(trimmed)
203
+ if (sent !== null) return { handle: sent[1], reply: sent[2].trim() }
204
+ const closing = CLOSING_MESSAGE.exec(trimmed)
205
+ if (closing !== null && AGENT_FINISHED.test(trimmed)) {
206
+ return { handle: AGENT_FINISHED.exec(trimmed)[1], reply: closing[1].trim() }
207
+ }
208
+ return null
209
+ }
210
+
211
+ /** Is this result just the acknowledgement that a subagent was dispatched? */
212
+ export function isDispatchAck(text) {
213
+ return typeof text === 'string' && DISPATCH_ACK.test(text.trim())
214
+ }
215
+
216
+ /** A fresh activity model. */
217
+ export function createWorkState() {
218
+ return {
219
+ status: 'idle',
220
+ streamActive: false,
221
+ streamKind: null,
222
+ activeTool: null,
223
+ lastOutputAt: 0,
224
+ lastEventAt: 0,
225
+ subagentCount: 0,
226
+ subagent: null,
227
+ /** Every subagent call in this session, keyed by call id. */
228
+ subagents: new Map(),
229
+ /** Call ids in the order they were dispatched: the queue. */
230
+ subagentOrder: [],
231
+ /** The subagent whose call is currently in flight. */
232
+ activeSubagent: null,
233
+ /** The most recent callback, which is what the telephone is ringing about. */
234
+ lastReply: null,
235
+ // The tail of whatever the model last put on the wire: what the little
236
+ // window on the desk shows.
237
+ preview: ''
238
+ }
239
+ }
240
+
241
+ /** The text a tool call was given, as one short line, for the bubble. */
242
+ function summarise(input) {
243
+ if (typeof input !== 'string' || input.trim().length === 0) return null
244
+ return input.replace(/\s+/g, ' ').trim()
245
+ }
246
+
247
+ /** Arguments that best describe what a call is for, most specific first. */
248
+ const ARGUMENT_KEYS = ['description', 'prompt', 'task', 'instructions', 'query', 'command', 'path', 'file_path', 'pattern']
249
+
250
+ /** Pull a human-meaningful argument out of a tool call's arguments. */
251
+ function describeCall(entry) {
252
+ let args = entry.arguments ?? entry.args ?? entry.input
253
+ // The plugin publishes the call arguments as the raw JSON string it received.
254
+ if (typeof args === 'string') {
255
+ try {
256
+ const parsed = JSON.parse(args)
257
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) args = parsed
258
+ } catch {
259
+ return summarise(args)
260
+ }
261
+ }
262
+ // With no arguments field at all, the entry itself may carry them (older
263
+ // plugin versions, or a hand-written record).
264
+ if (args === null || args === undefined) args = entry
265
+ if (args !== null && typeof args === 'object' && args !== entry) {
266
+ for (const key of ARGUMENT_KEYS) {
267
+ const value = args[key]
268
+ if (typeof value === 'string' && value.trim().length > 0) return summarise(value)
269
+ }
270
+ const first = Object.values(args).find((value) => typeof value === 'string' && value.trim().length > 0)
271
+ if (typeof first === 'string') return summarise(first)
272
+ }
273
+ if (args === entry) {
274
+ for (const key of ARGUMENT_KEYS) {
275
+ const value = entry[key]
276
+ if (typeof value === 'string' && value.trim().length > 0) return summarise(value)
277
+ }
278
+ }
279
+ // Fall back to the entry's own one-line summary, which already contains the
280
+ // arguments but leads with the tool name.
281
+ const text = summarise(entry.text)
282
+ if (text === null) return null
283
+ const name = typeof entry.tool === 'string' ? entry.tool : null
284
+ if (name !== null && text.startsWith(name)) return summarise(text.slice(name.length)) ?? name
285
+ return text
286
+ }
287
+
288
+ /**
289
+ * Fold one record into the activity model.
290
+ *
291
+ * @param {ReturnType<typeof createWorkState>} state
292
+ * @param {any} record
293
+ * @param {number} [now]
294
+ * @returns {{ changed: boolean, finishedTool: object | null }}
295
+ */
296
+ export function reduceWork(state, record, now = Date.now()) {
297
+ if (record === null || typeof record !== 'object') return { changed: false, finishedTool: null }
298
+
299
+ if (record.kind === 'status') {
300
+ const status = record.status ?? 'idle'
301
+ const changed = state.status !== status || state.streamActive !== (record.streamActive === true)
302
+ state.status = status
303
+ state.streamActive = record.streamActive === true
304
+ state.lastEventAt = eventTime(record, now)
305
+ return { changed, finishedTool: null }
306
+ }
307
+
308
+ if (record.kind === 'stream') {
309
+ const reasoning = typeof record.reasoning === 'string' ? record.reasoning : ''
310
+ const text = typeof record.text === 'string' ? record.text : ''
311
+ if (text.length > 0) state.preview = text
312
+ else if (reasoning.length > 0) state.preview = reasoning
313
+ const kind = reasoning.length > 0 ? 'thinking' : text.length > 0 ? 'typing' : state.streamKind
314
+ const changed = state.streamKind !== kind
315
+ state.streamKind = kind
316
+ state.streamActive = true
317
+ state.lastEventAt = eventTime(record, now)
318
+ return { changed, finishedTool: null }
319
+ }
320
+
321
+ if (record.kind === 'stream-end') {
322
+ state.streamActive = false
323
+ state.streamKind = null
324
+ state.lastEventAt = eventTime(record, now)
325
+ return { changed: true, finishedTool: null }
326
+ }
327
+
328
+ if (record.kind !== 'entry') return { changed: false, finishedTool: null }
329
+
330
+ const entry = record.entry
331
+ if (entry === null || entry === undefined) return { changed: false, finishedTool: null }
332
+
333
+ const at = eventTime(record, now)
334
+ const isTool = entry.tag === 'tool' || entry.tag === 'tool-call' || entry.tag === 'tool-result'
335
+ if (!isTool) {
336
+ // A subagent hanging up: its answer arrives as a message from the agent.
337
+ const notice = parseSubagentNotice(entry.text ?? entry.rawText)
338
+ if (notice !== null) {
339
+ const finished = [...state.subagents.values()].find(
340
+ (item) => item.handle === notice.handle && item.status === 'pending'
341
+ )
342
+ if (finished !== undefined) {
343
+ finished.status = 'done'
344
+ finished.result = notice.reply ?? ''
345
+ if (state.activeSubagent === finished.callId) state.activeSubagent = null
346
+ state.lastReply = { callId: finished.callId, index: finished.index, text: finished.result, at }
347
+ state.preview = finished.result
348
+ state.lastEventAt = at
349
+ return { changed: true, finishedTool: null }
350
+ }
351
+ }
352
+ if (typeof entry.rawText === 'string' && entry.rawText.length > 0) state.preview = entry.rawText
353
+ else if (typeof entry.text === 'string' && entry.text.length > 0 && entry.tag === 'assistant') state.preview = entry.text
354
+ state.lastEventAt = at
355
+ return { changed: false, finishedTool: null }
356
+ }
357
+
358
+ const kind = classifyTool(entry.tool ?? entry.name)
359
+ const phase = entry.phase ?? (entry.tag === 'tool-result' ? 'result' : 'call')
360
+
361
+ if (phase === 'call') {
362
+ if (kind === 'subagent') {
363
+ const callId = entry.callId ?? entry.key ?? `subagent-${state.subagentCount + 1}`
364
+ state.subagentCount += 1
365
+ const name = entry.tool ?? entry.name ?? 'task'
366
+ const described = describeCall(entry)
367
+ const record = {
368
+ callId,
369
+ index: state.subagentCount,
370
+ tool: name,
371
+ // With the raw arguments (the current plugin) this is the call exactly
372
+ // as written. Without them it falls back to the one-line summary the
373
+ // entry already carries, so the bubble still shows a call rather than
374
+ // an empty `task()`.
375
+ format: entry.arguments === undefined
376
+ ? (described === null ? `${name}()` : `${name}(${described})`)
377
+ : callFormat(name, entry.arguments),
378
+ input: described,
379
+ status: 'pending',
380
+ startedAt: at,
381
+ result: ''
382
+ }
383
+ state.subagents.set(callId, record)
384
+ state.subagentOrder.push(callId)
385
+ state.activeSubagent = callId
386
+ state.subagent = { index: record.index, input: describeCall(entry), name: record.tool }
387
+ state.preview = record.format
388
+ }
389
+ state.activeTool = {
390
+ callId: entry.callId ?? entry.key ?? null,
391
+ name: entry.tool ?? entry.name ?? 'tool',
392
+ kind,
393
+ at,
394
+ input: describeCall(entry)
395
+ }
396
+ state.preview = kind === 'subagent' ? undefined : (state.activeTool.input ?? state.preview)
397
+ // Shell output resets the silence clock so a chatty command never sleeps.
398
+ if (kind === 'shell') state.lastOutputAt = at
399
+ state.lastEventAt = now
400
+ return { changed: true, finishedTool: null }
401
+ }
402
+
403
+ // A result settles the tool it belongs to; shell output also keeps the
404
+ // orca awake.
405
+ const settled = state.activeTool
406
+ const resultCallId = entry.callId ?? entry.key ?? null
407
+ if (settled !== null && (settled.callId === null || settled.callId === resultCallId)) {
408
+ state.activeTool = null
409
+ }
410
+ // A subagent hanging up: record its reply, which is what the telephone will
411
+ // be ringing about.
412
+ const callId = resultCallId ?? state.activeSubagent
413
+ const finished = callId === null ? undefined : state.subagents.get(callId)
414
+ if (finished !== undefined && finished.status === 'pending') {
415
+ const ack = replyTextOf(entry)
416
+ if (isDispatchAck(ack)) {
417
+ // Handed out, not finished: a background subagent keeps its place in the
418
+ // queue, and its answer arrives later as a message from the agent.
419
+ finished.background = true
420
+ finished.handle = DISPATCH_ACK.exec(ack.trim())[1]
421
+ // The acknowledgement comes back at once, so the tool call is over in
422
+ // milliseconds. Remember when the call was placed: the handset is held
423
+ // for a moment afterwards, or the whole gesture is never seen.
424
+ finished.dispatchedAt = at
425
+ state.lastReply = null
426
+ } else {
427
+ finished.status = 'done'
428
+ finished.result = ack
429
+ if (state.activeSubagent === callId) state.activeSubagent = null
430
+ state.lastReply = { callId, index: finished.index, text: finished.result, at }
431
+ }
432
+ }
433
+ if (kind === 'shell' || settled?.kind === 'shell') {
434
+ state.lastOutputAt = at
435
+ if (settled?.kind === 'shell') state.activeTool = null
436
+ }
437
+ state.lastEventAt = at
438
+ return { changed: true, finishedTool: settled }
439
+ }
440
+
441
+ /**
442
+ * What the little window on the desk should show.
443
+ *
444
+ * The tail of the model's output while it writes, otherwise the call it is
445
+ * running — the same text the telephone bubble uses.
446
+ *
447
+ * @param {ReturnType<typeof createWorkState>} state
448
+ * @returns {string}
449
+ */
450
+ export function previewOf(state) {
451
+ const active = state.activeTool
452
+ if (active !== null && typeof active.input === 'string' && active.input.length > 0) return active.input
453
+ return typeof state.preview === 'string' ? state.preview : ''
454
+ }
455
+
456
+ /**
457
+ * The state to draw right now.
458
+ *
459
+ * @param {ReturnType<typeof createWorkState>} state
460
+ * @param {number} [now]
461
+ * @returns {{ kind: string, subagent: { index: number, input: string | null } | null }}
462
+ */
463
+ export function workStateOf(state, now = Date.now()) {
464
+ // How many subagents are still out. Every state carries it, so a caller can
465
+ // always ask the desk how deep the queue is.
466
+ const out = pendingSubagents(state)
467
+ const view = (kind, subagent, extra = {}) => ({ kind, subagent, waiting: false, pending: out, ...extra })
468
+
469
+ // A subagent calling back rings the telephone. Answering it is the most
470
+ // important thing on the desk, so it outranks everything else.
471
+ const reply = state.lastReply
472
+ if (reply !== null && typeof reply.at === 'number' && now - reply.at < RING_MS) {
473
+ const answered = state.subagents.get(reply.callId)
474
+ if (answered !== undefined) return view('ringing', subagentView(state, answered))
475
+ }
476
+
477
+ const tool = state.activeTool
478
+ if (tool !== null) {
479
+ if (tool.kind === 'subagent') {
480
+ const record = state.activeSubagent === null ? undefined : state.subagents.get(state.activeSubagent)
481
+ const held = record === undefined ? null : subagentView(state, record)
482
+ return view('calling', held ?? state.subagent)
483
+ }
484
+ if (tool.kind === 'search') return view('searching', null)
485
+ if (tool.kind === 'read') return view('reading', null)
486
+ if (tool.kind === 'write') return view('writing', null)
487
+ if (tool.kind === 'shell') {
488
+ // A command that has said nothing for a long time is indistinguishable
489
+ // from no work at all, so the orca falls asleep on it.
490
+ const silentFor = now - Math.max(state.lastOutputAt, tool.at)
491
+ return view(silentFor >= SILENT_MS ? 'sleep' : 'waiting', null)
492
+ }
493
+ return view('typing', null)
494
+ }
495
+
496
+ if (state.streamKind === 'thinking') return view('thinking', null)
497
+ if (state.streamKind === 'typing') return view('typing', null)
498
+
499
+ // Just after handing work out, the handset is still in the orca's flipper.
500
+ // Without this the call is never seen: a background dispatch is acknowledged
501
+ // immediately, so the tool is gone before the handset has finished rising.
502
+ const calling = [...state.subagents.values()].filter(
503
+ (item) => item.status === 'pending' && Number.isFinite(item.dispatchedAt) && now - item.dispatchedAt < CALL_MS
504
+ )
505
+ if (calling.length > 0) {
506
+ const latest = calling.reduce((best, item) => (item.dispatchedAt > best.dispatchedAt ? item : best))
507
+ return view('calling', subagentView(state, latest))
508
+ }
509
+
510
+ // Work handed out and nothing left to do: the orca dozes at the desk until
511
+ // one of the subagents rings back.
512
+ if (out > 0 && (state.status === 'idle' || state.status === 'error')) {
513
+ return view('sleep', null, { waiting: true })
514
+ }
515
+
516
+ switch (state.status) {
517
+ case 'running':
518
+ return view('typing', null)
519
+ case 'tool':
520
+ return view('waiting', null)
521
+ case 'waiting-approval':
522
+ return view('thinking', null)
523
+ case 'error':
524
+ return view('typing', null)
525
+ default:
526
+ return view('sleep', null)
527
+ }
528
+ }