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/LICENSE +28 -28
- package/README.i18n.yaml +6 -6
- package/README.md +122 -122
- package/README.zh.md +113 -113
- package/cordis.patch.yml +19 -19
- package/lib/client.js +6 -6
- package/lib/client.js.map +1 -1
- package/lib/types/config.d.ts +68 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/config.js +141 -0
- package/lib/types/frames.d.ts +37 -0
- package/lib/types/frames.d.ts.map +1 -0
- package/lib/types/frames.js +126 -0
- package/lib/types/index.d.ts +16 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +35 -6
- package/lib/types/lang.d.ts +100 -0
- package/lib/types/lang.d.ts.map +1 -0
- package/lib/types/lang.js +132 -0
- package/lib/types/phrases.d.ts +125 -6
- package/lib/types/phrases.d.ts.map +1 -1
- package/lib/types/phrases.js +362 -27
- package/lib/types/status.d.ts +62 -0
- package/lib/types/status.d.ts.map +1 -1
- package/lib/types/status.js +202 -22
- package/package.json +12 -2
- package/src/client/WorkingLine.module.css +74 -74
- package/src/client/WorkingLine.tsx +42 -42
- package/src/client/activity.ts +50 -50
- package/src/client/css-modules.d.ts +6 -6
- package/src/client/index.ts +42 -42
- package/src/config.ts +174 -0
- package/src/events.ts +45 -45
- package/src/frames.ts +138 -0
- package/src/index.ts +278 -228
- package/src/invariant.ts +69 -69
- package/src/lang.ts +145 -0
- package/src/phrases.ts +584 -180
- package/src/registration.ts +99 -99
- package/src/status.ts +706 -501
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,
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
readonly
|
|
24
|
-
/**
|
|
25
|
-
readonly
|
|
26
|
-
/**
|
|
27
|
-
readonly
|
|
28
|
-
/**
|
|
29
|
-
readonly
|
|
30
|
-
/**
|
|
31
|
-
readonly
|
|
32
|
-
/**
|
|
33
|
-
readonly
|
|
34
|
-
/** Wall-clock
|
|
35
|
-
readonly
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
/** Milliseconds
|
|
43
|
-
readonly
|
|
44
|
-
/**
|
|
45
|
-
readonly
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
readonly
|
|
54
|
-
/**
|
|
55
|
-
readonly
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
readonly
|
|
66
|
-
/**
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
readonly
|
|
76
|
-
|
|
77
|
-
readonly
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
this.
|
|
265
|
-
this.
|
|
266
|
-
this.
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
this.
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
:
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
+
}
|