|
1 | 1 | import { basename } from "node:path"; |
| 2 | +import type { AgentMessage } from "@earendil-works/pi-agent-core"; |
2 | 3 | import { Box, Text } from "ink"; |
3 | 4 | import { useEffect, useRef, useState } from "react"; |
4 | 5 | import type { ChatState } from "../types.js"; |
5 | 6 | import { Throbber } from "./Throbber.js"; |
6 | 7 |
|
| 8 | +/** Average chars-per-token across the major model families. Used only as a |
| 9 | + * fallback when the provider doesn't return usage info on message_end. */ |
| 10 | +const CHARS_PER_TOKEN = 4; |
| 11 | + |
7 | 12 | interface StatusProps { |
8 | 13 | state: ChatState; |
9 | 14 | cwd?: string; |
@@ -67,7 +72,7 @@ export function Status({ state, cwd, contextWindow = 200_000 }: StatusProps) { |
67 | 72 | const tokRate = useTokenRate(state); |
68 | 73 | const elapsedSec = useBusyElapsed(busy); |
69 | 74 | const u = state.usage; |
70 | | - const usedTokens = u.input + u.cacheRead; |
| 75 | + const usedTokens = estimateContextTokens(state); |
71 | 76 | const ctxPct = contextWindow > 0 ? Math.min(100, Math.round((usedTokens / contextWindow) * 100)) : 0; |
72 | 77 | const cwdLabel = cwd ? basename(cwd) || "/" : ""; |
73 | 78 | const modelLabel = state.model.name || state.model.id; |
@@ -145,38 +150,89 @@ function findRunningTool(state: ChatState): string | undefined { |
145 | 150 | } |
146 | 151 |
|
147 | 152 | /** |
148 | | - * Estimate the live token-output rate during streaming. Pi-ai only |
149 | | - * surfaces accurate `usage` at message_end, so for the live counter |
150 | | - * we approximate from the streaming message's character length using |
151 | | - * the common ~4-chars-per-token rule. Cheap, no extra deps, and |
152 | | - * accurate enough for a status-bar readout. |
| 153 | + * Estimate the live token-output rate during streaming using a 4-second |
| 154 | + * sliding window over recent character growth. This avoids dragging the |
| 155 | + * rate down with the pre-text wait period — a thinking-heavy model that |
| 156 | + * spent 60s reasoning before emitting its first token should show "120 |
| 157 | + * tok/s" once it starts streaming, not "5 tok/s averaged with the wait." |
153 | 158 | * |
154 | | - * Returns undefined when not streaming, or when too few chars have |
155 | | - * accumulated for the rate to be meaningful (so the bar doesn't |
156 | | - * flicker a noisy "9999 tok/s" in the first 100ms). |
| 159 | + * Returns undefined when not streaming, or when the window doesn't yet |
| 160 | + * have enough samples / delta for the rate to be meaningful (so the bar |
| 161 | + * doesn't flicker noisy values in the first half-second). |
157 | 162 | */ |
158 | 163 | function useTokenRate(state: ChatState): number | undefined { |
159 | | - const startRef = useRef<number | undefined>(undefined); |
160 | | - const [tick, setTick] = useState(0); |
| 164 | + const samplesRef = useRef<Array<{ t: number; c: number }>>([]); |
| 165 | + const charsRef = useRef(0); |
| 166 | + const [, setTick] = useState(0); |
161 | 167 | const streaming = state.status === "streaming"; |
| 168 | + |
| 169 | + // Keep the latest char count in a ref so the interval callback always |
| 170 | + // reads the live value rather than the closure-captured one. |
| 171 | + charsRef.current = streaming ? streamingChars(state) : 0; |
| 172 | + |
162 | 173 | useEffect(() => { |
163 | 174 | if (!streaming) { |
164 | | - startRef.current = undefined; |
| 175 | + samplesRef.current = []; |
165 | 176 | return; |
166 | 177 | } |
167 | | - if (startRef.current === undefined) startRef.current = Date.now(); |
168 | | - const id = setInterval(() => setTick((t) => t + 1), 500); |
| 178 | + const sample = () => { |
| 179 | + const now = Date.now(); |
| 180 | + samplesRef.current.push({ t: now, c: charsRef.current }); |
| 181 | + const cutoff = now - 4000; |
| 182 | + while (samplesRef.current.length > 0 && samplesRef.current[0].t < cutoff) { |
| 183 | + samplesRef.current.shift(); |
| 184 | + } |
| 185 | + setTick((n) => n + 1); |
| 186 | + }; |
| 187 | + sample(); // seed immediately |
| 188 | + const id = setInterval(sample, 500); |
169 | 189 | return () => clearInterval(id); |
170 | 190 | }, [streaming]); |
171 | | - if (!streaming || !startRef.current) return undefined; |
172 | | - const elapsedSec = (Date.now() - startRef.current) / 1000; |
173 | | - if (elapsedSec < 0.5) return undefined; |
174 | | - void tick; // force re-eval on each interval |
175 | | - const chars = streamingChars(state); |
176 | | - if (chars < 40) return undefined; |
177 | | - const tokens = chars / 4; |
178 | | - const rate = tokens / elapsedSec; |
179 | | - return Math.round(rate); |
| 191 | + |
| 192 | + if (!streaming || samplesRef.current.length < 2) return undefined; |
| 193 | + const oldest = samplesRef.current[0]; |
| 194 | + const newest = samplesRef.current[samplesRef.current.length - 1]; |
| 195 | + const dt = (newest.t - oldest.t) / 1000; |
| 196 | + if (dt < 0.5) return undefined; |
| 197 | + const dc = newest.c - oldest.c; |
| 198 | + if (dc < 10) return undefined; |
| 199 | + return Math.round(dc / CHARS_PER_TOKEN / dt); |
| 200 | +} |
| 201 | + |
| 202 | +/** |
| 203 | + * Tokens currently in the model's context, for the status-bar fill meter. |
| 204 | + * Prefers the last-turn's reported `input + cacheRead` from pi-ai, since |
| 205 | + * that's literally what the model saw. Falls back to char-based estimation |
| 206 | + * when the provider strips usage (e.g. some OAuth-fronted proxies) so the |
| 207 | + * bar still grows as the conversation grows. Streaming content is added |
| 208 | + * on top of the prior-turn baseline so the bar visibly fills during a turn |
| 209 | + * instead of jumping at message_end. |
| 210 | + */ |
| 211 | +export function estimateContextTokens(state: ChatState): number { |
| 212 | + if (state.turnUsage && state.turnUsage.input + state.turnUsage.cacheRead > 0) { |
| 213 | + const reported = state.turnUsage.input + state.turnUsage.cacheRead; |
| 214 | + const streamingExtra = Math.round(streamingChars(state) / CHARS_PER_TOKEN); |
| 215 | + return reported + streamingExtra; |
| 216 | + } |
| 217 | + let chars = 0; |
| 218 | + for (const msg of state.messages) chars += messageChars(msg); |
| 219 | + if (state.streaming) chars += messageChars(state.streaming); |
| 220 | + return Math.round(chars / CHARS_PER_TOKEN); |
| 221 | +} |
| 222 | + |
| 223 | +function messageChars(message: AgentMessage): number { |
| 224 | + if (typeof message.content === "string") return message.content.length; |
| 225 | + if (!Array.isArray(message.content)) return 0; |
| 226 | + let total = 0; |
| 227 | + for (const block of message.content) { |
| 228 | + if (block.type === "text") total += block.text.length; |
| 229 | + else if (block.type === "thinking") total += block.thinking.length; |
| 230 | + else if (block.type === "toolCall") { |
| 231 | + total += block.name.length; |
| 232 | + total += JSON.stringify(block.arguments ?? {}).length; |
| 233 | + } |
| 234 | + } |
| 235 | + return total; |
180 | 236 | } |
181 | 237 |
|
182 | 238 | /** Sum the visible text length of all text/thinking blocks in the live streaming message. */ |
|
0 commit comments