@groeponline/pi-wishcraft 1.16.0 → 1.23.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.
Files changed (45) hide show
  1. package/AGENTS.md +52 -22
  2. package/CHANGELOG.md +65 -0
  3. package/CONTRIBUTING.md +12 -1
  4. package/RELEASE.md +142 -49
  5. package/bash-mode/completion.ts +2 -2
  6. package/build-stamp.json +1 -1
  7. package/docs/agents/domain.md +60 -0
  8. package/docs/agents/issue-tracker.md +88 -0
  9. package/docs/configuration.md +6 -1
  10. package/docs/design/regression-testing.md +1 -1
  11. package/docs/testing.md +81 -0
  12. package/package.json +17 -10
  13. package/src/config/settings-registry.ts +2 -1
  14. package/src/config/structural-preset-table.ts +0 -1
  15. package/src/extension/commands/commands.ts +0 -1
  16. package/src/extension/core/peer-guard.ts +7 -2
  17. package/src/extension/core/segment-context.ts +139 -44
  18. package/src/extension/core/state.ts +1 -0
  19. package/src/extension/core/types.ts +5 -0
  20. package/src/extension/queue/queue-integration.ts +20 -2
  21. package/src/extension/session/session-lifecycle.ts +24 -3
  22. package/src/extension/skills/inline-invocation.ts +1 -1
  23. package/src/extension/ui/custom-editor.ts +1 -5
  24. package/src/extension/ui/deck/config-diagnostic-lines.ts +0 -6
  25. package/src/extension/ui/ports-panel.ts +0 -1
  26. package/src/extension/ui/token-overlays.ts +1 -1
  27. package/src/extension/welcome/welcome-integration.ts +65 -5
  28. package/src/i18n/nl.ts +3 -0
  29. package/src/segments/core.ts +0 -1
  30. package/src/segments/system.ts +12 -3
  31. package/src/studio/component.ts +0 -1
  32. package/src/theme/theme.ts +1 -1
  33. package/src/tools/graph.ts +0 -3
  34. package/src/usage/tps-ring.ts +172 -32
  35. package/src/welcome/banner.ts +14 -0
  36. package/src/welcome/overlay.ts +139 -7
  37. package/src/welcome/quick-launch.ts +44 -0
  38. package/src/welcome/types.ts +13 -0
  39. package/src/welcome/widgets/sessions-widget.ts +13 -9
  40. package/src/welcome/widgets/shortcuts-widget.ts +13 -5
  41. package/src/welcome/widgets/whats-new-widget.ts +16 -3
  42. package/src/working-vibes/frames.ts +81 -5
  43. package/src/working-vibes/index.ts +6 -1
  44. package/src/working-vibes/native-indicator.ts +71 -0
  45. package/src/working-vibes/storage.ts +5 -3
@@ -4,6 +4,7 @@ import { BashTranscriptStore } from "../../../bash-mode/transcript.ts";
4
4
  import { BashCompletionEngine } from "../../../bash-mode/completion.ts";
5
5
  import { parsePowerlineConfig } from "../../config/powerline-config.ts";
6
6
  import { policyFromEnvironment } from "../../motion/accessibility.ts";
7
+ import { defaultMotionFor } from "../../motion/catalog.ts";
7
8
  import { registerCustomSegments } from "../../segments/index.ts";
8
9
  import { registerCustomPresets } from "../../config/presets.ts";
9
10
  import { invalidateGitStatus } from "../../git/status.ts";
@@ -34,6 +35,7 @@ import {
34
35
  requestImmediateStatusRender,
35
36
  requestStatusRender,
36
37
  resetLayoutCache,
38
+ sampleTpsFromSession,
37
39
  } from "../core/segment-context.ts";
38
40
  import { getQueueContext } from "../queue/queue-context.ts";
39
41
  import {
@@ -62,6 +64,10 @@ import {
62
64
  clearSkillsCountPublisher,
63
65
  } from "../skills/skill-status.ts";
64
66
  import { maybeAppendReadHint } from "./read-hints.ts";
67
+ import {
68
+ applyNativeWorkingIndicator,
69
+ resetNativeWorkingIndicator,
70
+ } from "../../working-vibes/native-indicator.ts";
65
71
  import {
66
72
  dispatchSignalEvent,
67
73
  settleSignal,
@@ -181,7 +187,7 @@ export function registerSessionLifecycle(
181
187
  }
182
188
  });
183
189
 
184
- pi.on("session_shutdown", async (_event, ctx) => {
190
+ pi.on("session_shutdown", async (_event, _ctx) => {
185
191
  clearSkillsCountPublisher();
186
192
  rt.sessionGeneration++;
187
193
  rt.dismissWelcomeOverlay?.();
@@ -260,9 +266,16 @@ export function registerSessionLifecycle(
260
266
 
261
267
  // Generate themed working message before agent starts (has access to user's prompt)
262
268
  pi.on("before_agent_start", async (event, ctx) => {
263
- rt.lastUserPrompt = event.prompt;
269
+ const prompt =
270
+ event.messageType === "user"
271
+ ? event.prompt
272
+ : event.message.display && typeof event.message.content === "string"
273
+ ? event.message.content
274
+ : "";
275
+ rt.lastUserPrompt = prompt;
264
276
  if (ctx.hasUI) {
265
- onVibeBeforeAgentStart(event.prompt, ctx.ui.setWorkingMessage);
277
+ onVibeBeforeAgentStart(prompt, ctx.ui.setWorkingMessage);
278
+ applyNativeWorkingIndicator(ctx.ui, rt.motionPolicy, defaultMotionFor("thinking"));
266
279
  }
267
280
  });
268
281
 
@@ -270,6 +283,7 @@ export function registerSessionLifecycle(
270
283
  // Also dismiss welcome when agent starts responding (handles `p "command"` case)
271
284
  pi.on("agent_start", async (_event, ctx) => {
272
285
  rt.isStreaming = true;
286
+ rt.turnStartedAt = Date.now();
273
287
  rt.liveAssistantUsage = null;
274
288
  onVibeAgentStart();
275
289
  dismissWelcome(rt, ctx);
@@ -287,6 +301,11 @@ export function registerSessionLifecycle(
287
301
  rt.liveAssistantUsage = event.message.usage;
288
302
  rt.currentCtx = ctx;
289
303
  rt.layoutDirty = true;
304
+ try {
305
+ sampleTpsFromSession(rt, ctx);
306
+ } catch (error) {
307
+ if (!isStaleExtensionContextError(error)) throw error;
308
+ }
290
309
  rt.statusRenderScheduler.schedule(CONTEXT_STATUS_RENDER_MS);
291
310
  if (rt.signal.event !== "streaming") {
292
311
  dispatchSignalEvent(rt, config.appearance, "streaming");
@@ -406,6 +425,7 @@ export function registerSessionLifecycle(
406
425
 
407
426
  pi.on("agent_end", async (_event, ctx) => {
408
427
  rt.isStreaming = false;
428
+ rt.turnStartedAt = null;
409
429
  rt.liveAssistantUsage = null;
410
430
  rt.coreContextUsageCache.reset();
411
431
  dispatchSignalEvent(rt, config.appearance, "success");
@@ -423,6 +443,7 @@ export function registerSessionLifecycle(
423
443
  try {
424
444
  if (hasUI) {
425
445
  onVibeAgentEnd(ctx.ui.setWorkingMessage); // working-vibes internal state + reset message
446
+ resetNativeWorkingIndicator(ctx.ui);
426
447
  if (rt.stashedEditorText !== null) {
427
448
  if (ctx.ui.getEditorText().trim() === "") {
428
449
  ctx.ui.setEditorText(rt.stashedEditorText);
@@ -162,7 +162,7 @@ export function expandInlineTriggers(text: string): string {
162
162
  */
163
163
  export function setupInlineInvocation(
164
164
  pi: ExtensionAPI,
165
- rt: RuntimeState,
165
+ _rt: RuntimeState,
166
166
  ): void {
167
167
  pi.on("input", async (event: any) => {
168
168
  // Alleen interactive input verwerken
@@ -48,14 +48,10 @@ import {
48
48
  runPowerlineShortcut,
49
49
  stashOrRestoreEditorText,
50
50
  } from "../shortcuts/shortcuts-router.ts";
51
- import {
52
- dismissWelcome,
53
- scheduleDismissWelcome,
54
- } from "../welcome/welcome-control.ts";
51
+ import { scheduleDismissWelcome } from "../welcome/welcome-control.ts";
55
52
  import { config } from "../core/state.ts";
56
53
  import type { RuntimeState } from "../core/types.ts";
57
54
  import {
58
- ensureShellSession,
59
55
  getShellCwd,
60
56
  getShellHistoryEntries,
61
57
  getShellPath,
@@ -19,12 +19,6 @@ const MAX_CONFLICTS = 3;
19
19
  const MAX_UNKNOWN = 3;
20
20
  const MAX_INVALID = 4;
21
21
 
22
- const MARK: Record<"ok" | "warn" | "fail", string> = {
23
- ok: "✓",
24
- warn: "!",
25
- fail: "✗",
26
- };
27
-
28
22
  /** Localised heading for the config diagnosis block. */
29
23
  export function configDiagnosticTitle(): string {
30
24
  return tr("diag.title", "Config diagnosis");
@@ -21,7 +21,6 @@ import { matchesKey, type SelectItem, SelectList } from "@earendil-works/pi-tui"
21
21
  import { tr } from "../../i18n/index.ts";
22
22
  import { config } from "../core/state.ts";
23
23
  import {
24
- filterPorts,
25
24
  formatAddresses,
26
25
  formatOwner,
27
26
  formatPortsSummary,
@@ -35,7 +35,7 @@ export function buildTpsOverlayItems(rt: RuntimeState, ctx: any): SelectItem[] {
35
35
  const summary = summarizeTpsRing(
36
36
  tpsSamples,
37
37
  Date.now(),
38
- { input: snap.input, output: snap.output },
38
+ { input: snap.input, output: snap.output, cacheRead: snap.cacheRead },
39
39
  {
40
40
  windowMs: config.segmentOptions?.tps?.windowMs ?? 1000,
41
41
  override: process.env.POWERLINE_TPS,
@@ -183,7 +183,7 @@ export function setupWelcomeOverlay(rt: RuntimeState, ctx: any) {
183
183
  (
184
184
  tui: any,
185
185
  _theme: any,
186
- _keybindings: any,
186
+ keybindings: any,
187
187
  done: (result: void) => void,
188
188
  ) => {
189
189
  const welcome = new WelcomeComponent(
@@ -205,22 +205,51 @@ export function setupWelcomeOverlay(rt: RuntimeState, ctx: any) {
205
205
  let countdown = 30;
206
206
  let dismissed = false;
207
207
  let interval: ReturnType<typeof setInterval> | null = null;
208
+ let revealTimer: ReturnType<typeof setInterval> | null = null;
209
+
210
+ const stopTimers = () => {
211
+ if (interval) clearInterval(interval);
212
+ if (revealTimer) clearInterval(revealTimer);
213
+ interval = null;
214
+ revealTimer = null;
215
+ };
208
216
 
209
217
  const dismiss = () => {
210
218
  if (dismissed) return;
211
219
  dismissed = true;
212
- if (interval) clearInterval(interval);
220
+ stopTimers();
213
221
  rt.dismissWelcomeOverlay = null;
214
222
  done();
215
223
  };
216
224
 
217
225
  interval = setInterval(() => {
218
- if (dismissed) return;
226
+ if (dismissed || welcome.isTimerPaused()) return;
219
227
  countdown--;
220
228
  welcome.setCountdown(countdown);
221
229
  tui.requestRender();
222
230
  if (countdown <= 0) dismiss();
223
231
  }, 1000);
232
+ interval.unref?.();
233
+
234
+ // The countdown only repaints once a second, which stepped the boot
235
+ // reveal at 1 FPS. Repaint on the ambient heartbeat: bounded while
236
+ // the reveal window is open, continuous while the lantern animates.
237
+ const revealStartedAt = Date.now();
238
+ const animateArt = artSettings.animate;
239
+ revealTimer = setInterval(() => {
240
+ if (dismissed) {
241
+ if (revealTimer) clearInterval(revealTimer);
242
+ revealTimer = null;
243
+ return;
244
+ }
245
+ if (!animateArt && !bootRevealActive(revealStartedAt, Date.now())) {
246
+ if (revealTimer) clearInterval(revealTimer);
247
+ revealTimer = null;
248
+ return;
249
+ }
250
+ tui.requestRender();
251
+ }, BOOT_REVEAL_HEARTBEAT_MS);
252
+ revealTimer.unref?.();
224
253
 
225
254
  rt.dismissWelcomeOverlay = dismiss;
226
255
 
@@ -233,10 +262,41 @@ export function setupWelcomeOverlay(rt: RuntimeState, ctx: any) {
233
262
  focused: false,
234
263
  invalidate: () => welcome.invalidate(),
235
264
  render: (width: number) => welcome.render(width),
236
- handleInput: () => dismiss(),
265
+ handleInput: (data: string) => {
266
+ // Only non-printable navigation is intercepted; Tab/Shift+Tab
267
+ // switch section, arrows move inside it, Enter (only once the
268
+ // countdown is paused) primes the focused quick-launch row.
269
+ // Every printable key still just dismisses, so typing is never
270
+ // swallowed by the overlay.
271
+ const nextSection = data === "\t";
272
+ const prevSection = data === "\x1b[Z";
273
+ if (nextSection || prevSection) {
274
+ welcome.cycleSection(prevSection ? -1 : 1);
275
+ tui.requestRender();
276
+ return;
277
+ }
278
+ const up = keybindings?.matches?.(data, "tui.select.up") || data === "\x1b[A";
279
+ const down = keybindings?.matches?.(data, "tui.select.down") || data === "\x1b[B";
280
+ if (up || down) {
281
+ welcome.moveFocus(up ? -1 : 1);
282
+ tui.requestRender();
283
+ return;
284
+ }
285
+ const enter =
286
+ keybindings?.matches?.(data, "tui.select.confirm") ||
287
+ data === "\r" ||
288
+ data === "\n";
289
+ if (enter && welcome.isTimerPaused()) {
290
+ const insert = welcome.activate();
291
+ dismiss();
292
+ if (insert) ctx.ui.setEditorText?.(insert);
293
+ return;
294
+ }
295
+ dismiss();
296
+ },
237
297
  dispose: () => {
238
298
  dismissed = true;
239
- if (interval) clearInterval(interval);
299
+ stopTimers();
240
300
  },
241
301
  };
242
302
  },
package/src/i18n/nl.ts CHANGED
@@ -311,6 +311,9 @@ export const NL: Record<string, string> = {
311
311
  "welcome.tagline": "Schrijf het op, laat het stijgen, houd je focus helder.",
312
312
  "welcome.title": " pi-wishcraft ",
313
313
  "welcome.countdown": "Druk op een toets om verder te gaan ({n}s)",
314
+ "welcome.pausedHint": "↑/↓ nieuws bekijken · andere toets gaat verder",
315
+ "welcome.pausedHint.sessions": "↑/↓ sessies bekijken · tab wisselt sectie · andere toets gaat verder",
316
+ "welcome.pausedHint.shortcuts": "↑/↓ kiezen · enter vult de prompt · tab wisselt sectie",
314
317
  "welcome.whatsNew": "Wat is nieuw",
315
318
  "welcome.firstRun": "Aan de slag",
316
319
  "welcome.firstRun.1": "# <idee> — leg een gedachte vast zonder te onderbreken",
@@ -1,7 +1,6 @@
1
1
  import { hostname as osHostname } from "node:os";
2
2
  import { basename } from "node:path";
3
3
  import type {
4
- SegmentContext,
5
4
  SemanticColor,
6
5
  StatusLineSegment,
7
6
  } from "../config/types.ts";
@@ -226,17 +226,26 @@ export const tpsSegment: StatusLineSegment = {
226
226
  };
227
227
  }
228
228
  const windowMs = ctx.options.tps?.windowMs ?? 1000;
229
- const { output, input } = ctx.usageStats ?? { output: 0, input: 0 };
229
+ const { output, input, cacheRead } = ctx.usageStats ?? {
230
+ output: 0,
231
+ input: 0,
232
+ cacheRead: 0,
233
+ };
230
234
  const now = Date.now();
231
235
  pushTpsSample(
232
236
  tpsSamples,
233
- { at: now, output, input },
237
+ {
238
+ at: now,
239
+ output,
240
+ input,
241
+ uncachedInput: Math.max(0, input - cacheRead),
242
+ },
234
243
  ringMsForWindow(windowMs),
235
244
  );
236
245
  const { inRate, outRate } = ratesFromRing(
237
246
  tpsSamples,
238
247
  now,
239
- { input, output },
248
+ { input, output, cacheRead },
240
249
  windowMs,
241
250
  );
242
251
  const icons = getIcons();
@@ -126,7 +126,6 @@ function renderList(
126
126
 
127
127
  function renderDetail(theme: Theme, width: number, entry: SkillEntry | null, usage: ReadonlyMap<string, SkillUsage>): string[] {
128
128
  if (!entry) return [theme.fg("accent", "DETAIL"), theme.fg("dim", "Select a skill from the list")];
129
- const body = readSkillBody(entry.filePath);
130
129
  const refs = safeReadReferences(entry);
131
130
  const use = usage.get(entry.name);
132
131
  const health = entry.warning ? theme.fg("warning", "warn") : theme.fg("success", "ok");
@@ -1,4 +1,4 @@
1
- import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
1
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
  import { existsSync, readFileSync, statSync } from "node:fs";
3
3
  import { join, dirname } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
@@ -1,8 +1,5 @@
1
1
  const BRAILLE_CHAR_BASE = 0x2800;
2
2
 
3
- // Braille dot heights (0-7 for 4 vertical levels in 2 columns)
4
- const BRAILLE_MAP = [0x40, 0x4, 0x20, 0x2, 0x10, 0x1, 0x80, 0x8];
5
-
6
3
  /**
7
4
  * Render a sequence of numeric data points as a Braille sparkline string.
8
5
  */
@@ -1,17 +1,38 @@
1
1
  /**
2
- * Shared TPS ring math. The segment writes samples; `/tps` and Status → TPS
3
- * read the same ring. No second sampler.
2
+ * Shared TPS ring math. Stream events (`message_update`) and the segment both
3
+ * write samples; `/tps` and Status → TPS read the same ring.
4
+ *
5
+ * Three things the plain delta maths got wrong:
6
+ * - prompt tokens arrive once per turn, so a window delta reports the cache
7
+ * read (or nothing at all) instead of the rate the prompt was processed at;
8
+ * - output arrives in chunks, so a single 1s window is noisy;
9
+ * - a burst that lands right after a repaint looks like thousands of tokens/s.
10
+ * The ring therefore carries the turn start and the uncached input count on a
11
+ * sample, derives TTFT from them, and smooths output with an EMA. All of it
12
+ * lives on the ring: nothing here keeps state beyond the samples themselves.
4
13
  */
5
14
 
6
15
  export interface TpsSample {
7
16
  at: number;
8
17
  output: number;
9
18
  input: number;
19
+ /** Prompt tokens excluding `cacheRead`; falls back to `input`. */
20
+ uncachedInput?: number;
21
+ /**
22
+ * Timestamp (ms) the turn began (`agent_start`). Set on the first sample of
23
+ * a turn only, so the marker identifies where that turn starts in the ring.
24
+ */
25
+ turnStartAt?: number;
10
26
  }
11
27
 
12
28
  export interface TpsRates {
29
+ /** Uncached prompt tokens per second; TTFT-based while a turn is active. */
13
30
  inRate: number;
14
31
  outRate: number;
32
+ /** EMA over the pairwise output rates — the steady-state estimate. */
33
+ emaOutRate?: number;
34
+ /** Time to first token of the current turn; null when no turn is measured. */
35
+ ttftMs?: number | null;
15
36
  }
16
37
 
17
38
  export interface TpsSummary extends TpsRates {
@@ -26,14 +47,29 @@ export interface TpsSummary extends TpsRates {
26
47
 
27
48
  export const TPS_MIN_RING_MS = 5000;
28
49
  export const TPS_MAX_SAMPLES = 480;
50
+ /**
51
+ * Pairwise buckets shorter than this are coalesced into the next sample. Two
52
+ * repaints a couple of ms apart that straddle one token burst would otherwise
53
+ * report that burst as thousands of tokens per second.
54
+ */
55
+ export const TPS_MIN_PAIRWISE_DT_MS = 80;
56
+ /** Samples with identical counts closer together than this carry no signal. */
57
+ const TPS_DEDUPE_MS = 40;
58
+ /** Smoothing factor for the output EMA (0..1; higher = more reactive). */
59
+ export const TPS_EMA_ALPHA = 0.32;
29
60
 
30
- /** Live ring filled by the tps segment renderer. */
61
+ /** Live ring filled by stream events and the tps segment renderer. */
31
62
  export const tpsSamples: TpsSample[] = [];
32
63
 
33
64
  export function ringMsForWindow(windowMs: number): number {
34
65
  return Math.max(TPS_MIN_RING_MS, windowMs * 2);
35
66
  }
36
67
 
68
+ /** Prompt tokens that actually had to be processed for this sample. */
69
+ function uncachedOf(sample: TpsSample): number {
70
+ return sample.uncachedInput ?? sample.input;
71
+ }
72
+
37
73
  export function pruneTpsRing(
38
74
  samples: TpsSample[],
39
75
  now: number,
@@ -50,14 +86,103 @@ export function pushTpsSample(
50
86
  sample: TpsSample,
51
87
  ringMs: number,
52
88
  ): void {
89
+ const tail = samples[samples.length - 1];
90
+ if (
91
+ tail &&
92
+ tail.output === sample.output &&
93
+ tail.input === sample.input &&
94
+ sample.at - tail.at < TPS_DEDUPE_MS
95
+ ) {
96
+ pruneTpsRing(samples, sample.at, ringMs);
97
+ return;
98
+ }
53
99
  samples.push(sample);
54
100
  pruneTpsRing(samples, sample.at, ringMs);
55
101
  }
56
102
 
103
+ /**
104
+ * Locate the current turn in the ring and the first sample that produced
105
+ * tokens for it. Returns null when no turn marker is present, which is the
106
+ * normal case for a preset that never streams (`/tps` alone, replay, tests).
107
+ */
108
+ function measureTurn(samples: readonly TpsSample[]): {
109
+ turnStartAt: number;
110
+ firstTokenAt: number;
111
+ baseInput: number;
112
+ baseOutput: number;
113
+ } | null {
114
+ let marker = -1;
115
+ for (let i = samples.length - 1; i >= 0; i--) {
116
+ if (samples[i]!.turnStartAt !== undefined) {
117
+ marker = i;
118
+ break;
119
+ }
120
+ }
121
+ if (marker < 0) return null;
122
+
123
+ const start = samples[marker]!.turnStartAt!;
124
+ const before = samples[marker - 1];
125
+ const baseInput = before ? uncachedOf(before) : 0;
126
+ const baseOutput = before ? before.output : 0;
127
+
128
+ for (let i = marker; i < samples.length; i++) {
129
+ const cur = samples[i]!;
130
+ if (cur.output > baseOutput || uncachedOf(cur) > baseInput) {
131
+ return {
132
+ turnStartAt: start,
133
+ firstTokenAt: cur.at,
134
+ baseInput,
135
+ baseOutput,
136
+ };
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+
142
+ /** Pairwise buckets for peak/average reporting. */
143
+ function pairwiseRates(samples: readonly TpsSample[]): {
144
+ ins: number[];
145
+ outs: number[];
146
+ } {
147
+ const ins: number[] = [];
148
+ const outs: number[] = [];
149
+ if (samples.length < 2) return { ins, outs };
150
+ let anchor = samples[0]!;
151
+ for (let i = 1; i < samples.length; i++) {
152
+ const cur = samples[i]!;
153
+ const dtMs = cur.at - anchor.at;
154
+ if (dtMs < TPS_MIN_PAIRWISE_DT_MS) continue;
155
+ const dtSec = dtMs / 1000;
156
+ const dOut = cur.output - anchor.output;
157
+ const dIn = uncachedOf(cur) - uncachedOf(anchor);
158
+ if (dOut > 0) outs.push(dOut / dtSec);
159
+ if (dIn > 0) ins.push(dIn / dtSec);
160
+ anchor = cur;
161
+ }
162
+ return { ins, outs };
163
+ }
164
+
165
+ /** EMA over the pairwise output rates; undefined when nothing was generated. */
166
+ function emaOf(values: readonly number[]): number | undefined {
167
+ if (values.length === 0) return undefined;
168
+ let ema = values[0]!;
169
+ for (let i = 1; i < values.length; i++) {
170
+ ema = TPS_EMA_ALPHA * values[i]! + (1 - TPS_EMA_ALPHA) * ema;
171
+ }
172
+ return ema;
173
+ }
174
+
175
+ export interface TpsUsage {
176
+ input: number;
177
+ output: number;
178
+ /** Cached prompt tokens to exclude from the processed-input rate. */
179
+ cacheRead?: number;
180
+ }
181
+
57
182
  export function ratesFromRing(
58
183
  samples: readonly TpsSample[],
59
184
  now: number,
60
- usage: { input: number; output: number },
185
+ usage: TpsUsage,
61
186
  windowMs: number,
62
187
  ): TpsRates {
63
188
  let ref: TpsSample | null = null;
@@ -72,35 +197,40 @@ export function ratesFromRing(
72
197
  ref = s;
73
198
  }
74
199
  }
200
+
75
201
  let outRate = 0;
76
- let inRate = 0;
202
+ let windowInRate = 0;
77
203
  if (ref) {
78
204
  const dt = (now - ref.at) / 1000;
79
205
  const dOut = usage.output - ref.output;
80
206
  const dIn = usage.input - ref.input;
81
- if (dt > 0 && dOut >= 0) outRate = dOut / dt;
82
- if (dt > 0 && dIn >= 0) inRate = dIn / dt;
207
+ if (dt > 0 && dOut > 0) outRate = dOut / dt;
208
+ if (dt > 0 && dIn > 0) windowInRate = dIn / dt;
83
209
  }
84
- return { inRate, outRate };
85
- }
86
210
 
87
- function pairwiseRates(samples: readonly TpsSample[]): {
88
- ins: number[];
89
- outs: number[];
90
- } {
91
- const ins: number[] = [];
92
- const outs: number[] = [];
93
- for (let i = 1; i < samples.length; i++) {
94
- const prev = samples[i - 1]!;
95
- const cur = samples[i]!;
96
- const dt = (cur.at - prev.at) / 1000;
97
- if (dt <= 0) continue;
98
- const dOut = cur.output - prev.output;
99
- const dIn = cur.input - prev.input;
100
- if (dOut >= 0) outs.push(dOut / dt);
101
- if (dIn >= 0) ins.push(dIn / dt);
211
+ const turn = measureTurn(samples);
212
+ let ttftMs: number | null = null;
213
+ let inRate = windowInRate;
214
+ if (turn) {
215
+ // Prompt processing happens once, up front: its rate is the uncached
216
+ // token count over the time it took to produce the first token. Dividing
217
+ // it by the 1s window (and counting cacheRead) is what produced the
218
+ // nonsense input rates this replaces.
219
+ ttftMs = Math.max(1, turn.firstTokenAt - turn.turnStartAt);
220
+ const processed = Math.max(
221
+ 0,
222
+ usage.input - (usage.cacheRead ?? 0) - turn.baseInput,
223
+ );
224
+ inRate = processed > 0 ? processed / (ttftMs / 1000) : 0;
102
225
  }
103
- return { ins, outs };
226
+
227
+ const { outs } = pairwiseRates(samples);
228
+ return {
229
+ inRate,
230
+ outRate,
231
+ emaOutRate: emaOf(outs),
232
+ ttftMs,
233
+ };
104
234
  }
105
235
 
106
236
  function avg(values: readonly number[]): number {
@@ -111,18 +241,20 @@ function avg(values: readonly number[]): number {
111
241
  export function summarizeTpsRing(
112
242
  samples: readonly TpsSample[],
113
243
  now: number,
114
- usage: { input: number; output: number },
244
+ usage: TpsUsage,
115
245
  options: { windowMs?: number; override?: string | null } = {},
116
246
  ): TpsSummary {
117
247
  const windowMs = options.windowMs ?? 1000;
118
248
  const override = options.override?.trim() || null;
119
- const { inRate, outRate } = ratesFromRing(samples, now, usage, windowMs);
249
+ const rates = ratesFromRing(samples, now, usage, windowMs);
120
250
  const { ins, outs } = pairwiseRates(samples);
121
251
  return {
122
252
  override,
123
253
  windowMs,
124
- inRate,
125
- outRate,
254
+ inRate: rates.inRate,
255
+ outRate: rates.outRate,
256
+ emaOutRate: rates.emaOutRate,
257
+ ttftMs: rates.ttftMs,
126
258
  peakIn: ins.length ? Math.max(...ins) : 0,
127
259
  peakOut: outs.length ? Math.max(...outs) : 0,
128
260
  avgIn: avg(ins),
@@ -142,10 +274,18 @@ export function tpsOverlayLines(summary: TpsSummary): string[] {
142
274
  "clear with Configure or /tps (empty to show live)",
143
275
  ];
144
276
  }
145
- return [
277
+ const lines = [
146
278
  `window ${summary.windowMs}ms over ${TPS_MIN_RING_MS}ms ring`,
147
- `out ${formatTpsRate(summary.outRate)} peak ${formatTpsRate(summary.peakOut)} avg ${formatTpsRate(summary.avgOut)}`,
279
+ `out ${formatTpsRate(summary.outRate)} peak ${formatTpsRate(summary.peakOut)} avg ${formatTpsRate(summary.avgOut)}${
280
+ summary.emaOutRate === undefined
281
+ ? ""
282
+ : ` ema ${formatTpsRate(summary.emaOutRate)}`
283
+ }`,
148
284
  `in ${formatTpsRate(summary.inRate)} peak ${formatTpsRate(summary.peakIn)} avg ${formatTpsRate(summary.avgIn)}`,
149
- `samples ${summary.samples}`,
150
285
  ];
286
+ if (summary.ttftMs !== null && summary.ttftMs !== undefined) {
287
+ lines.push(`ttft ${Math.round(summary.ttftMs)}ms`);
288
+ }
289
+ lines.push(`samples ${summary.samples}`);
290
+ return lines;
151
291
  }
@@ -70,6 +70,20 @@ export function bootRevealActive(startedAt: number, now: number): boolean {
70
70
  return now - startedAt < BOOT_REVEAL_MS;
71
71
  }
72
72
 
73
+ /**
74
+ * Reveal time to hand the art renderer, or `undefined` when the steady layout
75
+ * should take over. After the reveal window an animated lantern keeps its own
76
+ * override (elapsed 0 = no fade, live frames) so the flame never freezes on
77
+ * the first paint; a still lantern falls back to the plain box art.
78
+ */
79
+ export function revealElapsedForArt(
80
+ elapsedMs: number,
81
+ animate: boolean,
82
+ ): number | undefined {
83
+ if (elapsedMs < BOOT_REVEAL_MS) return elapsedMs;
84
+ return animate ? 0 : undefined;
85
+ }
86
+
73
87
  /**
74
88
  * Welcome header - same layout as overlay but persistent (no countdown).
75
89
  * Used when quietStartup: true. The first ~1.5s after mount fades the art