dsh-working-activity 0.3.0 → 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
@@ -8,8 +8,9 @@
8
8
 
9
9
  import type { SessionEvent } from '@deepseek-ai/dsh-session'
10
10
  import {
11
- actionFor, donePhrase, failPhrase, fmtDuration, isGitTool, isNight, thinkingPhrase,
12
- waitingPhrase,
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,
13
14
  } from './phrases.js'
14
15
  import { t } from './lang.js'
15
16
 
@@ -46,6 +47,18 @@ export interface TurnStats {
46
47
  readonly toolCount: number
47
48
  }
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
+
49
62
  /** Configuration knobs for the state machine (subset of plugin Config). */
50
63
  export interface TrackerConfig {
51
64
  /** Playful copy pool on/off; false renders plain functional labels. */
@@ -54,6 +67,14 @@ export interface TrackerConfig {
54
67
  readonly detailLimit: number
55
68
  /** Hide the status line while idle. */
56
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
57
78
  }
58
79
 
59
80
  /** A tool execution in flight. */
@@ -157,6 +178,27 @@ export class ActivityTracker {
157
178
  private turnTokens = 0
158
179
  /** Completion prefix drawn ONCE at turn end so the done line stays stable. */
159
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
160
202
 
161
203
  /**
162
204
  * @param config - Behavioral knobs.
@@ -184,6 +226,35 @@ export class ActivityTracker {
184
226
  }
185
227
  }
186
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
+
187
258
  /** Consume one durable session event (turn/step/tool/stream). */
188
259
  onSessionEvent(event: SessionEvent): void {
189
260
  switch (event.type) {
@@ -201,6 +272,18 @@ export class ActivityTracker {
201
272
  this.narratedText = null
202
273
  this.lastChunkAt = 0
203
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
204
287
  this.setPhase('waiting', at)
205
288
  return
206
289
  }
@@ -221,6 +304,8 @@ export class ActivityTracker {
221
304
  this.recentStream = (this.recentStream + chunk.text).slice(-STREAM_BUFFER_CHARS)
222
305
  const narration = extractNarration(this.recentStream)
223
306
  if (narration !== null) this.narratedText = narration
307
+ // Streaming token estimate for the tps prefix (pi parity).
308
+ this.tokBuf += estimateTokens(chunk.text)
224
309
  }
225
310
  return
226
311
  }
@@ -237,6 +322,12 @@ export class ActivityTracker {
237
322
  if (this.phase === 'thinking' || this.phase === 'waiting') {
238
323
  this.thinkingMs += at - this.thinkingStartedAt
239
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
240
331
  const parsed = parseArguments(event.data.arguments)
241
332
  const action = this.config.phrases ? actionFor(event.data.name, this.customActions) : event.data.name
242
333
  const detail = detailFor(event.data.name, parsed, this.config.detailLimit)
@@ -264,6 +355,7 @@ export class ActivityTracker {
264
355
  active.endedAt = at
265
356
  this.toolMs += at - active.startedAt
266
357
  this.toolCount += 1
358
+ this.lastToolEndAt = at
267
359
  this.doneQueue.push({
268
360
  action: active.action,
269
361
  detail: active.detail,
@@ -337,11 +429,14 @@ export class ActivityTracker {
337
429
  }
338
430
  const fragment = toolFragment(tool)
339
431
  const elapsed = fmtDuration(Math.max(0, nowMs - tool.startedAt))
340
- const git = tool.isGit ? ' · git' : ''
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}` : ''
341
436
  const narration = this.freshNarration(nowMs)
342
437
  const line = narration === null
343
- ? `${fragment} · ${elapsed}${git}`
344
- : `⏵ ${narration} · ${fragment} · ${elapsed}${git}`
438
+ ? `${fragment} · ${elapsed}${git}${combo}`
439
+ : `⏵ ${narration} · ${fragment} · ${elapsed}${git}${combo}`
345
440
  return {
346
441
  phase: 'tool',
347
442
  line,
@@ -391,21 +486,33 @@ export class ActivityTracker {
391
486
  }
392
487
  }
393
488
  if (this.config.phrases) {
394
- if (nowMs - this.phraseChangedAt >= PHRASE_ROTATE_MS) {
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) {
395
498
  // Waiting (pre-first-token) draws from the waiting pool; thinking
396
- // rotates the playful copy pool with night mixing. Both pools are
397
- // language-aware, so a `/lang` switch shows on the next rotation.
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.
398
502
  this.previousPhrase = this.phase === 'waiting'
399
503
  ? waitingPhrase(this.previousPhrase)
400
- : thinkingPhrase(thinkingMs, this.previousPhrase, isNight(new Date(nowMs).getHours()))
504
+ : this.livelyPhrase(thinkingMs, nowMs)
401
505
  this.phraseChangedAt = nowMs
402
506
  }
403
507
  const phrase = this.previousPhrase ?? (this.phase === 'waiting'
404
508
  ? waitingPhrase()
405
- : thinkingPhrase(thinkingMs, undefined, isNight(new Date(nowMs).getHours())))
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)
406
513
  return {
407
514
  phase: this.phase,
408
- line: `${phrase} · ${elapsedLine}`,
515
+ line: `${tps}${phrase}${dots} · ${elapsedLine}`,
409
516
  phrase,
410
517
  toolCount: this.toolCount,
411
518
  turnElapsedMs: this.turnElapsedMs(nowMs),
@@ -423,9 +530,75 @@ export class ActivityTracker {
423
530
  }
424
531
  }
425
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
+
426
597
  private doneSummary(nowMs: number): { line: string; phrase?: string } {
427
598
  const { thinkingMs, toolMs, toolCount } = this.stats()
428
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}` : ''
429
602
  const tools = t(toolCount === 1 ? 'tool-count-one' : 'tool-count-many', { count: toolCount })
430
603
  const summary = t('done-summary', {
431
604
  tools,
@@ -433,14 +606,14 @@ export class ActivityTracker {
433
606
  tooling: fmtDuration(toolMs),
434
607
  })
435
608
  if (!this.config.phrases) {
436
- return { line: `${t('done-prefix')} · ${summary}${tokens}` }
609
+ return { line: `${t('done-prefix')} · ${summary}${sub}${combo}${tokens}` }
437
610
  }
438
611
  const last = this.doneQueue.at(-1)
439
612
  if (last !== undefined && nowMs - last.endedAt < DONE_FRAGMENT_MS) {
440
613
  const fragment = toolFragment(last)
441
- return { line: `${this.donePrefix} · ${fragment} · ${tools}${tokens}`, phrase: this.donePrefix }
614
+ return { line: `${this.donePrefix} · ${fragment} · ${tools}${sub}${combo}${tokens}`, phrase: this.donePrefix }
442
615
  }
443
- return { line: `${this.donePrefix} · ${summary}${tokens}`, phrase: this.donePrefix }
616
+ return { line: `${this.donePrefix} · ${summary}${sub}${combo}${tokens}`, phrase: this.donePrefix }
444
617
  }
445
618
 
446
619
  /** The fresh self-narration line, or null once the stream has been quiet. */
@@ -470,6 +643,20 @@ export class ActivityTracker {
470
643
 
471
644
  /** Rotate the thinking phrase every N render ticks (render cadence ≈ 500ms → ~4s). */
472
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 = ['', ' ·', ' ··', ' ···', ' ··', ' ·']
473
660
  /** Cap on replayed done cards; older entries drop. */
474
661
  const DONE_QUEUE_MAX = 6
475
662
  /** Show the last tool's fragment in the done line for this long after it ends. */
@@ -496,6 +683,14 @@ function fmtTokens(tokens: number): string {
496
683
  return String(tokens)
497
684
  }
498
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
+
499
694
  /** Parse a tool call's raw arguments JSON defensively. */
500
695
  function parseArguments(raw: string): Readonly<Record<string, unknown>> | undefined {
501
696
  if (raw.trim().length === 0) return undefined