pi-show-token-rate 0.1.0 → 0.2.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/README.md CHANGED
@@ -7,12 +7,10 @@ active model is streaming output tokens, in the bottom status bar of the TUI.
7
7
  ⚡ 62 tok/s
8
8
  ```
9
9
 
10
- While the model streams, the number is a **live** trailing-window rate. Once the
11
- response settles, the line keeps the last measured rate plus a session average:
12
-
13
- ```plaintext
14
- ⚡ 62 tok/s · avg 55
15
- ```
10
+ While the model streams, the number is a **live** trailing-window rate, coloured
11
+ by speed. When the stream ends the last reading keeps its value but is dimmed.
12
+ There is no session history and no settled average — the line only ever reports
13
+ a rate.
16
14
 
17
15
  The line is provider-agnostic: it measures whatever model is active, so there is
18
16
  no activation condition and nothing to configure per provider.
@@ -33,67 +31,100 @@ pi -e npm:pi-show-token-rate
33
31
 
34
32
  ## What the bar shows
35
33
 
36
- | Segment | Meaning |
37
- | ---------- | ------------------------------------------------------------------------------------------------------------- |
38
- | `⚡` | Label, always dim |
39
- | `~` | Present only when the number is estimated rather than provider-reported (see **Accuracy** below) |
40
- | `62` | Output tokens per second: live while streaming, the last response's rate once settled, rounded to whole tok/s |
41
- | `tok/s` | Unit, dim |
42
- | `· avg 55` | Duration-weighted average across completed responses; hidden until a second response finishes |
43
-
44
- The rate is colored green at **50 tok/s** and above, yellow at **20 tok/s** and
45
- above, and dim below that — a slow model reads as quiet, not as an error.
46
-
47
- The row stays blank until a response produces a measurable rate: the first
48
- token of a stream cannot be timed on its own. A response that ends without a
49
- measurable rate — an empty reply, or `/token-rate reset` — shows
50
- `⚡ tok/s —` instead.
34
+ | Segment | Meaning |
35
+ | ------- | ---------------------------------------------------------------------------------- |
36
+ | `⚡` | Marker: green while streaming, `dim` once the stream ends |
37
+ | `62` | Output tokens per second, rounded, live while streaming, kept once the stream ends |
38
+ | `tok/s` | Unit, dim |
39
+
40
+ While the model streams, the rate is coloured by speed:
41
+
42
+ | Rate | Colour |
43
+ | -------------- | ------ |
44
+ | above 50 tok/s | green |
45
+ | 10–50 tok/s | yellow |
46
+ | below 10 tok/s | red |
47
+
48
+ The colour is chosen from the number actually displayed, so the label and its
49
+ colour never disagree (e.g. `50` is yellow, `51` is green). The `⚡` marker is
50
+ green for as long as the model is streaming — regardless of the rate's own
51
+ colour — so the bar reads as live at a glance; once the stream ends the marker
52
+ goes back to `dim`, and the last reading is dimmed too.
53
+
54
+ The row starts blank and stays blank until a response produces a measurable
55
+ rate: the first token of a stream cannot be timed on its own, and the character
56
+ estimate waits for a full second of history before it is believed. The `⚡`
57
+ marker still turns green as soon as a response starts streaming; until a rate is
58
+ measurable the row carries the previous reading over, dimmed (or shows
59
+ `⚡ tok/s —` when nothing has been measured yet). A response that ends without a
60
+ measurable rate — an empty reply, or an aborted one — leaves the previous
61
+ reading in place.
51
62
 
52
63
  ## How the rate is measured
53
64
 
54
- - **Generation window only.** The clock starts at the first streamed token, not
55
- at the request. Prompt prefill and time-to-first-token are excluded, so the
56
- number answers "how fast does this model generate", not "how long did the
57
- request take".
65
+ - **Streaming only.** The rate is measured while the model produces tokens, and
66
+ nothing is settled afterwards. A response that is aborted or errors out is not
67
+ frozen at all — the bar keeps the previous reading.
58
68
  - **Trailing window while streaming.** Live updates average the last 3 seconds
59
69
  of streaming. Providers emit deltas in bursts and stall while writing a
60
70
  thinking block or tool-call JSON, so a per-event rate would flicker between 0
61
71
  and several hundred tok/s.
62
- - **Whole-response average once settled.** When `message_end` arrives, the
63
- response is scored over its full generation window and folded into the
64
- session average.
72
+ - **Two scales, two windows.** A provider's cumulative output-token count and
73
+ the character estimate are different units, so each gets its own trailing
74
+ window. Provider usage wins while its window is moving, fresh and plausible;
75
+ otherwise the estimate answers. A provider count that repeats a
76
+ constant, stops being reported, or resets cannot freeze the live number — its
77
+ window expires like any other.
78
+ - **Freeze, don't average.** When `message_end` arrives the current window value
79
+ is captured and the trackers are dropped; the bar keeps showing that reading,
80
+ dimmed.
65
81
 
66
82
  ## Accuracy
67
83
 
68
84
  Not every provider reports token usage while a response is streaming:
69
85
 
70
- - When `usage.output` is available (Anthropic and other providers that stream
71
- usage), the extension uses it — the number is exact.
72
- - When it is not (OpenAI-compatible endpoints typically send usage only on the
73
- final chunk), the extension estimates tokens as **streamed characters ÷ 4**,
74
- the same heuristic pi itself uses for context estimation. Estimated numbers
75
- are marked with a leading `~` so they are never mistaken for reported ones.
76
- - If usage only starts arriving halfway through a stream, the live number keeps
77
- its `~` and its scale until the response ends — mixing a reported count into
78
- a chars ÷ 4 window would spike the rate. The settled number is the exact
79
- one, and the `~` disappears with it.
80
-
81
- The same fallback is used to keep counting when a tool call is streamed as
82
- JSON, so tool-heavy turns still measure sensibly.
86
+ - When the provider reports a cumulative count that keeps advancing mid-stream,
87
+ the extension uses it — the number is exact.
88
+ - When it does not — OpenAI-compatible endpoints typically send usage only on
89
+ the final chunk, and Anthropic reports a small count at the start and only
90
+ updates it in a terminal event pi does not surface — the live number falls
91
+ back to a **character estimate**: CJK, kana, Hangul, and fullwidth punctuation
92
+ count as roughly one token each, text as one per four characters, and
93
+ tool-call argument JSON as one per two and a half — JSON keys and punctuation
94
+ tokenize denser than prose. The bar does not label which scale answered: the
95
+ estimate exists only to keep the number moving while the provider stays quiet.
96
+ - The two scales are measured in parallel, and a window whose newest reading has
97
+ fallen outside the trailing 3 seconds counts as unmeasurable, so a provider
98
+ count that stops advancing — a repeated constant, a dropped field, or a reset
99
+ counter — hands the row back to the estimate until it moves again.
100
+
101
+ ### Buffered tool calls
102
+
103
+ Characters say when text **arrived**, not when it was generated. Endpoints that
104
+ do not stream tool-call arguments token by token deliver a whole command — or a
105
+ whole file — in one delta, and crediting that at face value would print four
106
+ digits of tok/s. An estimated window therefore credits each interval with at
107
+ most **twice the pace it is already running at** (a floor of 100 tok/s covers
108
+ the first second, before the window has a pace of its own) and stays silent
109
+ until it has a full second of history; provider counts are never credited that
110
+ way. The ceiling follows the window's own **raw** pace, so a genuinely fast
111
+ stream still reaches its real rate within a second or two instead of being held
112
+ near the floor.
113
+
114
+ A reading above **5,000 tok/s** — faster than any production model streams today
115
+ — is refused for the same reason: providers that buffer a whole tool call and
116
+ flush it in one burst hand the extension thousands of tokens a few milliseconds
117
+ apart, and printing that window would show tens of thousands of tok/s. The line
118
+ keeps its previous value instead.
119
+
120
+ The wide/narrow weighting is a heuristic, not a tokenizer, so mixed scripts can
121
+ still read a few percent low or high.
83
122
 
84
123
  ## Slash command
85
124
 
86
- | Command | Effect |
87
- | ------------------- | --------------------------------------------------------------------------- |
88
- | `/token-rate` | Toggle the status line on or off |
89
- | `/token-rate stats` | Notify with the last / average / peak rate and how many responses were seen |
90
- | `/token-rate reset` | Drop collected statistics, keeping the current visibility |
91
-
92
- `stats` output looks like:
93
-
94
- ```plaintext
95
- last 62 tok/s · avg 55 tok/s · peak 91 tok/s · 12 responses · 3 estimated
96
- ```
125
+ | Command | Effect |
126
+ | ------------- | -------------------------------- |
127
+ | `/token-rate` | Toggle the status line on or off |
97
128
 
98
129
  ## Development
99
130
 
@@ -107,11 +138,14 @@ pnpm test # node --test
107
138
  pnpm typecheck # tsc --noEmit
108
139
  ```
109
140
 
110
- Tests cover the character estimator, the tracker (windowing, monotonicity,
111
- pruning), the summary math, the exact status-line strings, and the controller's
112
- throttling and toggle behavior. The suite drives an injected clock, so no test
113
- depends on wall-clock timing, and it uses a stub theme instead of the real ANSI
114
- palette.
141
+ Tests cover the character estimator, the trailing window (monotonicity, pruning,
142
+ credit guards), the two-scale tracker, the exact status-line strings, and the
143
+ controller's throttling, freezing and toggle behavior. Two windowing tests are
144
+ differential: they replay thousands of irregularly-spaced observations and check
145
+ `rate(now)` against the trailing-window definition at every step, so the
146
+ incremental pruning cannot drift from it. The suite drives an injected clock, so
147
+ no test depends on wall-clock timing, and it uses a stub theme instead of the
148
+ real ANSI palette.
115
149
 
116
150
  ## License
117
151
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-show-token-rate",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Shows the live output token rate (tok/s) of the current model in the pi TUI status bar.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -11,6 +11,15 @@
11
11
  "tui"
12
12
  ],
13
13
  "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/luohuidong/pi-extensions.git",
17
+ "directory": "packages/pi-show-token-rate"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/luohuidong/pi-extensions/issues"
21
+ },
22
+ "homepage": "https://github.com/luohuidong/pi-extensions/tree/main/packages/pi-show-token-rate#readme",
14
23
  "type": "module",
15
24
  "files": [
16
25
  "src/",
package/src/format.ts CHANGED
@@ -5,60 +5,72 @@
5
5
  * stats without crowding them:
6
6
  *
7
7
  * ⚡ 62 tok/s while the model streams (trailing-window rate)
8
- * ⚡ 62 tok/s · avg 55 once the response settles
9
- * ⚡ ~62 tok/s a `~` marks a chars ÷ 4 estimate instead of real usage
10
- * ⚡ tok/s — nothing measured yet
8
+ * ⚡ 62 tok/s the last measured rate, dimmed, once the stream ends
9
+ * ⚡ tok/s — nothing measured yet (marker still green while streaming)
10
+ *
11
+ * While streaming the rate is coloured by the spec's buckets — green above
12
+ * 50 tok/s, yellow from 10 through 50, red below 10 — and the `⚡` marker is
13
+ * green. Once the stream ends the number keeps its value but is dimmed, and
14
+ * the marker goes back to dim.
11
15
  */
12
16
 
13
17
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
14
18
 
15
- import type { RateSummary } from "./rate.ts";
16
-
17
19
  export type Theme = ExtensionContext["ui"]["theme"];
18
20
 
19
21
  const LABEL = "⚡";
20
22
  export const RATE_UNIT = "tok/s";
21
23
 
22
- /** At or above this many tok/s the rate is green. */
23
- const FAST_MIN = 50;
24
- /** At or above this many tok/s the rate is yellow; below that it is dim. */
25
- const SLOW_MIN = 20;
24
+ /** Strictly above this many displayed tok/s the rate is green. */
25
+ const FAST_ABOVE = 50;
26
+ /** At or above this many displayed tok/s the rate is yellow; below it, red. */
27
+ const SLOW_MIN = 10;
26
28
 
27
29
  /** Everything the status line can render. */
28
30
  export interface RateView {
29
31
  /** Tokens per second to show; omitted when nothing has been measured. */
30
32
  rate?: number;
31
- /** Mark the rate as estimated (`~` prefix) rather than provider-reported. */
32
- estimated?: boolean;
33
- /** Session average, appended as `· avg NN` when present. */
34
- average?: number;
33
+ /** While true the `⚡` marker is green. */
34
+ streaming?: boolean;
35
+ /**
36
+ * Whether `rate` is a live measurement. While streaming, a response that
37
+ * cannot be measured yet carries the last reading over under a green marker;
38
+ * `measured: false` dims that carried-over number so it is not read as live.
39
+ * Defaults to true whenever `streaming` is set.
40
+ */
41
+ measured?: boolean;
35
42
  }
36
43
 
37
44
  /**
38
- * Rate → short display string. Sub-10 values keep one decimal (a slow model
39
- * is exactly where precision is interesting); everything above is rounded to
40
- * whole tokens so the footer does not jitter.
41
- *
42
- * The value is rounded to one decimal *first*, so 9.99 renders as `10`
43
- * instead of `10.0` — the same shape 10.0 gets.
45
+ * The numeric value the label shows: one decimal below 10 (a slow model is
46
+ * exactly where precision is interesting), whole tokens above it. Colour is
47
+ * picked from this same value, so the label and its bucket never disagree.
44
48
  */
49
+ function displayedValue(value: number): number {
50
+ return value < 10 ? Math.round(value * 10) / 10 : Math.round(value);
51
+ }
52
+
53
+ /** Rate → short display string. */
45
54
  export function formatRate(value: number): string {
46
55
  if (!Number.isFinite(value) || value <= 0) return "—";
47
- const rounded = Math.round(value * 10) / 10;
48
- if (rounded < 10) return rounded.toFixed(1);
49
- return String(Math.round(rounded));
56
+ const shown = displayedValue(value);
57
+ if (shown < 10) return shown.toFixed(1);
58
+ return String(shown);
50
59
  }
51
60
 
52
61
  function rateColor(theme: Theme, value: number): string {
53
62
  const text = formatRate(value);
54
- if (value >= FAST_MIN) return theme.fg("success", text);
55
- if (value >= SLOW_MIN) return theme.fg("warning", text);
56
- // Slow is not an error — keep it readable but out of the way.
57
- return theme.fg("dim", text);
63
+ const shown = displayedValue(value);
64
+ if (shown > FAST_ABOVE) return theme.fg("success", text);
65
+ if (shown >= SLOW_MIN) return theme.fg("warning", text);
66
+ // Below the spec's slow threshold the rate reads as a problem, not as quiet.
67
+ return theme.fg("error", text);
58
68
  }
59
69
 
60
70
  export function formatStatusLine(theme: Theme, view: RateView): string {
61
- const label = theme.fg("dim", LABEL);
71
+ // The marker is green while the model streams — a live signal independent of
72
+ // the rate's own colour — and dim once the response is over.
73
+ const label = theme.fg(view.streaming ? "success" : "dim", LABEL);
62
74
  if (
63
75
  view.rate === undefined ||
64
76
  !Number.isFinite(view.rate) ||
@@ -67,39 +79,13 @@ export function formatStatusLine(theme: Theme, view: RateView): string {
67
79
  return [label, " ", theme.fg("dim", `${RATE_UNIT} —`)].join("");
68
80
  }
69
81
 
70
- const parts = [
71
- label,
72
- " ",
73
- view.estimated ? theme.fg("dim", "~") : "",
74
- rateColor(theme, view.rate),
75
- " ",
76
- theme.fg("dim", RATE_UNIT),
77
- ];
78
- if (
79
- view.average !== undefined &&
80
- Number.isFinite(view.average) &&
81
- view.average > 0
82
- ) {
83
- parts.push(
84
- theme.fg("dim", " · avg "),
85
- theme.fg("dim", formatRate(view.average)),
86
- );
87
- }
88
- return parts.join("");
89
- }
82
+ // Streaming is the only state where the number means "right now"; a
83
+ // carried-over reading (streaming but not measurable yet) and the frozen
84
+ // idle value are both shown dim.
85
+ const measured = view.streaming && view.measured !== false;
86
+ const rate = measured
87
+ ? rateColor(theme, view.rate)
88
+ : theme.fg("dim", formatRate(view.rate));
90
89
 
91
- /** One-line summary for the `/token-rate stats` notification. */
92
- export function formatSummary(summary: RateSummary): string {
93
- if (summary.count === 0 || !summary.last) return "no completed responses yet";
94
-
95
- const parts = [
96
- `last ${formatRate(summary.last.tokPerSec)} ${RATE_UNIT}`,
97
- `avg ${formatRate(summary.average ?? 0)} ${RATE_UNIT}`,
98
- `peak ${formatRate(summary.peak ?? 0)} ${RATE_UNIT}`,
99
- `${summary.count} ${summary.count === 1 ? "response" : "responses"}`,
100
- ];
101
- if (summary.estimated > 0) {
102
- parts.push(`${summary.estimated} estimated`);
103
- }
104
- return parts.join(" · ");
90
+ return [label, " ", rate, " ", theme.fg("dim", RATE_UNIT)].join("");
105
91
  }
package/src/index.ts CHANGED
@@ -1,25 +1,21 @@
1
1
  /**
2
2
  * Token rate for the pi TUI footer.
3
3
  *
4
- * Watches the assistant message stream and shows how fast the active model
5
- * is producing output tokens:
4
+ * Watches the assistant message stream and shows how fast the active model is
5
+ * producing output tokens:
6
6
  *
7
- * ⚡ 62 tok/s live, while the model streams
8
- * ⚡ 62 tok/s · avg 55 the last response plus the session average
7
+ * ⚡ 62 tok/s live while the model streams, dimmed once the stream ends
9
8
  *
10
- * The rate is measured over the generation window (first token → last token)
11
- * so prompt prefill does not drag it down, and the live number is a
12
- * trailing-window average so it stays readable between bursty deltas.
13
- *
14
- * This file only wires events to the controller in `./line.ts`.
9
+ * The rate is measured over a trailing window while tokens arrive, so it stays
10
+ * readable between bursty deltas; there is no session history and no settled
11
+ * average. This file only wires events to the controller in `./line.ts`.
15
12
  */
16
13
 
17
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
15
 
19
- import { formatSummary } from "./format.ts";
20
16
  import { TokenRateLine } from "./line.ts";
21
17
 
22
- const USAGE = "token-rate: /token-rate [stats|reset]";
18
+ const USAGE = "token-rate: /token-rate";
23
19
 
24
20
  export default function (pi: ExtensionAPI) {
25
21
  const line = new TokenRateLine();
@@ -43,22 +39,11 @@ export default function (pi: ExtensionAPI) {
43
39
  pi.registerCommand("token-rate", {
44
40
  description: "Show or hide the tok/s line in the status bar",
45
41
  handler: async (args, ctx) => {
46
- const sub = args.trim().toLowerCase();
47
-
48
- if (sub === "stats") {
49
- ctx.ui.notify(`token-rate: ${formatSummary(line.summary())}`, "info");
50
- return;
51
- }
52
42
  if (!ctx.hasUI) {
53
43
  ctx.ui.notify("token-rate: no UI in this mode", "warning");
54
44
  return;
55
45
  }
56
- if (sub === "reset") {
57
- line.reset(ctx);
58
- ctx.ui.notify("token-rate: statistics cleared", "info");
59
- return;
60
- }
61
- if (sub !== "") {
46
+ if (args.trim() !== "") {
62
47
  ctx.ui.notify(USAGE, "warning");
63
48
  return;
64
49
  }