dsh-working-activity 0.2.6 → 0.3.2

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,706 @@
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, compactPhrase, continuePhrase, donePhrase, failPhrase, fmtDuration,
12
+ holidayPhrase, isGitTool, isNight, isWeekend, modelQuip, overflowPhrase, rarePhrase,
13
+ RARE_CHANCE, RARE_PHRASES, EN_RARE_PHRASES, thinkingPhrase, weekendPhrase, waitingPhrase,
14
+ } from './phrases.js'
15
+ import { t } from './lang.js'
16
+
17
+ /** Public status phases a UI can render. */
18
+ export type ActivityPhase = 'idle' | 'waiting' | 'thinking' | 'tool' | 'done'
19
+
20
+ /** One snapshot of the model's activity, renderable by any UI. */
21
+ export interface ActivityState {
22
+ /** Which activity phase the model is in right now. */
23
+ readonly phase: ActivityPhase
24
+ /** Full human-readable status line (plain text, no ANSI). */
25
+ readonly line: string
26
+ /** Short label of the current work (tool action or stage), when any. */
27
+ readonly label?: string
28
+ /** Detail fragment (path / command / search pattern), when any. */
29
+ readonly detail?: string
30
+ /** The playful phrase currently shown. */
31
+ readonly phrase?: string
32
+ /** Tools completed in the current turn. */
33
+ readonly toolCount: number
34
+ /** Wall-clock milliseconds since the current turn started (0 when idle). */
35
+ readonly turnElapsedMs: number
36
+ /** Wall-clock time the current phase started, for animations. */
37
+ readonly phaseStartedAt: number
38
+ }
39
+
40
+ /** Per-turn thinking/tooling split, exposed for done summaries and stats. */
41
+ export interface TurnStats {
42
+ /** Milliseconds the model was thinking (between turn start and first tool / turn end). */
43
+ readonly thinkingMs: number
44
+ /** Milliseconds spent inside tool executions. */
45
+ readonly toolMs: number
46
+ /** Tools completed in the turn. */
47
+ readonly toolCount: number
48
+ }
49
+
50
+ /** Easter-egg toggles for the thinking phrase (pi extension parity). */
51
+ export interface TrackerFeatures {
52
+ /** Rare 1/150 easter eggs. */
53
+ readonly rareEggs?: boolean
54
+ /** Weekend greetings on Sat/Sun. */
55
+ readonly weekend?: boolean
56
+ /** Date-matched holiday / Lunar New Year copy. */
57
+ readonly holidays?: boolean
58
+ /** Night-owl copy between 00:00 and 06:00. */
59
+ readonly nightPhrases?: boolean
60
+ }
61
+
62
+ /** Configuration knobs for the state machine (subset of plugin Config). */
63
+ export interface TrackerConfig {
64
+ /** Playful copy pool on/off; false renders plain functional labels. */
65
+ readonly phrases: boolean
66
+ /** Maximum characters of a detail fragment (paths/commands). */
67
+ readonly detailLimit: number
68
+ /** Hide the status line while idle. */
69
+ readonly showIdle: boolean
70
+ /** Easter-egg toggles; absent flags default to on. */
71
+ readonly features?: TrackerFeatures
72
+ /** User custom phrases appended to the base thinking pool. */
73
+ readonly customPhrases?: readonly string[]
74
+ /** Show an estimated tokens/s prefix while streaming (pi parity). */
75
+ readonly showTokPerSec?: boolean
76
+ /** Work reminder after this many turn-hours (0 = off). */
77
+ readonly workRemindAt?: number
78
+ }
79
+
80
+ /** A tool execution in flight. */
81
+ interface ActiveTool {
82
+ readonly callId: string
83
+ readonly name: string
84
+ readonly action: string
85
+ readonly detail: string
86
+ readonly isGit: boolean
87
+ readonly startedAt: number
88
+ /** Whether the tool failed; set on tool/result while the card lingers. */
89
+ failed: boolean
90
+ /** tool/result time when settled, else undefined. */
91
+ endedAt?: number
92
+ }
93
+
94
+ /** A tool that finished but keeps its card in the replay queue. */
95
+ interface DoneTool {
96
+ readonly action: string
97
+ readonly detail: string
98
+ readonly failed: boolean
99
+ readonly endedAt: number
100
+ }
101
+
102
+ /** Format one tool into its display fragment (`跑个命令 npm test`). */
103
+ function toolFragment(tool: { action: string; detail: string }): string {
104
+ return tool.detail.length === 0 ? tool.action : `${tool.action} ${tool.detail}`
105
+ }
106
+
107
+ /** Simple non-ANSI string shortener by grapheme count. */
108
+ function shorten(value: string, limit: number): string {
109
+ const graphemes = Array.from(value)
110
+ if (graphemes.length <= limit) return value
111
+ return `${graphemes.slice(0, Math.max(0, limit - 1)).join('')}…`
112
+ }
113
+
114
+ /**
115
+ * Extract a displayable detail fragment from a tool call's parsed arguments.
116
+ * @param toolName - Registry tool name.
117
+ * @param args - Parsed tool arguments (lossless JSON by registry contract).
118
+ */
119
+ export function detailFor(toolName: string, args: Readonly<Record<string, unknown>> | undefined, limit: number): string {
120
+ if (args === undefined) return ''
121
+ const pickString = (...keys: readonly string[]): string => {
122
+ for (const key of keys) {
123
+ const value = args[key]
124
+ if (typeof value === 'string' && value.trim().length > 0) return value.trim()
125
+ }
126
+ return ''
127
+ }
128
+ const normalized = toolName.toLowerCase()
129
+ if (normalized === 'mcp' || normalized.startsWith('mcp__') || normalized.includes('__')) {
130
+ const action = pickString('action', 'tool', 'server', 'connect', 'describe')
131
+ return shorten(action, limit)
132
+ }
133
+ const path = pickString('path', 'file', 'file_path', 'filepath', 'target')
134
+ if (path.length > 0) return shorten(path, limit)
135
+ const command = pickString('command', 'cmd', 'cmdline')
136
+ if (command.length > 0) return shorten(command, limit)
137
+ const pattern = pickString('pattern', 'query', 'search')
138
+ if (pattern.length > 0) return shorten(pattern, limit)
139
+ const url = pickString('url')
140
+ if (url.length > 0) return shorten(url, limit)
141
+ if (/^(?:subagent|agent|task)$/i.test(toolName)) {
142
+ const description = pickString('description')
143
+ if (description.length > 0) return shorten(description, limit)
144
+ const prompt = pickString('prompt')
145
+ if (prompt.length > 0) return shorten(prompt, limit)
146
+ }
147
+ const named = pickString('name', 'server', 'tool', 'id', 'goal')
148
+ if (named.length > 0) return shorten(named, limit)
149
+ return ''
150
+ }
151
+
152
+ /**
153
+ * Track one agent's activity from its durable session events. Events from
154
+ * other sessions are ignored (the owning plugin feeds only the agent it
155
+ * displays). The tracker is deliberately single-agent: multi-session UIs
156
+ * instantiate one tracker per agent.
157
+ */
158
+ export class ActivityTracker {
159
+ private phase: ActivityPhase = 'idle'
160
+ private phaseStartedAt = 0
161
+ private turnStartedAt = 0
162
+ private thinkingStartedAt = 0
163
+ private thinkingMs = 0
164
+ private toolMs = 0
165
+ private toolCount = 0
166
+ private activeTools = new Map<string, ActiveTool>()
167
+ private doneQueue: DoneTool[] = []
168
+ private previousPhrase: string | undefined
169
+ private phraseChangedAt = 0
170
+ private waitingFirstToken = false
171
+ /** Latest `⏵` self-narration line extracted from the stream, or null. */
172
+ private narratedText: string | null = null
173
+ /** Wall-clock time of the most recent stream delta (narration freshness). */
174
+ private lastChunkAt = 0
175
+ /** Rolling stream buffer (reasoning + text deltas) for `⏵` extraction. */
176
+ private recentStream = ''
177
+ /** Total tokens reported across the turn's assistant messages. */
178
+ private turnTokens = 0
179
+ /** Completion prefix drawn ONCE at turn end so the done line stays stable. */
180
+ private donePrefix = t('done-prefix')
181
+ /** Easter eggs shown once per turn (holiday / rare / weekend). */
182
+ private holidayShown = false
183
+ private rareShown = false
184
+ private weekendShown = false
185
+ /** One-off copy pinned by an external event (interrupt / model switch /
186
+ * compaction / work reminder), shown until it expires. */
187
+ private pendingPhrase: string | null = null
188
+ private pendingUntil = 0
189
+ /** Git branch of the session cwd (fed by the host, best-effort). */
190
+ private gitBranch: string | undefined
191
+ /** Consecutive fast tool streak (combo). */
192
+ private streak = 0
193
+ private lastToolEndAt = 0
194
+ private maxStreak = 0
195
+ /** Subagent (agent/task) calls in the current turn. */
196
+ private subagentCount = 0
197
+ /** Work-reminder fired once per turn. */
198
+ private reminded = false
199
+ /** Streaming token estimate for the tps prefix. */
200
+ private tokBuf = 0
201
+ private tokWindowStart = 0
202
+
203
+ /**
204
+ * @param config - Behavioral knobs.
205
+ * @param now - Wall-clock supplier (injectable for tests).
206
+ * @param customActions - Exact-name custom action pools for {@link actionFor}.
207
+ */
208
+ constructor(
209
+ private readonly config: TrackerConfig,
210
+ private readonly now: () => number = Date.now,
211
+ private readonly customActions?: Readonly<Record<string, readonly string[]>>,
212
+ ) {}
213
+
214
+ /** Agent transitioned to running/idle. */
215
+ onAgentStatus(status: 'idle' | 'running'): void {
216
+ if (status === 'idle') {
217
+ // The turn end already moved us to the done phase; idle only clears the
218
+ // lingering done card after its display window.
219
+ if (this.phase !== 'done') this.phase = 'idle'
220
+ return
221
+ }
222
+ if (this.phase === 'idle') {
223
+ this.phase = 'waiting'
224
+ this.phaseStartedAt = this.now()
225
+ this.waitingFirstToken = true
226
+ }
227
+ }
228
+
229
+ /** The user interrupted the running turn: show a comeback quip next. */
230
+ onInterrupted(): void {
231
+ if (!this.config.phrases) return
232
+ this.pendingPhrase = continuePhrase()
233
+ this.pendingUntil = this.now() + PENDING_MS
234
+ }
235
+
236
+ /** The model was switched: quip for the new model id. */
237
+ onModelSwitch(modelId: string): void {
238
+ if (!this.config.phrases) return
239
+ const quip = modelQuip(modelId)
240
+ if (quip !== null) {
241
+ this.pendingPhrase = quip
242
+ this.pendingUntil = this.now() + PENDING_MS
243
+ }
244
+ }
245
+
246
+ /** A context compaction finished (or overflowed): quip about it. */
247
+ onCompact(kind: 'done' | 'overflow'): void {
248
+ if (!this.config.phrases) return
249
+ this.pendingPhrase = kind === 'overflow' ? overflowPhrase() : compactPhrase()
250
+ this.pendingUntil = this.now() + PENDING_MS
251
+ }
252
+
253
+ /** Feed the session cwd's git branch (best-effort, host-resolved). */
254
+ onGitBranch(branch: string | undefined): void {
255
+ this.gitBranch = branch
256
+ }
257
+
258
+ /** Consume one durable session event (turn/step/tool/stream). */
259
+ onSessionEvent(event: SessionEvent): void {
260
+ switch (event.type) {
261
+ case 'turn/start': {
262
+ const at = event.time
263
+ this.turnStartedAt = at
264
+ this.thinkingStartedAt = at
265
+ this.thinkingMs = 0
266
+ this.toolMs = 0
267
+ this.toolCount = 0
268
+ this.turnTokens = 0
269
+ this.activeTools.clear()
270
+ this.doneQueue = []
271
+ this.waitingFirstToken = true
272
+ this.narratedText = null
273
+ this.lastChunkAt = 0
274
+ this.recentStream = ''
275
+ // Easter eggs are once-per-turn: a fresh turn can roll them again.
276
+ this.holidayShown = false
277
+ this.rareShown = false
278
+ this.weekendShown = false
279
+ // Per-turn stats reset; the pending quip (interrupt/model/compact)
280
+ // survives across the turn boundary so it shows on the next think.
281
+ this.streak = 0
282
+ this.maxStreak = 0
283
+ this.subagentCount = 0
284
+ this.reminded = false
285
+ this.tokBuf = 0
286
+ this.tokWindowStart = at
287
+ this.setPhase('waiting', at)
288
+ return
289
+ }
290
+ case 'step/start':
291
+ if (this.phase === 'waiting' && !this.waitingFirstToken) {
292
+ // A new step without streamed output yet stay waiting.
293
+ }
294
+ return
295
+ case 'assistant/chunk': {
296
+ const chunk = event.data.chunk
297
+ this.lastChunkAt = event.time
298
+ if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') {
299
+ if (this.waitingFirstToken) {
300
+ this.waitingFirstToken = false
301
+ this.setPhase('thinking', event.time)
302
+ this.thinkingStartedAt = event.time
303
+ }
304
+ this.recentStream = (this.recentStream + chunk.text).slice(-STREAM_BUFFER_CHARS)
305
+ const narration = extractNarration(this.recentStream)
306
+ if (narration !== null) this.narratedText = narration
307
+ // Streaming token estimate for the tps prefix (pi parity).
308
+ this.tokBuf += estimateTokens(chunk.text)
309
+ }
310
+ return
311
+ }
312
+ case 'assistant/message': {
313
+ const usage = event.data.usage
314
+ if (usage !== undefined) {
315
+ this.turnTokens += usage.inputTokens + usage.outputTokens
316
+ + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
317
+ }
318
+ return
319
+ }
320
+ case 'tool/call': {
321
+ const at = event.time
322
+ if (this.phase === 'thinking' || this.phase === 'waiting') {
323
+ this.thinkingMs += at - this.thinkingStartedAt
324
+ }
325
+ // Combo streak: consecutive tools within COMBO_GAP_MS count up.
326
+ this.streak = (this.lastToolEndAt > 0 && at - this.lastToolEndAt <= COMBO_GAP_MS)
327
+ ? this.streak + 1
328
+ : 1
329
+ if (this.streak > this.maxStreak) this.maxStreak = this.streak
330
+ if (/^(?:subagent|agent|task)$/i.test(event.data.name)) this.subagentCount += 1
331
+ const parsed = parseArguments(event.data.arguments)
332
+ const action = this.config.phrases ? actionFor(event.data.name, this.customActions) : event.data.name
333
+ const detail = detailFor(event.data.name, parsed, this.config.detailLimit)
334
+ const active: ActiveTool = {
335
+ callId: event.data.callId,
336
+ name: event.data.name,
337
+ action,
338
+ detail,
339
+ isGit: isGitTool(event.data.name, parsed),
340
+ startedAt: at,
341
+ failed: false,
342
+ }
343
+ this.activeTools.set(event.data.callId, active)
344
+ this.setPhase('tool', at)
345
+ return
346
+ }
347
+ case 'tool/result': {
348
+ const at = event.time
349
+ // `ToolResultMessage.content` is the single-block `[ToolResultBlock]`
350
+ // tuple, so `block` is never absent and always a tool-result block.
351
+ const block = event.data.message.content[0]
352
+ const active = this.activeTools.get(block.toolCallId)
353
+ if (active === undefined) return
354
+ active.failed = event.data.error !== undefined || block.isError === true
355
+ active.endedAt = at
356
+ this.toolMs += at - active.startedAt
357
+ this.toolCount += 1
358
+ this.lastToolEndAt = at
359
+ this.doneQueue.push({
360
+ action: active.action,
361
+ detail: active.detail,
362
+ failed: active.failed,
363
+ endedAt: at,
364
+ })
365
+ if (this.doneQueue.length > DONE_QUEUE_MAX) this.doneQueue.shift()
366
+ this.activeTools.delete(block.toolCallId)
367
+ if (this.activeTools.size === 0) {
368
+ // Back to thinking (or a trailing done card if the turn just closed).
369
+ this.setPhase('thinking', at)
370
+ this.thinkingStartedAt = at
371
+ }
372
+ return
373
+ }
374
+ case 'turn/end': {
375
+ const at = event.time
376
+ if (this.activeTools.size > 0) {
377
+ // Tools still running at turn end: count their elapsed time as tool time.
378
+ for (const tool of this.activeTools.values()) {
379
+ this.toolMs += Math.max(0, at - tool.startedAt)
380
+ }
381
+ this.activeTools.clear()
382
+ } else if (this.phase === 'thinking' || this.phase === 'waiting') {
383
+ this.thinkingMs += Math.max(0, at - this.thinkingStartedAt)
384
+ }
385
+ // Draw the completion prefix ONCE so repeated renders of the done line
386
+ // stay stable (a fresh random per render would make it flicker). The
387
+ // pools are language-aware, so the line matches the language the turn
388
+ // ended in.
389
+ const lastTool = this.doneQueue.at(-1)
390
+ if (this.config.phrases) {
391
+ this.donePrefix = lastTool?.failed ? failPhrase() : donePhrase()
392
+ } else {
393
+ this.donePrefix = t('done-prefix')
394
+ }
395
+ this.setPhase('done', at)
396
+ return
397
+ }
398
+ default:
399
+ return
400
+ }
401
+ }
402
+
403
+ /** Render the current status snapshot at a wall-clock instant. */
404
+ render(nowMs: number = this.now()): ActivityState {
405
+ switch (this.phase) {
406
+ case 'idle':
407
+ return {
408
+ phase: 'idle',
409
+ line: '',
410
+ toolCount: 0,
411
+ turnElapsedMs: 0,
412
+ phaseStartedAt: this.phaseStartedAt,
413
+ }
414
+ case 'done': {
415
+ const summary = this.doneSummary(nowMs)
416
+ return {
417
+ phase: 'done',
418
+ line: summary.line,
419
+ toolCount: this.toolCount,
420
+ turnElapsedMs: this.turnElapsedMs(nowMs),
421
+ phaseStartedAt: this.phaseStartedAt,
422
+ ...(summary.phrase === undefined ? {} : { phrase: summary.phrase }),
423
+ }
424
+ }
425
+ case 'tool': {
426
+ const tool = this.primaryTool()
427
+ if (tool === undefined) {
428
+ return this.renderThinking(nowMs)
429
+ }
430
+ const fragment = toolFragment(tool)
431
+ const elapsed = fmtDuration(Math.max(0, nowMs - tool.startedAt))
432
+ const git = tool.isGit
433
+ ? (this.gitBranch !== undefined ? ` · git ${this.gitBranch}` : ' · git')
434
+ : ''
435
+ const combo = this.streak >= COMBO_SHOW_AT ? ` · 🔥x${this.streak}` : ''
436
+ const narration = this.freshNarration(nowMs)
437
+ const line = narration === null
438
+ ? `${fragment} · ${elapsed}${git}${combo}`
439
+ : `⏵ ${narration} · ${fragment} · ${elapsed}${git}${combo}`
440
+ return {
441
+ phase: 'tool',
442
+ line,
443
+ label: tool.action,
444
+ detail: tool.detail,
445
+ ...(narration === null ? {} : { phrase: narration }),
446
+ toolCount: this.toolCount,
447
+ turnElapsedMs: this.turnElapsedMs(nowMs),
448
+ phaseStartedAt: this.phaseStartedAt,
449
+ }
450
+ }
451
+ case 'waiting':
452
+ case 'thinking': {
453
+ const rendered = this.renderThinking(nowMs)
454
+ if (this.phase === 'waiting') {
455
+ return { ...rendered, phase: 'waiting' }
456
+ }
457
+ return rendered
458
+ }
459
+ }
460
+ }
461
+
462
+ /** Per-turn thinking/tooling split for stats consumers. */
463
+ stats(): TurnStats {
464
+ return {
465
+ thinkingMs: this.thinkingMs,
466
+ toolMs: this.toolMs,
467
+ toolCount: this.toolCount,
468
+ }
469
+ }
470
+
471
+ private renderThinking(nowMs: number): ActivityState {
472
+ const thinkingMs = this.phase === 'waiting'
473
+ ? 0
474
+ : this.thinkingMs + Math.max(0, nowMs - this.thinkingStartedAt)
475
+ const elapsed = fmtDuration(this.turnElapsedMs(nowMs))
476
+ const elapsedLine = t('line-elapsed', { elapsed })
477
+ const narration = this.freshNarration(nowMs)
478
+ if (narration !== null) {
479
+ return {
480
+ phase: this.phase,
481
+ line: `⏵ ${narration} · ${elapsedLine}`,
482
+ phrase: narration,
483
+ toolCount: this.toolCount,
484
+ turnElapsedMs: this.turnElapsedMs(nowMs),
485
+ phaseStartedAt: this.phaseStartedAt,
486
+ }
487
+ }
488
+ if (this.config.phrases) {
489
+ const pending = this.pendingPhraseAt(nowMs)
490
+ // Rare eggs linger longer (pi RARE_PHRASE_TICKS ≈ 7.5s).
491
+ const rotateMs = this.previousPhrase !== undefined && this.isRarePhrase(this.previousPhrase)
492
+ ? RARE_ROTATE_MS
493
+ : PHRASE_ROTATE_MS
494
+ if (pending !== null) {
495
+ this.previousPhrase = pending
496
+ this.phraseChangedAt = nowMs
497
+ } else if (nowMs - this.phraseChangedAt >= rotateMs) {
498
+ // Waiting (pre-first-token) draws from the waiting pool; thinking
499
+ // rotates the egg-aware lively pool (holiday / rare / weekend /
500
+ // night). Both pools are language-aware, so a `/lang` switch shows
501
+ // on the next rotation.
502
+ this.previousPhrase = this.phase === 'waiting'
503
+ ? waitingPhrase(this.previousPhrase)
504
+ : this.livelyPhrase(thinkingMs, nowMs)
505
+ this.phraseChangedAt = nowMs
506
+ }
507
+ const phrase = this.previousPhrase ?? (this.phase === 'waiting'
508
+ ? waitingPhrase()
509
+ : this.livelyPhrase(thinkingMs, nowMs))
510
+ // Ellipsis breathing (pi DOT_FRAMES) + optional estimated tps prefix.
511
+ const dots = DOT_FRAMES[Math.floor(nowMs / TICK_MS) % DOT_FRAMES.length]
512
+ const tps = this.tpsPrefix(nowMs)
513
+ return {
514
+ phase: this.phase,
515
+ line: `${tps}${phrase}${dots} · ${elapsedLine}`,
516
+ phrase,
517
+ toolCount: this.toolCount,
518
+ turnElapsedMs: this.turnElapsedMs(nowMs),
519
+ phaseStartedAt: this.phaseStartedAt,
520
+ }
521
+ }
522
+ const label = this.phase === 'waiting' ? t('waiting-label') : t('thinking-label')
523
+ return {
524
+ phase: this.phase,
525
+ line: `${label} · ${elapsedLine}`,
526
+ label,
527
+ toolCount: this.toolCount,
528
+ turnElapsedMs: this.turnElapsedMs(nowMs),
529
+ phaseStartedAt: this.phaseStartedAt,
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Pick the next thinking phrase with the pi extension's egg order:
535
+ * holiday (once per turn) → rare 1/150 (once per turn) → weekend greeting
536
+ * (once per turn) → elapsed-time tiers with night mixing. Every egg is
537
+ * gated by `config.features` (absent flags default to on).
538
+ */
539
+ private livelyPhrase(thinkingMs: number, nowMs: number): string {
540
+ const features = this.config.features ?? {}
541
+ const now = new Date(nowMs)
542
+ if (features.holidays !== false && !this.holidayShown) {
543
+ const holiday = holidayPhrase(now)
544
+ if (holiday !== null) {
545
+ this.holidayShown = true
546
+ return holiday
547
+ }
548
+ }
549
+ if (features.rareEggs !== false && !this.rareShown && Math.random() < RARE_CHANCE) {
550
+ this.rareShown = true
551
+ return rarePhrase(this.previousPhrase)
552
+ }
553
+ if (features.weekend !== false && !this.weekendShown && isWeekend(now)) {
554
+ this.weekendShown = true
555
+ return weekendPhrase(this.previousPhrase)
556
+ }
557
+ return thinkingPhrase(
558
+ thinkingMs,
559
+ this.previousPhrase,
560
+ features.nightPhrases !== false && isNight(now.getHours()),
561
+ this.config.customPhrases,
562
+ )
563
+ }
564
+
565
+ /** The pending one-off quip (interrupt / model / compact / work reminder)
566
+ * while it is still fresh; expired pending is cleared here. */
567
+ private pendingPhraseAt(nowMs: number): string | null {
568
+ if (this.pendingPhrase !== null) {
569
+ if (nowMs < this.pendingUntil) return this.pendingPhrase
570
+ this.pendingPhrase = null
571
+ }
572
+ const remindAt = this.config.workRemindAt ?? 0
573
+ if (!this.reminded && remindAt > 0) {
574
+ const hours = this.turnElapsedMs(nowMs) / 3_600_000
575
+ if (hours >= remindAt) {
576
+ this.reminded = true
577
+ return t('work-remind', { hours: Math.floor(hours) })
578
+ }
579
+ }
580
+ return null
581
+ }
582
+
583
+ /** Whether a phrase comes from the rare pool (longer display window). */
584
+ private isRarePhrase(phrase: string): boolean {
585
+ return RARE_PHRASES.includes(phrase) || EN_RARE_PHRASES.includes(phrase)
586
+ }
587
+
588
+ /** Estimated tokens/s while the stream is fresh (pi parity, opt-in). */
589
+ private tpsPrefix(nowMs: number): string {
590
+ if (!this.config.showTokPerSec || this.tokBuf <= 0) return ''
591
+ if (nowMs - this.lastChunkAt > TPS_WINDOW_MS) return ''
592
+ const windowSec = Math.max(1, (nowMs - this.tokWindowStart) / 1000)
593
+ const tps = Math.round(this.tokBuf / windowSec)
594
+ return tps > 0 ? `~${tps} tok/s · ` : ''
595
+ }
596
+
597
+ private doneSummary(nowMs: number): { line: string; phrase?: string } {
598
+ const { thinkingMs, toolMs, toolCount } = this.stats()
599
+ const tokens = this.turnTokens > 0 ? ` · 🔥 ${fmtTokens(this.turnTokens)}` : ''
600
+ const sub = this.subagentCount > 0 ? ` · ${t('subagent-count', { count: this.subagentCount })}` : ''
601
+ const combo = this.maxStreak >= COMBO_SHOW_AT ? ` · 🔥x${this.maxStreak}` : ''
602
+ const tools = t(toolCount === 1 ? 'tool-count-one' : 'tool-count-many', { count: toolCount })
603
+ const summary = t('done-summary', {
604
+ tools,
605
+ thinking: fmtDuration(thinkingMs),
606
+ tooling: fmtDuration(toolMs),
607
+ })
608
+ if (!this.config.phrases) {
609
+ return { line: `${t('done-prefix')} · ${summary}${sub}${combo}${tokens}` }
610
+ }
611
+ const last = this.doneQueue.at(-1)
612
+ if (last !== undefined && nowMs - last.endedAt < DONE_FRAGMENT_MS) {
613
+ const fragment = toolFragment(last)
614
+ return { line: `${this.donePrefix} · ${fragment} · ${tools}${sub}${combo}${tokens}`, phrase: this.donePrefix }
615
+ }
616
+ return { line: `${this.donePrefix} · ${summary}${sub}${combo}${tokens}`, phrase: this.donePrefix }
617
+ }
618
+
619
+ /** The fresh self-narration line, or null once the stream has been quiet. */
620
+ private freshNarration(nowMs: number): string | null {
621
+ if (this.narratedText === null) return null
622
+ if (nowMs - this.lastChunkAt > NARRATE_GRACE_MS) return null
623
+ return this.narratedText
624
+ }
625
+
626
+ private primaryTool(): ActiveTool | undefined {
627
+ let primary: ActiveTool | undefined
628
+ for (const tool of this.activeTools.values()) {
629
+ if (primary === undefined || tool.startedAt < primary.startedAt) primary = tool
630
+ }
631
+ return primary
632
+ }
633
+
634
+ private turnElapsedMs(nowMs: number): number {
635
+ return this.turnStartedAt === 0 ? 0 : Math.max(0, nowMs - this.turnStartedAt)
636
+ }
637
+
638
+ private setPhase(phase: ActivityPhase, atMs: number): void {
639
+ this.phase = phase
640
+ this.phaseStartedAt = atMs
641
+ }
642
+ }
643
+
644
+ /** Rotate the thinking phrase every N render ticks (render cadence ≈ 500ms → ~4s). */
645
+ const PHRASE_ROTATE_MS = 4000
646
+ /** Rare easter-egg phrases linger this long before rotation (pi ≈ 7.5s). */
647
+ const RARE_ROTATE_MS = 7500
648
+ /** One-off quips (interrupt / model / compact) display window. */
649
+ const PENDING_MS = 6000
650
+ /** Tools closer than this count as one combo streak. */
651
+ const COMBO_GAP_MS = 10_000
652
+ /** Streak at which the combo badge shows. */
653
+ const COMBO_SHOW_AT = 2
654
+ /** The tps estimate stays fresh this long after the last chunk. */
655
+ const TPS_WINDOW_MS = 3500
656
+ /** Render tick cadence (matches the TUI's 500ms activity timer). */
657
+ const TICK_MS = 500
658
+ /** Ellipsis breathing frames appended to thinking lines (pi parity). */
659
+ const DOT_FRAMES = ['', ' ·', ' ··', ' ···', ' ··', ' ·']
660
+ /** Cap on replayed done cards; older entries drop. */
661
+ const DONE_QUEUE_MAX = 6
662
+ /** Show the last tool's fragment in the done line for this long after it ends. */
663
+ const DONE_FRAGMENT_MS = 3000
664
+ /** Rolling stream buffer size for `⏵` narration extraction. */
665
+ const STREAM_BUFFER_CHARS = 300
666
+ /** A narration stays visible this long after the stream went quiet. */
667
+ const NARRATE_GRACE_MS = 5000
668
+
669
+ /** Extract the latest `⏵` self-narration line from a stream buffer. */
670
+ export function extractNarration(buffer: string): string | null {
671
+ const matches = [...buffer.matchAll(/⏵\s*([^\n⏵]{1,40})/g)]
672
+ if (matches.length === 0) return null
673
+ const latest = matches[matches.length - 1]?.[1]
674
+ if (latest === undefined) return null
675
+ const text = latest.replace(/[。..!!,,、;;]+$/, '').trim()
676
+ return text.length === 0 ? null : text
677
+ }
678
+
679
+ /** Format a token count compactly (`12.3k`, `1.2M`). */
680
+ function fmtTokens(tokens: number): string {
681
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
682
+ if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`
683
+ return String(tokens)
684
+ }
685
+
686
+ /** Coarse streaming token estimate (pi parity: CJK ×1.5, others ÷4). */
687
+ function estimateTokens(text: string): number {
688
+ const compact = text.replace(/\s/g, '')
689
+ if (compact.length === 0) return 0
690
+ const cjkCount = (compact.match(/[\u3400-\u9fff]/g) ?? []).length
691
+ return Math.max(1, Math.ceil(cjkCount * 1.5 + (compact.length - cjkCount) / 4))
692
+ }
693
+
694
+ /** Parse a tool call's raw arguments JSON defensively. */
695
+ function parseArguments(raw: string): Readonly<Record<string, unknown>> | undefined {
696
+ if (raw.trim().length === 0) return undefined
697
+ try {
698
+ const parsed: unknown = JSON.parse(raw)
699
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
700
+ return parsed as Readonly<Record<string, unknown>>
701
+ }
702
+ return undefined
703
+ } catch {
704
+ return undefined
705
+ }
706
+ }