@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.
- package/AGENTS.md +52 -22
- package/CHANGELOG.md +65 -0
- package/CONTRIBUTING.md +12 -1
- package/RELEASE.md +142 -49
- package/bash-mode/completion.ts +2 -2
- package/build-stamp.json +1 -1
- package/docs/agents/domain.md +60 -0
- package/docs/agents/issue-tracker.md +88 -0
- package/docs/configuration.md +6 -1
- package/docs/design/regression-testing.md +1 -1
- package/docs/testing.md +81 -0
- package/package.json +17 -10
- package/src/config/settings-registry.ts +2 -1
- package/src/config/structural-preset-table.ts +0 -1
- package/src/extension/commands/commands.ts +0 -1
- package/src/extension/core/peer-guard.ts +7 -2
- package/src/extension/core/segment-context.ts +139 -44
- package/src/extension/core/state.ts +1 -0
- package/src/extension/core/types.ts +5 -0
- package/src/extension/queue/queue-integration.ts +20 -2
- package/src/extension/session/session-lifecycle.ts +24 -3
- package/src/extension/skills/inline-invocation.ts +1 -1
- package/src/extension/ui/custom-editor.ts +1 -5
- package/src/extension/ui/deck/config-diagnostic-lines.ts +0 -6
- package/src/extension/ui/ports-panel.ts +0 -1
- package/src/extension/ui/token-overlays.ts +1 -1
- package/src/extension/welcome/welcome-integration.ts +65 -5
- package/src/i18n/nl.ts +3 -0
- package/src/segments/core.ts +0 -1
- package/src/segments/system.ts +12 -3
- package/src/studio/component.ts +0 -1
- package/src/theme/theme.ts +1 -1
- package/src/tools/graph.ts +0 -3
- package/src/usage/tps-ring.ts +172 -32
- package/src/welcome/banner.ts +14 -0
- package/src/welcome/overlay.ts +139 -7
- package/src/welcome/quick-launch.ts +44 -0
- package/src/welcome/types.ts +13 -0
- package/src/welcome/widgets/sessions-widget.ts +13 -9
- package/src/welcome/widgets/shortcuts-widget.ts +13 -5
- package/src/welcome/widgets/whats-new-widget.ts +16 -3
- package/src/working-vibes/frames.ts +81 -5
- package/src/working-vibes/index.ts +6 -1
- package/src/working-vibes/native-indicator.ts +71 -0
- 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,
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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");
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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
|
-
|
|
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",
|
package/src/segments/core.ts
CHANGED
package/src/segments/system.ts
CHANGED
|
@@ -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 ?? {
|
|
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
|
-
{
|
|
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();
|
package/src/studio/component.ts
CHANGED
|
@@ -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");
|
package/src/theme/theme.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
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";
|
package/src/tools/graph.ts
CHANGED
package/src/usage/tps-ring.ts
CHANGED
|
@@ -1,17 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared TPS ring math.
|
|
3
|
-
* read the same ring.
|
|
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:
|
|
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
|
|
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
|
|
82
|
-
if (dt > 0 && dIn
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/welcome/banner.ts
CHANGED
|
@@ -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
|