mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
Merge pull request #10059 from SivanCola/fix/turn-metrics-accuracy
Fix turn-metric accuracy and add live composer readings / 修正回合指标准确性并新增输入区实时读数
This commit is contained in:
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 {
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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": "快取",
|
||||
|
||||
@@ -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}秒",
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -9,14 +9,24 @@
|
||||
底部信息栏不再重复显示模型名称,本轮费用保留两位小数,费用详情仍保留
|
||||
原有精度。
|
||||
|
||||
圆环打开用量详情。**本轮耗时**扣除等待审批和用户回答的时间,并在控制器
|
||||
记录的完成时间停止;重试时间仍计入本轮。等待、重试和结束后保留本轮
|
||||
token 与吞吐指标,流式传输中的 token 为估算值。**会话耗时**单独展示
|
||||
会话累计值。新一轮开始时重置本轮指标;这些实时指标不是持久化历史报告。
|
||||
每个回合的完成指标只结算一次,后续后台作业状态更新不会覆盖它们。
|
||||
圆环打开用量详情。**本轮耗时**扣除等待用户的时间,包括审批、回答和 MCP
|
||||
交互,并在控制器记录的完成时间停止;重试时间仍计入本轮。等待、重试和
|
||||
结束后保留本轮 token 与吞吐指标。流式传输中的 token 按字符密度估算,
|
||||
不再统一按每 token 四个字符计:ASCII 约四个字符一 token,中日韩文字约
|
||||
1.3 个字符一 token。圆环里的吞吐和 token 两行是本轮口径,统计本轮累计
|
||||
输出,流式期间带估算标记;底部信息栏的吞吐是最近一次请求,其本轮 token
|
||||
项是"输入加输出"的回合总量。**会话耗时**单独展示会话累计值。新一轮开始时
|
||||
重置本轮指标;这些实时指标不是持久化历史报告。每个回合的完成指标只结算
|
||||
一次,后续后台作业状态更新不会覆盖它们。
|
||||
|
||||
输入区默认高度为 140px,并保留手动调整的高度。运行时使用适配主题的
|
||||
边框动画,减少动态效果设置下显示静态边框。审批、回答和重试提示仍然可见。
|
||||
边框动画,减少动态效果设置下显示静态边框。输入框上方有一条状态条,说明
|
||||
当前状态;本轮进行期间还会在右侧固定展示本轮耗时、tokens 累计与吞吐速度。
|
||||
分组靠颜色和位置,不靠标点。耗时排在最前,先回答"是不是卡住了";估算标记
|
||||
只落在 tokens 上,因为耗时是精确值、吞吐由它推导。状态条变窄时先让出状态词
|
||||
的宽度,绝不把某个读数从中间截断,并在低于阈值时整段去掉速率。吞吐只在
|
||||
模型正在出字时出现,避免把被等待冻住的速率当成当前速度展示。该状态条的
|
||||
实时区域仍然只播报稳定的状态文案。审批、回答和重试提示仍然可见。
|
||||
|
||||
底部信息栏将工作区和分支合并为一项:显示当前分支名,悬停提示同时展示
|
||||
工作区路径和分支。非 Git 工作区显示工作区名称,旧配置列表自动去重。
|
||||
|
||||
Reference in new issue
Block a user