From 15295b4689d60a5828234ca59e19f0abc8527340 Mon Sep 17 00:00:00 2001 From: Changjun Sun Date: Sun, 9 Aug 2026 23:02:26 +0800 Subject: [PATCH] feat: show live token output speed on conversation tabs --- docs/images/token-output-speed.png | Bin 0 -> 24694 bytes src/components/chat/message-input.tsx | 2 + .../chat/token-output-speed.test.tsx | 128 ++++++++++++++++++ src/components/chat/token-output-speed.tsx | 97 +++++++++++++ src/i18n/messages/ar.json | 2 + src/i18n/messages/de.json | 2 + src/i18n/messages/en.json | 2 + src/i18n/messages/es.json | 2 + src/i18n/messages/fr.json | 2 + src/i18n/messages/ja.json | 2 + src/i18n/messages/ko.json | 2 + src/i18n/messages/pt.json | 2 + src/i18n/messages/zh-CN.json | 2 + src/i18n/messages/zh-TW.json | 2 + src/lib/token-speed.test.ts | 73 ++++++++++ src/lib/token-speed.ts | 59 ++++++++ 16 files changed, 379 insertions(+) create mode 100644 docs/images/token-output-speed.png create mode 100644 src/components/chat/token-output-speed.test.tsx create mode 100644 src/components/chat/token-output-speed.tsx create mode 100644 src/lib/token-speed.test.ts create mode 100644 src/lib/token-speed.ts diff --git a/docs/images/token-output-speed.png b/docs/images/token-output-speed.png new file mode 100644 index 0000000000000000000000000000000000000000..a45f34fd2d7f9c3058a48e57347aa27c03f40167 GIT binary patch literal 24694 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 RPk@;_XTiJae_FETKLE1M6mkFn literal 0 HcmV?d00001 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 + } +}