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
6 changes: 6 additions & 0 deletions desktop/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4318,6 +4318,8 @@ export default function App() {
turnWaitAccumMs={state.turnWaitAccumMs}
promptWaitStartedAt={state.promptWaitStartedAt}
turnTokens={state.turnTokens}
turnOutputTokens={state.turnOutputTokens}
turnTextChars={state.turnTextChars}
turnArgChars={state.turnArgChars}
retry={state.retry}
suspendedByDecision={Boolean(decisionSurface)}
Expand All @@ -4344,6 +4346,10 @@ export default function App() {
sessionTurns={sessionTurns}
sessionTokens={state.sessionTokens}
turnTokens={state.turnTotalTokens}
lastTurnOutputTokens={state.lastTurnOutputTokens}
lastTurnStartAt={state.lastTurnStartAt}
lastTurnDoneAt={state.lastTurnDoneAt}
lastTurnWaitAccumMs={state.lastTurnWaitAccumMs}
turnCost={state.turnCost}
cost={state.sessionCost}
currency={state.sessionCurrency}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ eq(finalDeclaration(".provider-model-draft__option", "min-height"), undefined, "
eq(finalDeclaration(".provider-model-draft__option", "overflow"), "hidden", "provider model cards contain overflowing controls");

eq(finalDeclaration(".statusbar", "white-space"), "nowrap", "status bar keeps metrics on one row");
eq(finalDeclaration(".statusbar", "overflow"), "hidden", "status bar clips instead of overflowing");
eq(finalDeclaration(".statusbar", "overflow-y"), "hidden", "status bar hides vertical overflow");
clipsSingleLine(".statusbar__model");

for (const selector of [
Expand Down
41 changes: 34 additions & 7 deletions desktop/frontend/src/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,8 @@ export function Composer({
turnWaitAccumMs = 0,
promptWaitStartedAt,
turnTokens,
turnOutputTokens,
turnTextChars,
turnArgChars = 0,
retry,
suspendedByDecision = false,
Expand Down Expand Up @@ -618,6 +620,13 @@ export function Composer({
turnWaitAccumMs?: number;
promptWaitStartedAt?: number;
turnTokens?: number;
// Completion + reasoning tokens accumulated this turn — feeds the streaming
// TPS readout in the run ticker (composer-run-strip).
turnOutputTokens?: number;
// Cumulative character count of live text+reasoning in the current turn.
// Used as a fallback estimate (chars ÷ 4) when the provider does not emit
// per-chunk usage events with token counts during streaming.
turnTextChars?: number;
// Streaming tool-call argument chars (no usage event yet) — folded into the
// pill as an estimated-token tail so a long write_file body reads as
// progress, not a stall.
Expand Down Expand Up @@ -3427,9 +3436,18 @@ export function Composer({
const elapsedMs = Math.max(0, now - turnStartAt - waitAccumMs);
const words = SPINNER_WORDS[locale];
const word = words[Math.floor(elapsedMs / 3000) % words.length];
const liveTokens = (turnTokens ?? 0) + Math.round((turnArgChars ?? 0) / 4);
const usageTokens = (turnTokens ?? 0) + Math.round((turnArgChars ?? 0) / 4);
// Include streaming tool-call args in the estimate so TPS stays
// meaningful while the model streams a write_file / long tool body.
const estimatedChars = Math.round(((turnTextChars ?? 0) + (turnArgChars ?? 0)) / 4);
const liveTokens = usageTokens > 0 ? usageTokens : estimatedChars;
const tok = liveTokens > 0 ? ` · ↓ ${fmtTokens(liveTokens)} ${t("status.tokens")}` : "";
return `${word}… ${fmtElapsed(elapsedMs)}${tok}`;
const outTok: number = (turnOutputTokens ?? 0) > 0 ? (turnOutputTokens ?? 0) : estimatedChars;
const tps = outTok > 0 && elapsedMs >= 500 ? Math.round(outTok / (elapsedMs / 1000)) : null;
const tpsStr = tps !== null ? ` · ${tps} tokens/s` : "";
const suffix = `${tpsStr}${tok}`;
const prefix = `${word}… ${fmtElapsed(elapsedMs)}`;
return { prefix, suffix: suffix || null };
})()
: null;
const submitEmpty = !text.trim() && attachments.length === 0 && workspaceRefs.length === 0 &&
Expand Down Expand Up @@ -4059,11 +4077,20 @@ export function Composer({
{runStateText && (
<div className={`composer-run-strip${waitingPrompt ? " composer-run-strip--waiting" : ""}`}>
<span className="composer-run-strip__dot" aria-hidden="true" />
{/* The ticker re-renders every second; keep it out of the accessibility
tree and announce only the stable state text via the live region. */}
<span className="composer-run-strip__text" aria-hidden={runTicker ? true : undefined}>
{runTicker ?? runStateText}
</span>
{runTicker ? (
<>
<span className="composer-run-strip__text" aria-hidden="true">{runTicker.prefix}</span>
{runTicker.suffix && (
<Tooltip label={t("composer.runStripEstimateHint")}>
<span className="composer-run-strip__text" aria-hidden="true">{runTicker.suffix}</span>
</Tooltip>
)}
</>
) : (
<span className="composer-run-strip__text">
{runStateText}
</span>
)}
<span className="sr-only" role="status">{runStateText}</span>
</div>
)}
Expand Down
6 changes: 6 additions & 0 deletions desktop/frontend/src/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1414,6 +1414,12 @@ function statusBarItemLabel(id: StatusBarItemId, t: ReturnType<typeof useT>): st
return t("status.sessionTokensLabel");
case "turn_tokens":
return t("status.turnTokensLabel");
case "turn_tps":
return t("status.tpsLabel");
case "turn_output_tokens":
return t("status.outputTokensLabel");
case "turn_cache_tokens":
return t("status.cacheTokensLabel");
case "turn_cost":
return t("status.turnCostLabel");
case "session_turns":
Expand Down
71 changes: 69 additions & 2 deletions desktop/frontend/src/components/StatusBar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Activity, Check, ChevronsUpDown, CircleDollarSign, CircleGauge, Database, Folder, GitBranch, Laptop, Layers, Percent, RefreshCw, Server, Settings, Unplug, Wallet, Zap } from "lucide-react";
import { Activity, Check, ChevronsUpDown, CircleDollarSign, CircleGauge, Database, FileOutput, Folder, Gauge, GitBranch, HardDrive, Laptop, Layers, Percent, RefreshCw, Server, Settings, Unplug, Wallet, Zap } from "lucide-react";
import { AnchoredPopover } from "./AnchoredPopover";
import { RemoteConnectionErrorDialog } from "./RemoteConnectionErrorDialog";
import { Tooltip } from "./Tooltip";
Expand Down Expand Up @@ -67,6 +67,18 @@ function formatTurnCount(turns: number | undefined, t: Translator): string {
return t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns });
}


function formatTps(outputTokens?: number, startAt?: number, doneAt?: number, waitAccumMs?: number): string | null {
if (!outputTokens || outputTokens <= 0) return null;
if (!startAt || startAt <= 0) return null;
if (!doneAt || doneAt <= startAt) return null;
const elapsedSec = (doneAt - startAt - (waitAccumMs ?? 0)) / 1000;
if (elapsedSec < 0.001) return null;
const tps = outputTokens / elapsedSec;
if (tps < 1) return "<1 t/s";
return `${Math.round(tps)} t/s`;
}

const STATUS_SOURCE_ORDER = ["executor", "planner", "subagent", "compaction", "classifier", "title"];

function sourceLabel(source: string, t: Translator): string {
Expand Down Expand Up @@ -161,6 +173,10 @@ export function StatusBar({
sessionTurns,
sessionTokens,
turnTokens,
lastTurnOutputTokens,
lastTurnStartAt,
lastTurnDoneAt,
lastTurnWaitAccumMs = 0,
turnCost,
cost,
currency,
Expand All @@ -187,6 +203,10 @@ export function StatusBar({
sessionTurns?: number;
sessionTokens?: number;
turnTokens?: number;
lastTurnOutputTokens?: number;
lastTurnStartAt?: number;
lastTurnDoneAt?: number;
lastTurnWaitAccumMs?: number;
turnCost?: number;
cost?: number;
currency?: string;
Expand Down Expand Up @@ -225,6 +245,11 @@ export function StatusBar({
const tokenLabel = formatTokenCount(sessionTokens);
const turnTokenLabel = formatTokenCount(turnTokens);
const balanceLabel = balance?.available && balance.display ? balance.display : "-";
const tpsLabel = formatTps(lastTurnOutputTokens, lastTurnStartAt, lastTurnDoneAt, lastTurnWaitAccumMs);
const outputTokensLabel = usage?.completionTokens ? usage.completionTokens.toLocaleString() : "-";
const cacheHit = usage?.cacheHitTokens;
const cacheMiss = usage?.cacheMissTokens;
const hasCacheTokens = typeof cacheHit === "number" || typeof cacheMiss === "number";
const metricLabelStyle = labelStyle === "text" ? "text" : "icon";
const visibleItems = normalizeStatusBarItems(items);
const cacheTooltip = sourceCacheTooltip(t, t("status.cacheTitle"), context);
Expand Down Expand Up @@ -302,6 +327,38 @@ export function StatusBar({
</span>
</Tooltip>
),
turn_tps: (
<Tooltip label={t("status.tpsTitle")} className="statusbar__metric statusbar__metric--tps">
<span className="stat statusbar__tps">
<MetricLabel style={metricLabelStyle} icon={<Gauge size={12} />} label={t("status.tpsLabel")} />
<b className={tpsLabel === null ? "stat__value--empty" : undefined}>{tpsLabel ?? "-"}</b>
</span>
</Tooltip>
),
turn_output_tokens: (
<Tooltip label={t("status.outputTokensTitle")} className="statusbar__metric statusbar__metric--output-tokens">
<span className="stat statusbar__output-tokens">
<MetricLabel style={metricLabelStyle} icon={<FileOutput size={12} />} label={t("status.outputTokensLabel")} />
<b className={outputTokensLabel === "-" ? "stat__value--empty" : undefined}>{outputTokensLabel}</b>
</span>
</Tooltip>
),
turn_cache_tokens: (
<Tooltip label={t("status.cacheTokensTitle")} className="statusbar__metric statusbar__metric--cache-tokens">
<span className="stat statusbar__cache-tokens">
<MetricLabel style={metricLabelStyle} icon={<HardDrive size={12} />} label={t("status.cacheTokensLabel")} />
{hasCacheTokens ? (
<>
<span>{t("status.cacheHitShort")} </span><b>{typeof cacheHit === "number" && cacheHit > 0 ? cacheHit.toLocaleString() : "0"}</b>
<span className="statusbar__cache-sep">|</span>
<span>{t("status.cacheMissShort")} </span><b>{typeof cacheMiss === "number" && cacheMiss > 0 ? cacheMiss.toLocaleString() : "0"}</b>
</>
) : (
<b className="stat__value--empty">-</b>
)}
</span>
</Tooltip>
),
context: (
<Tooltip label={t("status.ctxTitle")} className="statusbar__metric statusbar__metric--ctx">
<span className="stat statusbar__ctx">
Expand Down Expand Up @@ -345,8 +402,18 @@ export function StatusBar({
const renderedItems = visibleItems
.map((id) => ({ id, node: itemRenderers[id] }))
.filter(({ node }) => node !== null && node !== undefined && node !== false);
const statusbarRef = useRef<HTMLDivElement>(null);
return (
<div className={`statusbar statusbar--${metricLabelStyle}`}>
<div
className={`statusbar statusbar--${metricLabelStyle}`}
ref={statusbarRef}
onWheel={(e) => {
const el = statusbarRef.current;
if (!el || el.scrollWidth <= el.clientWidth) return;
e.preventDefault();
el.scrollLeft += e.deltaY;
}}
>
<div className="statusbar__group statusbar__group--items">
<RemoteStatusBarChip
hosts={remoteHosts}
Expand Down
3 changes: 3 additions & 0 deletions desktop/frontend/src/lib/statusBarItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ export const STATUS_BAR_ITEM_IDS = [
"cache_avg",
"session_tokens",
"turn_tokens",
"turn_tps",
"turn_output_tokens",
"turn_cache_tokens",
"turn_cost",
"session_turns",
"context",
Expand Down
34 changes: 31 additions & 3 deletions desktop/frontend/src/lib/useController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,26 @@ interface State {
deliveryRecoveryActive: boolean;
discardTurn?: boolean;
turnStartAt: number;
turnDoneAt: number;
// (completionTokens + reasoningTokens) accumulated across usage events within
// the current turn. Used by the status bar for per-turn output-token display
// and TPS calculation.
turnOutputTokens: number;
// Cumulative character count of live assistant text + reasoning across the
// current turn. Used by the run-strip streaming TPS fallback when the provider
// does not emit per-chunk usage events with token counts.
turnTextChars: number;
// Time spent waiting on the user (approval/ask) within the current turn.
// Closed intervals accumulate here; an open interval uses promptWaitStartedAt
// so background tabs keep counting while not rendered by Composer.
turnWaitAccumMs: number;
// Last completed turn's values — preserved across turn boundaries so the
// status bar can display the most recent completed turn's TPS and token
// counts until the current turn finishes and overwrites them.
lastTurnOutputTokens: number;
lastTurnStartAt: number;
lastTurnDoneAt: number;
lastTurnWaitAccumMs: number;
promptWaitStartedAt?: number;
// promptEventClock() reading taken when the CURRENT pending prompt first
// arrived. Orders the prompt against reconciliation snapshots so a snapshot
Expand Down Expand Up @@ -216,7 +232,14 @@ export const initialState: State = {
deliveryRecoveryActive: false,
promptEpoch: 0,
turnStartAt: 0,
turnDoneAt: 0,
turnOutputTokens: 0,
turnTextChars: 0,
turnWaitAccumMs: 0,
lastTurnOutputTokens: 0,
lastTurnStartAt: 0,
lastTurnDoneAt: 0,
lastTurnWaitAccumMs: 0,
turnTokens: 0,
turnTotalTokens: 0,
turnCost: 0,
Expand Down Expand Up @@ -764,13 +787,16 @@ function endPromptWaitIfIdle(s: State, now = Date.now()): State {
return endPromptWait(s, now);
}

function resetTurnTiming(now = Date.now()): Pick<State, "turnStartAt" | "turnWaitAccumMs" | "promptWaitStartedAt" | "turnTokens" | "turnTotalTokens" | "turnCost" | "turnArgChars"> {
function resetTurnTiming(now = Date.now()): Pick<State, "turnStartAt" | "turnDoneAt" | "turnWaitAccumMs" | "promptWaitStartedAt" | "turnTokens" | "turnTotalTokens" | "turnOutputTokens" | "turnTextChars" | "turnCost" | "turnArgChars"> {
return {
turnStartAt: now,
turnDoneAt: 0,
turnWaitAccumMs: 0,
promptWaitStartedAt: undefined,
turnTokens: 0,
turnTotalTokens: 0,
turnOutputTokens: 0,
turnTextChars: 0,
turnCost: 0,
turnArgChars: 0,
};
Expand Down Expand Up @@ -863,7 +889,7 @@ function applyEvent(s: State, e: WireEvent): State {
reasoningStartedAt: base.reasoningStartedAt ?? (delta ? now : undefined),
reasoningCompletedAt: undefined,
};
return { ...s, items, live, currentAssistant: id, seq };
return { ...s, items, live, currentAssistant: id, seq, turnTextChars: live.text.length + live.reasoning.length };
}
case "message": {
const existingAssistant =
Expand Down Expand Up @@ -1004,6 +1030,7 @@ function applyEvent(s: State, e: WireEvent): State {
const updateContextGauge = updatesContextGauge(e.usage);
const used = e.usage && s.context.window && updateContextGauge ? e.usage.promptTokens : s.context.used;
const turnTokens = s.turnTokens + (e.usage?.completionTokens ?? 0);
const turnOutputTokens = s.turnOutputTokens + (e.usage?.completionTokens ?? 0) + (e.usage?.reasoningTokens ?? 0);
const usageTokens = usageTotalTokens(e.usage);
const turnTotalTokens = s.turnTotalTokens + usageTokens;
const sessionTokens = s.sessionTokens + usageTokens;
Expand All @@ -1014,7 +1041,7 @@ function applyEvent(s: State, e: WireEvent): State {
const usage = updateContextGauge ? e.usage : s.usage;
// The completed round's usage now accounts for the streamed tool-call
// arguments, so drop the live estimate rather than double-count it.
return { ...s, usage, context: { ...s.context, used, sessionTokens }, turnTokens, turnTotalTokens, turnCost, turnArgChars: 0, sessionTokens, sessionCost, sessionCurrency, usageSeq: s.usageSeq + 1 };
return { ...s, usage, context: { ...s.context, used, sessionTokens }, turnTokens, turnOutputTokens, turnTotalTokens, turnCost, turnArgChars: 0, sessionTokens, sessionCost, sessionCurrency, usageSeq: s.usageSeq + 1 };
}
case "notice":
return appendNoticeToState(s, e.level ?? "info", e.text ?? "", e.detail, e.code);
Expand Down Expand Up @@ -1078,6 +1105,7 @@ function applyEvent(s: State, e: WireEvent): State {
case "turn_done": {
if (s.pendingUser !== undefined) s = flushPendingUser(s);
const now = Date.now();
s = { ...s, turnDoneAt: now, lastTurnOutputTokens: s.turnOutputTokens, lastTurnStartAt: s.turnStartAt, lastTurnDoneAt: now, lastTurnWaitAccumMs: s.turnWaitAccumMs };
const workDurationMs = currentTurnDurationMs(s, now);
let lastUserIndex = -1;
let lastAssistantIndex = -1;
Expand Down
9 changes: 9 additions & 0 deletions desktop/frontend/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,7 @@ export const en = {
"composer.runWaitingApproval": "Waiting for your approval — {tool}",
"composer.runWaitingAsk": "Waiting for your answer",
"composer.runAnnounceRunning": "Reasonix is working",
"composer.runStripEstimateHint": "Streaming estimates based on character count. Final values are reported after each request and may differ.",
"composer.guidanceQueue": "Queued guidance",
"composer.guidanceCount": "Queued guidance {n}",
"composer.guidanceRemaining": "{n} more queued",
Expand Down Expand Up @@ -726,6 +727,14 @@ export const en = {
"status.sessionTokensTitle": "Model tokens spent in this session; not the current context-window usage.",
"status.turnTokensLabel": "turn tokens",
"status.turnTokensTitle": "Model tokens spent in the current or most recent turn.",
"status.tpsLabel": "tps",
"status.tpsTitle": "Output token throughput for the most recent request (completion + reasoning tokens per second).",
"status.outputTokensLabel": "out",
"status.outputTokensTitle": "Completion tokens generated in the most recent request.",
"status.cacheTokensLabel": "cache",
"status.cacheTokensTitle": "Prompt cache hit and miss token counts for the latest request.",
"status.cacheHitShort": "hit",
"status.cacheMissShort": "miss",
"status.turnCostLabel": "turn cost",
"status.turnCostTitle": "Estimated billable spend in the current or most recent turn.",
"status.compactLabel": "compact at",
Expand Down
9 changes: 9 additions & 0 deletions desktop/frontend/src/locales/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,7 @@ export const zhTW: Record<DictKey, string> = {
"composer.runWaitingApproval": "等待你核准 — {tool}",
"composer.runWaitingAsk": "等待你回答",
"composer.runAnnounceRunning": "正在執行",
"composer.runStripEstimateHint": "基於字元數估算的流式資料,與實際值可能存在偏差。每輪請求結束後顯示精確值。",
"composer.guidanceQueue": "待處理引導",
"composer.guidanceCount": "待處理引導 {n}",
"composer.guidanceRemaining": "還有 {n} 條",
Expand Down Expand Up @@ -2202,6 +2203,14 @@ export const zhTW: Record<DictKey, string> = {
"status.sessionTokensTitle": "本會話累計消耗的模型 tokens,不等於當前上下文視窗佔用。",
"status.turnTokensLabel": "本次 tokens",
"status.turnTokensTitle": "當前或最近一輪交流累計消耗的模型 tokens。",
"status.tpsLabel": "吞吐速度",
"status.tpsTitle": "最近一次請求的輸出 token 吞吐速度(含推理 token)。",
"status.outputTokensLabel": "輸出",
"status.outputTokensTitle": "最近一次請求生成的 completion tokens 數。",
"status.cacheTokensLabel": "快取",
"status.cacheTokensTitle": "最近一次請求的 prompt 快取命中與未命中 token 數。",
"status.cacheHitShort": "命中",
"status.cacheMissShort": "未命中",
"status.turnCostLabel": "本次費用",
"status.turnCostTitle": "當前或最近一輪交流的估算計費費用。",
"status.compactLabel": "壓縮閾值",
Expand Down
Loading