From ef073b8e750aa5a583169f48897d0b56c49266f4 Mon Sep 17 00:00:00 2001
From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com>
Date: Thu, 20 Aug 2026 18:56:10 -0700
Subject: [PATCH 1/2] fix(chat): don't count tool waits in output speed
The live tok/s reading was averaging in time spent waiting on tools, so a
fast model looked slow whenever a command was running. Pause the clock
while a tool is in flight, hold the last generating rate, and keep a
session average of tokens over generating time only.
Show that average in Session Details, the composer token popover, and the
bottom-left status bar.
---
.../chat/composer-context-usage.tsx | 21 ++-
.../conversations/session-details-content.tsx | 27 +++-
src/components/layout/status-bar-stats.tsx | 23 +++-
src/components/message/live-turn-stats.tsx | 7 +-
src/components/message/message-list-view.tsx | 1 +
src/hooks/use-session-output-speed.ts | 23 ++++
src/hooks/use-token-output-speed.test.ts | 116 +++++++++++++---
src/hooks/use-token-output-speed.ts | 86 ++++++++++--
src/i18n/messages/ar.json | 10 +-
src/i18n/messages/de.json | 10 +-
src/i18n/messages/en.json | 10 +-
src/i18n/messages/es.json | 10 +-
src/i18n/messages/fr.json | 10 +-
src/i18n/messages/ja.json | 10 +-
src/i18n/messages/ko.json | 10 +-
src/i18n/messages/pt.json | 10 +-
src/i18n/messages/zh-CN.json | 10 +-
src/i18n/messages/zh-TW.json | 10 +-
src/lib/session-output-speed.test.ts | 52 +++++++
src/lib/session-output-speed.ts | 128 ++++++++++++++++++
src/lib/token-speed.test.ts | 49 ++++++-
src/lib/token-speed.ts | 77 +++++++++--
22 files changed, 622 insertions(+), 88 deletions(-)
create mode 100644 src/hooks/use-session-output-speed.ts
create mode 100644 src/lib/session-output-speed.test.ts
create mode 100644 src/lib/session-output-speed.ts
diff --git a/src/components/chat/composer-context-usage.tsx b/src/components/chat/composer-context-usage.tsx
index 9f4a7ff212..fbe6c3412d 100644
--- a/src/components/chat/composer-context-usage.tsx
+++ b/src/components/chat/composer-context-usage.tsx
@@ -8,6 +8,8 @@ import { useTabStore } from "@/contexts/tab-context"
import { useConversationRuntimeStore } from "@/stores/conversation-runtime-store"
import { formatTokenCount } from "@/lib/token-format"
import { formatContextWindowPercent } from "@/lib/context-window"
+import { useSessionOutputSpeed } from "@/hooks/use-session-output-speed"
+import { formatTokPerSec } from "@/lib/token-speed"
import {
Popover,
PopoverContent,
@@ -46,6 +48,7 @@ export function ComposerContextUsage({ tabId }: { tabId: string | null }) {
: null
)
const usage = sessionStats?.total_usage
+ const outputSpeed = useSessionOutputSpeed(runtimeConversationId)
const subscribeConn = useCallback(
(cb: () => void) => {
@@ -117,8 +120,9 @@ export function ComposerContextUsage({ tabId }: { tabId: string | null }) {
}
const hasTokenSection = rows.length > 0
+ const hasOutputSpeed = outputSpeed != null
- if (!hasContext && !hasTokenSection) return null
+ if (!hasContext && !hasTokenSection && !hasOutputSpeed) return null
// Native hover hint mirroring the popover's headline (the popover stays for
// the full breakdown on click).
@@ -231,6 +235,21 @@ export function ComposerContextUsage({ tabId }: { tabId: string | null }) {
>
) : null}
+ {hasOutputSpeed ? (
+
+
{label}
@@ -312,6 +316,7 @@ export function SessionDetailsContent({
ctxMax
)
const durationMs = resolveSessionDurationMs(summary, stats)
+ const outputSpeed = useSessionOutputSpeed(summary.id)
// Never coerce an unknown `used` to 0 — some parsers infer the model's
// context cap without any usage figure, so render "— / max" rather than a
// bogus "0 / max".
@@ -324,11 +329,12 @@ export function SessionDetailsContent({
? formatTokenCount(ctxUsed)
: null
const hasTokenInfo =
- stats != null &&
- (totalTokens != null ||
- usage != null ||
- contextWindowValue != null ||
- durationMs > 0)
+ outputSpeed != null ||
+ (stats != null &&
+ (totalTokens != null ||
+ usage != null ||
+ contextWindowValue != null ||
+ durationMs > 0))
const numeric = "font-mono tabular-nums"
@@ -434,6 +440,15 @@ export function SessionDetailsContent({
{formatDuration(durationMs)}
)}
+ {outputSpeed != null && (
+
+ {formatTokPerSec(outputSpeed.averageTps)}
+
+ )}
) : (
{t("noStats")}
diff --git a/src/components/layout/status-bar-stats.tsx b/src/components/layout/status-bar-stats.tsx
index 914d5a026f..5b0c88540a 100644
--- a/src/components/layout/status-bar-stats.tsx
+++ b/src/components/layout/status-bar-stats.tsx
@@ -1,10 +1,13 @@
"use client"
-import { ChartNoAxesColumn, MonitorCloud } from "lucide-react"
+import { ChartNoAxesColumn, MonitorCloud, Plane } from "lucide-react"
import { useTranslations } from "next-intl"
import { useAppWorkspaceStore } from "@/stores/app-workspace-store"
import { useRemoteConnection } from "@/contexts/remote-connection-context"
import { useWorkbenchRoute } from "@/contexts/workbench-route-context"
+import { useTabStore } from "@/stores/tab-store"
+import { useSessionOutputSpeed } from "@/hooks/use-session-output-speed"
+import { formatTokPerSec } from "@/lib/token-speed"
import { cn } from "@/lib/utils"
/**
@@ -27,8 +30,15 @@ export function StatusBarStats() {
// codeg-server); local windows have no RemoteConnection in context.
const remoteConnection = useRemoteConnection()?.connection ?? null
const { routeId, setRoute } = useWorkbenchRoute()
+ const speedConversationId = useTabStore((s) => {
+ const tab = s.tabs.find((item) => item.id === s.activeTabId)
+ if (!tab || tab.kind !== "conversation") return null
+ return tab.runtimeConversationId ?? tab.conversationId ?? null
+ })
+ const outputSpeed = useSessionOutputSpeed(speedConversationId)
+ const tSpeed = useTranslations("Folder.statusBar.tokens")
- if (!remoteConnection && !stats) return null
+ if (!remoteConnection && !stats && outputSpeed == null) return null
return (
@@ -59,6 +69,15 @@ export function StatusBarStats() {
)}
+ {outputSpeed != null && (
+
+
+ {formatTokPerSec(outputSpeed.averageTps)}
+
+ )}
)
}
diff --git a/src/components/message/live-turn-stats.tsx b/src/components/message/live-turn-stats.tsx
index ee52545331..108f25e87c 100644
--- a/src/components/message/live-turn-stats.tsx
+++ b/src/components/message/live-turn-stats.tsx
@@ -16,11 +16,13 @@ import { FilePenLine, Plane, Timer } from "lucide-react"
import type { AgentType } from "@/lib/types"
import { AgentIcon } from "@/components/agent-icon"
import { useTokenOutputSpeed } from "@/hooks/use-token-output-speed"
+import { formatTokPerSec } from "@/lib/token-speed"
interface LiveTurnStatsProps {
message: LiveMessage
agentType: AgentType
isStreaming?: boolean
+ conversationId?: number | null
}
interface LineChangeStats {
@@ -311,12 +313,13 @@ export function LiveTurnStats({
message,
agentType,
isStreaming = true,
+ conversationId = null,
}: LiveTurnStatsProps) {
const locale = useLocale()
const t = useTranslations("Folder.chat.liveTurnStats")
const [elapsed, setElapsed] = useState(() => Date.now() - message.startedAt)
const editStats = useMemo(() => extractLiveEditStats(message), [message])
- const tps = useTokenOutputSpeed(message)
+ const tps = useTokenOutputSpeed(message, { conversationId })
const compactNumberFormatter = useMemo(
() =>
new Intl.NumberFormat(locale, {
@@ -392,7 +395,7 @@ export function LiveTurnStats({
aria-label={t("outputSpeedAria")}
className="h-3 w-3 shrink-0"
/>
- {tps.toFixed(1)} tok/s
+ {formatTokPerSec(tps)}
>
)}
diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx
index e9ac79c78d..fddda7689e 100644
--- a/src/components/message/message-list-view.tsx
+++ b/src/components/message/message-list-view.tsx
@@ -1154,6 +1154,7 @@ export function MessageListView({
message={liveMessage}
agentType={agentType}
isStreaming={connStatus === "prompting"}
+ conversationId={conversationId}
/>
)}
{/* Shared overlay stack pinned to the inline-start edge (top-left in LTR,
diff --git a/src/hooks/use-session-output-speed.ts b/src/hooks/use-session-output-speed.ts
new file mode 100644
index 0000000000..9dfb8fc8d7
--- /dev/null
+++ b/src/hooks/use-session-output-speed.ts
@@ -0,0 +1,23 @@
+"use client"
+
+import { useCallback, useSyncExternalStore } from "react"
+
+import {
+ getSessionOutputSpeed,
+ subscribeSessionOutputSpeed,
+ type SessionOutputSpeed,
+} from "@/lib/session-output-speed"
+
+export function useSessionOutputSpeed(
+ conversationId: number | null | undefined
+): SessionOutputSpeed | null {
+ const subscribe = useCallback(
+ (onStoreChange: () => void) => subscribeSessionOutputSpeed(onStoreChange),
+ []
+ )
+ const getSnapshot = useCallback(
+ () => getSessionOutputSpeed(conversationId),
+ [conversationId]
+ )
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
+}
diff --git a/src/hooks/use-token-output-speed.test.ts b/src/hooks/use-token-output-speed.test.ts
index cf4a182dff..a48c2f1f23 100644
--- a/src/hooks/use-token-output-speed.test.ts
+++ b/src/hooks/use-token-output-speed.test.ts
@@ -6,35 +6,57 @@ import type {
LiveContentBlock,
LiveMessage,
} from "@/contexts/acp-connections-context"
+import {
+ getSessionOutputSpeed,
+ resetSessionOutputSpeedForTests,
+} from "@/lib/session-output-speed"
function msg(blocks: LiveContentBlock[], id = "live-1"): LiveMessage {
return { id, role: "assistant", content: blocks, startedAt: 0 }
}
-/** A completed tool call — content the turn carries but never scores. */
-const TOOL_BLOCK: LiveContentBlock = {
- type: "tool_call",
- info: {
- tool_call_id: "tc-1",
- title: "tool",
- kind: "tool",
- status: "completed",
- content: null,
- raw_input: null,
- raw_output_chunks: [],
- raw_output_total_bytes: 0,
- locations: null,
- meta: null,
- images: [],
- },
+function toolBlock(
+ status: "pending" | "in_progress" | "completed"
+): LiveContentBlock {
+ return {
+ type: "tool_call",
+ info: {
+ tool_call_id: "tc-1",
+ title: "tool",
+ kind: "tool",
+ status,
+ content: null,
+ raw_input: null,
+ raw_output_chunks: [],
+ raw_output_total_bytes: 0,
+ locations: null,
+ meta: null,
+ images: [],
+ },
+ }
}
+/** A completed tool call — content the turn carries but never scores. */
+const TOOL_BLOCK: LiveContentBlock = toolBlock("completed")
+const RUNNING_TOOL: LiveContentBlock = toolBlock("in_progress")
+
let fakeNow = 0
-function mount(message: LiveMessage) {
+function mount(
+ message: LiveMessage,
+ options: { conversationId?: number; waiting?: boolean } = {}
+) {
return renderHook(
- ({ message }: { message: LiveMessage }) => useTokenOutputSpeed(message),
- { initialProps: { message } }
+ ({
+ message,
+ conversationId,
+ waiting,
+ }: {
+ message: LiveMessage
+ conversationId?: number
+ waiting?: boolean
+ }) => useTokenOutputSpeed(message, { conversationId, waiting }),
+ { initialProps: { message, ...options } }
)
}
@@ -55,6 +77,7 @@ beforeEach(() => {
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
+ resetSessionOutputSpeedForTests()
})
describe("useTokenOutputSpeed", () => {
@@ -152,9 +175,9 @@ describe("useTokenOutputSpeed", () => {
expect(result.current).toBeCloseTo(200)
})
- it("decays through a silent gap instead of freezing the reading", () => {
- // No new content at all — a quiet long-running tool, a retry backoff, or a
- // permission prompt blocking the turn. The reading has to fall.
+ it("decays through a silent generating gap instead of freezing the reading", () => {
+ // No new content and no in-flight tool — a slow decode, not a wait. The
+ // reading has to fall so a genuinely slow stream is not reported as fast.
const { result, rerender } = mount(
msg([{ type: "text", text: "a".repeat(80) }])
)
@@ -167,6 +190,55 @@ describe("useTokenOutputSpeed", () => {
expect(result.current as number).toBeLessThan(5)
})
+ it("holds the reading while a tool is running", () => {
+ const { result, rerender } = mount(
+ msg([{ type: "text", text: "a".repeat(80) }])
+ )
+ rerender({
+ message: msg([{ type: "text", text: "a".repeat(280) }]),
+ })
+ tick()
+ expect(result.current).toBeCloseTo(100)
+
+ rerender({
+ message: msg([{ type: "text", text: "a".repeat(280) }, RUNNING_TOOL]),
+ })
+ for (let i = 0; i < 20; i++) tick()
+ expect(result.current).toBeCloseTo(100)
+ })
+
+ it("does not fold a tool wait into the next generating sample", () => {
+ const { result, rerender } = mount(
+ msg([{ type: "text", text: "a".repeat(80) }]),
+ { conversationId: 9 }
+ )
+ rerender({
+ message: msg([{ type: "text", text: "a".repeat(280) }]),
+ conversationId: 9,
+ })
+ tick()
+ expect(result.current).toBeCloseTo(100)
+
+ rerender({
+ message: msg([{ type: "text", text: "a".repeat(280) }, RUNNING_TOOL]),
+ conversationId: 9,
+ })
+ for (let i = 0; i < 20; i++) tick()
+
+ // 200 new chars = 50 tokens in the 500ms sample after the wait.
+ rerender({
+ message: msg([{ type: "text", text: "a".repeat(480) }]),
+ conversationId: 9,
+ })
+ tick()
+ expect(result.current as number).toBeGreaterThan(50)
+ const session = getSessionOutputSpeed(9)
+ expect(session).not.toBeNull()
+ // Generating time is the two 500ms windows, not the 10s wait.
+ expect(session!.generatingMs).toBeLessThan(2000)
+ expect(session!.averageTps).toBeGreaterThan(50)
+ })
+
it("repaints on its own cadence, not on every delta", () => {
const { result, rerender } = mount(
msg([{ type: "text", text: "a".repeat(80) }])
diff --git a/src/hooks/use-token-output-speed.ts b/src/hooks/use-token-output-speed.ts
index a7fc94dde8..b40803c845 100644
--- a/src/hooks/use-token-output-speed.ts
+++ b/src/hooks/use-token-output-speed.ts
@@ -3,14 +3,17 @@
import { useEffect, useRef, useState } from "react"
import type { LiveMessage } from "@/contexts/acp-connections-context"
+import {
+ commitLiveSessionOutputSpeed,
+ migrateSessionOutputSpeed,
+ setLiveSessionOutputSpeed,
+} from "@/lib/session-output-speed"
import { TokenCountAccumulator, TokenSpeedTracker } from "@/lib/token-speed"
/**
* Sample period. One timer does all three jobs the reading needs — take a
- * measurement, repaint, and decay through a silent gap — so the gauge ticks at
- * a steady 2 Hz whether or not the wire is busy. Without a clock of its own the
- * reading would freeze rather than fall through a quiet long-running tool, a
- * retry backoff, or a permission / question prompt blocking the turn.
+ * measurement, repaint, and notice a tool wait — so the gauge ticks at a
+ * steady 2 Hz whether or not the wire is busy.
*
* The connection dispatches far more often than this (once per wire envelope),
* but the smoother is time-constant based rather than per-event, so a coarser
@@ -20,29 +23,61 @@ const SAMPLE_MS = 500
/** Clamp, so a nonsense spike can never stretch the row it renders in. */
const MAX_TPS = 999.9
+export interface TokenOutputSpeedOptions {
+ /** Runtime conversation id the session average is stored under. */
+ conversationId?: number | null
+ /**
+ * Extra "not generating" signal from outside the message (a permission
+ * prompt, a blocking question). In-flight tool calls on the message are
+ * detected here already.
+ */
+ waiting?: boolean
+}
+
+function liveMessageIsWaitingOnTool(message: LiveMessage): boolean {
+ return message.content.some(
+ (block) =>
+ block.type === "tool_call" &&
+ (block.info.status === "pending" || block.info.status === "in_progress")
+ )
+}
+
/**
* Live token-output-speed for one streaming turn, in tok/s. A local estimate
* from the message's `text` + `thinking` blocks — root agent only, since
* sub-agent output belongs to its own capsule rather than to this turn's rate.
*
+ * Time spent waiting on an in-flight tool (or `waiting`) is not generation
+ * and is excluded from both the live EWMA and the session average.
+ *
* `null` means "nothing to show": the turn has produced no output yet, or the
- * reading doesn't cover a meaningful slice of wall clock yet. The caller is
- * expected to mount this for the life of one turn and unmount when it ends.
+ * reading doesn't cover a meaningful slice of generating time yet. The caller
+ * is expected to mount this for the life of one turn and unmount when it ends.
*
* TODO: root-agent only; give sub-agent cards their own reading if cross-agent
* delegation speed becomes something users ask about.
*/
-export function useTokenOutputSpeed(message: LiveMessage): number | null {
+export function useTokenOutputSpeed(
+ message: LiveMessage,
+ options: TokenOutputSpeedOptions = {}
+): number | null {
const [tps, setTps] = useState
(null)
+ const { conversationId = null, waiting = false } = options
- // The sampler below runs off a timer rather than off this component's
- // render, so it reads the message through a ref. Written in an effect (never
- // during render); being one commit behind costs at most a frame, which the
- // cumulative counts absorb on the next sample anyway.
const messageRef = useRef(message)
+ const waitingRef = useRef(waiting)
+ const conversationIdRef = useRef(conversationId)
useEffect(() => {
messageRef.current = message
+ waitingRef.current = waiting
})
+ useEffect(() => {
+ const prev = conversationIdRef.current
+ if (prev != null && conversationId != null && prev !== conversationId) {
+ migrateSessionOutputSpeed(prev, conversationId)
+ }
+ conversationIdRef.current = conversationId
+ }, [conversationId])
useEffect(() => {
const counts = new TokenCountAccumulator()
@@ -55,6 +90,23 @@ export function useTokenOutputSpeed(message: LiveMessage): number | null {
*/
let emptyAt: number | null = null
+ const publishSession = () => {
+ const id = conversationIdRef.current
+ if (id == null) return
+ setLiveSessionOutputSpeed(
+ id,
+ tracker.generatingTokenCount,
+ tracker.generatingElapsedMs
+ )
+ }
+
+ const commitSession = () => {
+ const id = conversationIdRef.current
+ if (id == null) return
+ publishSession()
+ commitLiveSessionOutputSpeed(id)
+ }
+
/** Cumulative estimated tokens. Only appended text is scanned. */
const measure = (): number => {
const live = messageRef.current
@@ -62,6 +114,7 @@ export function useTokenOutputSpeed(message: LiveMessage): number | null {
// appending to it, so the accumulator's cached per-block prefixes can
// describe entirely different text — re-seat on an identity change.
if (live.id !== messageId) {
+ if (messageId != null) commitSession()
messageId = live.id
counts.reset()
tracker.reset()
@@ -112,14 +165,21 @@ export function useTokenOutputSpeed(message: LiveMessage): number | null {
emptyAt = null
}
+ const pause =
+ waitingRef.current || liveMessageIsWaitingOnTool(messageRef.current)
+
// `null` is "still seeding / warming up", not "zero" — hold the last
// reading rather than blinking out.
- const rate = tracker.observe(total, now)
+ const rate = tracker.observe(total, now, { pause })
+ publishSession()
if (rate == null) return
setTps(Math.min(Math.max(rate, 0), MAX_TPS))
}, SAMPLE_MS)
- return () => clearInterval(timer)
+ return () => {
+ clearInterval(timer)
+ commitSession()
+ }
}, [])
return tps
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json
index f4d082c807..7edf7de780 100644
--- a/src/i18n/messages/ar.json
+++ b/src/i18n/messages/ar.json
@@ -1829,6 +1829,8 @@
"cacheRead": "قراءة الذاكرة",
"contextWindow": "نافذة السياق",
"duration": "المدة",
+ "outputSpeed": "متوسط سرعة الإخراج",
+ "outputSpeedTooltip": "المتوسط أثناء كتابة النموذج. وقت انتظار الأدوات غير محسوب.",
"loadingStats": "جارٍ تحميل استخدام الرموز…",
"loadFailed": "فشل تحميل استخدام الرموز",
"noStats": "لا يوجد استخدام مسجّل",
@@ -1924,7 +1926,9 @@
"output": "إخراج",
"cacheRead": "قراءة الكاش",
"cacheWrite": "كتابة الكاش",
- "total": "الإجمالي"
+ "total": "الإجمالي",
+ "outputSpeed": "سرعة الإخراج",
+ "outputSpeedTooltip": "المتوسط أثناء كتابة النموذج. وقت انتظار الأدوات غير محسوب."
},
"quickActions": {
"title": "إجراءات سريعة",
@@ -3101,8 +3105,8 @@
"elapsedHours": "{value}س",
"elapsedMinutes": "{value}د",
"elapsedSeconds": "{value}ث",
- "outputSpeedAria": "السرعة التقديرية للإخراج",
- "outputSpeedTooltip": "السرعة التقديرية للإخراج (نص + تفكير)"
+ "outputSpeedAria": "سرعة الإخراج",
+ "outputSpeedTooltip": "أثناء كتابة النموذج. وقت انتظار الأدوات غير محسوب."
},
"jsonTree": {
"viewRaw": "عرض JSON الخام",
diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json
index 9501547529..f29bcc7ba0 100644
--- a/src/i18n/messages/de.json
+++ b/src/i18n/messages/de.json
@@ -1829,6 +1829,8 @@
"cacheRead": "Cache gelesen",
"contextWindow": "Kontextfenster",
"duration": "Dauer",
+ "outputSpeed": "Mittl. Ausgabegeschwindigkeit",
+ "outputSpeedTooltip": "Durchschnitt, während das Modell schreibt. Wartezeit auf Tools zählt nicht.",
"loadingStats": "Token-Nutzung wird geladen…",
"loadFailed": "Token-Nutzung konnte nicht geladen werden",
"noStats": "Keine Nutzung erfasst",
@@ -1924,7 +1926,9 @@
"output": "Ausgabe",
"cacheRead": "Cache lesen",
"cacheWrite": "Cache schreiben",
- "total": "Gesamt"
+ "total": "Gesamt",
+ "outputSpeed": "Ausgabegeschwindigkeit",
+ "outputSpeedTooltip": "Durchschnitt, während das Modell schreibt. Wartezeit auf Tools zählt nicht."
},
"quickActions": {
"title": "Schnellaktionen",
@@ -3101,8 +3105,8 @@
"elapsedHours": "{value} Std",
"elapsedMinutes": "{value} Min",
"elapsedSeconds": "{value} Sek",
- "outputSpeedAria": "Geschätzte Ausgabegeschwindigkeit",
- "outputSpeedTooltip": "Geschätzte Ausgabegeschwindigkeit (Text + Denken)"
+ "outputSpeedAria": "Ausgabegeschwindigkeit",
+ "outputSpeedTooltip": "Während das Modell schreibt. Wartezeit auf Tools zählt nicht."
},
"jsonTree": {
"viewRaw": "Rohes JSON anzeigen",
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index b707982aaf..0762f4e430 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -1829,6 +1829,8 @@
"cacheRead": "Cache Read",
"contextWindow": "Context Window",
"duration": "Duration",
+ "outputSpeed": "Avg. output speed",
+ "outputSpeedTooltip": "Average while the model is writing. Time spent waiting on tools is not counted.",
"loadingStats": "Loading token usage…",
"loadFailed": "Failed to load token usage",
"noStats": "No usage recorded",
@@ -1924,7 +1926,9 @@
"output": "Output",
"cacheRead": "Cache Read",
"cacheWrite": "Cache Write",
- "total": "Total"
+ "total": "Total",
+ "outputSpeed": "Output speed",
+ "outputSpeedTooltip": "Average while the model is writing. Time spent waiting on tools is not counted."
},
"quickActions": {
"title": "Quick actions",
@@ -3101,8 +3105,8 @@
"elapsedHours": "{value}h",
"elapsedMinutes": "{value}m",
"elapsedSeconds": "{value}s",
- "outputSpeedAria": "Estimated output speed",
- "outputSpeedTooltip": "Estimated output speed (text + thinking)"
+ "outputSpeedAria": "Output speed",
+ "outputSpeedTooltip": "While the model is writing. Time spent waiting on tools is not counted."
},
"jsonTree": {
"viewRaw": "Show raw JSON",
diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json
index 5ec92aedbd..74248b08ed 100644
--- a/src/i18n/messages/es.json
+++ b/src/i18n/messages/es.json
@@ -1829,6 +1829,8 @@
"cacheRead": "Caché leída",
"contextWindow": "Ventana de contexto",
"duration": "Duración",
+ "outputSpeed": "Vel. de salida media",
+ "outputSpeedTooltip": "Media mientras el modelo escribe. El tiempo de espera de herramientas no cuenta.",
"loadingStats": "Cargando uso de tokens…",
"loadFailed": "Error al cargar el uso de tokens",
"noStats": "Sin uso registrado",
@@ -1924,7 +1926,9 @@
"output": "Salida",
"cacheRead": "Lectura de caché",
"cacheWrite": "Escritura de caché",
- "total": "Total de tokens"
+ "total": "Total de tokens",
+ "outputSpeed": "Velocidad de salida",
+ "outputSpeedTooltip": "Media mientras el modelo escribe. El tiempo de espera de herramientas no cuenta."
},
"quickActions": {
"title": "Acciones rápidas",
@@ -3101,8 +3105,8 @@
"elapsedHours": "{value} h",
"elapsedMinutes": "{value} min",
"elapsedSeconds": "{value} s",
- "outputSpeedAria": "Velocidad de salida estimada",
- "outputSpeedTooltip": "Velocidad de salida estimada (texto + razonamiento)"
+ "outputSpeedAria": "Velocidad de salida",
+ "outputSpeedTooltip": "Mientras el modelo escribe. El tiempo de espera de herramientas no cuenta."
},
"jsonTree": {
"viewRaw": "Ver JSON sin formato",
diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json
index 85781a8bf0..6fcaf37180 100644
--- a/src/i18n/messages/fr.json
+++ b/src/i18n/messages/fr.json
@@ -1829,6 +1829,8 @@
"cacheRead": "Cache lu",
"contextWindow": "Fenêtre de contexte",
"duration": "Durée",
+ "outputSpeed": "Vitesse de sortie moy.",
+ "outputSpeedTooltip": "Moyenne pendant que le modèle écrit. L'attente des outils n'est pas comptée.",
"loadingStats": "Chargement de l'utilisation des tokens…",
"loadFailed": "Échec du chargement de l'utilisation des tokens",
"noStats": "Aucune utilisation enregistrée",
@@ -1924,7 +1926,9 @@
"output": "Sortie",
"cacheRead": "Lecture cache",
"cacheWrite": "Écriture cache",
- "total": "Total des tokens"
+ "total": "Total des tokens",
+ "outputSpeed": "Vitesse de sortie",
+ "outputSpeedTooltip": "Moyenne pendant que le modèle écrit. L'attente des outils n'est pas comptée."
},
"quickActions": {
"title": "Actions rapides",
@@ -3101,8 +3105,8 @@
"elapsedHours": "{value} h",
"elapsedMinutes": "{value} min",
"elapsedSeconds": "{value} s",
- "outputSpeedAria": "Vitesse de sortie estimée",
- "outputSpeedTooltip": "Vitesse de sortie estimée (texte + raisonnement)"
+ "outputSpeedAria": "Vitesse de sortie",
+ "outputSpeedTooltip": "Pendant que le modèle écrit. L'attente des outils n'est pas comptée."
},
"jsonTree": {
"viewRaw": "Afficher le JSON brut",
diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json
index 55be21f779..5aa9f508f1 100644
--- a/src/i18n/messages/ja.json
+++ b/src/i18n/messages/ja.json
@@ -1829,6 +1829,8 @@
"cacheRead": "キャッシュ読取",
"contextWindow": "コンテキストウィンドウ",
"duration": "所要時間",
+ "outputSpeed": "平均出力速度",
+ "outputSpeedTooltip": "モデルが書いている間の平均速度。ツール待ち時間は含めません。",
"loadingStats": "トークン使用量を読み込み中…",
"loadFailed": "トークン使用量の読み込みに失敗しました",
"noStats": "使用量の記録なし",
@@ -1924,7 +1926,9 @@
"output": "出力",
"cacheRead": "キャッシュ読み取り",
"cacheWrite": "キャッシュ書き込み",
- "total": "合計"
+ "total": "合計",
+ "outputSpeed": "出力速度",
+ "outputSpeedTooltip": "モデルが書いている間の平均速度。ツール待ち時間は含めません。"
},
"quickActions": {
"title": "クイック操作",
@@ -3101,8 +3105,8 @@
"elapsedHours": "{value}時間",
"elapsedMinutes": "{value}分",
"elapsedSeconds": "{value}秒",
- "outputSpeedAria": "推定出力速度",
- "outputSpeedTooltip": "推定出力速度(テキスト + 思考)"
+ "outputSpeedAria": "出力速度",
+ "outputSpeedTooltip": "モデルが書いている間の速度。ツール待ち時間は含めません。"
},
"jsonTree": {
"viewRaw": "生の JSON を表示",
diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json
index ed59bc9bd9..1942cdff57 100644
--- a/src/i18n/messages/ko.json
+++ b/src/i18n/messages/ko.json
@@ -1829,6 +1829,8 @@
"cacheRead": "캐시 읽기",
"contextWindow": "컨텍스트 창",
"duration": "소요 시간",
+ "outputSpeed": "평균 출력 속도",
+ "outputSpeedTooltip": "모델이 쓰는 동안의 평균 속도. 도구 대기 시간은 포함하지 않습니다.",
"loadingStats": "토큰 사용량 불러오는 중…",
"loadFailed": "토큰 사용량을 불러오지 못했습니다",
"noStats": "사용량 기록 없음",
@@ -1924,7 +1926,9 @@
"output": "출력",
"cacheRead": "캐시 읽기",
"cacheWrite": "캐시 쓰기",
- "total": "합계"
+ "total": "합계",
+ "outputSpeed": "출력 속도",
+ "outputSpeedTooltip": "모델이 쓰는 동안의 평균 속도. 도구 대기 시간은 포함하지 않습니다."
},
"quickActions": {
"title": "빠른 작업",
@@ -3101,8 +3105,8 @@
"elapsedHours": "{value}시간",
"elapsedMinutes": "{value}분",
"elapsedSeconds": "{value}초",
- "outputSpeedAria": "예상 출력 속도",
- "outputSpeedTooltip": "예상 출력 속도(텍스트 + 추론)"
+ "outputSpeedAria": "출력 속도",
+ "outputSpeedTooltip": "모델이 쓰는 동안의 속도. 도구 대기 시간은 포함하지 않습니다."
},
"jsonTree": {
"viewRaw": "원본 JSON 보기",
diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json
index f957a119c9..b0c64af681 100644
--- a/src/i18n/messages/pt.json
+++ b/src/i18n/messages/pt.json
@@ -1829,6 +1829,8 @@
"cacheRead": "Cache lido",
"contextWindow": "Janela de contexto",
"duration": "Duração",
+ "outputSpeed": "Vel. média de saída",
+ "outputSpeedTooltip": "Média enquanto o modelo escreve. A espera por ferramentas não conta.",
"loadingStats": "Carregando uso de tokens…",
"loadFailed": "Falha ao carregar o uso de tokens",
"noStats": "Nenhum uso registrado",
@@ -1924,7 +1926,9 @@
"output": "Saída",
"cacheRead": "Leitura de cache",
"cacheWrite": "Gravação de cache",
- "total": "Total de tokens"
+ "total": "Total de tokens",
+ "outputSpeed": "Velocidade de saída",
+ "outputSpeedTooltip": "Média enquanto o modelo escreve. A espera por ferramentas não conta."
},
"quickActions": {
"title": "Ações rápidas",
@@ -3101,8 +3105,8 @@
"elapsedHours": "{value} h",
"elapsedMinutes": "{value} min",
"elapsedSeconds": "{value} s",
- "outputSpeedAria": "Velocidade de saída estimada",
- "outputSpeedTooltip": "Velocidade de saída estimada (texto + raciocínio)"
+ "outputSpeedAria": "Velocidade de saída",
+ "outputSpeedTooltip": "Enquanto o modelo escreve. A espera por ferramentas não conta."
},
"jsonTree": {
"viewRaw": "Ver JSON bruto",
diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json
index 933fbdd0e2..caf690e57e 100644
--- a/src/i18n/messages/zh-CN.json
+++ b/src/i18n/messages/zh-CN.json
@@ -1829,6 +1829,8 @@
"cacheRead": "缓存读取",
"contextWindow": "上下文窗口",
"duration": "时长",
+ "outputSpeed": "平均输出速度",
+ "outputSpeedTooltip": "模型正在写时的平均速度。等待工具完成的时间不计入。",
"loadingStats": "正在加载 Token 用量…",
"loadFailed": "加载 Token 用量失败",
"noStats": "暂无用量记录",
@@ -1924,7 +1926,9 @@
"output": "输出",
"cacheRead": "缓存读取",
"cacheWrite": "缓存写入",
- "total": "总计"
+ "total": "总计",
+ "outputSpeed": "输出速度",
+ "outputSpeedTooltip": "模型正在写时的平均速度。等待工具完成的时间不计入。"
},
"quickActions": {
"title": "快捷操作",
@@ -3101,8 +3105,8 @@
"elapsedHours": "{value} 小时",
"elapsedMinutes": "{value} 分钟",
"elapsedSeconds": "{value} 秒",
- "outputSpeedAria": "估算输出速度",
- "outputSpeedTooltip": "估算输出速度(正文 + 思考)"
+ "outputSpeedAria": "输出速度",
+ "outputSpeedTooltip": "模型正在写时的速度。等待工具完成的时间不计入。"
},
"jsonTree": {
"viewRaw": "查看原始 JSON",
diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json
index 0b38589a41..15a17ef335 100644
--- a/src/i18n/messages/zh-TW.json
+++ b/src/i18n/messages/zh-TW.json
@@ -1829,6 +1829,8 @@
"cacheRead": "快取讀取",
"contextWindow": "上下文視窗",
"duration": "時長",
+ "outputSpeed": "平均輸出速度",
+ "outputSpeedTooltip": "模型正在寫時的平均速度。等待工具完成的時間不計入。",
"loadingStats": "正在載入 Token 用量…",
"loadFailed": "載入 Token 用量失敗",
"noStats": "尚無用量記錄",
@@ -1924,7 +1926,9 @@
"output": "輸出",
"cacheRead": "快取讀取",
"cacheWrite": "快取寫入",
- "total": "總計"
+ "total": "總計",
+ "outputSpeed": "輸出速度",
+ "outputSpeedTooltip": "模型正在寫時的平均速度。等待工具完成的時間不計入。"
},
"quickActions": {
"title": "快捷操作",
@@ -3101,8 +3105,8 @@
"elapsedHours": "{value} 小時",
"elapsedMinutes": "{value} 分鐘",
"elapsedSeconds": "{value} 秒",
- "outputSpeedAria": "估算輸出速度",
- "outputSpeedTooltip": "估算輸出速度(正文 + 思考)"
+ "outputSpeedAria": "輸出速度",
+ "outputSpeedTooltip": "模型正在寫時的速度。等待工具完成的時間不計入。"
},
"jsonTree": {
"viewRaw": "檢視原始 JSON",
diff --git a/src/lib/session-output-speed.test.ts b/src/lib/session-output-speed.test.ts
new file mode 100644
index 0000000000..9461c974e0
--- /dev/null
+++ b/src/lib/session-output-speed.test.ts
@@ -0,0 +1,52 @@
+import { afterEach, describe, expect, it } from "vitest"
+
+import {
+ commitLiveSessionOutputSpeed,
+ getSessionOutputSpeed,
+ migrateSessionOutputSpeed,
+ resetSessionOutputSpeedForTests,
+ setLiveSessionOutputSpeed,
+} from "./session-output-speed"
+
+afterEach(() => {
+ resetSessionOutputSpeedForTests()
+})
+
+describe("session output speed", () => {
+ it("hides a reading until generating time is meaningful", () => {
+ setLiveSessionOutputSpeed(1, 10, 100)
+ expect(getSessionOutputSpeed(1)).toBeNull()
+ })
+
+ it("reports tokens over generating time", () => {
+ setLiveSessionOutputSpeed(1, 100, 1000)
+ const snap = getSessionOutputSpeed(1)
+ expect(snap?.averageTps).toBeCloseTo(100)
+ expect(snap?.generatingMs).toBe(1000)
+ expect(snap?.outputTokens).toBe(100)
+ })
+
+ it("replaces the live slot instead of stacking samples", () => {
+ setLiveSessionOutputSpeed(1, 50, 500)
+ setLiveSessionOutputSpeed(1, 100, 1000)
+ expect(getSessionOutputSpeed(1)?.outputTokens).toBe(100)
+ })
+
+ it("commits a finished turn so the next live slot cannot overwrite it", () => {
+ setLiveSessionOutputSpeed(1, 100, 1000)
+ commitLiveSessionOutputSpeed(1)
+ setLiveSessionOutputSpeed(1, 50, 1000)
+ const snap = getSessionOutputSpeed(1)
+ expect(snap?.outputTokens).toBe(150)
+ expect(snap?.generatingMs).toBe(2000)
+ expect(snap?.averageTps).toBeCloseTo(75)
+ })
+
+ it("migrates a virtual runtime id onto the persisted conversation", () => {
+ setLiveSessionOutputSpeed(-7, 80, 1000)
+ commitLiveSessionOutputSpeed(-7)
+ migrateSessionOutputSpeed(-7, 42)
+ expect(getSessionOutputSpeed(-7)).toBeNull()
+ expect(getSessionOutputSpeed(42)?.outputTokens).toBe(80)
+ })
+})
diff --git a/src/lib/session-output-speed.ts b/src/lib/session-output-speed.ts
new file mode 100644
index 0000000000..adc59d49ab
--- /dev/null
+++ b/src/lib/session-output-speed.ts
@@ -0,0 +1,128 @@
+/**
+ * Per-conversation generating-only output speed.
+ *
+ * Live tok/s is a one-turn EWMA. This store keeps the session average:
+ * estimated output tokens / generating milliseconds, with tool waits (and
+ * any other `pause`) excluded. The live turn writes its current generating
+ * totals into a replaceable "live" slot so a 2 Hz sample does not
+ * double-count; committing that slot at turn end (or unmount) folds it
+ * into the session.
+ *
+ * In-memory only: a reload has no generating clock to recover from the
+ * transcript, so we would rather show nothing than a wall-clock rate that
+ * includes every tool wait.
+ */
+
+export interface SessionOutputSpeed {
+ averageTps: number
+ generatingMs: number
+ outputTokens: number
+}
+
+const WARMUP_MS = 300
+
+type Slot = {
+ committedTokens: number
+ committedMs: number
+ liveTokens: number
+ liveMs: number
+}
+
+const slots = new Map()
+const listeners = new Set<() => void>()
+
+function emit(): void {
+ for (const listener of listeners) listener()
+}
+
+function slotOf(conversationId: number): Slot {
+ let slot = slots.get(conversationId)
+ if (!slot) {
+ slot = {
+ committedTokens: 0,
+ committedMs: 0,
+ liveTokens: 0,
+ liveMs: 0,
+ }
+ slots.set(conversationId, slot)
+ }
+ return slot
+}
+
+function snapshotOf(slot: Slot): SessionOutputSpeed | null {
+ const generatingMs = slot.committedMs + slot.liveMs
+ const outputTokens = slot.committedTokens + slot.liveTokens
+ if (generatingMs < WARMUP_MS) return null
+ return {
+ averageTps: outputTokens / (generatingMs / 1000),
+ generatingMs,
+ outputTokens,
+ }
+}
+
+/** Replace the current turn's generating totals (not additive). */
+export function setLiveSessionOutputSpeed(
+ conversationId: number,
+ generatingTokens: number,
+ generatingMs: number
+): void {
+ const slot = slotOf(conversationId)
+ if (slot.liveTokens === generatingTokens && slot.liveMs === generatingMs) {
+ return
+ }
+ slot.liveTokens = generatingTokens
+ slot.liveMs = generatingMs
+ emit()
+}
+
+/** Fold the live turn into the session and clear the live slot. */
+export function commitLiveSessionOutputSpeed(conversationId: number): void {
+ const slot = slots.get(conversationId)
+ if (!slot) return
+ if (slot.liveTokens === 0 && slot.liveMs === 0) return
+ slot.committedTokens += slot.liveTokens
+ slot.committedMs += slot.liveMs
+ slot.liveTokens = 0
+ slot.liveMs = 0
+ emit()
+}
+
+/** Follow a virtual runtime id that reconciled to a persisted conversation. */
+export function migrateSessionOutputSpeed(
+ fromConversationId: number,
+ toConversationId: number
+): void {
+ if (fromConversationId === toConversationId) return
+ const from = slots.get(fromConversationId)
+ if (!from) return
+ const to = slots.get(toConversationId)
+ if (!to) {
+ slots.set(toConversationId, from)
+ } else {
+ to.committedTokens += from.committedTokens + from.liveTokens
+ to.committedMs += from.committedMs + from.liveMs
+ }
+ slots.delete(fromConversationId)
+ emit()
+}
+
+export function getSessionOutputSpeed(
+ conversationId: number | null | undefined
+): SessionOutputSpeed | null {
+ if (conversationId == null) return null
+ const slot = slots.get(conversationId)
+ return slot ? snapshotOf(slot) : null
+}
+
+export function subscribeSessionOutputSpeed(listener: () => void): () => void {
+ listeners.add(listener)
+ return () => {
+ listeners.delete(listener)
+ }
+}
+
+/** Test helper: drop every session so cases cannot leak into each other. */
+export function resetSessionOutputSpeedForTests(): void {
+ slots.clear()
+ emit()
+}
diff --git a/src/lib/token-speed.test.ts b/src/lib/token-speed.test.ts
index 1da75faa74..e1834ff2f3 100644
--- a/src/lib/token-speed.test.ts
+++ b/src/lib/token-speed.test.ts
@@ -2,10 +2,18 @@ import { describe, expect, it } from "vitest"
import {
countChars,
estimateTokens,
+ formatTokPerSec,
TokenCountAccumulator,
TokenSpeedTracker,
} from "./token-speed"
+describe("formatTokPerSec", () => {
+ it("matches the live-turn one-decimal tok/s label", () => {
+ expect(formatTokPerSec(47.23)).toBe("47.2 tok/s")
+ expect(formatTokPerSec(0)).toBe("0.0 tok/s")
+ })
+})
+
describe("estimateTokens", () => {
it("counts latin at 4 chars per token", () => {
expect(estimateTokens("hello world")).toBeCloseTo(10 / 4)
@@ -178,11 +186,47 @@ describe("TokenSpeedTracker", () => {
expect(rate as number).toBeLessThan(200)
})
- it("decays toward zero over a long pause", () => {
+ it("holds the reading across a paused wait instead of decaying", () => {
+ const tracker = new TokenSpeedTracker()
+ tracker.observe(0, 0)
+ const generating = tracker.observe(100, 1000)
+ expect(generating).toBeCloseTo(100)
+ // 9s of tool wait: same token count, pause=true.
+ const held = tracker.observe(100, 10_000, { pause: true })
+ expect(held).toBeCloseTo(generating as number)
+ })
+
+ it("does not dilute the next generating sample by a paused wait", () => {
+ const tracker = new TokenSpeedTracker()
+ tracker.observe(0, 0)
+ tracker.observe(100, 1000)
+ tracker.observe(100, 10_000, { pause: true })
+ // 50 tokens in the 1s after the wait — 50 tok/s, not 50 / 10s.
+ const resumed = tracker.observe(150, 11_000)
+ expect(resumed).toBeGreaterThan(40)
+ expect(resumed as number).toBeLessThan(80)
+ })
+
+ it("counts a slow generating gap (no pause) as real time", () => {
+ const tracker = new TokenSpeedTracker()
+ tracker.observe(0, 0)
+ tracker.observe(100, 1000)
+ // 1s of silence while still generating (slow decode) must pull the rate.
+ const slowed = tracker.observe(100, 2000)
+ expect(slowed).toBeLessThan(80)
+ expect(slowed as number).toBeGreaterThan(20)
+ })
+
+ it("reports generating-only average excluding paused waits", () => {
const tracker = new TokenSpeedTracker()
tracker.observe(0, 0)
tracker.observe(100, 1000)
- expect(tracker.observe(100, 10_000)).toBeLessThan(1)
+ tracker.observe(100, 10_000, { pause: true })
+ tracker.observe(200, 11_000)
+ // 200 tokens over 2s generating, not 11s wall.
+ expect(tracker.average()).toBeCloseTo(100)
+ expect(tracker.generatingElapsedMs).toBe(2000)
+ expect(tracker.generatingTokenCount).toBeCloseTo(200)
})
it("ignores non-positive time deltas", () => {
@@ -205,5 +249,6 @@ describe("TokenSpeedTracker", () => {
tracker.observe(100, 1000)
tracker.reset()
expect(tracker.observe(0, 2000)).toBeNull()
+ expect(tracker.average()).toBeNull()
})
})
diff --git a/src/lib/token-speed.ts b/src/lib/token-speed.ts
index ebed8107bf..5210844aac 100644
--- a/src/lib/token-speed.ts
+++ b/src/lib/token-speed.ts
@@ -185,23 +185,38 @@ export class TokenCountAccumulator {
}
}
+export interface TokenSpeedObserveOptions {
+ /**
+ * True while the turn is blocked on something that is not generation
+ * (an in-flight tool, a permission prompt, a question). Idle time is
+ * skipped: the clock advances so the next generating sample is not
+ * diluted, and the EWMA holds instead of decaying toward zero.
+ */
+ pause?: boolean
+}
+
/**
* First-order low-pass over instantaneous token rates. Time-constant based
* (rather than per-sample alpha) so the reading doesn't depend on how regularly
- * the caller samples, and so a bursty thinking stream followed by a tool pause
- * decays toward zero instead of pinning the old reading.
+ * the caller samples.
+ *
+ * Generation time is the only time that counts. A tool wait, a permission
+ * prompt, or any other pause is `observe(..., { pause: true })`: the last
+ * reading is held and the idle span is excluded from the next sample's `dt`.
+ * Short gaps *while generating* (batched deltas, a slow decode) still feed a
+ * zero instant so a genuinely slow stream is not mistaken for a fast one.
*
* The accumulated weight is tracked alongside the average and divided back out
- * (the bias correction Adam uses). Without it the filter cold-starts from its
- * first sample, and a turn's first sample is routinely near-zero — it lands on
- * whatever fraction of a batched content delta had arrived by then. That made
- * the reading open at `0.0` and crawl toward the truth over several seconds.
- * With the correction it is the turn's cumulative average until `TAU_MS` has
+ * (bias correction). Without it the filter cold-starts from its first sample,
+ * and a turn's first sample is routinely near-zero — it lands on whatever
+ * fraction of a batched content delta had arrived by then. That made the
+ * reading open at `0.0` and crawl toward the truth over several seconds. With
+ * the correction it is the turn's cumulative average until `TAU_MS` has
* elapsed, and an exponential window after that.
*/
export class TokenSpeedTracker {
private static readonly TAU_MS = 1500
- /** Hold the reading back until it covers a meaningful slice of wall clock. */
+ /** Hold the reading back until it covers a meaningful slice of generating time. */
private static readonly WARMUP_MS = 300
private lastTime: number | null = null
@@ -209,6 +224,8 @@ export class TokenSpeedTracker {
private elapsed = 0
private ewma = 0
private weight = 0
+ private generatingMs = 0
+ private generatingTokens = 0
reset(): void {
this.lastTime = null
@@ -216,11 +233,36 @@ export class TokenSpeedTracker {
this.elapsed = 0
this.ewma = 0
this.weight = 0
+ this.generatingMs = 0
+ this.generatingTokens = 0
+ }
+
+ /** Generating-only elapsed time, excluding pauses. */
+ get generatingElapsedMs(): number {
+ return this.generatingMs
+ }
+
+ /** Tokens observed during generating windows. */
+ get generatingTokenCount(): number {
+ return this.generatingTokens
+ }
+
+ /**
+ * Tokens / generating-seconds for this tracker (one turn). `null` until
+ * enough generating time has accumulated to be meaningful.
+ */
+ average(): number | null {
+ if (this.generatingMs < TokenSpeedTracker.WARMUP_MS) return null
+ return this.generatingTokens / (this.generatingMs / 1000)
}
/** Feed the cumulative estimated token count at `nowMs`; returns smoothed
* tok/s, or `null` while seeding / warming up. */
- observe(totalTokens: number, nowMs: number): number | null {
+ observe(
+ totalTokens: number,
+ nowMs: number,
+ options: TokenSpeedObserveOptions = {}
+ ): number | null {
if (this.lastTime == null) {
this.lastTime = nowMs
this.lastTokens = totalTokens
@@ -228,10 +270,20 @@ export class TokenSpeedTracker {
}
const dt = nowMs - this.lastTime
if (dt <= 0) return this.read()
- const instant = (totalTokens - this.lastTokens) / (dt / 1000)
+ if (options.pause) {
+ // Hold the last reading and drop this span so a later generating
+ // sample is not tokens / (generate + wait).
+ this.lastTime = nowMs
+ this.lastTokens = totalTokens
+ return this.read()
+ }
+ const delta = totalTokens - this.lastTokens
+ const instant = delta / (dt / 1000)
this.lastTime = nowMs
this.lastTokens = totalTokens
this.elapsed += dt
+ this.generatingMs += dt
+ if (delta > 0) this.generatingTokens += delta
const alpha = 1 - Math.exp(-dt / TokenSpeedTracker.TAU_MS)
this.ewma = this.ewma * (1 - alpha) + instant * alpha
// Stays exactly equal to `1 - exp(-elapsed / TAU_MS)`.
@@ -246,3 +298,8 @@ export class TokenSpeedTracker {
return this.ewma / this.weight
}
}
+
+/** Format a tok/s reading the same way the live turn row does. */
+export function formatTokPerSec(tps: number): string {
+ return `${tps.toFixed(1)} tok/s`
+}
From 496d65d83bbc516c3731006d20439ace47704b4c Mon Sep 17 00:00:00 2001
From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com>
Date: Thu, 20 Aug 2026 19:03:51 -0700
Subject: [PATCH 2/2] fix(chat): show average tok/s in session details and
Token Usage
Keep the live generating rate off the status bar. Persist the average on
the session itself (Session Details) and on the Token Usage page: a
generation-time headline plus tok/s on each heaviest-session row.
---
src-tauri/src/commands/token_usage.rs | 72 +++++++++++++----
src-tauri/src/models/token_usage.rs | 3 +
.../chat/composer-context-usage.tsx | 21 +----
.../conversations/session-details-content.tsx | 15 ++--
src/components/layout/status-bar-stats.tsx | 23 +-----
.../token-usage/token-usage-page.tsx | 78 ++++++++++++-------
src/i18n/messages/ar.json | 6 +-
src/i18n/messages/de.json | 6 +-
src/i18n/messages/en.json | 6 +-
src/i18n/messages/es.json | 6 +-
src/i18n/messages/fr.json | 6 +-
src/i18n/messages/ja.json | 6 +-
src/i18n/messages/ko.json | 6 +-
src/i18n/messages/pt.json | 6 +-
src/i18n/messages/zh-CN.json | 6 +-
src/i18n/messages/zh-TW.json | 6 +-
src/lib/token-speed.test.ts | 13 ++++
src/lib/token-speed.ts | 13 ++++
src/lib/types.ts | 3 +
19 files changed, 182 insertions(+), 119 deletions(-)
diff --git a/src-tauri/src/commands/token_usage.rs b/src-tauri/src/commands/token_usage.rs
index c48009ce26..550974fd35 100644
--- a/src-tauri/src/commands/token_usage.rs
+++ b/src-tauri/src/commands/token_usage.rs
@@ -323,6 +323,14 @@ fn bucket_key(date: NaiveDate, bucket: TokenUsageBucket) -> String {
// ─── Aggregation ────────────────────────────────────────────────────────
+/// Per-conversation running sums, keyed by conversation id:
+/// `(total_tokens, output_tokens, duration_ms, turns, last_activity, agent, folder_id)`.
+type ConversationAcc = (u64, u64, u64, u64, DateTime, String, i32);
+
+/// [`ConversationAcc`] with the conversation id folded in as the first element,
+/// so the top-conversations list can be sorted as one flat tuple.
+type ConversationTotals = (i32, u64, u64, u64, u64, DateTime, String, i32);
+
/// Running sums for one group (a bucket, or one slice of a breakdown).
#[derive(Debug, Default, Clone)]
struct Acc {
@@ -416,7 +424,7 @@ pub(crate) fn aggregate_report(
let mut by_agent: HashMap = HashMap::new();
let mut by_model: HashMap = HashMap::new();
let mut heat: HashMap<(u8, u8), (u64, u64)> = HashMap::new();
- let mut per_conversation: HashMap, String, i32)> = HashMap::new();
+ let mut per_conversation: HashMap = HashMap::new();
let mut active_dates: HashSet = HashSet::new();
let mut first_activity: Option> = None;
let mut last_activity: Option> = None;
@@ -452,6 +460,8 @@ pub(crate) fn aggregate_report(
cell.1 += 1;
let entry = per_conversation.entry(row.conversation_id).or_insert((
+ 0,
+ 0,
0,
0,
row.occurred_at,
@@ -459,9 +469,11 @@ pub(crate) fn aggregate_report(
row.folder_id,
));
entry.0 += row.total_tokens.max(0) as u64;
- entry.1 += 1;
- if row.occurred_at > entry.2 {
- entry.2 = row.occurred_at;
+ entry.1 += row.output_tokens.max(0) as u64;
+ entry.2 += row.duration_ms.max(0) as u64;
+ entry.3 += 1;
+ if row.occurred_at > entry.4 {
+ entry.4 = row.occurred_at;
}
active_dates.insert(date);
@@ -518,10 +530,10 @@ pub(crate) fn aggregate_report(
.collect();
heatmap.sort_by_key(|a| (a.weekday, a.hour));
- let mut top: Vec<(i32, u64, u64, DateTime, String, i32)> = per_conversation
+ let mut top: Vec = per_conversation
.into_iter()
- .map(|(id, (tokens, turns, last, agent, folder_id))| {
- (id, tokens, turns, last, agent, folder_id)
+ .map(|(id, (tokens, output, duration_ms, turns, last, agent, folder_id))| {
+ (id, tokens, output, duration_ms, turns, last, agent, folder_id)
})
.collect();
// Ties broken by id so the list is stable across identical requests.
@@ -530,15 +542,19 @@ pub(crate) fn aggregate_report(
let top_conversations: Vec = top
.into_iter()
.map(
- |(id, tokens, turns, last, agent, folder_id)| TokenUsageConversationItem {
- conversation_id: id,
- // Filled by the command layer, which owns the DB handle.
- title: None,
- agent_type: agent,
- folder_label: opts.folder_labels.get(&folder_id).cloned(),
- total_tokens: tokens,
- turn_count: turns,
- last_activity_at: last,
+ |(id, tokens, output, duration_ms, turns, last, agent, folder_id)| {
+ TokenUsageConversationItem {
+ conversation_id: id,
+ // Filled by the command layer, which owns the DB handle.
+ title: None,
+ agent_type: agent,
+ folder_label: opts.folder_labels.get(&folder_id).cloned(),
+ total_tokens: tokens,
+ output_tokens: output,
+ turn_count: turns,
+ duration_ms,
+ last_activity_at: last,
+ }
},
)
.collect();
@@ -1625,6 +1641,30 @@ mod tests {
assert_eq!(report.by_model[0].key, UNKNOWN_MODEL);
}
+ #[test]
+ fn top_conversations_carry_output_tokens_and_duration() {
+ let labels = HashMap::new();
+ let mut heavy = row("2026-08-01T10:00:00Z", 100);
+ heavy.output_tokens = 40;
+ heavy.duration_ms = 2000;
+ let mut light = row("2026-08-01T11:00:00Z", 10);
+ light.conversation_id = 2;
+ light.output_tokens = 5;
+ light.duration_ms = 500;
+
+ let report = aggregate_report(
+ &[heavy, light],
+ &[],
+ &opts(&labels, TokenUsageBucket::Day, 0, None, None),
+ );
+ assert_eq!(report.top_conversations[0].conversation_id, 1);
+ assert_eq!(report.top_conversations[0].output_tokens, 40);
+ assert_eq!(report.top_conversations[0].duration_ms, 2000);
+ assert_eq!(report.top_conversations[1].conversation_id, 2);
+ assert_eq!(report.top_conversations[1].output_tokens, 5);
+ assert_eq!(report.top_conversations[1].duration_ms, 500);
+ }
+
#[test]
fn heatmap_uses_local_weekday_and_hour() {
let labels = HashMap::new();
diff --git a/src-tauri/src/models/token_usage.rs b/src-tauri/src/models/token_usage.rs
index ee4eb8a49f..a2e71f849f 100644
--- a/src-tauri/src/models/token_usage.rs
+++ b/src-tauri/src/models/token_usage.rs
@@ -154,7 +154,10 @@ pub struct TokenUsageConversationItem {
pub agent_type: String,
pub folder_label: Option,
pub total_tokens: u64,
+ pub output_tokens: u64,
pub turn_count: u64,
+ /// Summed recorded generation time of the counted turns.
+ pub duration_ms: u64,
pub last_activity_at: DateTime,
}
diff --git a/src/components/chat/composer-context-usage.tsx b/src/components/chat/composer-context-usage.tsx
index fbe6c3412d..9f4a7ff212 100644
--- a/src/components/chat/composer-context-usage.tsx
+++ b/src/components/chat/composer-context-usage.tsx
@@ -8,8 +8,6 @@ import { useTabStore } from "@/contexts/tab-context"
import { useConversationRuntimeStore } from "@/stores/conversation-runtime-store"
import { formatTokenCount } from "@/lib/token-format"
import { formatContextWindowPercent } from "@/lib/context-window"
-import { useSessionOutputSpeed } from "@/hooks/use-session-output-speed"
-import { formatTokPerSec } from "@/lib/token-speed"
import {
Popover,
PopoverContent,
@@ -48,7 +46,6 @@ export function ComposerContextUsage({ tabId }: { tabId: string | null }) {
: null
)
const usage = sessionStats?.total_usage
- const outputSpeed = useSessionOutputSpeed(runtimeConversationId)
const subscribeConn = useCallback(
(cb: () => void) => {
@@ -120,9 +117,8 @@ export function ComposerContextUsage({ tabId }: { tabId: string | null }) {
}
const hasTokenSection = rows.length > 0
- const hasOutputSpeed = outputSpeed != null
- if (!hasContext && !hasTokenSection && !hasOutputSpeed) return null
+ if (!hasContext && !hasTokenSection) return null
// Native hover hint mirroring the popover's headline (the popover stays for
// the full breakdown on click).
@@ -235,21 +231,6 @@ export function ComposerContextUsage({ tabId }: { tabId: string | null }) {
>
) : null}
- {hasOutputSpeed ? (
-
- {t("outputSpeed")}
-
- {formatTokPerSec(outputSpeed.averageTps)}
-
-
- ) : null}
)
diff --git a/src/components/conversations/session-details-content.tsx b/src/components/conversations/session-details-content.tsx
index a3d641adb0..52f27116a2 100644
--- a/src/components/conversations/session-details-content.tsx
+++ b/src/components/conversations/session-details-content.tsx
@@ -22,7 +22,7 @@ import { getFolderConversation } from "@/lib/api"
import { useCopiedFlag } from "@/hooks/use-copied-flag"
import { pickModelFromTurns } from "./active-session-details"
import { useSessionOutputSpeed } from "@/hooks/use-session-output-speed"
-import { formatTokPerSec } from "@/lib/token-speed"
+import { averageOutputTps, formatTokPerSec } from "@/lib/token-speed"
import { AgentIcon } from "@/components/agent-icon"
import { ConversationStatusDot } from "./conversation-status-dot"
@@ -316,7 +316,12 @@ export function SessionDetailsContent({
ctxMax
)
const durationMs = resolveSessionDurationMs(summary, stats)
- const outputSpeed = useSessionOutputSpeed(summary.id)
+ const liveOutputSpeed = useSessionOutputSpeed(summary.id)
+ const recordedOutputTps = averageOutputTps(
+ usage?.output_tokens ?? 0,
+ stats?.total_duration_ms ?? 0
+ )
+ const outputTps = liveOutputSpeed?.averageTps ?? recordedOutputTps
// Never coerce an unknown `used` to 0 — some parsers infer the model's
// context cap without any usage figure, so render "— / max" rather than a
// bogus "0 / max".
@@ -329,7 +334,7 @@ export function SessionDetailsContent({
? formatTokenCount(ctxUsed)
: null
const hasTokenInfo =
- outputSpeed != null ||
+ outputTps != null ||
(stats != null &&
(totalTokens != null ||
usage != null ||
@@ -440,13 +445,13 @@ export function SessionDetailsContent({
{formatDuration(durationMs)}
)}
- {outputSpeed != null && (
+ {outputTps != null && (
- {formatTokPerSec(outputSpeed.averageTps)}
+ {formatTokPerSec(outputTps)}
)}
diff --git a/src/components/layout/status-bar-stats.tsx b/src/components/layout/status-bar-stats.tsx
index 5b0c88540a..914d5a026f 100644
--- a/src/components/layout/status-bar-stats.tsx
+++ b/src/components/layout/status-bar-stats.tsx
@@ -1,13 +1,10 @@
"use client"
-import { ChartNoAxesColumn, MonitorCloud, Plane } from "lucide-react"
+import { ChartNoAxesColumn, MonitorCloud } from "lucide-react"
import { useTranslations } from "next-intl"
import { useAppWorkspaceStore } from "@/stores/app-workspace-store"
import { useRemoteConnection } from "@/contexts/remote-connection-context"
import { useWorkbenchRoute } from "@/contexts/workbench-route-context"
-import { useTabStore } from "@/stores/tab-store"
-import { useSessionOutputSpeed } from "@/hooks/use-session-output-speed"
-import { formatTokPerSec } from "@/lib/token-speed"
import { cn } from "@/lib/utils"
/**
@@ -30,15 +27,8 @@ export function StatusBarStats() {
// codeg-server); local windows have no RemoteConnection in context.
const remoteConnection = useRemoteConnection()?.connection ?? null
const { routeId, setRoute } = useWorkbenchRoute()
- const speedConversationId = useTabStore((s) => {
- const tab = s.tabs.find((item) => item.id === s.activeTabId)
- if (!tab || tab.kind !== "conversation") return null
- return tab.runtimeConversationId ?? tab.conversationId ?? null
- })
- const outputSpeed = useSessionOutputSpeed(speedConversationId)
- const tSpeed = useTranslations("Folder.statusBar.tokens")
- if (!remoteConnection && !stats && outputSpeed == null) return null
+ if (!remoteConnection && !stats) return null
return (
@@ -69,15 +59,6 @@ export function StatusBarStats() {
)}
- {outputSpeed != null && (
-
-
- {formatTokPerSec(outputSpeed.averageTps)}
-
- )}
)
}
diff --git a/src/components/token-usage/token-usage-page.tsx b/src/components/token-usage/token-usage-page.tsx
index 841ff8e90f..03977f64ff 100644
--- a/src/components/token-usage/token-usage-page.tsx
+++ b/src/components/token-usage/token-usage-page.tsx
@@ -54,6 +54,7 @@ import { toErrorMessage } from "@/lib/app-error"
import { getAgentLabel } from "@/lib/custom-agents"
import { subscribe } from "@/lib/platform"
import { formatTokenCount } from "@/lib/token-format"
+import { averageOutputTps, formatTokPerSec } from "@/lib/token-speed"
import {
averagePerActiveDay,
averagePerConversation,
@@ -561,6 +562,9 @@ export function TokenUsagePage() {
const totals = report?.totals
const cache = totals ? cacheHitRate(totals) : null
+ const averageTps = totals
+ ? averageOutputTps(totals.output_tokens, totals.duration_ms)
+ : null
const heat = useMemo(
() => buildHeatMatrix(report?.heatmap ?? []),
[report?.heatmap]
@@ -1204,9 +1208,15 @@ export function TokenUsagePage() {
className="border-s border-t lg:border-t-0"
label={t("tileGenTime")}
value={formatDuration(totals.duration_ms)}
- hint={`${t("avgPerActiveDay")} ${formatTokenCount(
- Math.round(averagePerActiveDay(totals))
- )}`}
+ hint={
+ averageTps != null
+ ? t("avgOutputSpeed", {
+ speed: formatTokPerSec(averageTps),
+ })
+ : `${t("avgPerActiveDay")} ${formatTokenCount(
+ Math.round(averagePerActiveDay(totals))
+ )}`
+ }
/>
@@ -1384,31 +1394,45 @@ export function TokenUsagePage() {
) : (
- {report.top_conversations.map((c, i) => (
- -
- {
+ const sessionTps = averageOutputTps(
+ c.output_tokens,
+ c.duration_ms
+ )
+ return (
+
-
- {String(i + 1).padStart(2, "0")}
-
-
- {c.title || t("untitledSession")}
-
-
- {c.folder_label}
-
-
- {getAgentLabel(c.agent_type as AgentType)}
-
-
- {formatTokenCount(c.total_tokens)}
-
-
- ))}
+
+ {String(i + 1).padStart(2, "0")}
+
+
+ {c.title || t("untitledSession")}
+
+
+ {c.folder_label}
+
+
+ {getAgentLabel(c.agent_type as AgentType)}
+
+
+ {formatTokenCount(c.total_tokens)}
+
+ {sessionTps != null && (
+
+ {formatTokPerSec(sessionTps)}
+
+ )}
+
+ )
+ })}
)}
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json
index 7edf7de780..ccf90c34f7 100644
--- a/src/i18n/messages/ar.json
+++ b/src/i18n/messages/ar.json
@@ -1926,9 +1926,7 @@
"output": "إخراج",
"cacheRead": "قراءة الكاش",
"cacheWrite": "كتابة الكاش",
- "total": "الإجمالي",
- "outputSpeed": "سرعة الإخراج",
- "outputSpeedTooltip": "المتوسط أثناء كتابة النموذج. وقت انتظار الأدوات غير محسوب."
+ "total": "الإجمالي"
},
"quickActions": {
"title": "إجراءات سريعة",
@@ -5095,6 +5093,8 @@
"tileTurns": "الأدوار",
"tileActiveDays": "الأيام النشطة",
"tileGenTime": "زمن التوليد",
+ "avgOutputSpeed": "متوسط {speed}",
+ "outputSpeedTooltip": "المتوسط أثناء كتابة النموذج. وقت انتظار الأدوات غير محسوب.",
"vsPrevious": "مقارنة بالفترة السابقة",
"deltaNew": "جديد",
"trendTitle": "الاستهلاك عبر الزمن",
diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json
index f29bcc7ba0..5d5a8a6f8d 100644
--- a/src/i18n/messages/de.json
+++ b/src/i18n/messages/de.json
@@ -1926,9 +1926,7 @@
"output": "Ausgabe",
"cacheRead": "Cache lesen",
"cacheWrite": "Cache schreiben",
- "total": "Gesamt",
- "outputSpeed": "Ausgabegeschwindigkeit",
- "outputSpeedTooltip": "Durchschnitt, während das Modell schreibt. Wartezeit auf Tools zählt nicht."
+ "total": "Gesamt"
},
"quickActions": {
"title": "Schnellaktionen",
@@ -5095,6 +5093,8 @@
"tileTurns": "Turns",
"tileActiveDays": "Aktive Tage",
"tileGenTime": "Generierungszeit",
+ "avgOutputSpeed": "Ø {speed}",
+ "outputSpeedTooltip": "Durchschnitt, während das Modell schreibt. Wartezeit auf Tools zählt nicht.",
"vsPrevious": "ggü. Vorperiode",
"deltaNew": "neu",
"trendTitle": "Verbrauch im Zeitverlauf",
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index 0762f4e430..2e196e24d5 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -1926,9 +1926,7 @@
"output": "Output",
"cacheRead": "Cache Read",
"cacheWrite": "Cache Write",
- "total": "Total",
- "outputSpeed": "Output speed",
- "outputSpeedTooltip": "Average while the model is writing. Time spent waiting on tools is not counted."
+ "total": "Total"
},
"quickActions": {
"title": "Quick actions",
@@ -5095,6 +5093,8 @@
"tileTurns": "Turns",
"tileActiveDays": "Active days",
"tileGenTime": "Generation time",
+ "avgOutputSpeed": "Avg. {speed}",
+ "outputSpeedTooltip": "Average while the model is writing. Time spent waiting on tools is not counted.",
"vsPrevious": "vs. previous period",
"deltaNew": "new",
"trendTitle": "Usage over time",
diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json
index 74248b08ed..5a72be1491 100644
--- a/src/i18n/messages/es.json
+++ b/src/i18n/messages/es.json
@@ -1926,9 +1926,7 @@
"output": "Salida",
"cacheRead": "Lectura de caché",
"cacheWrite": "Escritura de caché",
- "total": "Total de tokens",
- "outputSpeed": "Velocidad de salida",
- "outputSpeedTooltip": "Media mientras el modelo escribe. El tiempo de espera de herramientas no cuenta."
+ "total": "Total de tokens"
},
"quickActions": {
"title": "Acciones rápidas",
@@ -5095,6 +5093,8 @@
"tileTurns": "Turnos",
"tileActiveDays": "Días activos",
"tileGenTime": "Tiempo de generación",
+ "avgOutputSpeed": "Media {speed}",
+ "outputSpeedTooltip": "Media mientras el modelo escribe. El tiempo de espera de herramientas no cuenta.",
"vsPrevious": "frente al periodo anterior",
"deltaNew": "nuevo",
"trendTitle": "Uso en el tiempo",
diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json
index 6fcaf37180..89507fe0e1 100644
--- a/src/i18n/messages/fr.json
+++ b/src/i18n/messages/fr.json
@@ -1926,9 +1926,7 @@
"output": "Sortie",
"cacheRead": "Lecture cache",
"cacheWrite": "Écriture cache",
- "total": "Total des tokens",
- "outputSpeed": "Vitesse de sortie",
- "outputSpeedTooltip": "Moyenne pendant que le modèle écrit. L'attente des outils n'est pas comptée."
+ "total": "Total des tokens"
},
"quickActions": {
"title": "Actions rapides",
@@ -5095,6 +5093,8 @@
"tileTurns": "Tours",
"tileActiveDays": "Jours actifs",
"tileGenTime": "Temps de génération",
+ "avgOutputSpeed": "Moy. {speed}",
+ "outputSpeedTooltip": "Moyenne pendant que le modèle écrit. L'attente des outils n'est pas comptée.",
"vsPrevious": "vs période précédente",
"deltaNew": "nouveau",
"trendTitle": "Consommation dans le temps",
diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json
index 5aa9f508f1..99391af855 100644
--- a/src/i18n/messages/ja.json
+++ b/src/i18n/messages/ja.json
@@ -1926,9 +1926,7 @@
"output": "出力",
"cacheRead": "キャッシュ読み取り",
"cacheWrite": "キャッシュ書き込み",
- "total": "合計",
- "outputSpeed": "出力速度",
- "outputSpeedTooltip": "モデルが書いている間の平均速度。ツール待ち時間は含めません。"
+ "total": "合計"
},
"quickActions": {
"title": "クイック操作",
@@ -5095,6 +5093,8 @@
"tileTurns": "ターン数",
"tileActiveDays": "稼働日数",
"tileGenTime": "生成時間",
+ "avgOutputSpeed": "平均 {speed}",
+ "outputSpeedTooltip": "モデルが書いている間の平均速度。ツール待ち時間は含めません。",
"vsPrevious": "前期間との比較",
"deltaNew": "新規",
"trendTitle": "使用量の推移",
diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json
index 1942cdff57..844f70a1c5 100644
--- a/src/i18n/messages/ko.json
+++ b/src/i18n/messages/ko.json
@@ -1926,9 +1926,7 @@
"output": "출력",
"cacheRead": "캐시 읽기",
"cacheWrite": "캐시 쓰기",
- "total": "합계",
- "outputSpeed": "출력 속도",
- "outputSpeedTooltip": "모델이 쓰는 동안의 평균 속도. 도구 대기 시간은 포함하지 않습니다."
+ "total": "합계"
},
"quickActions": {
"title": "빠른 작업",
@@ -5095,6 +5093,8 @@
"tileTurns": "대화 턴",
"tileActiveDays": "활동 일수",
"tileGenTime": "생성 시간",
+ "avgOutputSpeed": "평균 {speed}",
+ "outputSpeedTooltip": "모델이 쓰는 동안의 평균 속도. 도구 대기 시간은 포함하지 않습니다.",
"vsPrevious": "이전 기간 대비",
"deltaNew": "신규",
"trendTitle": "사용량 추이",
diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json
index b0c64af681..5200ab6971 100644
--- a/src/i18n/messages/pt.json
+++ b/src/i18n/messages/pt.json
@@ -1926,9 +1926,7 @@
"output": "Saída",
"cacheRead": "Leitura de cache",
"cacheWrite": "Gravação de cache",
- "total": "Total de tokens",
- "outputSpeed": "Velocidade de saída",
- "outputSpeedTooltip": "Média enquanto o modelo escreve. A espera por ferramentas não conta."
+ "total": "Total de tokens"
},
"quickActions": {
"title": "Ações rápidas",
@@ -5095,6 +5093,8 @@
"tileTurns": "Turnos",
"tileActiveDays": "Dias ativos",
"tileGenTime": "Tempo de geração",
+ "avgOutputSpeed": "Média {speed}",
+ "outputSpeedTooltip": "Média enquanto o modelo escreve. A espera por ferramentas não conta.",
"vsPrevious": "vs. período anterior",
"deltaNew": "novo",
"trendTitle": "Uso ao longo do tempo",
diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json
index caf690e57e..1fcffeb79b 100644
--- a/src/i18n/messages/zh-CN.json
+++ b/src/i18n/messages/zh-CN.json
@@ -1926,9 +1926,7 @@
"output": "输出",
"cacheRead": "缓存读取",
"cacheWrite": "缓存写入",
- "total": "总计",
- "outputSpeed": "输出速度",
- "outputSpeedTooltip": "模型正在写时的平均速度。等待工具完成的时间不计入。"
+ "total": "总计"
},
"quickActions": {
"title": "快捷操作",
@@ -5095,6 +5093,8 @@
"tileTurns": "对话轮次",
"tileActiveDays": "活跃天数",
"tileGenTime": "生成时长",
+ "avgOutputSpeed": "平均 {speed}",
+ "outputSpeedTooltip": "模型正在写时的平均速度。等待工具完成的时间不计入。",
"vsPrevious": "对比上一周期",
"deltaNew": "新增",
"trendTitle": "用量趋势",
diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json
index 15a17ef335..51932dd1e6 100644
--- a/src/i18n/messages/zh-TW.json
+++ b/src/i18n/messages/zh-TW.json
@@ -1926,9 +1926,7 @@
"output": "輸出",
"cacheRead": "快取讀取",
"cacheWrite": "快取寫入",
- "total": "總計",
- "outputSpeed": "輸出速度",
- "outputSpeedTooltip": "模型正在寫時的平均速度。等待工具完成的時間不計入。"
+ "total": "總計"
},
"quickActions": {
"title": "快捷操作",
@@ -5095,6 +5093,8 @@
"tileTurns": "對話輪次",
"tileActiveDays": "活躍天數",
"tileGenTime": "生成時長",
+ "avgOutputSpeed": "平均 {speed}",
+ "outputSpeedTooltip": "模型正在寫時的平均速度。等待工具完成的時間不計入。",
"vsPrevious": "對比上一週期",
"deltaNew": "新增",
"trendTitle": "用量趨勢",
diff --git a/src/lib/token-speed.test.ts b/src/lib/token-speed.test.ts
index e1834ff2f3..5b52025959 100644
--- a/src/lib/token-speed.test.ts
+++ b/src/lib/token-speed.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"
import {
+ averageOutputTps,
countChars,
estimateTokens,
formatTokPerSec,
@@ -14,6 +15,18 @@ describe("formatTokPerSec", () => {
})
})
+describe("averageOutputTps", () => {
+ it("divides output tokens by recorded generation seconds", () => {
+ expect(averageOutputTps(100, 1000)).toBeCloseTo(100)
+ })
+
+ it("returns null until both sides are meaningful", () => {
+ expect(averageOutputTps(0, 1000)).toBeNull()
+ expect(averageOutputTps(100, 0)).toBeNull()
+ expect(averageOutputTps(100, 200)).toBeNull()
+ })
+})
+
describe("estimateTokens", () => {
it("counts latin at 4 chars per token", () => {
expect(estimateTokens("hello world")).toBeCloseTo(10 / 4)
diff --git a/src/lib/token-speed.ts b/src/lib/token-speed.ts
index 5210844aac..b4eb691038 100644
--- a/src/lib/token-speed.ts
+++ b/src/lib/token-speed.ts
@@ -303,3 +303,16 @@ export class TokenSpeedTracker {
export function formatTokPerSec(tps: number): string {
return `${tps.toFixed(1)} tok/s`
}
+
+/**
+ * Session/dashboard average: output tokens over recorded generation time.
+ * `null` until both sides are meaningful, so a 0ms or 0-output row does not
+ * render as `0.0 tok/s`.
+ */
+export function averageOutputTps(
+ outputTokens: number,
+ durationMs: number
+): number | null {
+ if (!(outputTokens > 0) || durationMs < 300) return null
+ return outputTokens / (durationMs / 1000)
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 390454c217..d81a2fcaae 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -2042,7 +2042,10 @@ export interface TokenUsageConversationItem {
agent_type: string
folder_label: string | null
total_tokens: number
+ output_tokens: number
turn_count: number
+ /** Summed recorded generation time of the counted turns. */
+ duration_ms: number
last_activity_at: string
}