dsh-working-activity 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/status.ts ADDED
@@ -0,0 +1,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, 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
+ }