dsh-working-activity 0.2.6 → 0.3.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/src/status.ts CHANGED
@@ -1,501 +1,511 @@
1
- /**
2
- * Pure activity state machine for the working-activity status line. Consumes
3
- * session events (turn/step/tool/stream) plus agent running/idle transitions
4
- * and renders a human-readable status line at any wall-clock instant. No I/O,
5
- * no timers, no cordis — deterministic given the event stream and a clock.
6
- * @module @deepseek-ai/dsh-working-activity/status
7
- */
8
-
9
- import type { SessionEvent } from '@deepseek-ai/dsh-session'
10
- import {
11
- actionFor, fmtDuration, isGitTool, isNight, pickPhrase, thinkingPhrase,
12
- WAITING_PHRASES, DONE_PHRASES, FAIL_PHRASES,
13
- } from './phrases.js'
14
-
15
- /** Public status phases a UI can render. */
16
- export type ActivityPhase = 'idle' | 'waiting' | 'thinking' | 'tool' | 'done'
17
-
18
- /** One snapshot of the model's activity, renderable by any UI. */
19
- export interface ActivityState {
20
- /** Which activity phase the model is in right now. */
21
- readonly phase: ActivityPhase
22
- /** Full human-readable status line (plain text, no ANSI). */
23
- readonly line: string
24
- /** Short label of the current work (tool action or stage), when any. */
25
- readonly label?: string
26
- /** Detail fragment (path / command / search pattern), when any. */
27
- readonly detail?: string
28
- /** The playful phrase currently shown. */
29
- readonly phrase?: string
30
- /** Tools completed in the current turn. */
31
- readonly toolCount: number
32
- /** Wall-clock milliseconds since the current turn started (0 when idle). */
33
- readonly turnElapsedMs: number
34
- /** Wall-clock time the current phase started, for animations. */
35
- readonly phaseStartedAt: number
36
- }
37
-
38
- /** Per-turn thinking/tooling split, exposed for done summaries and stats. */
39
- export interface TurnStats {
40
- /** Milliseconds the model was thinking (between turn start and first tool / turn end). */
41
- readonly thinkingMs: number
42
- /** Milliseconds spent inside tool executions. */
43
- readonly toolMs: number
44
- /** Tools completed in the turn. */
45
- readonly toolCount: number
46
- }
47
-
48
- /** Configuration knobs for the state machine (subset of plugin Config). */
49
- export interface TrackerConfig {
50
- /** Playful copy pool on/off; false renders plain functional labels. */
51
- readonly phrases: boolean
52
- /** Maximum characters of a detail fragment (paths/commands). */
53
- readonly detailLimit: number
54
- /** Hide the status line while idle. */
55
- readonly showIdle: boolean
56
- }
57
-
58
- /** A tool execution in flight. */
59
- interface ActiveTool {
60
- readonly callId: string
61
- readonly name: string
62
- readonly action: string
63
- readonly detail: string
64
- readonly isGit: boolean
65
- readonly startedAt: number
66
- /** Whether the tool failed; set on tool/result while the card lingers. */
67
- failed: boolean
68
- /** tool/result time when settled, else undefined. */
69
- endedAt?: number
70
- }
71
-
72
- /** A tool that finished but keeps its card in the replay queue. */
73
- interface DoneTool {
74
- readonly action: string
75
- readonly detail: string
76
- readonly failed: boolean
77
- readonly endedAt: number
78
- }
79
-
80
- /** Format one tool into its display fragment (`跑个命令 npm test`). */
81
- function toolFragment(tool: { action: string; detail: string }): string {
82
- return tool.detail.length === 0 ? tool.action : `${tool.action} ${tool.detail}`
83
- }
84
-
85
- /** Simple non-ANSI string shortener by grapheme count. */
86
- function shorten(value: string, limit: number): string {
87
- const graphemes = Array.from(value)
88
- if (graphemes.length <= limit) return value
89
- return `${graphemes.slice(0, Math.max(0, limit - 1)).join('')}…`
90
- }
91
-
92
- /**
93
- * Extract a displayable detail fragment from a tool call's parsed arguments.
94
- * @param toolName - Registry tool name.
95
- * @param args - Parsed tool arguments (lossless JSON by registry contract).
96
- */
97
- export function detailFor(toolName: string, args: Readonly<Record<string, unknown>> | undefined, limit: number): string {
98
- if (args === undefined) return ''
99
- const pickString = (...keys: readonly string[]): string => {
100
- for (const key of keys) {
101
- const value = args[key]
102
- if (typeof value === 'string' && value.trim().length > 0) return value.trim()
103
- }
104
- return ''
105
- }
106
- const normalized = toolName.toLowerCase()
107
- if (normalized === 'mcp' || normalized.startsWith('mcp__') || normalized.includes('__')) {
108
- const action = pickString('action', 'tool', 'server', 'connect', 'describe')
109
- return shorten(action, limit)
110
- }
111
- const path = pickString('path', 'file', 'file_path', 'filepath', 'target')
112
- if (path.length > 0) return shorten(path, limit)
113
- const command = pickString('command', 'cmd', 'cmdline')
114
- if (command.length > 0) return shorten(command, limit)
115
- const pattern = pickString('pattern', 'query', 'search')
116
- if (pattern.length > 0) return shorten(pattern, limit)
117
- const url = pickString('url')
118
- if (url.length > 0) return shorten(url, limit)
119
- if (/^(?:subagent|agent|task)$/i.test(toolName)) {
120
- const description = pickString('description')
121
- if (description.length > 0) return shorten(description, limit)
122
- const prompt = pickString('prompt')
123
- if (prompt.length > 0) return shorten(prompt, limit)
124
- }
125
- const named = pickString('name', 'server', 'tool', 'id', 'goal')
126
- if (named.length > 0) return shorten(named, limit)
127
- return ''
128
- }
129
-
130
- /**
131
- * Track one agent's activity from its durable session events. Events from
132
- * other sessions are ignored (the owning plugin feeds only the agent it
133
- * displays). The tracker is deliberately single-agent: multi-session UIs
134
- * instantiate one tracker per agent.
135
- */
136
- export class ActivityTracker {
137
- private phase: ActivityPhase = 'idle'
138
- private phaseStartedAt = 0
139
- private turnStartedAt = 0
140
- private thinkingStartedAt = 0
141
- private thinkingMs = 0
142
- private toolMs = 0
143
- private toolCount = 0
144
- private activeTools = new Map<string, ActiveTool>()
145
- private doneQueue: DoneTool[] = []
146
- private previousPhrase: string | undefined
147
- private phraseChangedAt = 0
148
- private waitingFirstToken = false
149
- /** Latest `⏵` self-narration line extracted from the stream, or null. */
150
- private narratedText: string | null = null
151
- /** Wall-clock time of the most recent stream delta (narration freshness). */
152
- private lastChunkAt = 0
153
- /** Rolling stream buffer (reasoning + text deltas) for `⏵` extraction. */
154
- private recentStream = ''
155
- /** Total tokens reported across the turn's assistant messages. */
156
- private turnTokens = 0
157
- /** Completion prefix drawn ONCE at turn end so the done line stays stable. */
158
- private donePrefix = '搞定 ✓'
159
-
160
- /**
161
- * @param config - Behavioral knobs.
162
- * @param now - Wall-clock supplier (injectable for tests).
163
- * @param customActions - Exact-name custom action pools for {@link actionFor}.
164
- */
165
- constructor(
166
- private readonly config: TrackerConfig,
167
- private readonly now: () => number = Date.now,
168
- private readonly customActions?: Readonly<Record<string, readonly string[]>>,
169
- ) {}
170
-
171
- /** Agent transitioned to running/idle. */
172
- onAgentStatus(status: 'idle' | 'running'): void {
173
- if (status === 'idle') {
174
- // The turn end already moved us to the done phase; idle only clears the
175
- // lingering done card after its display window.
176
- if (this.phase !== 'done') this.phase = 'idle'
177
- return
178
- }
179
- if (this.phase === 'idle') {
180
- this.phase = 'waiting'
181
- this.phaseStartedAt = this.now()
182
- this.waitingFirstToken = true
183
- }
184
- }
185
-
186
- /** Consume one durable session event (turn/step/tool/stream). */
187
- onSessionEvent(event: SessionEvent): void {
188
- switch (event.type) {
189
- case 'turn/start': {
190
- const at = event.time
191
- this.turnStartedAt = at
192
- this.thinkingStartedAt = at
193
- this.thinkingMs = 0
194
- this.toolMs = 0
195
- this.toolCount = 0
196
- this.turnTokens = 0
197
- this.activeTools.clear()
198
- this.doneQueue = []
199
- this.waitingFirstToken = true
200
- this.narratedText = null
201
- this.lastChunkAt = 0
202
- this.recentStream = ''
203
- this.setPhase('waiting', at)
204
- return
205
- }
206
- case 'step/start':
207
- if (this.phase === 'waiting' && !this.waitingFirstToken) {
208
- // A new step without streamed output yet — stay waiting.
209
- }
210
- return
211
- case 'assistant/chunk': {
212
- const chunk = event.data.chunk
213
- this.lastChunkAt = event.time
214
- if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') {
215
- if (this.waitingFirstToken) {
216
- this.waitingFirstToken = false
217
- this.setPhase('thinking', event.time)
218
- this.thinkingStartedAt = event.time
219
- }
220
- this.recentStream = (this.recentStream + chunk.text).slice(-STREAM_BUFFER_CHARS)
221
- const narration = extractNarration(this.recentStream)
222
- if (narration !== null) this.narratedText = narration
223
- }
224
- return
225
- }
226
- case 'assistant/message': {
227
- const usage = event.data.usage
228
- if (usage !== undefined) {
229
- this.turnTokens += usage.inputTokens + usage.outputTokens
230
- + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
231
- }
232
- return
233
- }
234
- case 'tool/call': {
235
- const at = event.time
236
- if (this.phase === 'thinking' || this.phase === 'waiting') {
237
- this.thinkingMs += at - this.thinkingStartedAt
238
- }
239
- const parsed = parseArguments(event.data.arguments)
240
- const action = this.config.phrases ? actionFor(event.data.name, this.customActions) : event.data.name
241
- const detail = detailFor(event.data.name, parsed, this.config.detailLimit)
242
- const active: ActiveTool = {
243
- callId: event.data.callId,
244
- name: event.data.name,
245
- action,
246
- detail,
247
- isGit: isGitTool(event.data.name, parsed),
248
- startedAt: at,
249
- failed: false,
250
- }
251
- this.activeTools.set(event.data.callId, active)
252
- this.setPhase('tool', at)
253
- return
254
- }
255
- case 'tool/result': {
256
- const at = event.time
257
- // `ToolResultMessage.content` is the single-block `[ToolResultBlock]`
258
- // tuple, so `block` is never absent and always a tool-result block.
259
- const block = event.data.message.content[0]
260
- const active = this.activeTools.get(block.toolCallId)
261
- if (active === undefined) return
262
- active.failed = event.data.error !== undefined || block.isError === true
263
- active.endedAt = at
264
- this.toolMs += at - active.startedAt
265
- this.toolCount += 1
266
- this.doneQueue.push({
267
- action: active.action,
268
- detail: active.detail,
269
- failed: active.failed,
270
- endedAt: at,
271
- })
272
- if (this.doneQueue.length > DONE_QUEUE_MAX) this.doneQueue.shift()
273
- this.activeTools.delete(block.toolCallId)
274
- if (this.activeTools.size === 0) {
275
- // Back to thinking (or a trailing done card if the turn just closed).
276
- this.setPhase('thinking', at)
277
- this.thinkingStartedAt = at
278
- }
279
- return
280
- }
281
- case 'turn/end': {
282
- const at = event.time
283
- if (this.activeTools.size > 0) {
284
- // Tools still running at turn end: count their elapsed time as tool time.
285
- for (const tool of this.activeTools.values()) {
286
- this.toolMs += Math.max(0, at - tool.startedAt)
287
- }
288
- this.activeTools.clear()
289
- } else if (this.phase === 'thinking' || this.phase === 'waiting') {
290
- this.thinkingMs += Math.max(0, at - this.thinkingStartedAt)
291
- }
292
- // Draw the completion prefix ONCE so repeated renders of the done line
293
- // stay stable (a fresh random per render would make it flicker).
294
- const lastTool = this.doneQueue.at(-1)
295
- if (this.config.phrases) {
296
- this.donePrefix = lastTool?.failed ? pickPhrase(FAIL_PHRASES) : pickPhrase(DONE_PHRASES)
297
- } else {
298
- this.donePrefix = '搞定 ✓'
299
- }
300
- this.setPhase('done', at)
301
- return
302
- }
303
- default:
304
- return
305
- }
306
- }
307
-
308
- /** Render the current status snapshot at a wall-clock instant. */
309
- render(nowMs: number = this.now()): ActivityState {
310
- switch (this.phase) {
311
- case 'idle':
312
- return {
313
- phase: 'idle',
314
- line: '',
315
- toolCount: 0,
316
- turnElapsedMs: 0,
317
- phaseStartedAt: this.phaseStartedAt,
318
- }
319
- case 'done': {
320
- const summary = this.doneSummary(nowMs)
321
- return {
322
- phase: 'done',
323
- line: summary.line,
324
- toolCount: this.toolCount,
325
- turnElapsedMs: this.turnElapsedMs(nowMs),
326
- phaseStartedAt: this.phaseStartedAt,
327
- ...(summary.phrase === undefined ? {} : { phrase: summary.phrase }),
328
- }
329
- }
330
- case 'tool': {
331
- const tool = this.primaryTool()
332
- if (tool === undefined) {
333
- return this.renderThinking(nowMs)
334
- }
335
- const fragment = toolFragment(tool)
336
- const elapsed = fmtDuration(Math.max(0, nowMs - tool.startedAt))
337
- const git = tool.isGit ? ' · git' : ''
338
- const narration = this.freshNarration(nowMs)
339
- const line = narration === null
340
- ? `${fragment} · ${elapsed}${git}`
341
- : `⏵ ${narration} · ${fragment} · ${elapsed}${git}`
342
- return {
343
- phase: 'tool',
344
- line,
345
- label: tool.action,
346
- detail: tool.detail,
347
- ...(narration === null ? {} : { phrase: narration }),
348
- toolCount: this.toolCount,
349
- turnElapsedMs: this.turnElapsedMs(nowMs),
350
- phaseStartedAt: this.phaseStartedAt,
351
- }
352
- }
353
- case 'waiting':
354
- case 'thinking': {
355
- const rendered = this.renderThinking(nowMs)
356
- if (this.phase === 'waiting') {
357
- return { ...rendered, phase: 'waiting' }
358
- }
359
- return rendered
360
- }
361
- }
362
- }
363
-
364
- /** Per-turn thinking/tooling split for stats consumers. */
365
- stats(): TurnStats {
366
- return {
367
- thinkingMs: this.thinkingMs,
368
- toolMs: this.toolMs,
369
- toolCount: this.toolCount,
370
- }
371
- }
372
-
373
- private renderThinking(nowMs: number): ActivityState {
374
- const thinkingMs = this.phase === 'waiting'
375
- ? 0
376
- : this.thinkingMs + Math.max(0, nowMs - this.thinkingStartedAt)
377
- const elapsed = fmtDuration(this.turnElapsedMs(nowMs))
378
- const narration = this.freshNarration(nowMs)
379
- if (narration !== null) {
380
- return {
381
- phase: this.phase,
382
- line: `⏵ ${narration} · 总${elapsed}`,
383
- phrase: narration,
384
- toolCount: this.toolCount,
385
- turnElapsedMs: this.turnElapsedMs(nowMs),
386
- phaseStartedAt: this.phaseStartedAt,
387
- }
388
- }
389
- if (this.config.phrases) {
390
- if (nowMs - this.phraseChangedAt >= PHRASE_ROTATE_MS) {
391
- // Waiting (pre-first-token) draws from the waiting pool; thinking
392
- // rotates the playful copy pool with night mixing.
393
- this.previousPhrase = this.phase === 'waiting'
394
- ? pickPhrase(WAITING_PHRASES, this.previousPhrase)
395
- : thinkingPhrase(thinkingMs, this.previousPhrase, isNight(new Date(nowMs).getHours()))
396
- this.phraseChangedAt = nowMs
397
- }
398
- const phrase = this.previousPhrase ?? (this.phase === 'waiting'
399
- ? pickPhrase(WAITING_PHRASES)
400
- : thinkingPhrase(thinkingMs, undefined, isNight(new Date(nowMs).getHours())))
401
- return {
402
- phase: this.phase,
403
- line: `${phrase} · 总${elapsed}`,
404
- phrase,
405
- toolCount: this.toolCount,
406
- turnElapsedMs: this.turnElapsedMs(nowMs),
407
- phaseStartedAt: this.phaseStartedAt,
408
- }
409
- }
410
- const label = this.phase === 'waiting' ? '等待模型响应' : '思考中'
411
- return {
412
- phase: this.phase,
413
- line: `${label} · 总${elapsed}`,
414
- label,
415
- toolCount: this.toolCount,
416
- turnElapsedMs: this.turnElapsedMs(nowMs),
417
- phaseStartedAt: this.phaseStartedAt,
418
- }
419
- }
420
-
421
- private doneSummary(nowMs: number): { line: string; phrase?: string } {
422
- const { thinkingMs, toolMs, toolCount } = this.stats()
423
- const tokens = this.turnTokens > 0 ? ` · 🔥 ${fmtTokens(this.turnTokens)}` : ''
424
- const base = `${this.donePrefix} · ${toolCount} 工具 · 想${fmtDuration(thinkingMs)} 干${fmtDuration(toolMs)}${tokens}`
425
- if (!this.config.phrases) {
426
- return { line: `搞定 · ${toolCount} 工具 · 想${fmtDuration(thinkingMs)} 干${fmtDuration(toolMs)}${tokens}` }
427
- }
428
- const last = this.doneQueue.at(-1)
429
- if (last !== undefined && nowMs - last.endedAt < DONE_FRAGMENT_MS) {
430
- const fragment = toolFragment(last)
431
- return { line: `${this.donePrefix} · ${fragment} · ${toolCount} 工具${tokens}`, phrase: this.donePrefix }
432
- }
433
- return { line: base, ...(this.donePrefix === '搞定 ✓' ? {} : { phrase: this.donePrefix }) }
434
- }
435
-
436
- /** The fresh self-narration line, or null once the stream has been quiet. */
437
- private freshNarration(nowMs: number): string | null {
438
- if (this.narratedText === null) return null
439
- if (nowMs - this.lastChunkAt > NARRATE_GRACE_MS) return null
440
- return this.narratedText
441
- }
442
-
443
- private primaryTool(): ActiveTool | undefined {
444
- let primary: ActiveTool | undefined
445
- for (const tool of this.activeTools.values()) {
446
- if (primary === undefined || tool.startedAt < primary.startedAt) primary = tool
447
- }
448
- return primary
449
- }
450
-
451
- private turnElapsedMs(nowMs: number): number {
452
- return this.turnStartedAt === 0 ? 0 : Math.max(0, nowMs - this.turnStartedAt)
453
- }
454
-
455
- private setPhase(phase: ActivityPhase, atMs: number): void {
456
- this.phase = phase
457
- this.phaseStartedAt = atMs
458
- }
459
- }
460
-
461
- /** Rotate the thinking phrase every N render ticks (render cadence 500ms → ~4s). */
462
- const PHRASE_ROTATE_MS = 4000
463
- /** Cap on replayed done cards; older entries drop. */
464
- const DONE_QUEUE_MAX = 6
465
- /** Show the last tool's fragment in the done line for this long after it ends. */
466
- const DONE_FRAGMENT_MS = 3000
467
- /** Rolling stream buffer size for `⏵` narration extraction. */
468
- const STREAM_BUFFER_CHARS = 300
469
- /** A narration stays visible this long after the stream went quiet. */
470
- const NARRATE_GRACE_MS = 5000
471
-
472
- /** Extract the latest `⏵` self-narration line from a stream buffer. */
473
- export function extractNarration(buffer: string): string | null {
474
- const matches = [...buffer.matchAll(/⏵\s*([^\n⏵]{1,40})/g)]
475
- if (matches.length === 0) return null
476
- const latest = matches[matches.length - 1]?.[1]
477
- if (latest === undefined) return null
478
- const text = latest.replace(/[。..!!,,、;;]+$/, '').trim()
479
- return text.length === 0 ? null : text
480
- }
481
-
482
- /** Format a token count compactly (`12.3k`, `1.2M`). */
483
- function fmtTokens(tokens: number): string {
484
- if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
485
- if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`
486
- return String(tokens)
487
- }
488
-
489
- /** Parse a tool call's raw arguments JSON defensively. */
490
- function parseArguments(raw: string): Readonly<Record<string, unknown>> | undefined {
491
- if (raw.trim().length === 0) return undefined
492
- try {
493
- const parsed: unknown = JSON.parse(raw)
494
- if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
495
- return parsed as Readonly<Record<string, unknown>>
496
- }
497
- return undefined
498
- } catch {
499
- return undefined
500
- }
501
- }
1
+ /**
2
+ * Pure activity state machine for the working-activity status line. Consumes
3
+ * session events (turn/step/tool/stream) plus agent running/idle transitions
4
+ * and renders a human-readable status line at any wall-clock instant. No I/O,
5
+ * no timers, no cordis — deterministic given the event stream and a clock.
6
+ * @module @deepseek-ai/dsh-working-activity/status
7
+ */
8
+
9
+ import type { SessionEvent } from '@deepseek-ai/dsh-session'
10
+ import {
11
+ actionFor, donePhrase, failPhrase, fmtDuration, isGitTool, isNight, thinkingPhrase,
12
+ waitingPhrase,
13
+ } from './phrases.js'
14
+ import { t } from './lang.js'
15
+
16
+ /** Public status phases a UI can render. */
17
+ export type ActivityPhase = 'idle' | 'waiting' | 'thinking' | 'tool' | 'done'
18
+
19
+ /** One snapshot of the model's activity, renderable by any UI. */
20
+ export interface ActivityState {
21
+ /** Which activity phase the model is in right now. */
22
+ readonly phase: ActivityPhase
23
+ /** Full human-readable status line (plain text, no ANSI). */
24
+ readonly line: string
25
+ /** Short label of the current work (tool action or stage), when any. */
26
+ readonly label?: string
27
+ /** Detail fragment (path / command / search pattern), when any. */
28
+ readonly detail?: string
29
+ /** The playful phrase currently shown. */
30
+ readonly phrase?: string
31
+ /** Tools completed in the current turn. */
32
+ readonly toolCount: number
33
+ /** Wall-clock milliseconds since the current turn started (0 when idle). */
34
+ readonly turnElapsedMs: number
35
+ /** Wall-clock time the current phase started, for animations. */
36
+ readonly phaseStartedAt: number
37
+ }
38
+
39
+ /** Per-turn thinking/tooling split, exposed for done summaries and stats. */
40
+ export interface TurnStats {
41
+ /** Milliseconds the model was thinking (between turn start and first tool / turn end). */
42
+ readonly thinkingMs: number
43
+ /** Milliseconds spent inside tool executions. */
44
+ readonly toolMs: number
45
+ /** Tools completed in the turn. */
46
+ readonly toolCount: number
47
+ }
48
+
49
+ /** Configuration knobs for the state machine (subset of plugin Config). */
50
+ export interface TrackerConfig {
51
+ /** Playful copy pool on/off; false renders plain functional labels. */
52
+ readonly phrases: boolean
53
+ /** Maximum characters of a detail fragment (paths/commands). */
54
+ readonly detailLimit: number
55
+ /** Hide the status line while idle. */
56
+ readonly showIdle: boolean
57
+ }
58
+
59
+ /** A tool execution in flight. */
60
+ interface ActiveTool {
61
+ readonly callId: string
62
+ readonly name: string
63
+ readonly action: string
64
+ readonly detail: string
65
+ readonly isGit: boolean
66
+ readonly startedAt: number
67
+ /** Whether the tool failed; set on tool/result while the card lingers. */
68
+ failed: boolean
69
+ /** tool/result time when settled, else undefined. */
70
+ endedAt?: number
71
+ }
72
+
73
+ /** A tool that finished but keeps its card in the replay queue. */
74
+ interface DoneTool {
75
+ readonly action: string
76
+ readonly detail: string
77
+ readonly failed: boolean
78
+ readonly endedAt: number
79
+ }
80
+
81
+ /** Format one tool into its display fragment (`跑个命令 npm test`). */
82
+ function toolFragment(tool: { action: string; detail: string }): string {
83
+ return tool.detail.length === 0 ? tool.action : `${tool.action} ${tool.detail}`
84
+ }
85
+
86
+ /** Simple non-ANSI string shortener by grapheme count. */
87
+ function shorten(value: string, limit: number): string {
88
+ const graphemes = Array.from(value)
89
+ if (graphemes.length <= limit) return value
90
+ return `${graphemes.slice(0, Math.max(0, limit - 1)).join('')}…`
91
+ }
92
+
93
+ /**
94
+ * Extract a displayable detail fragment from a tool call's parsed arguments.
95
+ * @param toolName - Registry tool name.
96
+ * @param args - Parsed tool arguments (lossless JSON by registry contract).
97
+ */
98
+ export function detailFor(toolName: string, args: Readonly<Record<string, unknown>> | undefined, limit: number): string {
99
+ if (args === undefined) return ''
100
+ const pickString = (...keys: readonly string[]): string => {
101
+ for (const key of keys) {
102
+ const value = args[key]
103
+ if (typeof value === 'string' && value.trim().length > 0) return value.trim()
104
+ }
105
+ return ''
106
+ }
107
+ const normalized = toolName.toLowerCase()
108
+ if (normalized === 'mcp' || normalized.startsWith('mcp__') || normalized.includes('__')) {
109
+ const action = pickString('action', 'tool', 'server', 'connect', 'describe')
110
+ return shorten(action, limit)
111
+ }
112
+ const path = pickString('path', 'file', 'file_path', 'filepath', 'target')
113
+ if (path.length > 0) return shorten(path, limit)
114
+ const command = pickString('command', 'cmd', 'cmdline')
115
+ if (command.length > 0) return shorten(command, limit)
116
+ const pattern = pickString('pattern', 'query', 'search')
117
+ if (pattern.length > 0) return shorten(pattern, limit)
118
+ const url = pickString('url')
119
+ if (url.length > 0) return shorten(url, limit)
120
+ if (/^(?:subagent|agent|task)$/i.test(toolName)) {
121
+ const description = pickString('description')
122
+ if (description.length > 0) return shorten(description, limit)
123
+ const prompt = pickString('prompt')
124
+ if (prompt.length > 0) return shorten(prompt, limit)
125
+ }
126
+ const named = pickString('name', 'server', 'tool', 'id', 'goal')
127
+ if (named.length > 0) return shorten(named, limit)
128
+ return ''
129
+ }
130
+
131
+ /**
132
+ * Track one agent's activity from its durable session events. Events from
133
+ * other sessions are ignored (the owning plugin feeds only the agent it
134
+ * displays). The tracker is deliberately single-agent: multi-session UIs
135
+ * instantiate one tracker per agent.
136
+ */
137
+ export class ActivityTracker {
138
+ private phase: ActivityPhase = 'idle'
139
+ private phaseStartedAt = 0
140
+ private turnStartedAt = 0
141
+ private thinkingStartedAt = 0
142
+ private thinkingMs = 0
143
+ private toolMs = 0
144
+ private toolCount = 0
145
+ private activeTools = new Map<string, ActiveTool>()
146
+ private doneQueue: DoneTool[] = []
147
+ private previousPhrase: string | undefined
148
+ private phraseChangedAt = 0
149
+ private waitingFirstToken = false
150
+ /** Latest `⏵` self-narration line extracted from the stream, or null. */
151
+ private narratedText: string | null = null
152
+ /** Wall-clock time of the most recent stream delta (narration freshness). */
153
+ private lastChunkAt = 0
154
+ /** Rolling stream buffer (reasoning + text deltas) for `⏵` extraction. */
155
+ private recentStream = ''
156
+ /** Total tokens reported across the turn's assistant messages. */
157
+ private turnTokens = 0
158
+ /** Completion prefix drawn ONCE at turn end so the done line stays stable. */
159
+ private donePrefix = t('done-prefix')
160
+
161
+ /**
162
+ * @param config - Behavioral knobs.
163
+ * @param now - Wall-clock supplier (injectable for tests).
164
+ * @param customActions - Exact-name custom action pools for {@link actionFor}.
165
+ */
166
+ constructor(
167
+ private readonly config: TrackerConfig,
168
+ private readonly now: () => number = Date.now,
169
+ private readonly customActions?: Readonly<Record<string, readonly string[]>>,
170
+ ) {}
171
+
172
+ /** Agent transitioned to running/idle. */
173
+ onAgentStatus(status: 'idle' | 'running'): void {
174
+ if (status === 'idle') {
175
+ // The turn end already moved us to the done phase; idle only clears the
176
+ // lingering done card after its display window.
177
+ if (this.phase !== 'done') this.phase = 'idle'
178
+ return
179
+ }
180
+ if (this.phase === 'idle') {
181
+ this.phase = 'waiting'
182
+ this.phaseStartedAt = this.now()
183
+ this.waitingFirstToken = true
184
+ }
185
+ }
186
+
187
+ /** Consume one durable session event (turn/step/tool/stream). */
188
+ onSessionEvent(event: SessionEvent): void {
189
+ switch (event.type) {
190
+ case 'turn/start': {
191
+ const at = event.time
192
+ this.turnStartedAt = at
193
+ this.thinkingStartedAt = at
194
+ this.thinkingMs = 0
195
+ this.toolMs = 0
196
+ this.toolCount = 0
197
+ this.turnTokens = 0
198
+ this.activeTools.clear()
199
+ this.doneQueue = []
200
+ this.waitingFirstToken = true
201
+ this.narratedText = null
202
+ this.lastChunkAt = 0
203
+ this.recentStream = ''
204
+ this.setPhase('waiting', at)
205
+ return
206
+ }
207
+ case 'step/start':
208
+ if (this.phase === 'waiting' && !this.waitingFirstToken) {
209
+ // A new step without streamed output yet — stay waiting.
210
+ }
211
+ return
212
+ case 'assistant/chunk': {
213
+ const chunk = event.data.chunk
214
+ this.lastChunkAt = event.time
215
+ if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') {
216
+ if (this.waitingFirstToken) {
217
+ this.waitingFirstToken = false
218
+ this.setPhase('thinking', event.time)
219
+ this.thinkingStartedAt = event.time
220
+ }
221
+ this.recentStream = (this.recentStream + chunk.text).slice(-STREAM_BUFFER_CHARS)
222
+ const narration = extractNarration(this.recentStream)
223
+ if (narration !== null) this.narratedText = narration
224
+ }
225
+ return
226
+ }
227
+ case 'assistant/message': {
228
+ const usage = event.data.usage
229
+ if (usage !== undefined) {
230
+ this.turnTokens += usage.inputTokens + usage.outputTokens
231
+ + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
232
+ }
233
+ return
234
+ }
235
+ case 'tool/call': {
236
+ const at = event.time
237
+ if (this.phase === 'thinking' || this.phase === 'waiting') {
238
+ this.thinkingMs += at - this.thinkingStartedAt
239
+ }
240
+ const parsed = parseArguments(event.data.arguments)
241
+ const action = this.config.phrases ? actionFor(event.data.name, this.customActions) : event.data.name
242
+ const detail = detailFor(event.data.name, parsed, this.config.detailLimit)
243
+ const active: ActiveTool = {
244
+ callId: event.data.callId,
245
+ name: event.data.name,
246
+ action,
247
+ detail,
248
+ isGit: isGitTool(event.data.name, parsed),
249
+ startedAt: at,
250
+ failed: false,
251
+ }
252
+ this.activeTools.set(event.data.callId, active)
253
+ this.setPhase('tool', at)
254
+ return
255
+ }
256
+ case 'tool/result': {
257
+ const at = event.time
258
+ // `ToolResultMessage.content` is the single-block `[ToolResultBlock]`
259
+ // tuple, so `block` is never absent and always a tool-result block.
260
+ const block = event.data.message.content[0]
261
+ const active = this.activeTools.get(block.toolCallId)
262
+ if (active === undefined) return
263
+ active.failed = event.data.error !== undefined || block.isError === true
264
+ active.endedAt = at
265
+ this.toolMs += at - active.startedAt
266
+ this.toolCount += 1
267
+ this.doneQueue.push({
268
+ action: active.action,
269
+ detail: active.detail,
270
+ failed: active.failed,
271
+ endedAt: at,
272
+ })
273
+ if (this.doneQueue.length > DONE_QUEUE_MAX) this.doneQueue.shift()
274
+ this.activeTools.delete(block.toolCallId)
275
+ if (this.activeTools.size === 0) {
276
+ // Back to thinking (or a trailing done card if the turn just closed).
277
+ this.setPhase('thinking', at)
278
+ this.thinkingStartedAt = at
279
+ }
280
+ return
281
+ }
282
+ case 'turn/end': {
283
+ const at = event.time
284
+ if (this.activeTools.size > 0) {
285
+ // Tools still running at turn end: count their elapsed time as tool time.
286
+ for (const tool of this.activeTools.values()) {
287
+ this.toolMs += Math.max(0, at - tool.startedAt)
288
+ }
289
+ this.activeTools.clear()
290
+ } else if (this.phase === 'thinking' || this.phase === 'waiting') {
291
+ this.thinkingMs += Math.max(0, at - this.thinkingStartedAt)
292
+ }
293
+ // Draw the completion prefix ONCE so repeated renders of the done line
294
+ // stay stable (a fresh random per render would make it flicker). The
295
+ // pools are language-aware, so the line matches the language the turn
296
+ // ended in.
297
+ const lastTool = this.doneQueue.at(-1)
298
+ if (this.config.phrases) {
299
+ this.donePrefix = lastTool?.failed ? failPhrase() : donePhrase()
300
+ } else {
301
+ this.donePrefix = t('done-prefix')
302
+ }
303
+ this.setPhase('done', at)
304
+ return
305
+ }
306
+ default:
307
+ return
308
+ }
309
+ }
310
+
311
+ /** Render the current status snapshot at a wall-clock instant. */
312
+ render(nowMs: number = this.now()): ActivityState {
313
+ switch (this.phase) {
314
+ case 'idle':
315
+ return {
316
+ phase: 'idle',
317
+ line: '',
318
+ toolCount: 0,
319
+ turnElapsedMs: 0,
320
+ phaseStartedAt: this.phaseStartedAt,
321
+ }
322
+ case 'done': {
323
+ const summary = this.doneSummary(nowMs)
324
+ return {
325
+ phase: 'done',
326
+ line: summary.line,
327
+ toolCount: this.toolCount,
328
+ turnElapsedMs: this.turnElapsedMs(nowMs),
329
+ phaseStartedAt: this.phaseStartedAt,
330
+ ...(summary.phrase === undefined ? {} : { phrase: summary.phrase }),
331
+ }
332
+ }
333
+ case 'tool': {
334
+ const tool = this.primaryTool()
335
+ if (tool === undefined) {
336
+ return this.renderThinking(nowMs)
337
+ }
338
+ const fragment = toolFragment(tool)
339
+ const elapsed = fmtDuration(Math.max(0, nowMs - tool.startedAt))
340
+ const git = tool.isGit ? ' · git' : ''
341
+ const narration = this.freshNarration(nowMs)
342
+ const line = narration === null
343
+ ? `${fragment} · ${elapsed}${git}`
344
+ : `⏵ ${narration} · ${fragment} · ${elapsed}${git}`
345
+ return {
346
+ phase: 'tool',
347
+ line,
348
+ label: tool.action,
349
+ detail: tool.detail,
350
+ ...(narration === null ? {} : { phrase: narration }),
351
+ toolCount: this.toolCount,
352
+ turnElapsedMs: this.turnElapsedMs(nowMs),
353
+ phaseStartedAt: this.phaseStartedAt,
354
+ }
355
+ }
356
+ case 'waiting':
357
+ case 'thinking': {
358
+ const rendered = this.renderThinking(nowMs)
359
+ if (this.phase === 'waiting') {
360
+ return { ...rendered, phase: 'waiting' }
361
+ }
362
+ return rendered
363
+ }
364
+ }
365
+ }
366
+
367
+ /** Per-turn thinking/tooling split for stats consumers. */
368
+ stats(): TurnStats {
369
+ return {
370
+ thinkingMs: this.thinkingMs,
371
+ toolMs: this.toolMs,
372
+ toolCount: this.toolCount,
373
+ }
374
+ }
375
+
376
+ private renderThinking(nowMs: number): ActivityState {
377
+ const thinkingMs = this.phase === 'waiting'
378
+ ? 0
379
+ : this.thinkingMs + Math.max(0, nowMs - this.thinkingStartedAt)
380
+ const elapsed = fmtDuration(this.turnElapsedMs(nowMs))
381
+ const elapsedLine = t('line-elapsed', { elapsed })
382
+ const narration = this.freshNarration(nowMs)
383
+ if (narration !== null) {
384
+ return {
385
+ phase: this.phase,
386
+ line: `⏵ ${narration} · ${elapsedLine}`,
387
+ phrase: narration,
388
+ toolCount: this.toolCount,
389
+ turnElapsedMs: this.turnElapsedMs(nowMs),
390
+ phaseStartedAt: this.phaseStartedAt,
391
+ }
392
+ }
393
+ if (this.config.phrases) {
394
+ if (nowMs - this.phraseChangedAt >= PHRASE_ROTATE_MS) {
395
+ // Waiting (pre-first-token) draws from the waiting pool; thinking
396
+ // rotates the playful copy pool with night mixing. Both pools are
397
+ // language-aware, so a `/lang` switch shows on the next rotation.
398
+ this.previousPhrase = this.phase === 'waiting'
399
+ ? waitingPhrase(this.previousPhrase)
400
+ : thinkingPhrase(thinkingMs, this.previousPhrase, isNight(new Date(nowMs).getHours()))
401
+ this.phraseChangedAt = nowMs
402
+ }
403
+ const phrase = this.previousPhrase ?? (this.phase === 'waiting'
404
+ ? waitingPhrase()
405
+ : thinkingPhrase(thinkingMs, undefined, isNight(new Date(nowMs).getHours())))
406
+ return {
407
+ phase: this.phase,
408
+ line: `${phrase} · ${elapsedLine}`,
409
+ phrase,
410
+ toolCount: this.toolCount,
411
+ turnElapsedMs: this.turnElapsedMs(nowMs),
412
+ phaseStartedAt: this.phaseStartedAt,
413
+ }
414
+ }
415
+ const label = this.phase === 'waiting' ? t('waiting-label') : t('thinking-label')
416
+ return {
417
+ phase: this.phase,
418
+ line: `${label} · ${elapsedLine}`,
419
+ label,
420
+ toolCount: this.toolCount,
421
+ turnElapsedMs: this.turnElapsedMs(nowMs),
422
+ phaseStartedAt: this.phaseStartedAt,
423
+ }
424
+ }
425
+
426
+ private doneSummary(nowMs: number): { line: string; phrase?: string } {
427
+ const { thinkingMs, toolMs, toolCount } = this.stats()
428
+ const tokens = this.turnTokens > 0 ? ` · 🔥 ${fmtTokens(this.turnTokens)}` : ''
429
+ const tools = t(toolCount === 1 ? 'tool-count-one' : 'tool-count-many', { count: toolCount })
430
+ const summary = t('done-summary', {
431
+ tools,
432
+ thinking: fmtDuration(thinkingMs),
433
+ tooling: fmtDuration(toolMs),
434
+ })
435
+ if (!this.config.phrases) {
436
+ return { line: `${t('done-prefix')} · ${summary}${tokens}` }
437
+ }
438
+ const last = this.doneQueue.at(-1)
439
+ if (last !== undefined && nowMs - last.endedAt < DONE_FRAGMENT_MS) {
440
+ const fragment = toolFragment(last)
441
+ return { line: `${this.donePrefix} · ${fragment} · ${tools}${tokens}`, phrase: this.donePrefix }
442
+ }
443
+ return { line: `${this.donePrefix} · ${summary}${tokens}`, phrase: this.donePrefix }
444
+ }
445
+
446
+ /** The fresh self-narration line, or null once the stream has been quiet. */
447
+ private freshNarration(nowMs: number): string | null {
448
+ if (this.narratedText === null) return null
449
+ if (nowMs - this.lastChunkAt > NARRATE_GRACE_MS) return null
450
+ return this.narratedText
451
+ }
452
+
453
+ private primaryTool(): ActiveTool | undefined {
454
+ let primary: ActiveTool | undefined
455
+ for (const tool of this.activeTools.values()) {
456
+ if (primary === undefined || tool.startedAt < primary.startedAt) primary = tool
457
+ }
458
+ return primary
459
+ }
460
+
461
+ private turnElapsedMs(nowMs: number): number {
462
+ return this.turnStartedAt === 0 ? 0 : Math.max(0, nowMs - this.turnStartedAt)
463
+ }
464
+
465
+ private setPhase(phase: ActivityPhase, atMs: number): void {
466
+ this.phase = phase
467
+ this.phaseStartedAt = atMs
468
+ }
469
+ }
470
+
471
+ /** Rotate the thinking phrase every N render ticks (render cadence ≈ 500ms → ~4s). */
472
+ const PHRASE_ROTATE_MS = 4000
473
+ /** Cap on replayed done cards; older entries drop. */
474
+ const DONE_QUEUE_MAX = 6
475
+ /** Show the last tool's fragment in the done line for this long after it ends. */
476
+ const DONE_FRAGMENT_MS = 3000
477
+ /** Rolling stream buffer size for `⏵` narration extraction. */
478
+ const STREAM_BUFFER_CHARS = 300
479
+ /** A narration stays visible this long after the stream went quiet. */
480
+ const NARRATE_GRACE_MS = 5000
481
+
482
+ /** Extract the latest `⏵` self-narration line from a stream buffer. */
483
+ export function extractNarration(buffer: string): string | null {
484
+ const matches = [...buffer.matchAll(/⏵\s*([^\n⏵]{1,40})/g)]
485
+ if (matches.length === 0) return null
486
+ const latest = matches[matches.length - 1]?.[1]
487
+ if (latest === undefined) return null
488
+ const text = latest.replace(/[。..!!,,、;;]+$/, '').trim()
489
+ return text.length === 0 ? null : text
490
+ }
491
+
492
+ /** Format a token count compactly (`12.3k`, `1.2M`). */
493
+ function fmtTokens(tokens: number): string {
494
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
495
+ if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`
496
+ return String(tokens)
497
+ }
498
+
499
+ /** Parse a tool call's raw arguments JSON defensively. */
500
+ function parseArguments(raw: string): Readonly<Record<string, unknown>> | undefined {
501
+ if (raw.trim().length === 0) return undefined
502
+ try {
503
+ const parsed: unknown = JSON.parse(raw)
504
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
505
+ return parsed as Readonly<Record<string, unknown>>
506
+ }
507
+ return undefined
508
+ } catch {
509
+ return undefined
510
+ }
511
+ }