diff --git a/docs/images/token-output-speed.png b/docs/images/token-output-speed.png new file mode 100644 index 000000000..a45f34fd2 Binary files /dev/null and b/docs/images/token-output-speed.png differ diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 2d88beee4..adfa3d20b 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -127,6 +127,7 @@ import { } from "@/components/chat/conversation-context-bar" import { ComposerContextUsage } from "@/components/chat/composer-context-usage" import { ComposerConnectionStatus } from "@/components/chat/composer-connection-status" +import { TokenOutputSpeed } from "@/components/chat/token-output-speed" import { InlineModeSelector } from "@/components/chat/mode-selector" import { InlineSessionConfigSelector } from "@/components/chat/session-config-selector" import { ModelOptionPicker } from "@/components/chat/model-option-picker" @@ -3872,6 +3873,7 @@ export function MessageInput({ send button's right edge in the action bar above — no centring slot, which would inset the narrow icon and break the alignment. */}
+
diff --git a/src/components/chat/token-output-speed.test.tsx b/src/components/chat/token-output-speed.test.tsx new file mode 100644 index 000000000..0c2222352 --- /dev/null +++ b/src/components/chat/token-output-speed.test.tsx @@ -0,0 +1,128 @@ +import { act, render, screen } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { TokenOutputSpeed } from "./token-output-speed" +import enMessages from "@/i18n/messages/en.json" +import type { + ConnectionState, + LiveContentBlock, +} from "@/contexts/acp-connections-context" + +const mock = vi.hoisted(() => { + const state: { + conn: ConnectionState | undefined + listener: (() => void) | null + } = { + conn: undefined, + listener: null, + } + return { + subscribeKey: (_key: string, cb: () => void) => { + state.listener = cb + return () => { + if (state.listener === cb) state.listener = null + } + }, + getConnection: () => state.conn, + emit: () => state.listener?.(), + setConn: (conn: ConnectionState | undefined) => { + state.conn = conn + }, + } +}) + +vi.mock("@/contexts/acp-connections-context", () => ({ + useConnectionStore: () => ({ + subscribeKey: mock.subscribeKey, + getConnection: mock.getConnection, + }), +})) + +function conn(blocks: LiveContentBlock[]): ConnectionState { + return { + connectionId: "c1", + contextKey: "tab1", + agentType: "codex", + workingDir: null, + status: "prompting", + liveMessage: { + id: "live-1", + role: "assistant", + content: blocks, + startedAt: 0, + }, + } as unknown as ConnectionState +} + +function renderBadge() { + return render( + + + + ) +} + +let fakeNow = 0 + +afterEach(() => { + vi.restoreAllMocks() + fakeNow = 0 +}) + +describe("TokenOutputSpeed", () => { + it("shows an estimated tok/s while prompting", () => { + fakeNow = 1000 + vi.spyOn(performance, "now").mockImplementation(() => fakeNow) + mock.setConn(conn([{ type: "text", text: "a".repeat(80) }])) + renderBadge() + expect(screen.queryByText(/\d+\.\d tok\/s/)).toBeNull() + + fakeNow = 2000 + mock.setConn(conn([{ type: "text", text: "a".repeat(480) }])) + act(() => mock.emit()) + expect(screen.getByText(/100\.0 tok\/s/)).toBeTruthy() + }) + + it("counts thinking blocks and skips sub-agent blocks", () => { + fakeNow = 1000 + vi.spyOn(performance, "now").mockImplementation(() => fakeNow) + mock.setConn( + conn([ + { type: "text", text: "a".repeat(80) }, + { type: "thinking", text: "a".repeat(80) }, + { type: "text", text: "a".repeat(800), parentToolUseId: "pt-1" }, + ]) + ) + renderBadge() + + fakeNow = 2000 + mock.setConn( + conn([ + { type: "text", text: "a".repeat(160) }, + { type: "thinking", text: "a".repeat(160) }, + { type: "text", text: "a".repeat(900), parentToolUseId: "pt-1" }, + ]) + ) + act(() => mock.emit()) + // 160 visible root chars → 40 tokens in the second second (plus the + // first second's 40 seeded as baseline): expect 40.0 tok/s. + expect(screen.getByText(/40\.0 tok\/s/)).toBeTruthy() + }) + + it("hides when the connection is not prompting", () => { + const idle = conn([]) + idle.status = "connected" + mock.setConn(idle) + renderBadge() + expect(screen.queryByText(/\d+\.\d tok\/s/)).toBeNull() + }) + + it("hides when there is no live message", () => { + const noLive = conn([]) + noLive.liveMessage = null + mock.setConn(noLive) + renderBadge() + expect(screen.queryByText(/\d+\.\d tok\/s/)).toBeNull() + }) +}) diff --git a/src/components/chat/token-output-speed.tsx b/src/components/chat/token-output-speed.tsx new file mode 100644 index 000000000..8e6eb7624 --- /dev/null +++ b/src/components/chat/token-output-speed.tsx @@ -0,0 +1,97 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { Zap } from "lucide-react" +import { useTranslations } from "next-intl" +import { useConnectionStore } from "@/contexts/acp-connections-context" +import { estimateTokens, TokenSpeedTracker } from "@/lib/token-speed" + +const THROTTLE_MS = 500 +const MAX_TPS = 999.9 + +/** + * Live token-output-speed badge for one conversation tab, rendered in the + * status row below the composer (before the context-window circle). Shows only + * while the tab's agent is prompting; the reading is a local estimate from the + * streamed `text` + `thinking` blocks (root agent only, no sub-agent content). + * ponytail: root-agent only; give sub-agent cards their own badge if cross- + * agent delegation speed becomes something users ask about. + */ +export function TokenOutputSpeed({ tabId }: { tabId: string | null }) { + const t = useTranslations("Folder.statusBar.tokens") + const store = useConnectionStore() + const [tps, setTps] = useState(null) + const lastRenderRef = useRef(0) + + useEffect(() => { + if (!tabId) return + const tracker = new TokenSpeedTracker() + lastRenderRef.current = 0 + + // Mid-turn attach (refresh / reconnect): seed the baseline from whatever + // has already streamed so the first delta after mount measures a real + // rate instead of the whole accumulated text. + const conn = store.getConnection(tabId) + const content = + conn?.status === "prompting" && conn.liveMessage + ? conn.liveMessage.content + : null + if (content && content.length > 0) { + let total = 0 + for (const block of content) { + if ( + (block.type === "text" || block.type === "thinking") && + block.parentToolUseId == null + ) { + total += estimateTokens(block.text) + } + } + tracker.observe(total, performance.now()) + } + + return store.subscribeKey(tabId, () => { + const next = store.getConnection(tabId) + const nextContent = + next?.status === "prompting" && next.liveMessage + ? next.liveMessage.content + : null + // Turn ended, or a fresh turn started with empty content (STATUS_CHANGED + // to prompting rebuilds liveMessage) — reset the baseline and hide. + if (!nextContent || nextContent.length === 0) { + tracker.reset() + lastRenderRef.current = 0 + setTps(null) + return + } + let total = 0 + for (const block of nextContent) { + if ( + (block.type === "text" || block.type === "thinking") && + block.parentToolUseId == null + ) { + total += estimateTokens(block.text) + } + } + const now = performance.now() + const rate = tracker.observe(total, now) + if (rate == null) return + if (now - lastRenderRef.current >= THROTTLE_MS) { + lastRenderRef.current = now + setTps(Math.min(Math.max(rate, 0), MAX_TPS)) + } + }) + }, [store, tabId]) + + if (tps == null) return null + + return ( + + + {tps.toFixed(1)} tok/s + + ) +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index e3b720214..aa85b3b5a 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1713,6 +1713,8 @@ "tokens": { "contextWindowUsageAria": "استخدام نافذة السياق", "contextWindow": "نافذة السياق", + "outputSpeedAria": "السرعة التقديرية للإخراج", + "outputSpeedTooltip": "السرعة التقديرية للإخراج (نص + تفكير)", "usedMax": "المستخدم / الحد الأقصى", "tokenUsage": "استخدام الرموز", "input": "إدخال", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index b63fe4f12..dc4456463 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1713,6 +1713,8 @@ "tokens": { "contextWindowUsageAria": "Kontextfenster-Nutzung", "contextWindow": "Kontextfenster", + "outputSpeedAria": "Geschätzte Ausgabegeschwindigkeit", + "outputSpeedTooltip": "Geschätzte Ausgabegeschwindigkeit (Text + Denken)", "usedMax": "Verwendet / Max", "tokenUsage": "Token-Nutzung", "input": "Eingabe", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 2027ac0eb..4bd494fbc 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1713,6 +1713,8 @@ "tokens": { "contextWindowUsageAria": "Context window usage", "contextWindow": "Context Window", + "outputSpeedAria": "Estimated output speed", + "outputSpeedTooltip": "Estimated output speed (text + thinking)", "usedMax": "Used / Max", "tokenUsage": "Token Usage", "input": "Input", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 126886136..8ce7c5c27 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1713,6 +1713,8 @@ "tokens": { "contextWindowUsageAria": "Uso de la ventana de contexto", "contextWindow": "Ventana de contexto", + "outputSpeedAria": "Velocidad de salida estimada", + "outputSpeedTooltip": "Velocidad de salida estimada (texto + razonamiento)", "usedMax": "Usado / Máx", "tokenUsage": "Uso de tokens", "input": "Entrada", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 4fdac37d1..786f958fa 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1713,6 +1713,8 @@ "tokens": { "contextWindowUsageAria": "Utilisation de la fenêtre de contexte", "contextWindow": "Fenêtre de contexte", + "outputSpeedAria": "Vitesse de sortie estimée", + "outputSpeedTooltip": "Vitesse de sortie estimée (texte + raisonnement)", "usedMax": "Utilisé / Max", "tokenUsage": "Utilisation des tokens", "input": "Entrée", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 2e13d588a..73ef43d72 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1713,6 +1713,8 @@ "tokens": { "contextWindowUsageAria": "コンテキストウィンドウ使用率", "contextWindow": "コンテキストウィンドウ", + "outputSpeedAria": "推定出力速度", + "outputSpeedTooltip": "推定出力速度(テキスト + 思考)", "usedMax": "使用 / 最大", "tokenUsage": "トークン使用量", "input": "入力", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 95d050c9d..49b27feca 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1713,6 +1713,8 @@ "tokens": { "contextWindowUsageAria": "컨텍스트 윈도우 사용량", "contextWindow": "컨텍스트 윈도우", + "outputSpeedAria": "예상 출력 속도", + "outputSpeedTooltip": "예상 출력 속도(텍스트 + 추론)", "usedMax": "사용 / 최대", "tokenUsage": "토큰 사용량", "input": "입력", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 41ca4e8e9..170663563 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1713,6 +1713,8 @@ "tokens": { "contextWindowUsageAria": "Uso da janela de contexto", "contextWindow": "Janela de contexto", + "outputSpeedAria": "Velocidade de saída estimada", + "outputSpeedTooltip": "Velocidade de saída estimada (texto + raciocínio)", "usedMax": "Usado / Máx", "tokenUsage": "Uso de tokens", "input": "Entrada", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index b65f15e46..844420c03 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1713,6 +1713,8 @@ "tokens": { "contextWindowUsageAria": "上下文窗口使用率", "contextWindow": "上下文窗口", + "outputSpeedAria": "估算输出速度", + "outputSpeedTooltip": "估算输出速度(正文 + 思考)", "usedMax": "已用 / 上限", "tokenUsage": "Token 用量", "input": "输入", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 57af307c9..fa8ec5da4 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1713,6 +1713,8 @@ "tokens": { "contextWindowUsageAria": "上下文視窗使用率", "contextWindow": "上下文視窗", + "outputSpeedAria": "估算輸出速度", + "outputSpeedTooltip": "估算輸出速度(正文 + 思考)", "usedMax": "已用 / 上限", "tokenUsage": "Token 用量", "input": "輸入", diff --git a/src/lib/token-speed.test.ts b/src/lib/token-speed.test.ts new file mode 100644 index 000000000..bbf9ea4a7 --- /dev/null +++ b/src/lib/token-speed.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest" +import { estimateTokens, TokenSpeedTracker } from "./token-speed" + +describe("estimateTokens", () => { + it("counts latin at 4 chars per token", () => { + expect(estimateTokens("hello world")).toBeCloseTo(10 / 4) + }) + + it("counts cjk at 1.8 chars per token", () => { + expect(estimateTokens("你好世界")).toBeCloseTo(4 / 1.8) + }) + + it("skips whitespace", () => { + expect(estimateTokens(" a b ")).toBeCloseTo(2 / 4) + }) + + it("mixes cjk and latin", () => { + expect(estimateTokens("你好 world")).toBeCloseTo(2 / 1.8 + 5 / 4) + }) + + it("counts fullwidth punctuation as cjk", () => { + expect(estimateTokens("你好,世界!")).toBeCloseTo(6 / 1.8) + }) + + it("returns 0 for empty or whitespace-only text", () => { + expect(estimateTokens("")).toBe(0) + expect(estimateTokens(" ")).toBe(0) + }) +}) + +describe("TokenSpeedTracker", () => { + it("seeds the baseline on the first observation", () => { + const tracker = new TokenSpeedTracker() + expect(tracker.observe(0, 0)).toBeNull() + }) + + it("computes a constant rate", () => { + const tracker = new TokenSpeedTracker() + tracker.observe(0, 0) + expect(tracker.observe(100, 1000)).toBeCloseTo(100) + }) + + it("smooths a burst followed by silence", () => { + const tracker = new TokenSpeedTracker() + tracker.observe(0, 0) + tracker.observe(200, 1000) + const rate = tracker.observe(200, 2000) + expect(rate).toBeGreaterThan(0) + expect(rate).toBeLessThan(200) + }) + + it("decays toward zero over a long pause", () => { + const tracker = new TokenSpeedTracker() + tracker.observe(0, 0) + tracker.observe(100, 1000) + expect(tracker.observe(100, 10_000)).toBeLessThan(1) + }) + + it("ignores non-positive time deltas", () => { + const tracker = new TokenSpeedTracker() + tracker.observe(0, 1000) + expect(tracker.observe(50, 1000)).toBeNull() + expect(tracker.observe(100, 500)).toBeNull() + }) + + it("resets to a fresh baseline", () => { + const tracker = new TokenSpeedTracker() + tracker.observe(0, 0) + tracker.observe(100, 1000) + tracker.reset() + expect(tracker.observe(0, 2000)).toBeNull() + }) +}) diff --git a/src/lib/token-speed.ts b/src/lib/token-speed.ts new file mode 100644 index 000000000..cb460ed6a --- /dev/null +++ b/src/lib/token-speed.ts @@ -0,0 +1,59 @@ +/** + * Pure helpers behind the live token-output-speed badge. Everything here is a + * function of its arguments — no clock, no DOM — so the heuristic and the + * smoothing are exactly testable. + * + * The char→token ratios are deliberately rough ("大差不差"): CJK text + * tokenizes denser than Latin, so we count CJK (ideographs + fullwidth forms) + * at ~1.8 chars/token and every other visible character at ~4 chars/token. + * Whitespace is skipped entirely. + * ponytail: fixed ratios; calibrate per model from reported turn usage if + * accuracy ever matters more than the live gauge. + */ + +const CJK_RE = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uFF00-\uFFEF]/g + +export function estimateTokens(text: string): number { + if (!text) return 0 + const cjk = text.match(CJK_RE)?.length ?? 0 + const other = text.replace(/\s/g, "").length - cjk + return cjk / 1.8 + other / 4 +} + +/** + * First-order low-pass over instantaneous token rates. Time-constant based + * (rather than per-event alpha) so a bursty thinking stream followed by a tool + * pause decays toward zero instead of pinning the old reading. + */ +export class TokenSpeedTracker { + private static readonly TAU_MS = 1500 + + private lastTime: number | null = null + private lastTokens = 0 + private ewma: number | null = null + + reset(): void { + this.lastTime = null + this.lastTokens = 0 + this.ewma = null + } + + /** Feed the cumulative estimated token count at `nowMs`; returns smoothed + * tok/s, or `null` while seeding / when the observation can't be used. */ + observe(totalTokens: number, nowMs: number): number | null { + if (this.lastTime == null) { + this.lastTime = nowMs + this.lastTokens = totalTokens + return null + } + const dt = nowMs - this.lastTime + if (dt <= 0) return this.ewma + const instant = (totalTokens - this.lastTokens) / (dt / 1000) + this.lastTime = nowMs + this.lastTokens = totalTokens + const alpha = 1 - Math.exp(-dt / TokenSpeedTracker.TAU_MS) + this.ewma = + this.ewma == null ? instant : this.ewma * (1 - alpha) + instant * alpha + return this.ewma + } +}