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/README.i18n.yaml +6 -0
- package/README.md +109 -0
- package/README.zh.md +111 -0
- package/lib/types/events.d.ts +42 -0
- package/lib/types/events.d.ts.map +1 -0
- package/lib/types/events.js +9 -0
- package/lib/types/index.d.ts +61 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +151 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/invariant.js +61 -0
- package/lib/types/phrases.d.ts +55 -0
- package/lib/types/phrases.d.ts.map +1 -0
- package/lib/types/phrases.js +163 -0
- package/lib/types/status.d.ts +110 -0
- package/lib/types/status.d.ts.map +1 -0
- package/lib/types/status.js +440 -0
- package/package.json +61 -0
- package/src/events.ts +45 -0
- package/src/index.ts +211 -0
- package/src/invariant.ts +69 -0
- package/src/phrases.ts +180 -0
- package/src/status.ts +501 -0
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
|
+
}
|