pi-one-ui 0.3.1 → 0.4.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.
@@ -7,7 +7,6 @@ import type {
7
7
  import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
8
8
  import type { PolishedTuiConfig } from "../../app/config/shell.ts";
9
9
  import { type FooterState, modelLabelFor } from "../footer/index.ts";
10
- import { markAccentRailLayoutEditor } from "./accent-rail-layout-patch.ts";
11
10
  import {
12
11
  type EditorFactory,
13
12
  markEditorFactory,
@@ -22,14 +21,11 @@ export type EditorFactoryRuntime = {
22
21
  readonly getConfig: () => PolishedTuiConfig;
23
22
  readonly getState: () => FooterState;
24
23
  readonly getThinkingLevel: () => ReturnType<ExtensionAPI["getThinkingLevel"]>;
25
- readonly getContextWindow: (ctx: ExtensionContext) => number | undefined;
26
- readonly getContextPercent: (ctx: ExtensionContext) => number | undefined;
27
24
  readonly getAgentDurationMs: () => number;
28
25
  readonly isAgentActive: () => boolean;
29
26
  readonly getProjectRoot: () => string | undefined;
30
27
  readonly onRender: (requestRender: () => void) => void;
31
28
  readonly onDecorationActive: (active: boolean) => void;
32
- readonly isAccentRailActive: () => boolean;
33
29
  };
34
30
 
35
31
  /**
@@ -49,7 +45,7 @@ export function createEditorFactory(
49
45
  keybindings: KeybindingsManager,
50
46
  ) => {
51
47
  runtime.onRender(() => tui.requestRender());
52
- const editor = new PolishedEditor(
48
+ return new PolishedEditor(
53
49
  tui,
54
50
  theme,
55
51
  keybindings,
@@ -60,12 +56,6 @@ export function createEditorFactory(
60
56
  () => editorDecoration(ctx, runtime),
61
57
  runtime.onDecorationActive,
62
58
  );
63
- markAccentRailLayoutEditor(
64
- editor,
65
- runtime.ownerToken,
66
- runtime.isAccentRailActive,
67
- );
68
- return editor;
69
59
  }) as ZentuiEditorFactory;
70
60
  return markEditorFactory(factory, runtime.ownerToken);
71
61
  }
@@ -89,7 +79,7 @@ export function createWrappedEditorFactory(
89
79
  keybindings: KeybindingsManager,
90
80
  ) => {
91
81
  runtime.onRender(() => tui.requestRender());
92
- const editor = new WrappedPolishedEditor(
82
+ return new WrappedPolishedEditor(
93
83
  baseFactory(tui, theme, keybindings),
94
84
  runtime.sessionTheme,
95
85
  runtime.getConfig,
@@ -98,12 +88,6 @@ export function createWrappedEditorFactory(
98
88
  () => editorDecoration(ctx, runtime),
99
89
  runtime.onDecorationActive,
100
90
  );
101
- markAccentRailLayoutEditor(
102
- editor,
103
- runtime.ownerToken,
104
- runtime.isAccentRailActive,
105
- );
106
- return editor;
107
91
  }) as ZentuiEditorFactory;
108
92
  return markWrappedEditorFactory(factory, baseFactory, runtime.ownerToken);
109
93
  }
@@ -150,8 +134,6 @@ function editorDecoration(
150
134
  costLabel: state.costLabel,
151
135
  modelLabel: modelLabelFor(state, config.components.editor.modelLabel),
152
136
  thinkingLevel: runtime.getThinkingLevel(),
153
- contextPercent: runtime.getContextPercent(ctx),
154
- contextWindow: runtime.getContextWindow(ctx),
155
137
  sessionName: ctx.sessionManager.getSessionName() ?? "",
156
138
  agentDurationMs: runtime.getAgentDurationMs(),
157
139
  agentActive: runtime.isAgentActive(),
@@ -1,9 +1,3 @@
1
- export {
2
- type AccentRailLayoutPatchDiagnostic,
3
- installHostAccentRailLayoutPatch,
4
- markAccentRailLayoutEditor,
5
- retainAccentRailLayoutPatchInstallation,
6
- } from "./accent-rail-layout-patch.ts";
7
1
  export {
8
2
  type EditorChangeResult,
9
3
  EditorLayoutController,
@@ -2,13 +2,7 @@ import { basename, isAbsolute, relative, sep } from "node:path";
2
2
  import type { Theme } from "@earendil-works/pi-coding-agent";
3
3
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
4
  import type { ZentuiConfig } from "../../app/config/shell.ts";
5
- import {
6
- buildContextGauge,
7
- contextColorTier,
8
- formatCount,
9
- formatCwdLabel,
10
- formatElapsedDuration,
11
- } from "../../shared/format.ts";
5
+ import { formatCwdLabel, formatElapsedDuration } from "../../shared/format.ts";
12
6
  import { sanitizeEditorMetadataText } from "./editor-metadata-format.ts";
13
7
 
14
8
  export { formatElapsedDuration } from "../../shared/format.ts";
@@ -53,8 +47,6 @@ export type MinimalistEditorMetadata = {
53
47
  costLabel?: string;
54
48
  modelLabel?: string;
55
49
  thinkingLevel?: string;
56
- contextPercent?: number;
57
- contextWindow?: number;
58
50
  sessionName?: string;
59
51
  agentDurationMs?: number;
60
52
  agentActive?: boolean;
@@ -201,7 +193,6 @@ function renderTopRight(
201
193
  metadata: MinimalistEditorMetadata,
202
194
  uiTheme: Theme,
203
195
  config: ZentuiConfig,
204
- availableWidth: number,
205
196
  renderBorder: (text: string) => string,
206
197
  renderThinking: (text: string) => string,
207
198
  ): string {
@@ -235,46 +226,6 @@ function renderTopRight(
235
226
  if (thinking && thinking.toLowerCase() !== "off") {
236
227
  parts.push(renderThinking(thinking));
237
228
  }
238
- if (
239
- metadata.contextPercent !== undefined &&
240
- Number.isFinite(metadata.contextPercent)
241
- ) {
242
- const percent = Math.round(
243
- Math.max(0, Math.min(999, metadata.contextPercent)),
244
- );
245
- const tier = contextColorTier(
246
- percent,
247
- config.components.editor.styles.minimalist.contextThresholds,
248
- );
249
- const style =
250
- tier === "error"
251
- ? config.colors.contextError
252
- : tier === "warning"
253
- ? config.colors.contextWarning
254
- : config.colors.contextNormal;
255
- const total =
256
- config.components.editor.styles.minimalist.contextFormat ===
257
- "percent-total" &&
258
- metadata.contextWindow !== undefined &&
259
- Number.isFinite(metadata.contextWindow) &&
260
- metadata.contextWindow > 0
261
- ? `/${formatCount(metadata.contextWindow)}`
262
- : "";
263
- const text = `${percent}%${total}`;
264
- let context = renderStyleForSource(uiTheme, source, style, text);
265
- if (config.components.editor.styles.minimalist.contextGauge) {
266
- for (const gaugeWidth of [5, 3]) {
267
- const gauge = `[${buildContextGauge(percent, gaugeWidth, config.icons.mode === "ascii")}] ${text}`;
268
- const styledGauge = renderStyleForSource(uiTheme, source, style, gauge);
269
- const candidate = joinParts([...parts, styledGauge]);
270
- if (visibleWidth(candidate) <= availableWidth) {
271
- context = styledGauge;
272
- break;
273
- }
274
- }
275
- }
276
- parts.push(context);
277
- }
278
229
  return joinParts(parts);
279
230
  }
280
231
 
@@ -500,7 +451,6 @@ export function renderMinimalistFrame({
500
451
  );
501
452
  const topViewport = viewportLabel("above", viewport?.above);
502
453
  const topLeft = joinStyled([topViewport, topMetadata], separator);
503
- const topRightBudget = Math.max(0, width - 8 - visibleWidth(topLeft));
504
454
  const topFallbacks = [
505
455
  joinStyled([topViewport, topOperational], separator),
506
456
  topOperational,
@@ -516,7 +466,6 @@ export function renderMinimalistFrame({
516
466
  metadata,
517
467
  uiTheme,
518
468
  config,
519
- topRightBudget,
520
469
  renderBorder,
521
470
  renderThinking,
522
471
  ),
@@ -12,17 +12,13 @@ import {
12
12
  truncateToWidth,
13
13
  visibleWidth,
14
14
  } from "@earendil-works/pi-tui";
15
- import type { EditorStyle, ZentuiConfig } from "../../app/config/shell.ts";
15
+ import type { ZentuiConfig } from "../../app/config/shell.ts";
16
16
  import {
17
17
  EDITOR_ACCENT_FALLBACK,
18
18
  EDITOR_BORDER_FALLBACK,
19
19
  renderStyleForSourceOrFallback,
20
20
  safeThemeFg,
21
21
  } from "../../shared/style.ts";
22
- import {
23
- ACCENT_RAIL_CHROME_WIDTH,
24
- renderAccentRailEditorFrame,
25
- } from "./accent-rail-editor.ts";
26
22
  import { renderCompletionPalette } from "./completion-menu.ts";
27
23
  import { renderEditorMetadataFormat } from "./editor-metadata-format.ts";
28
24
  import {
@@ -128,17 +124,6 @@ type PolishedFrameResult = {
128
124
  decorated: boolean;
129
125
  };
130
126
 
131
- type AccentRailFrameAdapterOptions = {
132
- width: number;
133
- baseRendered: string[];
134
- autocompleteSource: AutocompleteEditorInternals;
135
- autocompleteCapture?: AutocompleteCapture;
136
- uiTheme: Theme;
137
- config: ZentuiConfig;
138
- ownedFrame?: PolishedFrameSplit;
139
- trustedBaseFrame?: boolean;
140
- };
141
-
142
127
  type MinimalistFrameAdapterOptions = {
143
128
  width: number;
144
129
  baseRendered: string[];
@@ -342,61 +327,19 @@ function fillLine(content: string, width: number): string {
342
327
  return `${truncated}${pad}`;
343
328
  }
344
329
 
345
- function isLowRailPolishedStyle(style: EditorStyle): boolean {
346
- return style === "opencode-copy-friendly";
347
- }
348
-
349
- function selectedPolishedConfig(config: ZentuiConfig) {
350
- switch (config.components.editor.style) {
351
- case "opencode":
352
- return config.components.editor.styles.opencode;
353
- case "opencode-copy-friendly":
354
- return config.components.editor.styles["opencode-copy-friendly"];
355
- case "accent-rail":
356
- case "minimalist":
357
- return undefined;
358
- }
359
- }
360
-
361
- function lowRailPrompt(
362
- config: ZentuiConfig,
363
- uiTheme: Theme,
364
- reset: string,
365
- ): string {
366
- const promptIcon = config.icons.editorPrompt;
367
- return promptIcon
368
- ? `${renderStyleForSourceOrFallback(
369
- uiTheme,
370
- config.components.editor.colorSource,
371
- config.colors.editorPrompt ?? config.colors.editorAccent,
372
- EDITOR_ACCENT_FALLBACK,
373
- promptIcon,
374
- )}${reset} `
375
- : "";
376
- }
377
-
378
330
  function getEditorChromeWidths(
379
331
  config: ZentuiConfig,
380
332
  uiTheme: Theme,
381
333
  reset: string,
382
334
  ) {
383
- const lowRail = isLowRailPolishedStyle(config.components.editor.style);
384
- const prompt = lowRailPrompt(config, uiTheme, reset);
385
- const rail = lowRail
386
- ? ""
387
- : `${renderStyleForSourceOrFallback(
388
- uiTheme,
389
- config.components.editor.colorSource,
390
- config.colors.editorAccent,
391
- EDITOR_ACCENT_FALLBACK,
392
- config.icons.rail,
393
- )}${reset} `;
394
- return {
395
- prompt,
396
- promptWidth: visibleWidth(prompt),
397
- rail,
398
- railWidth: lowRail ? visibleWidth(prompt) : visibleWidth(rail),
399
- };
335
+ const rail = `${renderStyleForSourceOrFallback(
336
+ uiTheme,
337
+ config.components.editor.colorSource,
338
+ config.colors.editorAccent,
339
+ EDITOR_ACCENT_FALLBACK,
340
+ config.icons.rail,
341
+ )}${reset} `;
342
+ return { rail, railWidth: visibleWidth(rail) };
400
343
  }
401
344
 
402
345
  function composeMetadataLine(
@@ -464,31 +407,6 @@ function unwrapPolishedFrameOnly(
464
407
  const interior = lines.slice(1, -1);
465
408
  if (interior.length < 3) return undefined;
466
409
 
467
- if (isLowRailPolishedStyle(config.components.editor.style)) {
468
- if (
469
- plainRenderedText(interior[0] ?? "").trim() !== "" ||
470
- plainRenderedText(interior.at(-2) ?? "").trim() !== "" ||
471
- !(interior.at(-1) ?? "").startsWith(" ")
472
- )
473
- return undefined;
474
-
475
- const { prompt, promptWidth } = getEditorChromeWidths(
476
- config,
477
- uiTheme,
478
- "\x1b[0m",
479
- );
480
- const continuation = " ".repeat(promptWidth);
481
- const content = interior.slice(1, -2);
482
- const unwrapped: string[] = [];
483
- for (let index = 0; index < content.length; index++) {
484
- const prefix = index === 0 ? prompt : continuation;
485
- const line = content[index] ?? "";
486
- if (prefix && !line.startsWith(prefix)) return undefined;
487
- unwrapped.push(prefix ? line.slice(prefix.length) : line);
488
- }
489
- return { editorLines: unwrapped, viewport };
490
- }
491
-
492
410
  const { rail } = getEditorChromeWidths(config, uiTheme, "\x1b[0m");
493
411
  if (!rail || interior.some((line) => !line.startsWith(rail)))
494
412
  return undefined;
@@ -569,65 +487,6 @@ function readVimStatus(
569
487
  return safeThemeFg(uiTheme, vimModeColor(normalized), label);
570
488
  }
571
489
 
572
- function renderAccentRailFrameFromBase({
573
- width,
574
- baseRendered,
575
- autocompleteSource,
576
- autocompleteCapture,
577
- uiTheme,
578
- config,
579
- ownedFrame,
580
- trustedBaseFrame = false,
581
- }: AccentRailFrameAdapterOptions): PolishedFrameResult {
582
- if (width < ACCENT_RAIL_CHROME_WIDTH + 1 || baseRendered.length < 2) {
583
- return { lines: clampRenderedLines(baseRendered, width), decorated: false };
584
- }
585
- if (ownedFrame && !isPolishedFrameSplit(ownedFrame, baseRendered.length)) {
586
- return { lines: clampRenderedLines(baseRendered, width), decorated: false };
587
- }
588
- const autocomplete = ownedFrame
589
- ? { known: true as const, count: ownedFrame.trailingLines.length }
590
- : autocompleteCount(autocompleteSource, autocompleteCapture, baseRendered);
591
- if (!autocomplete.known) {
592
- return { lines: clampRenderedLines(baseRendered, width), decorated: false };
593
- }
594
- const editorFrame =
595
- !ownedFrame && autocomplete.count > 0
596
- ? baseRendered.slice(0, -autocomplete.count)
597
- : baseRendered;
598
- const autocompleteLines = ownedFrame
599
- ? ownedFrame.trailingLines
600
- : autocomplete.count > 0
601
- ? baseRendered.slice(-autocomplete.count)
602
- : [];
603
- if (editorFrame.length < 2) {
604
- return { lines: clampRenderedLines(baseRendered, width), decorated: false };
605
- }
606
- const parsedTop = parseEditorBorder(editorFrame[0] ?? "", "above");
607
- const parsedBottom = parseEditorBorder(editorFrame.at(-1) ?? "", "below");
608
- if (!ownedFrame && !trustedBaseFrame && (!parsedTop || !parsedBottom)) {
609
- return { lines: clampRenderedLines(baseRendered, width), decorated: false };
610
- }
611
- const editorLines = ownedFrame?.editorLines ?? editorFrame.slice(1, -1);
612
- const viewport = ownedFrame?.viewport ?? {
613
- above: parsedTop?.count,
614
- below: parsedBottom?.count,
615
- };
616
- const lines = renderAccentRailEditorFrame({
617
- width,
618
- editorLines,
619
- autocompleteLines,
620
- viewport,
621
- uiTheme,
622
- config,
623
- });
624
- POLISHED_FRAME_SPLITS.set(lines, {
625
- rows: Object.freeze([...lines]),
626
- split: { editorLines, trailingLines: autocompleteLines, viewport },
627
- });
628
- return { lines, decorated: true };
629
- }
630
-
631
490
  function renderMinimalistFrameFromBase({
632
491
  width,
633
492
  baseRendered,
@@ -784,16 +643,10 @@ export function renderPolishedEditorFrame({
784
643
  if (width <= 2) return clampRenderedLines(editorLines, width);
785
644
  const reset = "\x1b[0m";
786
645
  const colorSource = config.components.editor.colorSource;
787
- const { prompt, promptWidth, rail, railWidth } = getEditorChromeWidths(
788
- config,
789
- uiTheme,
790
- reset,
791
- );
646
+ const { rail, railWidth } = getEditorChromeWidths(config, uiTheme, reset);
792
647
  const innerWidth = Math.max(0, width - railWidth);
793
- const lowRailContinuation = " ".repeat(promptWidth);
794
648
  const meta = renderEditorMetadataFormat(
795
- selectedPolishedConfig(config)?.metadataFormat ??
796
- config.components.editor.styles.opencode.metadataFormat,
649
+ config.components.editor.styles.opencode.metadataFormat,
797
650
  {
798
651
  model: modelMeta.modelLabel,
799
652
  modelId: modelMeta.modelId ?? "",
@@ -805,11 +658,6 @@ export function renderPolishedEditorFrame({
805
658
  uiTheme,
806
659
  config,
807
660
  );
808
- const lowRailMeta = composeMetadataLine(
809
- meta,
810
- rightStatus,
811
- Math.max(0, width - 1),
812
- );
813
661
  const railedMeta = composeMetadataLine(meta, rightStatus, innerWidth);
814
662
 
815
663
  const renderStaticBorder = (text: string) =>
@@ -849,7 +697,7 @@ export function renderPolishedEditorFrame({
849
697
  ),
850
698
  );
851
699
  const completionLines =
852
- selectedPolishedConfig(config)?.completionMenu === "palette"
700
+ config.components.editor.styles.opencode.completionMenu === "palette"
853
701
  ? renderCompletionPalette({
854
702
  lines: autocompleteLines,
855
703
  width,
@@ -859,25 +707,12 @@ export function renderPolishedEditorFrame({
859
707
  })
860
708
  : autocompleteLines;
861
709
  const lines = ["", ...editorLines, "", railedMeta];
862
- const renderedLines = isLowRailPolishedStyle(config.components.editor.style)
863
- ? [
864
- top,
865
- "",
866
- ...editorLines.map(
867
- (line, index) =>
868
- `${index === 0 ? prompt : lowRailContinuation}${fillLine(line, innerWidth)}`,
869
- ),
870
- "",
871
- ` ${truncateToWidth(lowRailMeta, Math.max(0, width - 1), "")}`,
872
- bottom,
873
- ...completionLines,
874
- ]
875
- : [
876
- top,
877
- ...lines.map((line) => `${rail}${fillLine(line, innerWidth)}`),
878
- bottom,
879
- ...completionLines,
880
- ];
710
+ const renderedLines = [
711
+ top,
712
+ ...lines.map((line) => `${rail}${fillLine(line, innerWidth)}`),
713
+ bottom,
714
+ ...completionLines,
715
+ ];
881
716
 
882
717
  return clampRenderedLines(renderedLines, width);
883
718
  }
@@ -921,36 +756,6 @@ export class PolishedEditor extends CustomEditor {
921
756
  this.reportMinimalistDecoration(false);
922
757
  return clampRenderedLines(super.render(width), width);
923
758
  }
924
- if (config.components.editor.style === "accent-rail") {
925
- this.reportMinimalistDecoration(false);
926
- if (width < ACCENT_RAIL_CHROME_WIDTH + 1) {
927
- return clampRenderedLines(super.render(width), width);
928
- }
929
- let captured: { value: string[]; capture?: AutocompleteCapture };
930
- try {
931
- captured = renderWithAutocompleteCapture(
932
- this as unknown as AutocompleteEditorInternals,
933
- () => super.render(width - ACCENT_RAIL_CHROME_WIDTH),
934
- );
935
- } catch {
936
- return clampRenderedLines(super.render(width), width);
937
- }
938
- try {
939
- const result = renderAccentRailFrameFromBase({
940
- width,
941
- baseRendered: captured.value,
942
- autocompleteSource: this as unknown as AutocompleteEditorInternals,
943
- autocompleteCapture: captured.capture,
944
- uiTheme: this.uiTheme,
945
- config,
946
- trustedBaseFrame: true,
947
- });
948
- if (result.decorated) return result.lines;
949
- } catch {
950
- // Decoration is optional; preserve the completed same-render rows below.
951
- }
952
- return clampRenderedLines(captured.value, width);
953
- }
954
759
  if (config.components.editor.style === "minimalist") {
955
760
  if (width <= 4) {
956
761
  this.reportMinimalistDecoration(false);
@@ -1155,43 +960,6 @@ export class WrappedPolishedEditor implements EditorComponent {
1155
960
  this.reportMinimalistDecoration(false);
1156
961
  return clampRenderedLines(this.base.render(width), width);
1157
962
  }
1158
- if (config.components.editor.style === "accent-rail") {
1159
- this.reportMinimalistDecoration(false);
1160
- if (width < ACCENT_RAIL_CHROME_WIDTH + 1) {
1161
- return clampRenderedLines(this.base.render(width), width);
1162
- }
1163
- let captured: { value: string[]; capture?: AutocompleteCapture };
1164
- try {
1165
- captured = renderWithAutocompleteCapture(this.base, () =>
1166
- this.base.render(width - ACCENT_RAIL_CHROME_WIDTH),
1167
- );
1168
- } catch {
1169
- return clampRenderedLines(this.base.render(width), width);
1170
- }
1171
- try {
1172
- const provenance = inspectPolishedFrameProvenance(
1173
- this.base,
1174
- captured.value,
1175
- config,
1176
- this.uiTheme,
1177
- );
1178
- if (provenance.safe) {
1179
- const result = renderAccentRailFrameFromBase({
1180
- width,
1181
- baseRendered: captured.value,
1182
- autocompleteSource: this.base,
1183
- autocompleteCapture: captured.capture,
1184
- uiTheme: this.uiTheme,
1185
- config,
1186
- ownedFrame: provenance.ownedFrame,
1187
- });
1188
- if (result.decorated) return result.lines;
1189
- }
1190
- } catch {
1191
- // Decoration is optional; preserve the completed same-render rows below.
1192
- }
1193
- return clampRenderedLines(captured.value, width);
1194
- }
1195
963
  if (config.components.editor.style === "minimalist") {
1196
964
  if (width <= 4) {
1197
965
  this.reportMinimalistDecoration(false);
@@ -10,6 +10,7 @@ import { isSafeSgrStylePrefix } from "../../shared/style.ts";
10
10
  import { renderWorkingLineHigh } from "./working-line.ts";
11
11
 
12
12
  export const TURN_SUMMARY_ENTRY_TYPE = "zentui-turn-summary";
13
+ export const MIN_OUTPUT_RATE_WINDOW_MS = 500;
13
14
  const TURN_SUMMARY_VERSION = 3;
14
15
  const SGR_RESET = "\x1b[0m";
15
16
 
@@ -18,6 +19,7 @@ export type LiveTokenDisplay = Readonly<{
18
19
  input: number;
19
20
  output: number;
20
21
  outputApproximate: boolean;
22
+ outputTokensPerSecond?: number;
21
23
  }>;
22
24
  export type ThoughtSnapshot = Readonly<{ durationMs: number; active: boolean }>;
23
25
 
@@ -70,6 +72,8 @@ type ResponseState = {
70
72
  outputAnchor: number;
71
73
  outputAnchorCodePoints: number;
72
74
  liveOutputFloor: number;
75
+ startedAt: number;
76
+ outputTokensPerSecond?: number;
73
77
  lastDisplay?: LiveTokenDisplay;
74
78
  estimateIncomplete: boolean;
75
79
  estimateEnabled: boolean;
@@ -515,7 +519,7 @@ export class InteractionMetricsTracker {
515
519
  this.commitSnapshot(run, run.response);
516
520
  this.closeResponse(run, run.response, now);
517
521
  }
518
- this.openResponse(run);
522
+ this.openResponse(run, now);
519
523
  }
520
524
 
521
525
  messageUpdate(
@@ -549,7 +553,7 @@ export class InteractionMetricsTracker {
549
553
  contentChanged = this.applyContentEvent(response, validated);
550
554
  }
551
555
  const usageChanged = tokens ? this.applyLiveUsage(response, tokens) : false;
552
- const display = this.liveDisplay(response);
556
+ const display = this.liveDisplay(response, now);
553
557
  const displayChanged = !this.sameDisplay(response.lastDisplay, display);
554
558
  response.lastDisplay = display;
555
559
  return {
@@ -570,20 +574,31 @@ export class InteractionMetricsTracker {
570
574
  if (response.responseIdKey && key && response.responseIdKey !== key)
571
575
  return { status: "rejected" };
572
576
  if (!response.responseIdKey && key) response.responseIdKey = key;
573
- const preCloseDisplay = this.liveDisplay(response);
574
577
  let source: "final" | "last-snapshot" | "no-snapshot";
575
578
  if (tokens) {
576
579
  response.snapshot = tokens;
577
580
  response.hasSnapshot = true;
581
+ this.updateOutputRate(response, tokens.output, now);
578
582
  source = "final";
579
583
  } else source = response.hasSnapshot ? "last-snapshot" : "no-snapshot";
584
+ const preCloseDisplay = this.liveDisplay(
585
+ response,
586
+ tokens ? undefined : now,
587
+ );
580
588
  this.commitSnapshot(run, response);
581
589
  const committed = this.totalTokens();
582
590
  const preserveEstimate =
583
591
  source !== "final" && response.lastDisplay?.outputApproximate === true;
592
+ const outputRate = preCloseDisplay.outputTokensPerSecond;
584
593
  const displayTokens = preserveEstimate
585
594
  ? { ...preCloseDisplay, outputApproximate: true }
586
- : { ...committed, outputApproximate: false };
595
+ : {
596
+ ...committed,
597
+ outputApproximate: false,
598
+ ...(outputRate === undefined
599
+ ? {}
600
+ : { outputTokensPerSecond: outputRate }),
601
+ };
587
602
  run.lastClosedDisplay = preserveEstimate ? displayTokens : undefined;
588
603
  this.closeResponse(run, response, now);
589
604
  return { status: "accepted", tokens: committed, displayTokens, source };
@@ -770,7 +785,11 @@ export class InteractionMetricsTracker {
770
785
  );
771
786
  }
772
787
 
773
- private openResponse(run: RunState, key?: string): ResponseState {
788
+ private openResponse(
789
+ run: RunState,
790
+ now: number,
791
+ key?: string,
792
+ ): ResponseState {
774
793
  run.responseOrdinal = safeAdd(run.responseOrdinal, 1);
775
794
  run.lastClosedDisplay = undefined;
776
795
  const estimateEnabled =
@@ -787,6 +806,7 @@ export class InteractionMetricsTracker {
787
806
  outputAnchor: 0,
788
807
  outputAnchorCodePoints: 0,
789
808
  liveOutputFloor: 0,
809
+ startedAt: safeTime(now),
790
810
  estimateIncomplete: !estimateEnabled,
791
811
  estimateEnabled,
792
812
  };
@@ -996,7 +1016,7 @@ export class InteractionMetricsTracker {
996
1016
  return true;
997
1017
  }
998
1018
 
999
- private liveDisplay(response: ResponseState): LiveTokenDisplay {
1019
+ private liveDisplay(response: ResponseState, now?: number): LiveTokenDisplay {
1000
1020
  const committed = this.committedTokens();
1001
1021
  const postAnchorCodePoints = Math.max(
1002
1022
  0,
@@ -1013,6 +1033,8 @@ export class InteractionMetricsTracker {
1013
1033
  responseOutput === response.outputAnchor &&
1014
1034
  !response.estimateIncomplete;
1015
1035
  response.liveOutputFloor = responseOutput;
1036
+ if (now !== undefined) this.updateOutputRate(response, responseOutput, now);
1037
+ const outputRate = response.outputTokensPerSecond;
1016
1038
  return {
1017
1039
  input: safeAdd(
1018
1040
  committed.input,
@@ -1020,9 +1042,26 @@ export class InteractionMetricsTracker {
1020
1042
  ),
1021
1043
  output: safeAdd(committed.output, responseOutput),
1022
1044
  outputApproximate: !exactCoverage,
1045
+ ...(outputRate === undefined
1046
+ ? {}
1047
+ : { outputTokensPerSecond: outputRate }),
1023
1048
  };
1024
1049
  }
1025
1050
 
1051
+ private updateOutputRate(
1052
+ response: ResponseState,
1053
+ responseOutput: number,
1054
+ now: number,
1055
+ ): void {
1056
+ const elapsedMs = durationMs(response.startedAt, now);
1057
+ const rate =
1058
+ elapsedMs >= MIN_OUTPUT_RATE_WINDOW_MS && responseOutput > 0
1059
+ ? Math.min(Number.MAX_SAFE_INTEGER, (responseOutput * 1000) / elapsedMs)
1060
+ : undefined;
1061
+ response.outputTokensPerSecond =
1062
+ rate !== undefined && rate >= 0.1 ? rate : undefined;
1063
+ }
1064
+
1026
1065
  private runTokens(run: RunState): InteractionTokens {
1027
1066
  return run.response?.hasSnapshot
1028
1067
  ? addTokens(run.committed, run.response.snapshot)
@@ -1104,7 +1143,8 @@ export class InteractionMetricsTracker {
1104
1143
  return (
1105
1144
  left?.input === right?.input &&
1106
1145
  left?.output === right?.output &&
1107
- left?.outputApproximate === right?.outputApproximate
1146
+ left?.outputApproximate === right?.outputApproximate &&
1147
+ left?.outputTokensPerSecond === right?.outputTokensPerSecond
1108
1148
  );
1109
1149
  }
1110
1150
  }