Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/images/token-output-speed.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/components/chat/message-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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. */}
<div className="flex shrink-0 items-center gap-3 pr-px">
<TokenOutputSpeed tabId={attachmentTabId ?? null} />
<ComposerContextUsage tabId={attachmentTabId ?? null} />
<ComposerConnectionStatus tabId={attachmentTabId ?? null} />
</div>
Expand Down
128 changes: 128 additions & 0 deletions src/components/chat/token-output-speed.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<NextIntlClientProvider messages={enMessages} locale="en">
<TokenOutputSpeed tabId="tab1" />
</NextIntlClientProvider>
)
}

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()
})
})
97 changes: 97 additions & 0 deletions src/components/chat/token-output-speed.tsx
Original file line number Diff line number Diff line change
@@ -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<number | null>(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 (
<span
aria-label={t("outputSpeedAria")}
className="flex items-center gap-1"
title={t("outputSpeedTooltip")}
>
<Zap className="size-3.5" />
{tps.toFixed(1)} tok/s
</span>
)
}
2 changes: 2 additions & 0 deletions src/i18n/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -1713,6 +1713,8 @@
"tokens": {
"contextWindowUsageAria": "استخدام نافذة السياق",
"contextWindow": "نافذة السياق",
"outputSpeedAria": "السرعة التقديرية للإخراج",
"outputSpeedTooltip": "السرعة التقديرية للإخراج (نص + تفكير)",
"usedMax": "المستخدم / الحد الأقصى",
"tokenUsage": "استخدام الرموز",
"input": "إدخال",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -1713,6 +1713,8 @@
"tokens": {
"contextWindowUsageAria": "コンテキストウィンドウ使用率",
"contextWindow": "コンテキストウィンドウ",
"outputSpeedAria": "推定出力速度",
"outputSpeedTooltip": "推定出力速度(テキスト + 思考)",
"usedMax": "使用 / 最大",
"tokenUsage": "トークン使用量",
"input": "入力",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/messages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -1713,6 +1713,8 @@
"tokens": {
"contextWindowUsageAria": "컨텍스트 윈도우 사용량",
"contextWindow": "컨텍스트 윈도우",
"outputSpeedAria": "예상 출력 속도",
"outputSpeedTooltip": "예상 출력 속도(텍스트 + 추론)",
"usedMax": "사용 / 최대",
"tokenUsage": "토큰 사용량",
"input": "입력",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/messages/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/messages/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1713,6 +1713,8 @@
"tokens": {
"contextWindowUsageAria": "上下文窗口使用率",
"contextWindow": "上下文窗口",
"outputSpeedAria": "估算输出速度",
"outputSpeedTooltip": "估算输出速度(正文 + 思考)",
"usedMax": "已用 / 上限",
"tokenUsage": "Token 用量",
"input": "输入",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/messages/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -1713,6 +1713,8 @@
"tokens": {
"contextWindowUsageAria": "上下文視窗使用率",
"contextWindow": "上下文視窗",
"outputSpeedAria": "估算輸出速度",
"outputSpeedTooltip": "估算輸出速度(正文 + 思考)",
"usedMax": "已用 / 上限",
"tokenUsage": "Token 用量",
"input": "輸入",
Expand Down
Loading
Loading