Merge pull request #10059 from SivanCola/fix/turn-metrics-accuracy

Fix turn-metric accuracy and add live composer readings / 修正回合指标准确性并新增输入区实时读数
This commit is contained in:
SivanCola authored and GitHub committed 2026-09-10 18:07:39 +08:00
commit 6b6b08cd22
16 files changed
+565 -96

No files matched your search

@@ -64,6 +64,31 @@ try {
assert.equal(retry.turnStartAt, active.turnStartAt);
assert.equal(retry.turnOutputTokens, 20, "same-turn retry retains counters");
assert.deepEqual(metrics(completed), [21_000, 21_000, 24, 2_000, 0, true], "other tab transitions cannot mutate a completed state");
// An MCP interaction is a user wait like an approval or an ask. Clearing the
// approval while one is outstanding must not close the shared interval, or the
// MCP wait never reaches turnWaitAccumMs and the turn clock overcounts it.
now = 5_000;
const bothPrompts: State = { ...initialState, running: true, turnActive: true,
turnStartAt: 1_000, promptWaitStartedAt: 1_000,
approval: { id: "a1", tool: "write_file", subject: "Run command" },
mcpInteraction: { id: "m1", server: "srv", mode: "form", message: "Fill the form" } };
const approvalCleared = reducer(bothPrompts, { type: "clearApproval" });
assert.equal(approvalCleared.promptWaitStartedAt, 1_000,
"clearing an approval leaves a concurrent MCP wait open");
assert.equal(approvalCleared.pendingPrompt, true,
"an outstanding MCP interaction still counts as a pending prompt");
now = 9_000;
const mcpAnswered = reducer(approvalCleared, {
type: "expire_prompt", id: "m1", epoch: approvalCleared.promptEpoch, kind: "mcp" });
assert.equal(mcpAnswered.promptWaitStartedAt, undefined, "the last prompt closes the interval");
assert.equal(mcpAnswered.turnWaitAccumMs, 8_000, "the whole MCP wait is charged exactly once");
now = 5_000;
const drain: State = { ...bothPrompts };
const drained = reducer(drain, { type: "approval_drained", ids: ["a1"], epoch: drain.promptEpoch });
assert.equal(drained.promptWaitStartedAt, 1_000,
"an auto-drained approval also leaves a concurrent MCP wait open");
console.log("completed-turn telemetry: all assertions passed");
} finally {
Date.now = originalNow;
@@ -1,8 +1,10 @@
// Run: tsx src/__tests__/composer-run-strip.test.tsx
//
// Ordinary work uses the perimeter trace and an accessible status announcement.
// Timing/throughput live in the context popover; approval/ask retain an in-card
// attention strip, and stop keeps a fixed home next to send.
// Ordinary work keeps the perimeter trace and now carries a live
// token/throughput strip too; the accessible announcement still speaks the
// stable state text alone. Timing/throughput detail lives in the context
// popover; approval/ask retain an in-card attention strip, and stop keeps a
// fixed home next to send.
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
@@ -302,7 +304,8 @@ console.log("\ncomposer run strip");
const { root, calls } = await renderComposer({ running: true, turnStartAt: Date.now() });
const strip = document.querySelector(".composer-card .composer-run-strip");
eq(strip, null, "ordinary running state has no visible strip");
eq(strip?.querySelector(".composer-run-strip__text")?.textContent, "Reasonix is working",
"ordinary running state shows the run strip");
const live = document.querySelector(".composer-card .sr-only[role=\"status\"]");
eq(live?.textContent, "Reasonix is working", "live region announces the stable state text only");
ok(document.querySelector(".composer-card--running") !== null, "running card keeps its running modifier");
@@ -353,10 +356,11 @@ console.log("\ncomposer run strip");
);
await rerender({ pendingAsk: false, disabled: false });
ok(
document.querySelector(".composer-run-strip") === null,
"resolving the prompt removes the attention strip",
);
const resolved = document.querySelector(".composer-run-strip");
eq(resolved?.classList.contains("composer-run-strip--waiting"), false,
"resolving the prompt removes the attention strip");
eq(resolved?.querySelector(".composer-run-strip__text")?.textContent, "Reasonix is working",
"the resolved prompt falls back to the ordinary work strip");
await act(async () => {
root.unmount();
@@ -514,7 +518,8 @@ console.log("\ncomposer run strip");
});
const ticker = await readRunMetrics();
ok(ticker.includes("10 tokens/s"), "streaming TPS uses provider-output time instead of full turn age");
ok(ticker.includes("10 t/s"), "streaming TPS uses provider-output time instead of full turn age");
ok(ticker.includes("≈10 t/s"), "the streaming reading carries the estimate marker");
ok(ticker.includes("18 tokens"), "streaming token total adds the current request estimate to completed usage");
await act(async () => {
@@ -523,6 +528,52 @@ console.log("\ncomposer run strip");
dom.window.close();
}
// The run strip carries those same readings during ordinary work, and withholds
// throughput once the model stops emitting rather than freezing a stale rate.
{
const dom = installDom();
const live = { id: "assistant-1", text: "x".repeat(40), reasoning: "", reasoningComplete: false };
const stripText = () => document.querySelector(".composer-run-strip__text")?.textContent ?? "";
const { root, rerender } = await renderComposer({
running: true,
tabId: "tab-strip",
turnStartAt: Date.now() - 60_000,
turnTokens: 8,
turnOutputTokens: 10,
turnOutputCharsAtUsage: 0,
turnModelActiveMs: 2_000,
turnModelActiveAt: Date.now(),
liveStore: { subscribe: () => () => {}, getSnapshot: () => live },
});
ok(stripText().includes("18 tokens"), "the run strip carries the live token readout");
ok(stripText().includes("10 t/s"), "an emitting model contributes throughput to the strip");
eq((stripText().match(/≈/g) ?? []).length, 1, "the strip marks the estimate once");
ok(!/[()·]/.test(stripText()), "no grouping punctuation: colour and position do the separating");
const readings = document.querySelector(".composer-run-strip__metrics");
const readingsText = readings?.textContent ?? "";
ok(/^ \d+(m \d+)?s ≈18 tokens 10 t\/s$/.test(readingsText),
`readings read as clock, tokens, throughput (got "${readingsText}")`);
const shed = document.querySelector(".composer-run-strip__metric--optional");
eq(shed?.textContent, " 10 t/s", "throughput owns the trailing segment so a narrow strip sheds it whole");
ok(!(shed?.textContent ?? "").includes("tokens"),
"the clock and token count sit outside the shedable segment and are never cut");
await rerender({ turnModelActiveAt: undefined });
ok(stripText().includes("18 tokens"), "the token readout survives the model going quiet");
ok(!stripText().includes("t/s"), "a quiet model withholds throughput instead of freezing a rate");
eq(document.querySelector(".composer-run-strip__metric--optional"), null,
"a quiet model contributes no shedable segment");
await rerender({ running: false, turnDoneAt: Date.now() });
ok(stripText() === "", "a settled turn drops the strip readings");
await act(async () => {
root.unmount();
});
dom.window.close();
}
// Metrics survive wait/retry/completion and derive completed time from the
// controller timestamp, including when mounting an already completed tab.
{
@@ -536,7 +587,7 @@ console.log("\ncomposer run strip");
ok((await readRunMetrics()).includes("100 tokens"), "approval wait retains turn tokens");
await rerender({ pendingApprovalLabel: null, disabled: false,
retry: { attempt: 1, max: 3 } });
ok((await readRunMetrics()).includes("10 tokens/s"), "retry retains throughput");
ok((await readRunMetrics()).includes("10 t/s"), "retry retains throughput");
await rerender({ running: false, retry: undefined, turnDoneAt: start + 20_000,
lastTurnOutputTokens: 24, turnWaitAccumMs: 0 });
const completed = await readRunMetrics();
@@ -626,7 +677,7 @@ console.log("\ncomposer run strip");
const card = document.querySelector(".composer-card") as HTMLElement;
eq(card.style.getPropertyValue("--composer-height"), "104px", "render path writes the logical height, not a compensated one");
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "0px", "ordinary running state reserves no strip height");
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "30px", "ordinary running state reserves the metrics strip height");
// Drag while running: the live writer stays in logical-height space.
await act(async () => {
@@ -641,7 +692,7 @@ console.log("\ncomposer run strip");
await flushTimers();
});
eq(card.style.getPropertyValue("--composer-height"), "124px", "drag release keeps the same logical-height space as the render path");
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "0px", "dragging does not restore the removed strip reservation");
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "30px", "dragging keeps the running strip reservation");
eq(handle.getAttribute("aria-valuenow"), "124", "separator reports the logical height");
await rerender({ running: false, turnStartAt: undefined });
@@ -0,0 +1,119 @@
import assert from "node:assert/strict";
import {
formatElapsedMs, outputQuarters, tokensFromQuarters, turnMetrics, unbilledOutputTokens,
} from "../lib/turnMetrics";
const eq = (actual: unknown, expected: unknown, label: string) =>
assert.equal(actual, expected, label);
const ok = (value: unknown, label: string) => assert.ok(value, label);
// The flat `chars / 4` this module replaces. Every ASCII fixture in the suite
// was written against this expression, so any divergence is a regression.
const legacyEstimate = (chars: number) => Math.round(chars / 4);
// --- ASCII identity: the load-bearing guarantee -----------------------------
for (const n of [0, 1, 3, 4, 5, 7, 8, 15, 16, 17, 40, 41, 99, 100, 101, 1_000, 4_003]) {
const s = "x".repeat(n);
eq(outputQuarters(s), n, `ASCII weights one quarter per char (n=${n})`);
eq(tokensFromQuarters(outputQuarters(s)), legacyEstimate(n), `ASCII estimate matches chars/4 (n=${n})`);
}
eq(tokensFromQuarters(0), 0, "empty output estimates zero");
// --- CJK density ------------------------------------------------------------
// Han is three UTF-8 bytes, so it is worth three times the old flat density.
eq(outputQuarters("中"), 3, "a Han character carries three quarters");
eq(tokensFromQuarters(outputQuarters("中".repeat(16))), 12, "16 Han chars estimate 12 tokens");
eq(legacyEstimate(16), 4, "the legacy flat estimate would have said 4");
ok(tokensFromQuarters(outputQuarters("中".repeat(16))) > legacyEstimate(16) * 2,
"CJK output is no longer underestimated threefold");
eq(outputQuarters("é"), 2, "a two-byte rune carries two quarters");
eq(outputQuarters("😀"), 4, "an astral pair carries four quarters and consumes both units");
eq(outputQuarters("中".repeat(4), 2), 6, "the start index skips leading characters");
// Mixed scripts: density must sit between the ASCII floor and the Han ceiling.
const mixed = "hi 中文 there";
const mixedQuarters = outputQuarters(mixed);
ok(mixedQuarters > mixed.length, "mixed text outweighs its character count");
ok(mixedQuarters < mixed.length * 3, "mixed text stays below the pure-Han ceiling");
eq(outputQuarters("中文abc"), outputQuarters("abc中文"), "weight is order-independent");
// --- Unbilled window --------------------------------------------------------
const buf = (text: string, reasoning = "") => ({ text, reasoning });
eq(unbilledOutputTokens(undefined, 0, 0), 0, "no buffers estimates nothing");
eq(unbilledOutputTokens(buf("x".repeat(40)), 0, 0), 10, "whole ASCII buffer matches chars/4");
eq(unbilledOutputTokens(buf("x".repeat(40)), 0, 0), legacyEstimate(40), "ASCII parity at the buffer level");
eq(unbilledOutputTokens(buf("中".repeat(40)), 0, 0), 30, "whole CJK buffer is weighted");
eq(unbilledOutputTokens(buf("x".repeat(40)), 16, 0), 6, "billed prefix is excluded from the estimate");
eq(unbilledOutputTokens(buf("x".repeat(40)), 16, 0), legacyEstimate(24), "partial-bill ASCII parity");
eq(unbilledOutputTokens(buf("", ""), 0, 40), 10, "argument characters are charged as ASCII");
eq(unbilledOutputTokens(buf("x".repeat(8)), 0, 8), 4, "buffer and arguments sum before rounding");
eq(unbilledOutputTokens(buf("x".repeat(4)), 99, 0), 0, "an over-billed buffer floors at zero");
// Tail slice: all of `text` is billed before any of `reasoning`.
eq(unbilledOutputTokens(buf("x".repeat(20), "中".repeat(4)), 8, 0), 4,
"a billed prefix is consumed from the text buffer first");
eq(unbilledOutputTokens(buf("x".repeat(20), "中".repeat(4)), 4, 0), 5,
"a window ending exactly at the text boundary stays pure ASCII weight");
eq(unbilledOutputTokens(buf("x".repeat(20), "中".repeat(8)), 2, 0), 10,
"a window reaching into the reasoning tail picks up Han weight");
ok(unbilledOutputTokens(buf("x".repeat(20), "中".repeat(8)), 2, 0) > legacyEstimate(26),
"the reasoning tail is no longer priced as ASCII");
// --- The consolidated derivation matches the inline expression it replaces ---
const legacyRunMetrics = (i: Parameters<typeof turnMetrics>[0]) => {
if (!i.turnStartAt || (!i.running && !i.turnDoneAt)) return null;
const metricsNow = i.turnDoneAt || i.now;
const elapsedMs = Math.max(0, metricsNow - i.turnStartAt
- (i.turnDoneAt ? i.lastTurnWaitAccumMs ?? i.waitAccumMs : i.waitAccumMs));
const liveChars = (i.live?.text.length ?? 0) + (i.live?.reasoning.length ?? 0);
const inFlightChars = Math.max(0, liveChars - (i.turnOutputCharsAtUsage ?? 0)) + (i.turnArgChars ?? 0);
const estimatedChars = !i.turnDoneAt ? legacyEstimate(inFlightChars)
: Math.max(0, (i.lastTurnOutputTokens ?? i.turnOutputTokens ?? 0) - (i.turnOutputTokens ?? 0));
const outTok = (i.turnOutputTokens ?? 0) + estimatedChars;
const modelActiveAt = i.liveModelActiveAt ?? i.turnModelActiveAt;
const modelElapsedMs = Math.max(0, i.turnModelActiveMs
+ (modelActiveAt && modelActiveAt > 0 ? Math.max(0, metricsNow - modelActiveAt) : 0));
const tps = outTok > 0 && modelElapsedMs >= 500 ? Math.round(outTok / (modelElapsedMs / 1000)) : null;
return { tokens: (i.turnTokens ?? 0) + estimatedChars, outTok, elapsedMs, tps };
};
const base = {
now: 60_000, turnStartAt: 1_000, turnDoneAt: undefined, running: true, waitAccumMs: 0,
turnTokens: 8, turnOutputTokens: 10, turnModelActiveMs: 2_000,
turnOutputCharsAtUsage: 0, turnArgChars: 0, live: buf("x".repeat(40)),
};
for (const fixture of [
base,
{ ...base, turnDoneAt: 20_000, lastTurnOutputTokens: 24, lastTurnWaitAccumMs: 5_000 },
{ ...base, waitAccumMs: 3_000, turnModelActiveAt: 55_000, liveModelActiveAt: 58_000 },
{ ...base, turnModelActiveMs: 400, live: undefined },
{ ...base, turnOutputTokens: 0, turnTokens: 0, live: buf("", "") },
{ ...base, live: buf("x".repeat(40), "y".repeat(8)), turnArgChars: 12 },
]) {
const next = turnMetrics(fixture);
const legacy = legacyRunMetrics(fixture);
ok(next && legacy, "both derivations produce a reading for the ASCII fixture");
eq(next!.tokens, legacy!.tokens, "token reading matches the inline expression");
eq(next!.outputTokens, legacy!.outTok, "output reading matches the inline expression");
eq(next!.elapsedMs, legacy!.elapsedMs, "elapsed reading matches the inline expression");
eq(next!.tps, legacy!.tps, "throughput reading matches the inline expression");
}
eq(turnMetrics({ ...base, turnStartAt: 0 }), null, "a turn without a start anchor has no reading");
eq(turnMetrics({ ...base, running: false, turnDoneAt: undefined }), null,
"neither running nor completed yields no reading");
eq(turnMetrics({ ...base, running: false, turnDoneAt: 20_000 }).tps, 5,
"a settled turn divides its billed output by the model-active window");
eq(turnMetrics(base).tps, 10, "a streaming turn adds the in-flight estimate to the numerator");
eq(turnMetrics({ ...base, turnModelActiveMs: 400 }).tps, null, "sub-500ms windows report no throughput");
eq(turnMetrics({ ...base, running: false, turnDoneAt: 20_000, lastTurnOutputEstimated: true }).estimated,
true, "a settled estimate is flagged");
eq(turnMetrics(base).estimated, true, "a streaming estimate is flagged");
eq(turnMetrics({ ...base, turnOutputEstimated: false, live: buf("") }).estimated, false,
"a stream with nothing estimated is not flagged");
// --- Elapsed label ----------------------------------------------------------
eq(formatElapsedMs(0), "0s", "zero seconds");
eq(formatElapsedMs(4_999), "4s", "sub-minute truncates");
eq(formatElapsedMs(20_000), "20s", "seconds");
eq(formatElapsedMs(60_000), "1m 0s", "minute boundary");
eq(formatElapsedMs(83_421), "1m 23s", "minutes and seconds");
console.log("turn metrics: all assertions passed");
@@ -44,6 +44,8 @@ export function projectConversation({ local, remote, tab, activeTabId, backgroun
turnOutputCharsAtUsage: runtime.turnOutputCharsAtUsage,
turnModelActiveAt: runtime.turnModelActiveAt, turnModelActiveMs: runtime.turnModelActiveMs,
turnArgChars: runtime.turnArgChars, retry: runtime.retry,
turnOutputEstimated: runtime.turnOutputEstimated,
lastTurnOutputEstimated: runtime.lastTurnOutputEstimated,
readStatuses: runtime.readStatuses,
};
return {
+79 -37
View File
@@ -34,7 +34,8 @@ import {
type ComposerInvocation,
type StructuredInvocationSubmit,
} from "../lib/invocationDisplay";
import { formatTokens } from "../lib/format";
import { formatTokens, formatTps } from "../lib/format";
import { formatElapsedMs, turnMetrics } from "../lib/turnMetrics";
import type { CancelOutcome } from "../lib/inboxCancel";
import type { ControllerLiveStore } from "../lib/useController";
import { clearLayoutSize, loadOptionalLayoutSize, saveLayoutSize } from "../lib/layoutPreferences";
@@ -415,12 +416,6 @@ function loadComposerHeight(): number | null {
return loadOptionalLayoutSize("composerHeight", clampComposerHeight) ?? clampComposerHeight(COMPOSER_DEFAULT_HEIGHT);
}
function fmtElapsed(ms: number): string {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
return `${Math.floor(s / 60)}m ${s % 60}s`;
}
// --- past:chats hover preview helpers (PR-C2) ---
// Pure formatting helpers used by the past:chats list tooltip. They never read
// from disk, never call PreviewSession — they only shape the data that already
@@ -598,6 +593,8 @@ export function Composer({
turnModelActiveMs = 0,
liveStore,
turnArgChars = 0,
turnOutputEstimated,
lastTurnOutputEstimated,
retry,
suspendedByDecision = false,
pendingApprovalLabel,
@@ -695,14 +692,18 @@ export function Composer({
// Active provider-output time for the current turn; excludes tool gaps.
turnModelActiveAt?: number;
turnModelActiveMs?: number;
// Live-stream subscription for the character-count TPS fallback (chars ÷ 4)
// when the provider does not emit per-chunk usage events with token counts
// during streaming. Subscribing here keeps text deltas off the main state
// tree — only the composer re-renders, matching the controller's live-store
// contract (pure stream deltas must not re-render the controller owner).
// Live-stream subscription for the character-density TPS fallback (see
// lib/turnMetrics) when the provider does not emit per-chunk usage events
// with token counts during streaming. Subscribing here keeps text deltas off
// the main state tree — only the composer re-renders, matching the
// controller's live-store contract (pure stream deltas must not re-render the
// controller owner).
liveStore?: ControllerLiveStore;
// Streaming argument characters provide estimated progress before usage arrives.
turnArgChars?: number;
// Whether the provider flagged this turn's usage as reconstructed.
turnOutputEstimated?: boolean;
lastTurnOutputEstimated?: boolean;
retry?: RecoveryRetry;
// True while a footer decision surface (approval / ask / clear context) owns
// the UI. Pauses the model-work ticker without rendering a "waiting approval"
@@ -3663,7 +3664,12 @@ export function Composer({
: pendingAsk
? "ask"
: null;
const showRunStrip = Boolean(retry || waitingPrompt || finishing || runtimeState.unknown || runtimeState.kind === "background_job" || runtimeState.kind === "cancelling");
// Ordinary work keeps the strip too: it carries the live token/throughput
// readout, so it is no longer reserved for states the glow ring cannot name.
// `!suspendedByDecision` mirrors the run-state chain, which yields no label
// while a decision surface owns the footer; without it the reservation would
// hold a strip's height open with nothing to draw in it.
const showRunStrip = Boolean((running && !suspendedByDecision) || retry || waitingPrompt || finishing || runtimeState.unknown || runtimeState.kind === "background_job" || runtimeState.kind === "cancelling");
const effectiveComposerHeight = composerHeight === null
? null
: resolveComposerContentSizing({
@@ -3781,12 +3787,9 @@ export function Composer({
(cb: () => void) => liveStore?.subscribe(tabId, cb) ?? (() => {}),
[liveStore, tabId],
);
const liveTextChars = useSyncExternalStore(
const liveOutput = useSyncExternalStore(
subscribeLiveText,
() => {
const live = liveStore?.getSnapshot(tabId);
return live ? live.text.length + live.reasoning.length : 0;
},
() => liveStore?.getSnapshot(tabId),
);
const liveModelActiveAt = useSyncExternalStore(
subscribeLiveText,
@@ -3803,24 +3806,51 @@ export function Composer({
: running && !suspendedByDecision
? turnPhaseLabel
: null;
const runMetrics = turnStartAt && (running || turnDoneAt)
? (() => {
const metricsNow = turnDoneAt || now;
const elapsedMs = Math.max(0, metricsNow - turnStartAt - (turnDoneAt ? lastTurnWaitAccumMs ?? waitAccumMs : waitAccumMs));
const usageTokens = turnTokens ?? 0;
// Include streaming tool-call args in the estimate so TPS stays
// meaningful while the model streams a write_file / long tool body.
const inFlightChars = Math.max(0, liveTextChars - (turnOutputCharsAtUsage ?? 0)) + (turnArgChars ?? 0);
const estimatedChars = !turnDoneAt ? Math.round(inFlightChars / 4)
: Math.max(0, (lastTurnOutputTokens ?? turnOutputTokens ?? 0) - (turnOutputTokens ?? 0));
const liveTokens = usageTokens + estimatedChars;
const outTok: number = (turnOutputTokens ?? 0) + estimatedChars;
const modelActiveAt = liveModelActiveAt ?? turnModelActiveAt;
const modelElapsedMs = Math.max(0, turnModelActiveMs + (modelActiveAt && modelActiveAt > 0 ? Math.max(0, metricsNow - modelActiveAt) : 0));
const tps = outTok > 0 && modelElapsedMs >= 500 ? Math.round(outTok / (modelElapsedMs / 1000)) : null;
return { elapsed: fmtElapsed(elapsedMs), tokens: liveTokens > 0 ? `${formatTokens(liveTokens)} ${t("status.tokens")}` : null, tps: tps !== null ? `${tps} tokens/s` : null };
})()
: null;
// Second-quantized: the run strip has no sub-second resolution, and `now` is
// a fresh Date.now() every render, so keying the memo on it would never hit.
const metricsTick = Math.floor(now / 1000);
const runMetrics = useMemo(() => {
const metrics = turnMetrics({
now, turnStartAt, turnDoneAt, running, waitAccumMs, lastTurnWaitAccumMs,
turnTokens, turnOutputTokens, lastTurnOutputTokens, turnOutputCharsAtUsage,
turnArgChars, turnModelActiveMs, turnModelActiveAt, liveModelActiveAt,
live: liveOutput, turnOutputEstimated, lastTurnOutputEstimated,
});
if (!metrics) return null;
// The parenthesised group reads as a subordinate clause, so the state word
// keeps its own sentence. Elapsed leads because the strip's real job is
// answering "is this stuck?". Only the token reading carries the estimate
// cue: the clock is exact, and throughput is derived from it. The popover
// keeps a per-value cue because it also shows settled, exact readings.
const estimate = metrics.estimated ? "≈" : "";
const live = metrics.tokens > 0 && !turnDoneAt;
// Punctuation is not what groups these: the readings are pinned right and
// dimmed, so a long state word ellipsises instead of cutting them. The
// throughput is returned apart because it is the one reading the strip may
// shed whole when the composer is narrow.
const stripParts = live
? [formatElapsedMs(metrics.elapsedMs),
`${estimate}${formatTokens(metrics.tokens)} ${t("status.tokens")}`]
: [];
const stripSpeed = live && (liveModelActiveAt ?? turnModelActiveAt) && metrics.tps !== null
? formatTps(metrics.tps)
: "";
return {
elapsed: formatElapsedMs(metrics.elapsedMs),
tokens: metrics.tokens > 0
? `${metrics.estimated ? "≈" : ""}${formatTokens(metrics.tokens)} ${t("status.tokens")}`
: null,
tps: formatTps(metrics.tps, metrics.estimated),
stripParts,
stripSpeed,
};
}, [metricsTick, running, turnStartAt, turnDoneAt, waitAccumMs, lastTurnWaitAccumMs,
turnTokens, turnOutputTokens, lastTurnOutputTokens, turnOutputCharsAtUsage, turnArgChars,
turnModelActiveMs, turnModelActiveAt, liveModelActiveAt, liveOutput, turnOutputEstimated,
lastTurnOutputEstimated, t]);
// The strip's own sr-only sibling keeps announcing the stable state alone, so
// these churning numbers stay out of the live region.
const runStrip = runMetrics?.stripParts.length ? runMetrics : null;
const submitEmpty = !text.trim() && attachments.length === 0 && workspaceRefs.length === 0 &&
!invocations.some((invocation) => invocation.command.kind === "skill");
const submitBlocked = submitting || (!pendingFollowup && (pendingPaste > 0 || (submitEmpty && !(goalModeOn && !activeGoal)) || disabled || (!running && submitDisabled) || readOnly));
@@ -4396,7 +4426,19 @@ export function Composer({
{(readStatusText || (showRunStrip && runStateText)) && (
<div className={`composer-run-strip${waitingPrompt ? " composer-run-strip--waiting" : ""}`}>
{!finishing && !runtimeState.unknown && <span className="composer-run-strip__dot" aria-hidden="true" />}
<span className="composer-run-strip__text">{readStatusText || runStateText}</span>
<span className="composer-run-strip__text">
<span className="composer-run-strip__state">{readStatusText || runStateText}</span>
{runStrip && (
<span className="composer-run-strip__metrics">
{runStrip.stripParts.map((part) => (
<span className="composer-run-strip__metric" key={part}>{` ${part}`}</span>
))}
{runStrip.stripSpeed && (
<span className="composer-run-strip__metric composer-run-strip__metric--optional">{` ${runStrip.stripSpeed}`}</span>
)}
</span>
)}
</span>
</div>
)}
<span className="sr-only" role="status">{readStatusText || runStateText}</span>
@@ -218,9 +218,13 @@ export function ContextWindowRing({ enabled = true, context, tabId, turnCost, tu
<span className="context-ring-popover__value">{elapsed}</span>
</div>
)}
{turnMetrics && <div className="context-ring-popover__turn-metrics" title={t("composer.runStripEstimateHint")}>
{[[t("context.turnTime"), turnMetrics.elapsed], [t("status.tpsLabel"), turnMetrics.tps], [t("status.turnTokensLabel"), turnMetrics.tokens]].map(([label, value]) => value && (
<div className="context-ring-popover__row" key={label}>
{turnMetrics && <div className="context-ring-popover__turn-metrics">
{([
[t("context.turnTime"), t("context.turnTimeTitle"), turnMetrics.elapsed],
[t("status.turnTpsLabel"), t("status.turnTpsTitle"), turnMetrics.tps],
[t("status.turnOutputTokensLabel"), t("status.turnOutputTokensTitle"), turnMetrics.tokens],
] as const).map(([label, hint, value]) => value && (
<div className="context-ring-popover__row" key={label} title={hint}>
<span className="context-ring-popover__label">{label}</span>
<span className="context-ring-popover__value">{value}</span>
</div>
@@ -4,6 +4,7 @@ import { AnchoredPopover } from "./AnchoredPopover";
import { RemoteConnectionErrorDialog } from "./RemoteConnectionErrorDialog";
import { Tooltip } from "./Tooltip";
import { contextWindowPercentages } from "../lib/contextWindow";
import { formatTps } from "../lib/format";
import { useI18n, type Translator } from "../lib/i18n";
import { formatMoneyLocalized } from "../lib/money";
import { normalizeStatusBarItems, type StatusBarItemId } from "../lib/statusBarItems";
@@ -67,12 +68,6 @@ function formatTurnCount(turns: number | undefined, t: Translator): string {
if (typeof turns !== "number" || turns < 0) return "-";
return t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns });
}
function formatTps(tps?: number | null, estimated = false): string | null {
if (!tps || tps <= 0) return null;
const prefix = estimated ? "≈" : "";
if (tps < 1) return `${prefix}<1 t/s`;
return `${prefix}${Math.round(tps)} t/s`;
}
const STATUS_SOURCE_ORDER = ["executor", "planner", "subagent", "compaction", "classifier", "title"];
+20
View File
@@ -78,3 +78,23 @@ export function formatOptionalTokens(tokens?: number | null, options?: TokenForm
if (typeof tokens !== "number" || tokens <= 0) return "-";
return formatTokens(tokens, options);
}
/**
* Format an output-throughput reading for display.
*
* Returns `null` when unmeasurable so callers can omit the row entirely. Sub-one
* readings collapse to `<1 t/s` rather than rounding away to `0 t/s`, and an
* estimated reading carries the product-wide `≈` cue.
*
* @example
* formatTps(20) // "20 t/s"
* formatTps(20, true) // "≈20 t/s"
* formatTps(1 / 3) // "<1 t/s"
* formatTps(0) // null
*/
export function formatTps(tps?: number | null, estimated = false): string | null {
if (!tps || tps <= 0) return null;
const prefix = estimated ? "≈" : "";
if (tps < 1) return `${prefix}<1 t/s`;
return `${prefix}${Math.round(tps)} t/s`;
}
+125
View File
@@ -0,0 +1,125 @@
export interface LiveOutputBuffers {
readonly text: string;
readonly reasoning: string;
}
export interface TurnMetricInput {
now: number;
turnStartAt: number | undefined;
turnDoneAt: number | undefined;
running: boolean;
/** Controller wait plus any composer-local pause, in ms. */
waitAccumMs: number;
lastTurnWaitAccumMs?: number;
turnTokens?: number;
turnOutputTokens?: number;
lastTurnOutputTokens?: number;
turnOutputCharsAtUsage?: number;
turnArgChars?: number;
turnModelActiveMs: number;
turnModelActiveAt?: number;
liveModelActiveAt?: number;
live?: LiveOutputBuffers;
turnOutputEstimated?: boolean;
lastTurnOutputEstimated?: boolean;
}
export interface TurnMetrics {
elapsedMs: number;
tokens: number;
outputTokens: number;
tps: number | null;
estimated: boolean;
}
/**
* UTF-8 byte weight of `s` from `from`, in quarter-tokens. Matches the backend
* model in internal/agent/agent.go estimateTokensFromBytes, which is CJK-aware
* by construction: ASCII counts 1, Han/kana/hangul 3.
*/
export function outputQuarters(s: string, from = 0): number {
let quarters = 0;
for (let i = from; i < s.length; i++) {
const c = s.charCodeAt(i);
if (c < 0x80) quarters += 1;
else if (c < 0x800) quarters += 2;
else if (c >= 0xd800 && c <= 0xdbff) {
quarters += 4;
i += 1;
} else quarters += 3;
}
return quarters;
}
/**
* `Math.round(quarters / 4)` is the previous flat `Math.round(chars / 4)`
* expression verbatim: pure ASCII sums one quarter per char, so `quarters`
* equals the char count and every existing value is reproduced exactly.
*/
export function tokensFromQuarters(quarters: number): number {
return quarters > 0 ? Math.round(quarters / 4) : 0;
}
/**
* Output tokens streamed since the last billed usage event.
*
* `billedChars` is a SUM of both buffer lengths, so the billed/unbilled
* boundary cannot be located inside either buffer. The newest characters are
* attributed in stream order (all of `text`, then the tail of `reasoning`) —
* exact when the stream ended in text, locally bounded otherwise. Streaming
* tool-call arguments arrive as a bare count, so they are charged as ASCII.
*/
export function unbilledOutputTokens(
buffers: LiveOutputBuffers | undefined,
billedChars: number,
argChars: number,
): number {
const textChars = buffers?.text.length ?? 0;
const reasoningChars = buffers?.reasoning.length ?? 0;
const window = Math.max(0, textChars + reasoningChars - Math.max(0, billedChars || 0));
const extra = Math.max(0, argChars);
if (buffers === undefined || window === 0) return tokensFromQuarters(window + extra);
let quarters = extra;
if (window >= textChars) quarters += outputQuarters(buffers.text);
else quarters += outputQuarters(buffers.text, textChars - window);
const fromReasoning = window - textChars;
if (fromReasoning > 0) {
quarters += outputQuarters(buffers.reasoning, Math.max(0, reasoningChars - fromReasoning));
}
return tokensFromQuarters(quarters);
}
/** Locale-free elapsed label shared by every surface showing a turn clock. */
export function formatElapsedMs(ms: number): string {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
return `${Math.floor(s / 60)}m ${s % 60}s`;
}
/**
* The single definition of the turn's elapsed, token and throughput readings.
* Returns null when no turn is open or settled, matching the run-strip gate.
*/
export function turnMetrics(input: TurnMetricInput): TurnMetrics | null {
if (!input.turnStartAt || (!input.running && !input.turnDoneAt)) return null;
const metricsNow = input.turnDoneAt || input.now;
const waitMs = input.turnDoneAt
? input.lastTurnWaitAccumMs ?? input.waitAccumMs
: input.waitAccumMs;
const elapsedMs = Math.max(0, metricsNow - input.turnStartAt - waitMs);
const estimatedTokens = input.turnDoneAt
? Math.max(0, (input.lastTurnOutputTokens ?? input.turnOutputTokens ?? 0) - (input.turnOutputTokens ?? 0))
: unbilledOutputTokens(input.live, input.turnOutputCharsAtUsage ?? 0, input.turnArgChars ?? 0);
const outputTokens = (input.turnOutputTokens ?? 0) + estimatedTokens;
const modelActiveAt = input.liveModelActiveAt ?? input.turnModelActiveAt;
const modelElapsedMs = Math.max(0, input.turnModelActiveMs
+ (modelActiveAt && modelActiveAt > 0 ? Math.max(0, metricsNow - modelActiveAt) : 0));
// Below the gate the reading is too noisy to be worth a number.
const tps = outputTokens > 0 && modelElapsedMs >= 500
? Math.round(outputTokens / (modelElapsedMs / 1000))
: null;
const estimated = input.turnDoneAt
? input.lastTurnOutputEstimated === true
: estimatedTokens > 0 || input.turnOutputEstimated === true;
return { elapsedMs, tokens: (input.turnTokens ?? 0) + estimatedTokens, outputTokens, tps, estimated };
}
+13 -9
View File
@@ -11,6 +11,7 @@ import { addBreadcrumb } from "./breadcrumbs";
import { app, onEvent, onReady, onRuntimeRebuilt, onTabMeta, onTopicActivation } from "./bridge";
import { startControllerEventRecovery } from "./controllerEventRecovery";
import { metaFromTab } from "./controllerTabMeta";
import { tokensFromQuarters, unbilledOutputTokens } from "./turnMetrics";
export { metaFromTab } from "./controllerTabMeta";
import { invalidateCache } from "./composerHistory";
import { formatInboxCancelError } from "./inboxError";
@@ -1141,8 +1142,11 @@ function endPromptWait(s: State, now = Date.now()): State {
};
}
// An MCP interaction is a user wait like any other prompt: closing the interval
// while one is outstanding would drop that wait from turnWaitAccumMs entirely,
// because the later answer's endPromptWait finds no open interval to close.
function endPromptWaitIfIdle(s: State, now = Date.now()): State {
if (s.approval || s.ask) return s;
if (s.approval || s.ask || s.mcpInteraction) return s;
return endPromptWait(s, now);
}
@@ -1187,11 +1191,11 @@ function endTurnModelActivity(s: State, now = Date.now(), stashForUsage = false)
function snapshotCompletedTurnTelemetry(s: State, now = Date.now()): State {
if (!s.turnStartAt || s.turnDoneAt > 0) return s;
const settled = endPromptWait(endTurnModelActivity(s, now), now);
const liveChars = (settled.live?.text.length ?? 0) + (settled.live?.reasoning.length ?? 0);
const inFlightChars = settled.turnOutputTokens > 0
? Math.max(0, liveChars - settled.turnOutputCharsAtUsage) + settled.turnArgChars
: settled.turnOutputChars + settled.turnArgChars;
const estimatedInFlightTokens = Math.round(inFlightChars / 4);
// `turnOutputChars` is a bare count with no buffer to weight, so the rolled
// back-attempt branch stays ASCII-priced.
const estimatedInFlightTokens = settled.turnOutputTokens > 0
? unbilledOutputTokens(settled.live, settled.turnOutputCharsAtUsage, settled.turnArgChars)
: tokensFromQuarters(settled.turnOutputChars + settled.turnArgChars);
return {
...settled,
turnDoneAt: now,
@@ -2340,7 +2344,7 @@ export function reducer(s: State, a: Action): State {
}
case "local_notice": return { ...s, running: a.preserveRuntime ? s.running : false, turnActive: a.preserveRuntime ? s.turnActive : false, seq: s.seq + 1, items: [...s.items, { kind: "notice", id: `n${s.seq}`, level: a.level, text: a.text }] };
case "clearApproval": {
const next = { ...s, approval: undefined, pendingPrompt: Boolean(s.ask), resolvedPromptId: s.approval?.id ?? s.resolvedPromptId };
const next = { ...s, approval: undefined, pendingPrompt: Boolean(s.ask || s.mcpInteraction), resolvedPromptId: s.approval?.id ?? s.resolvedPromptId };
return endPromptWaitIfIdle(next);
}
case "clearAsk": {
@@ -2376,12 +2380,12 @@ export function reducer(s: State, a: Action): State {
// drain result must also belong to this controller's prompt-id epoch.
case "approval_drained": {
if (s.promptEpoch !== a.epoch || !s.approval || !a.ids.includes(s.approval.id)) return s;
const next = { ...s, approval: undefined, pendingPrompt: Boolean(s.ask), resolvedPromptId: s.approval.id };
const next = { ...s, approval: undefined, pendingPrompt: Boolean(s.ask || s.mcpInteraction), resolvedPromptId: s.approval.id };
return endPromptWaitIfIdle(next);
}
case "ask_submit_succeeded": {
if (s.promptEpoch !== a.epoch || s.ask?.id !== a.id) return s;
const next = { ...s, ask: undefined, pendingPrompt: Boolean(s.approval), resolvedPromptId: a.id };
const next = { ...s, ask: undefined, pendingPrompt: Boolean(s.approval || s.mcpInteraction), resolvedPromptId: a.id };
return endPromptWaitIfIdle(next);
}
// The optimistic clearApproval/clearAsk tombstone was wrong: the backend
+9 -4
View File
@@ -831,10 +831,10 @@ export const en = {
"composer.readStatusSource": "The remaining content could not be verified.",
"composer.readStatusStalled": "Repeated reads returned no new content.",
"composer.readStatusRecovery": "Read a smaller range or continue independent work; the full review remains incomplete.",
"composer.turnPhaseWorking": "Working…",
"composer.turnPhaseChecking": "Checking…",
"composer.turnPhaseVerifying": "Verifying…",
"composer.turnPhaseReviewing": "Reviewing…",
"composer.turnPhaseWorking": "Working",
"composer.turnPhaseChecking": "Checking",
"composer.turnPhaseVerifying": "Verifying",
"composer.turnPhaseReviewing": "Reviewing",
"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}",
@@ -890,6 +890,10 @@ export const en = {
"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.turnTpsLabel": "turn tps",
"status.turnTpsTitle": "Turn-cumulative output throughput (completion + reasoning tokens): settled values for completed requests plus a character-density estimate while streaming.",
"status.turnOutputTokensLabel": "turn output tokens",
"status.turnOutputTokensTitle": "Model output tokens for this turn, including reasoning but excluding input. Estimated while streaming.",
"status.outputTokensLabel": "out",
"status.outputTokensTitle": "Completion tokens generated in the most recent request.",
"status.cacheTokensLabel": "cache",
@@ -3177,6 +3181,7 @@ export const en = {
"context.time": "Runtime",
"context.sessionTime": "Session time",
"context.turnTime": "Turn time",
"context.turnTimeTitle": "Wall-clock time for this turn, excluding approval, answer and MCP-interaction waits. Tool execution counts toward the turn.",
"context.durationSeconds": "{seconds}s",
"context.durationMinutesSeconds": "{minutes}m {seconds}s",
+9 -4
View File
@@ -674,10 +674,10 @@ export const zhTW: Record<DictKey, string> = {
"composer.readStatusSource": "無法驗證剩餘內容。",
"composer.readStatusStalled": "重複讀取沒有取得新內容。",
"composer.readStatusRecovery": "可縮小讀取範圍或繼續獨立工作;全文審查尚未完成。",
"composer.turnPhaseWorking": "工作…",
"composer.turnPhaseChecking": "檢查…",
"composer.turnPhaseVerifying": "驗證…",
"composer.turnPhaseReviewing": "複審…",
"composer.turnPhaseWorking": "工作中",
"composer.turnPhaseChecking": "檢查中",
"composer.turnPhaseVerifying": "驗證中",
"composer.turnPhaseReviewing": "複審中",
"composer.runStripEstimateHint": "基於字元數的流式估算,與實際值可能有偏差;請求結束後顯示精確值。",
"composer.guidanceQueue": "待處理引導",
"composer.guidanceCount": "待處理引導 {n}",
@@ -2343,6 +2343,7 @@ export const zhTW: Record<DictKey, string> = {
"context.time": "執行時間",
"context.sessionTime": "會話耗時",
"context.turnTime": "本輪耗時",
"context.turnTimeTitle": "本輪牆鐘耗時,已扣除等待審批、回答和 MCP 互動的時間;工具執行時間計入本輪。",
"context.durationSeconds": "{seconds}秒",
"context.durationMinutesSeconds": "{minutes}分{seconds}秒",
@@ -2572,6 +2573,10 @@ export const zhTW: Record<DictKey, string> = {
"status.turnTokensTitle": "當前或最近一輪交流累計消耗的模型 tokens。",
"status.tpsLabel": "tps",
"status.tpsTitle": "最近請求的輸出吞吐(含推理 token)。",
"status.turnTpsLabel": "本輪吞吐",
"status.turnTpsTitle": "本輪累計輸出吞吐(含推理 token);已完成請求用精確值,串流期間按字元密度估算。",
"status.turnOutputTokensLabel": "本輪輸出 tokens",
"status.turnOutputTokensTitle": "本輪模型輸出的 tokens(含推理 token),不含輸入;串流期間為估算值。",
"status.outputTokensLabel": "輸出",
"status.outputTokensTitle": "最近請求的 completion tokens 數。",
"status.cacheTokensLabel": "快取",
+9 -4
View File
@@ -832,10 +832,10 @@ export const zh: Record<DictKey, string> = {
"composer.readStatusSource": "无法验证剩余内容。",
"composer.readStatusStalled": "重复读取没有获得新内容。",
"composer.readStatusRecovery": "可缩小读取范围或继续独立工作;全文审查尚未完成。",
"composer.turnPhaseWorking": "工作…",
"composer.turnPhaseChecking": "检查…",
"composer.turnPhaseVerifying": "验证…",
"composer.turnPhaseReviewing": "复审…",
"composer.turnPhaseWorking": "工作中",
"composer.turnPhaseChecking": "检查中",
"composer.turnPhaseVerifying": "验证中",
"composer.turnPhaseReviewing": "复审中",
"composer.runStripEstimateHint": "基于字符数的流式估算,与实际值可能有偏差;请求结束后显示精确值。",
"composer.guidanceQueue": "待处理引导",
"composer.guidanceCount": "待处理引导 {n}",
@@ -891,6 +891,10 @@ export const zh: Record<DictKey, string> = {
"status.turnTokensTitle": "当前或最近一轮交流累计消耗的模型 tokens。",
"status.tpsLabel": "吞吐速度",
"status.tpsTitle": "最近请求的输出吞吐(含推理 token)。",
"status.turnTpsLabel": "本轮吞吐",
"status.turnTpsTitle": "本轮累计输出吞吐(含推理 token);已完成请求用精确值,流式期间按字符密度估算。",
"status.turnOutputTokensLabel": "本轮输出 tokens",
"status.turnOutputTokensTitle": "本轮模型输出的 tokens(含推理 token),不含输入;流式期间为估算值。",
"status.outputTokensLabel": "输出",
"status.outputTokensTitle": "最近请求的 completion tokens 数。",
"status.cacheTokensLabel": "缓存",
@@ -3180,6 +3184,7 @@ export const zh: Record<DictKey, string> = {
"context.time": "运行时间",
"context.sessionTime": "会话耗时",
"context.turnTime": "本轮耗时",
"context.turnTimeTitle": "本轮墙钟耗时,已扣除等待审批、回答和 MCP 交互的时间;工具执行时间计入本轮。",
"context.durationSeconds": "{seconds}秒",
"context.durationMinutesSeconds": "{minutes}分{seconds}秒",
+43 -1
View File
@@ -6770,6 +6770,9 @@ body > .mermaid-diagram--fullscreen {
color: var(--accent);
font-family: var(--sans);
font-size: 12px;
/* Lets the readings drop the rate by width instead of grinding it down to a
half-cut number. */
container-type: inline-size;
}
.composer-card--waiting .composer-run-strip {
/* inherited from the card border tint; no extra rule needed */
@@ -6783,11 +6786,50 @@ body > .mermaid-diagram--fullscreen {
animation: pulse 1.2s ease-in-out infinite;
}
.composer-run-strip__text {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
min-width: 0;
overflow: hidden;
font-variant-numeric: tabular-nums;
}
/* Punctuation is not what groups these. The state word carries the accent and
the readings are pinned right, so a long state word ellipsises instead of
truncating a reading; a single ellipsised line would cut inside a number and
leave it reading as a whole one. */
.composer-run-strip__state {
flex: 0 9 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
/* The readings never shrink, so every pixel the strip gives up comes out of the
state word. That is what keeps a reading from being cut mid-number. */
.composer-run-strip__metrics {
display: flex;
align-items: baseline;
flex: 0 0 auto;
column-gap: 14px;
color: var(--fg-dim);
}
/* `pre` keeps the space that separates the readings in the text layer too:
flex strips a leading space from the block's first character. */
.composer-run-strip__metric {
flex: 0 0 auto;
white-space: pre;
}
/* Throughput is the only reading that may be shed, and it is shed whole: it
never shrinks, so it cannot be ground down to a half-cut number, and the
container query removes it before it would have to overflow. */
.composer-run-strip__metric--optional {
flex: 0 0 auto;
}
@container (max-width: 350px) {
.composer-run-strip__metric--optional {
display: none;
}
}
.composer-run-strip--waiting {
color: color-mix(in srgb, var(--warn) 74%, var(--fg));
+25 -10
View File
@@ -10,18 +10,33 @@ reasoning effort have independent selectors; unsupported models hide effort.
The status bar no longer repeats the model name. Its turn cost uses two decimal
places; detailed cost values retain their existing precision.
The context ring opens usage details. **Turn time** excludes user-approval and
answer waits and stops at the controller's completion timestamp. Retry time
remains part of the turn. Turn tokens and throughput remain available during
waits, retries, and after completion; in-flight tokens are estimates. **Session
time** is the separately reported session aggregate. Starting a new turn resets
turn metrics. These live metrics are not a persisted historical report.
Completed metrics are settled once per turn; later background-job updates do
not replace them.
The context ring opens usage details. **Turn time** excludes waits on the user,
whether an approval, an answer, or an MCP interaction, and stops at the
controller's completion timestamp. Retry time remains part of the turn. Turn
tokens and throughput remain available during waits, retries, and after
completion. In-flight tokens are estimated from character density rather than a
flat four characters per token: ASCII prices at roughly four characters per
token and CJK at roughly 1.3. The ring's throughput and token rows are
turn-scoped, counting the turn's cumulative output and carrying the estimate cue
while streaming; the status bar's throughput item covers the most recent request
and its turn-token item is the turn's prompt-plus-output total. **Session time**
is the separately reported session aggregate. Starting a new turn resets turn
metrics. These live metrics are not a persisted historical report. Completed
metrics are settled once per turn; later background-job updates do not replace
them.
The composer defaults to 140px and preserves manual resizing. Running work
uses a theme-aware perimeter trace; reduced motion uses a static outline.
Approval, answer, and retry notices remain visible.
uses a theme-aware perimeter trace; reduced motion uses a static outline. Above
the input, a run strip names the current state and, while a turn is live, pins
its readings to the right: the turn clock, the running token total, and
throughput. Colour and position do the separating, not punctuation. The clock
reads first, so the strip answers "is this stuck?"; only the token reading
carries the estimate cue, since the clock is exact and throughput derives from
the reading. A narrow strip spends the state word's width first, never cuts a
reading mid-number, and drops the rate whole below its threshold. Throughput
also appears only while the model is emitting, so a rate frozen by a wait is
never shown as a current speed. The strip's live region still announces the
stable state text alone. Approval, answer, and retry notices remain visible.
The bottom status bar combines workspace and branch into one item: it shows
the branch name, with both workspace path and branch in the tooltip. Non-Git
+16 -6
View File
@@ -9,14 +9,24 @@
底部信息栏不再重复显示模型名称,本轮费用保留两位小数,费用详情仍保留
原有精度。
圆环打开用量详情。**本轮耗时**扣除等待审批和用户回答的时间,并在控制器
记录的完成时间停止;重试时间仍计入本轮。等待、重试和结束后保留本轮
token 与吞吐指标,流式传输中的 token 为估算值。**会话耗时**单独展示
会话累计值。新一轮开始时重置本轮指标;这些实时指标不是持久化历史报告。
每个回合的完成指标只结算一次,后续后台作业状态更新不会覆盖它们。
圆环打开用量详情。**本轮耗时**扣除等待用户的时间,包括审批、回答和 MCP
交互,并在控制器记录的完成时间停止;重试时间仍计入本轮。等待、重试和
结束后保留本轮 token 与吞吐指标。流式传输中的 token 按字符密度估算,
不再统一按每 token 四个字符计:ASCII 约四个字符一 token,中日韩文字约
1.3 个字符一 token。圆环里的吞吐和 token 两行是本轮口径,统计本轮累计
输出,流式期间带估算标记;底部信息栏的吞吐是最近一次请求,其本轮 token
项是"输入加输出"的回合总量。**会话耗时**单独展示会话累计值。新一轮开始时
重置本轮指标;这些实时指标不是持久化历史报告。每个回合的完成指标只结算
一次,后续后台作业状态更新不会覆盖它们。
输入区默认高度为 140px,并保留手动调整的高度。运行时使用适配主题的
边框动画,减少动态效果设置下显示静态边框。审批、回答和重试提示仍然可见。
边框动画,减少动态效果设置下显示静态边框。输入框上方有一条状态条,说明
当前状态;本轮进行期间还会在右侧固定展示本轮耗时、tokens 累计与吞吐速度。
分组靠颜色和位置,不靠标点。耗时排在最前,先回答"是不是卡住了";估算标记
只落在 tokens 上,因为耗时是精确值、吞吐由它推导。状态条变窄时先让出状态词
的宽度,绝不把某个读数从中间截断,并在低于阈值时整段去掉速率。吞吐只在
模型正在出字时出现,避免把被等待冻住的速率当成当前速度展示。该状态条的
实时区域仍然只播报稳定的状态文案。审批、回答和重试提示仍然可见。
底部信息栏将工作区和分支合并为一项:显示当前分支名,悬停提示同时展示
工作区路径和分支。非 Git 工作区显示工作区名称,旧配置列表自动去重。