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
+ }
+}