Skip to content

Commit 8ed8f69

Browse files
committed
feat(tui): show step decode speed in the footer
1 parent 26ff97e commit 8ed8f69

12 files changed

Lines changed: 120 additions & 37 deletions

apps/pythinker-code/src/tui/components/chrome/footer.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import chalk from 'chalk';
1212
import { effectiveModelAlias } from '@pymodel/pythinker-code-sdk';
1313

1414
import { ALL_TIPS, type ToolbarTip } from '#/tui/constant/tips';
15-
import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/dance';
15+
import { isRainbowHatching, renderHatchFooterModel } from '#/tui/easter-eggs/hatch';
1616
import { currentTheme } from '#/tui/theme';
1717
import type { ColorPalette } from '#/tui/theme/colors';
1818
import type { AppState } from '#/tui/types';
@@ -195,6 +195,7 @@ export class FooterComponent implements Component {
195195
private gitCacheWorkDir: string;
196196
private transientHint: string | null = null;
197197
private warningHint: string | null = null;
198+
private streamSpeedTps: number | null = null;
198199
private goalSnapshotKey: string | null = null;
199200
private goalObservedAtMs = Date.now();
200201
private goalTimer: ReturnType<typeof setInterval> | null = null;
@@ -245,6 +246,17 @@ export class FooterComponent implements Component {
245246
}
246247
}
247248

249+
/**
250+
* Decode speed of the most recently completed step (tokens/s), shown next
251+
* to the context readout on line 2. `null` hides it (turn end, or a step
252+
* too short to measure — see `computeDecodeTps`).
253+
*/
254+
setStreamSpeed(tps: number | null): void {
255+
if (this.streamSpeedTps === tps) return;
256+
this.streamSpeedTps = tps;
257+
this.onRefresh();
258+
}
259+
248260
/**
249261
* Short-lived hint that replaces the rotating toolbar tips on line 1.
250262
* Used by the exit-confirmation double-tap flow to show "Press Ctrl+C
@@ -337,28 +349,34 @@ export class FooterComponent implements Component {
337349
}
338350
}
339351

340-
// ── Line 2: hint (bottom-left) + context (right) ──
352+
// ── Line 2: hint (bottom-left) + context + stream speed (right) ──
341353
const contextText = formatContextStatus(
342354
state.contextUsage,
343355
state.contextTokens,
344356
state.maxContextTokens,
345357
);
346-
const contextWidth = visibleWidth(contextText);
358+
const speedSuffix =
359+
this.streamSpeedTps === null ? '' : ` · ${this.streamSpeedTps.toFixed(1)} t/s`;
360+
const rightWidth = visibleWidth(contextText) + visibleWidth(speedSuffix);
347361
let line2: string;
348362
const hint = this.transientHint ?? this.warningHint;
349363
if (hint) {
350-
const maxHintWidth = Math.max(0, width - contextWidth - 1);
364+
const maxHintWidth = Math.max(0, width - rightWidth - 1);
351365
const shownHint =
352366
visibleWidth(hint) <= maxHintWidth ? hint : truncateToWidth(hint, maxHintWidth, '…');
353367
const hintWidth = visibleWidth(shownHint);
354-
const pad = Math.max(0, width - hintWidth - contextWidth);
368+
const pad = Math.max(0, width - hintWidth - rightWidth);
355369
line2 =
356370
chalk.hex(colors.warning).bold(shownHint) +
357371
' '.repeat(pad) +
358-
chalk.hex(colors.text)(contextText);
372+
chalk.hex(colors.text)(contextText) +
373+
chalk.hex(colors.textDim)(speedSuffix);
359374
} else {
360-
const leftPad = Math.max(0, width - contextWidth);
361-
line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText);
375+
const leftPad = Math.max(0, width - rightWidth);
376+
line2 =
377+
' '.repeat(leftPad) +
378+
chalk.hex(colors.text)(contextText) +
379+
chalk.hex(colors.textDim)(speedSuffix);
362380
}
363381

364382
return [truncateToWidth(line1, width), truncateToWidth(line2, width)];
@@ -413,8 +431,8 @@ export class FooterComponent implements Component {
413431
: '';
414432
const modelLabel = `${model}${thinkingLabel}`;
415433
let renderedModelLabel = chalk.hex(colors.text)(modelLabel);
416-
if (isRainbowDancing()) {
417-
renderedModelLabel = renderDanceFooterModel(modelLabel);
434+
if (isRainbowHatching()) {
435+
renderedModelLabel = renderHatchFooterModel(modelLabel);
418436
}
419437
slots['model'] = [renderedModelLabel];
420438
}

apps/pythinker-code/src/tui/controllers/session-event-handler.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ import { openUrl } from '#/utils/open-url';
7171
import { currentTheme } from '#/tui/theme';
7272
import type { ColorToken } from '#/tui/theme';
7373
import { errorReportHintLine } from '../constant/feedback';
74-
import { formatStepDebugTiming } from '#/utils/usage/debug-timing';
74+
import { computeDecodeTps, formatStepDebugTiming } from '#/utils/usage/debug-timing';
7575
import { nextTranscriptId } from '../utils/transcript-id';
7676
import type { BtwPanelController } from './btw-panel';
7777
import { isPluginMcpToolName, PluginUpdateNotifier } from './plugin-update-notifier';
@@ -373,6 +373,8 @@ export class SessionEventHandler {
373373
this.host.handleTurnEnded?.(event);
374374
this.host.streamingUI.flushNow();
375375
this.clearStepRetry();
376+
// The last step's decode speed no longer applies once the turn ends.
377+
this.host.state.footer.setStreamSpeed(null);
376378
if (event.reason === 'cancelled') {
377379
this.markActiveAgentDynamicWorkflowsCancelled();
378380
}
@@ -436,6 +438,9 @@ export class SessionEventHandler {
436438
this.clearStepRetry();
437439
this.host.noteStepUsage(event.usage);
438440
this.maybeShowDebugTiming(event);
441+
this.host.state.footer.setStreamSpeed(
442+
computeDecodeTps(event.usage?.output, event.llmStreamDurationMs),
443+
);
439444

440445
if (event.providerFinishReason === 'filtered') {
441446
this.host.showNotice(

apps/pythinker-code/src/tui/pythinker-tui.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ import { SessionReplayRenderer } from './controllers/session-replay';
120120
import { StagingLeaseTracker, type StagingLease } from './controllers/staging-leases';
121121
import { StreamingUIController } from './controllers/streaming-ui';
122122
import { TasksBrowserController } from './controllers/tasks-browser';
123-
import { installRainbowDance } from './easter-eggs/dance';
123+
import { installRainbowHatch } from './easter-eggs/hatch';
124124
import { adaptPanelResponse } from './reverse-rpc/approval/adapter';
125125
import { ApprovalController } from './reverse-rpc/approval/controller';
126126
import { createApprovalRequestHandler } from './reverse-rpc/approval/handler';
@@ -321,7 +321,7 @@ export class PythinkerTUI {
321321
private terminalFocusTrackingDispose: (() => void) | undefined;
322322
private terminalThemeTrackingDispose: (() => void) | undefined;
323323
private clipboardImageHintController: ClipboardImageHintController | undefined;
324-
private uninstallRainbowDance: () => void;
324+
private uninstallRainbowHatch: () => void;
325325
private signalCleanupHandlers: Array<() => void> = [];
326326
private isShuttingDown = false;
327327
private backgroundRefreshPromise: Promise<void> | undefined;
@@ -417,7 +417,7 @@ export class PythinkerTUI {
417417
this.engineV2 = startupInput.engineV2 ?? false;
418418
this.startupNotice = startupInput.startupNotice;
419419
this.state = createTUIState(tuiOptions);
420-
this.uninstallRainbowDance = installRainbowDance(() => {
420+
this.uninstallRainbowHatch = installRainbowHatch(() => {
421421
this.state.ui.requestRender();
422422
});
423423

@@ -907,7 +907,7 @@ export class PythinkerTUI {
907907
} finally {
908908
this.sessionEventHandler.stopAllMcpServerStatusSpinners();
909909
this.sessionEventHandler.clearStepRetryAttemptTimer();
910-
this.uninstallRainbowDance();
910+
this.uninstallRainbowHatch();
911911
try {
912912
await this.state.terminal.drainInput();
913913
} catch {
@@ -1061,10 +1061,6 @@ export class PythinkerTUI {
10611061
// Input Dispatch
10621062
// =========================================================================
10631063

1064-
handlePlanToggle(next: boolean): void {
1065-
void slashCommands.handlePlanCommand(this, next ? 'on' : 'off');
1066-
}
1067-
10681064
handleInputModeChange(mode: 'prompt' | 'bash'): void {
10691065
this.setAppState({ inputMode: mode });
10701066
this.updateEditorBorderHighlight();

apps/pythinker-code/src/utils/usage/debug-timing.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,22 @@ export interface StepTimingInput {
3535
// instead of a meaningless ratio.
3636
const MIN_STREAM_MS_FOR_TPS = 50;
3737

38+
/**
39+
* Step decode speed in raw tokens/s, or null when the ratio would be
40+
* meaningless: unknown/zero output, or a decode window below
41+
* `MIN_STREAM_MS_FOR_TPS` where `Date.now()`'s ~1ms quantization dominates
42+
* and short tool-call turns would report inflated rates like tens of
43+
* thousands of tok/s. Callers format the ratio (one decimal is conventional).
44+
*/
45+
export function computeDecodeTps(
46+
outputTokens: number | undefined,
47+
streamMs: number | undefined,
48+
): number | null {
49+
if (outputTokens === undefined || outputTokens <= 0) return null;
50+
if (streamMs === undefined || streamMs < MIN_STREAM_MS_FOR_TPS) return null;
51+
return outputTokens / (streamMs / 1000);
52+
}
53+
3854
export function formatStepDebugTiming(input: StepTimingInput): string | undefined {
3955
const latency = input.llmFirstTokenLatencyMs;
4056
const streamMs = input.llmStreamDurationMs;
@@ -43,10 +59,10 @@ export function formatStepDebugTiming(input: StepTimingInput): string | undefine
4359
const parts: string[] = [`TTFT: ${formatTtft(input)}`];
4460
const outputTokens = input.usage?.output;
4561
if (outputTokens !== undefined && outputTokens > 0) {
46-
if (streamMs >= MIN_STREAM_MS_FOR_TPS) {
47-
const tps = (outputTokens / (streamMs / 1000)).toFixed(1);
62+
const tps = computeDecodeTps(outputTokens, streamMs);
63+
if (tps !== null) {
4864
parts.push(
49-
`TPS: ${tps} tok/s (${outputTokens} tokens in ${formatDuration(streamMs)}${formatDecodeSplit(input)})`,
65+
`TPS: ${tps.toFixed(1)} tok/s (${outputTokens} tokens in ${formatDuration(streamMs)}${formatDecodeSplit(input)})`,
5066
);
5167
} else {
5268
parts.push(

apps/pythinker-code/test/tui/components/chrome/footer.test.ts

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import chalk from 'chalk';
22
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
33

44
import { FooterComponent } from '#/tui/components/chrome/footer';
5-
import { setRainbowDance, type RainbowDanceController } from '#/tui/easter-eggs/dance';
5+
import { setRainbowHatch, type RainbowHatchController } from '#/tui/easter-eggs/hatch';
66
import { currentTheme, darkColors, lightColors } from '#/tui/theme';
77
import type { ModelAlias } from '@pymodel/pythinker-code-sdk';
88
import type { AppState } from '#/tui/types';
@@ -17,19 +17,19 @@ function truecolorCodes(text: string): Set<string> {
1717
return codes;
1818
}
1919

20-
// Dark dance colors the footer never uses outside of /dance.
20+
// Dark hatch colors the footer never uses outside of /hatch.
2121
const RAINBOW_CYAN = '91,192,190';
2222
const RAINBOW_GREEN = '78,200,126';
2323

24-
function setDanceView(colored: boolean, phase: number): void {
25-
const dance: RainbowDanceController = {
24+
function setHatchView(colored: boolean, phase: number): void {
25+
const hatch: RainbowHatchController = {
2626
colored,
2727
phase,
2828
start: () => {},
2929
stop: () => {},
3030
dispose: () => {},
3131
};
32-
setRainbowDance(dance);
32+
setRainbowHatch(hatch);
3333
}
3434

3535
const appState: AppState = {
@@ -70,11 +70,11 @@ describe('FooterComponent', () => {
7070

7171
afterEach(() => {
7272
chalk.level = previousChalkLevel;
73-
setRainbowDance(undefined);
73+
setRainbowHatch(undefined);
7474
});
7575

7676
it('paints the model name in rainbow while colored', () => {
77-
setDanceView(true, 0);
77+
setHatchView(true, 0);
7878
const footer = new FooterComponent(appState);
7979

8080
const codes = truecolorCodes(footer.render(120).join('\n'));
@@ -85,7 +85,7 @@ describe('FooterComponent', () => {
8585
expect(codes.has(RAINBOW_GREEN)).toBe(true);
8686
});
8787

88-
it('renders the model name in its normal color when not dancing', () => {
88+
it('renders the model name in its normal color when not hatching', () => {
8989
const footer = new FooterComponent(appState);
9090

9191
const codes = truecolorCodes(footer.render(120).join('\n'));
@@ -223,3 +223,34 @@ describe('FooterComponent line-2 hints', () => {
223223
expect(stripAnsi(footer.render(120)[1] ?? '')).not.toContain('Goal objective is too long');
224224
});
225225
});
226+
227+
228+
describe('FooterComponent stream speed', () => {
229+
function stripAnsiSpeed(text: string): string {
230+
return text.replaceAll(/\[[0-9;]*m/g, '');
231+
}
232+
233+
it('hides the speed badge until a step completes', () => {
234+
const footer = new FooterComponent(appState);
235+
236+
expect(stripAnsiSpeed(footer.render(120)[1] ?? '')).not.toContain('t/s');
237+
});
238+
239+
it('shows the last step decode speed next to the context readout', () => {
240+
const footer = new FooterComponent(appState);
241+
footer.setStreamSpeed(38.44);
242+
243+
const line2 = stripAnsiSpeed(footer.render(120)[1] ?? '');
244+
245+
expect(line2).toContain('context:');
246+
expect(line2).toContain('· 38.4 t/s');
247+
});
248+
249+
it('clears the speed badge on null', () => {
250+
const footer = new FooterComponent(appState);
251+
footer.setStreamSpeed(12.5);
252+
footer.setStreamSpeed(null);
253+
254+
expect(stripAnsiSpeed(footer.render(120)[1] ?? '')).not.toContain('t/s');
255+
});
256+
});

apps/pythinker-code/test/tui/controllers/session-event-handler-background-task.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ function makeSessionEventHost() {
124124
todoPanel: { getTodos: vi.fn(() => []) },
125125
transcriptContainer: { addChild: vi.fn() },
126126
tasksBrowser: undefined,
127-
footer: { setBackgroundCounts: vi.fn() },
127+
footer: { setBackgroundCounts: vi.fn(), setStreamSpeed: vi.fn() },
128128
ui: { requestRender: vi.fn() },
129129
},
130130
session: { id: 's1' },

apps/pythinker-code/test/tui/controllers/session-event-handler-compaction.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getBuiltInPalette } from '#/tui/theme';
66
function makeHost() {
77
const host = {
88
state: {
9+
footer: { setStreamSpeed: vi.fn() },
910
appState: {
1011
sessionId: 's1',
1112
streamingPhase: 'waiting',

apps/pythinker-code/test/tui/controllers/session-event-handler-goal-queue.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) {
4747
};
4848
const host = {
4949
state: {
50+
footer: { setStreamSpeed: vi.fn() },
5051
appState: {
5152
sessionId: 's1',
5253
streamingPhase: 'waiting',

apps/pythinker-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ function makeHost() {
2020
};
2121
const host = {
2222
state: {
23+
footer: { setStreamSpeed: vi.fn() },
2324
appState: {
2425
sessionId: 's1',
2526
streamingPhase: 'waiting',

apps/pythinker-code/test/tui/controllers/session-event-handler-step-retry.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getBuiltInPalette } from '#/tui/theme';
66
function makeHost() {
77
const host = {
88
state: {
9+
footer: { setStreamSpeed: vi.fn() },
910
appState: {
1011
sessionId: 's1',
1112
streamingPhase: 'waiting',

0 commit comments

Comments
 (0)