From 4834b77521c20624f8eb7c0772cb7b05d3d1575f Mon Sep 17 00:00:00 2001 From: TossPig Date: Sun, 25 Oct 2026 19:14:49 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(chat):=20=E4=BC=98=E5=8C=96=E7=94=A8?= =?UTF-8?q?=E9=87=8F=E5=B1=95=E7=A4=BA=E5=B9=B6=E5=A2=9E=E5=8A=A0=E5=B9=B3?= =?UTF-8?q?=E5=9D=87=E8=BE=93=E5=87=BA=E9=80=9F=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/AppShell.tsx | 21 +- components/ChatWindow.tsx | 12 +- components/MessageUsage.test.mjs | 133 +++++++++++++ components/MessageUsage.tsx | 73 +++++++ components/MessageView.tsx | 48 ++--- components/UsageIcon.tsx | 20 ++ docs/agents/sessions.md | 6 +- e2e/usage-notice.mjs | 322 +++++++++++++++++++++++++++++++ lib/i18n/messages/en.ts | 7 + lib/i18n/messages/zh-CN.ts | 7 + lib/i18n/messages/zh-TW.ts | 7 + lib/message-usage.test.mjs | 78 ++++++++ lib/message-usage.ts | 65 +++++++ 13 files changed, 750 insertions(+), 49 deletions(-) create mode 100644 components/MessageUsage.test.mjs create mode 100644 components/MessageUsage.tsx create mode 100644 components/UsageIcon.tsx create mode 100644 e2e/usage-notice.mjs create mode 100644 lib/message-usage.test.mjs create mode 100644 lib/message-usage.ts diff --git a/components/AppShell.tsx b/components/AppShell.tsx index e02bdd8240..4a65a5978b 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useGlobalKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; import { SessionSidebar, type SelectSessionOptions, type SessionSidebarControl } from "./SessionSidebar"; import { ChatWindow } from "./ChatWindow"; +import { UsageIcon } from "./UsageIcon"; import { NewSessionContextBar, type NewSessionContextControl } from "./NewSessionContextBar"; import type { ChatScrollPosition } from "@/lib/chat-scroll-position"; import { FileViewer } from "./FileViewer"; @@ -1951,17 +1952,13 @@ export function AppShell() { <> {tokens && tokens.input > 0 && ( - + {formatCompact(tokens.input)} )} {tokens && tokens.output > 0 && ( - + {formatCompact(tokens.output)} )} @@ -1985,25 +1982,19 @@ export function AppShell() { <> {tokens && tokens.input > 0 && ( - + {formatCompact(tokens.input)} )} {tokens && tokens.output > 0 && ( - + {formatCompact(tokens.output)} )} {tokens && tokens.cacheRead > 0 && ( - + {formatCompact(tokens.cacheRead)} )} diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 788e10f4b3..49dfb43c8b 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -12,6 +12,7 @@ import { collapsesProcessDetails, countToolCallBlocks, getDisplayableAssistantBl import { extractTurnWrittenFiles, type WrittenFile } from "@/lib/turn-written-files"; import { getFinalAnswerViews, keepWrittenFiles, type FinalAnswerViews } from "@/lib/turn-views"; import { formatTurnDuration, getTurnDurationMs } from "@/lib/turn-duration"; +import { summarizeTurnUsage, type MessageUsageSummary } from "@/lib/message-usage"; import { buildQuotedSelection } from "@/lib/quoted-selection"; import { dropMentionText, splitDroppedItems, uploadFiles, type DroppedItem } from "@/lib/file-upload-client"; import { MessageView } from "./MessageView"; @@ -306,6 +307,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi onOpenSettings, deferInitialScroll: Boolean(pendingScrollRestore), }); + const showUsageDetails = useCallback((message: string) => addNotice({ message }), [addNotice]); // Only whether a reply is streaming and has content yet: the chunks themselves // re-render StreamingReply alone. const isStreaming = useStreamingSelector(streamingStore, selectIsStreaming); @@ -1120,7 +1122,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi if (idx === lastUserIdx) { (lastUserMsgRef as { current: HTMLDivElement | null }).current = el; } }; - const renderMessage = (idx: number, options: { attachRef?: boolean; keyPrefix?: string; messageOverride?: AgentMessage; showTimestamp?: boolean; writtenFiles?: WrittenFile[]; recoverTruncation?: boolean; turnDurationMs?: number; withNotices?: boolean } = {}): ReactNode => { + const renderMessage = (idx: number, options: { attachRef?: boolean; keyPrefix?: string; messageOverride?: AgentMessage; showTimestamp?: boolean; writtenFiles?: WrittenFile[]; recoverTruncation?: boolean; turnDurationMs?: number; usageSummary?: MessageUsageSummary; withNotices?: boolean } = {}): ReactNode => { const msg = options.messageOverride ?? messages[idx]; if (isHiddenCustomMessage(msg)) return null; // pi's showCacheMissNotices (#1265): read from the stored message, not a turn view's copy. @@ -1166,6 +1168,8 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi sessionId={session?.id ?? sessionIdRef.current ?? undefined} writtenFiles={options.writtenFiles} turnDurationMs={options.turnDurationMs} + usageSummary={options.usageSummary} + onShowUsageDetails={showUsageDetails} onCompact={options.recoverTruncation ? handleCompact : undefined} isCompacting={options.recoverTruncation ? isCompacting : undefined} compactError={options.recoverTruncation ? compactError : undefined} @@ -1284,7 +1288,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi would otherwise stay open once its answer shows up, e.g. when switching between an answered and an unanswered leaf of the same turn. Manual toggles survive every other re-render. */} - + {processViews} , @@ -1307,8 +1311,8 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi rendered.push(renderMessage(finalAssistantIdx, { messageOverride: finalAnswerMessage, writtenFiles, - // Without process details the turn's time goes under the answer. - turnDurationMs: processViews.length === 0 ? turnDurationMs : undefined, + turnDurationMs, + usageSummary: hasAnchor ? summarizeTurnUsage(messages, groupStartIdx, endIdx, turnDurationMs) : undefined, recoverTruncation: endIdx === messages.length && !isStreaming && !hasAssistantAnswer(finalAnswerMessage), })); } diff --git a/components/MessageUsage.test.mjs b/components/MessageUsage.test.mjs new file mode 100644 index 0000000000..2d7aa47603 --- /dev/null +++ b/components/MessageUsage.test.mjs @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { jsx: { runtime: "automatic" }, tsconfigPaths: true }); +const React = await jiti.import("react"); +const { renderToStaticMarkup } = await jiti.import("react-dom/server"); +const { MessageUsage, usageDetailsText } = await jiti.import("./MessageUsage.tsx"); +const { UsageIcon } = await jiti.import("./UsageIcon.tsx"); +const { MessageView } = await jiti.import("./MessageView.tsx"); +const { I18nProvider } = await jiti.import("@/hooks/useI18n"); +const { getLocalePlugin } = await jiti.import("@/lib/i18n/registry"); +const summary = { + usage: { + input: 314_138, output: 7_241, cacheRead: 1_600_640, cacheWrite: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.8608 }, + }, + scope: "turn", complete: true, durationMs: 10_000, +}; +const translate = (locale) => (key, params) => { + const text = getLocalePlugin(locale).messages[key]; + assert.ok(text, `missing ${locale} ${key}`); + return text.replace(/\{(\w+)\}/g, (_, name) => String(params[name])); +}; +const render = (component, props) => renderToStaticMarkup(React.createElement(I18nProvider, null, React.createElement(component, props))); + +test("compact summary uses shared thin SVGs, decimal K/M and actual average output rate", () => { + const html = render(MessageUsage, { summary, onShowDetails() {} }); + assert.match(html, /314\.1K/); + assert.match(html, /7\.2K/); + assert.match(html, /1\.6M/); + assert.match(html, /\$0\.8608/); + assert.match(html, /724\.1 t\/s/); + assert.match(html, /took 10s/); + assert.match(html, / { + const withWrite = { ...summary, usage: { ...summary.usage, cacheWrite: 12_000 } }; + const html = render(MessageUsage, { summary: withWrite }); + const icons = [...html.matchAll(/]*>/g)].map(([tag]) => tag); + assert.equal(icons.length, 4); + assert.match(icons[0], /color:var\(--accent\)/); + assert.match(icons[1], /color:var\(--accent\)/); + assert.match(icons[2], /color:var\(--text-muted\)/); + assert.match(icons[3], /color:var\(--text-muted\)/); + const withoutIcons = html.replace(//g, ""); + assert.doesNotMatch(withoutIcons, /color:var\(--accent\)|color:var\(--text-muted\)/); + assert.match(withoutIcons, /color:var\(--text-dim\)/); + for (const kind of ["input", "output", "cacheRead", "cacheWrite"]) { + const neutral = render(UsageIcon, { kind }); + assert.doesNotMatch(neutral, /style="[^"]*color:/, `${kind} is not colored by default`); + assert.match(neutral, /stroke="currentColor"/); + } +}); + +test("details contain exact counts, total, cost, timing and scope in all three locales", () => { + for (const locale of ["en", "zh-CN", "zh-TW"]) { + const text = usageDetailsText(summary, translate(locale), locale); + for (const count of ["314,138", "7,241", "1,600,640", "1,922,019", "$0.8608", "724.1 t/s"]) assert.ok(text.includes(count), `${locale}: ${count}`); + assert.ok(text.startsWith(translate(locale)("chat.usageTurn"))); + assert.ok(!text.includes(translate(locale)("session.cacheWrite")), "zero cache write is omitted"); + } +}); + +test("details keep the recorded fee precision, including very small nonzero costs", () => { + for (const cost of [0.00001234, 0.123456789, 0]) { + const precise = { ...summary, usage: { ...summary.usage, cost: { ...summary.usage.cost, total: cost } } }; + assert.ok(usageDetailsText(precise, translate("en"), "en").includes(`Cost: $${cost}`)); + } +}); + +test("read-only viewers have no dead button, click label or keyboard focus target", () => { + const html = render(MessageUsage, { summary }); + assert.match(html, / { + const withWrite = { ...summary, usage: { ...summary.usage, cacheWrite: 12_000 } }; + const html = render(MessageUsage, { summary: withWrite }); + assert.match(html, /12K/); + assert.match(html, /M1\.5 5a3\.5 3\.5/); + assert.match(usageDetailsText(withWrite, translate("en"), "en"), /Cache Write: 12,000/); +}); + +test("old or invalid timing and partial totals hide the rate instead of guessing", () => { + for (const durationMs of [undefined, 0, -1, NaN, Infinity]) { + const html = render(MessageUsage, { summary: { ...summary, durationMs } }); + assert.doesNotMatch(html, /[\d.] t\/s|NaN|Infinity/); + } + const partial = { ...summary, complete: false }; + assert.doesNotMatch(render(MessageUsage, { summary: partial }), /[\d.] t\/s/); + assert.match(usageDetailsText(partial, translate("en"), "en"), /Partial usage/); +}); + +test("finished message uses supplied turn totals, not the final response's own usage", () => { + const message = { + role: "assistant", provider: "p", model: "m", timestamp: 1_000, durationMs: 500, + content: [{ type: "text", text: "Done" }], + usage: { ...summary.usage, input: 12, output: 7 }, + }; + const html = render(MessageView, { message, usageSummary: summary, onShowUsageDetails() {} }); + assert.match(html, /314\.1K/); + assert.match(html, /724\.1 t\/s/); + assert.doesNotMatch(html, /14\.0 t\/s/); + const responseHtml = render(MessageView, { message }); + assert.match(responseHtml, /14\.0 t\/s/); + assert.match(responseHtml, /Response usage/); + const liveHtml = render(MessageView, { message, isStreaming: true }); + assert.doesNotMatch(liveHtml, /class="message-usage"/); +}); + +test("derived usage changes and click callbacks participate in MessageView memoization", () => { + const props = { message: { role: "assistant", content: [] }, usageSummary: summary }; + assert.equal(MessageView.compare(props, { ...props, usageSummary: structuredClone(summary) }), true); + assert.equal(MessageView.compare(props, { ...props, usageSummary: { ...summary, durationMs: 20_000 } }), false); + assert.equal(MessageView.compare(props, { ...props, onShowUsageDetails() {} }), false); +}); + +test("chat uses the existing toast path and original turn range for summary and duration", async () => { + const source = await readFile(new URL("./ChatWindow.tsx", import.meta.url), "utf8"); + assert.match(source, /addNotice\(\{ message \}\)/); + assert.match(source, /onShowUsageDetails=\{showUsageDetails\}/); + assert.match(source, /usageSummary: hasAnchor \? summarizeTurnUsage\(messages, groupStartIdx, endIdx, turnDurationMs\) : undefined/); + assert.match(source, /durationMs=\{finalAnswerMessage \? undefined : turnDurationMs\}/); +}); diff --git a/components/MessageUsage.tsx b/components/MessageUsage.tsx new file mode 100644 index 0000000000..e0a4fdb0c1 --- /dev/null +++ b/components/MessageUsage.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { Fragment } from "react"; +import { useI18n } from "@/hooks/useI18n"; +import { averageOutputRate, formatUsageTokens, type MessageUsageSummary } from "@/lib/message-usage"; +import { formatTurnDuration } from "@/lib/turn-duration"; +import { UsageIcon, type UsageIconKind } from "./UsageIcon"; + +type Translate = (key: string, params?: Record) => string; + +/** Unabbreviated counts in the same scope as the clickable summary. */ +export function usageDetailsText(summary: MessageUsageSummary, t: Translate, locale: string): string { + const { usage, scope, durationMs } = summary; + const count = (value: number) => value.toLocaleString(locale); + const lines = [ + t(scope === "turn" ? "chat.usageTurn" : "chat.usageResponse"), + ...(["input", "output", "cacheRead", "cacheWrite"] as const) + .filter((key) => key === "input" || key === "output" || usage[key] > 0) + .map((key) => `${t(`session.${key}`)}: ${count(usage[key])}`), + `${t("session.total")}: ${count(usage.input + usage.output + usage.cacheRead + usage.cacheWrite)}`, + `${t("session.cost")}: $${usage.cost?.total ?? 0}`, + ]; + if (durationMs !== undefined && Number.isFinite(durationMs) && durationMs >= 0) { + lines.push(`${t("chat.usageDuration")}: ${(durationMs / 1_000).toLocaleString(locale, { maximumFractionDigits: 3 })}s`); + } + const rate = averageOutputRate(summary); + if (rate !== undefined) lines.push(`${t("chat.usageAverage")}: ${rate.toFixed(1)} t/s`); + if (!summary.complete) lines.push(t("chat.usageIncomplete")); + return lines.join("\n"); +} + +export function MessageUsage({ summary, onShowDetails }: { + summary: MessageUsageSummary; + onShowDetails?: (message: string) => void; +}) { + const { t, locale } = useI18n(); + const { usage, durationMs } = summary; + const parts = (["input", "output", "cacheRead", "cacheWrite"] as UsageIconKind[]) + .filter((kind) => usage[kind] > 0) + .map((kind) => ( + + {formatUsageTokens(usage[kind])} + + )); + if (usage.cost?.total) parts.push(${usage.cost.total.toFixed(4)}); + if (durationMs !== undefined && Number.isFinite(durationMs) && durationMs >= 0) { + parts.push({t("chat.turnDuration", { time: formatTurnDuration(durationMs) })}); + } + const rate = averageOutputRate(summary); + if (rate !== undefined) { + parts.push({rate.toFixed(1)} t/s); + } + if (parts.length === 0) return null; + const details = usageDetailsText(summary, t, locale); + const Tag = onShowDetails ? "button" : "span"; + return ( + onShowDetails(details) : undefined} + style={{ + display: "inline-flex", alignItems: "center", flexWrap: "wrap", gap: "2px 5px", + minWidth: 0, maxWidth: "100%", padding: "2px 0", border: "none", background: "none", + font: "inherit", fontSize: 11, color: "var(--text-dim)", textAlign: "left", + cursor: onShowDetails ? "pointer" : "default", fontVariantNumeric: "tabular-nums", + }} + > + {parts.map((part, index) => {index > 0 && }{part})} + + ); +} diff --git a/components/MessageView.tsx b/components/MessageView.tsx index 78ff10777b..8c8cc7b039 100644 --- a/components/MessageView.tsx +++ b/components/MessageView.tsx @@ -11,6 +11,9 @@ import { useIsMobile } from "@/hooks/useIsMobile"; import { parseCompactionSummary } from "@/lib/compaction-summary"; import { getAssistantErrorMessage, getThinkingPreview, hasAssistantAnswer, isAssistantTruncated, isEmptyThinkingBlock, splitThinkingPieces } from "@/lib/message-display"; import { formatTurnDuration } from "@/lib/turn-duration"; +import { sameUsageSummary, type MessageUsageSummary } from "@/lib/message-usage"; +import { MessageUsage } from "./MessageUsage"; +import { UsageIcon } from "./UsageIcon"; import { diffGapLabel, parseUnifiedPatch, unifiedDiffLines, type SplitDiffCell, type SplitDiffFile, type SplitDiffGap, type UnifiedDiffLine } from "@/lib/patch"; import { applyPatchPreviewToFiles, applyPatchResultHasFailures, extractApplyPatchPaths, getApplyPatchInputText, parseApplyPatchInput } from "@/lib/apply-patch"; import { isApplyPatchToolName, isEditToolName, isWriteToolName } from "@/lib/tool-names"; @@ -217,6 +220,8 @@ interface Props { writtenFiles?: WrittenFile[]; /** The whole turn's time, for a final answer with no process details to show it. */ turnDurationMs?: number; + usageSummary?: MessageUsageSummary; + onShowUsageDetails?: (message: string) => void; onCompact?: () => void; isCompacting?: boolean; compactError?: string | null; @@ -287,12 +292,12 @@ function haveSameRelevantToolResults( return true; } -export const MessageView = memo(function MessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, onOpenSubagent, entryId, searchBlock, onFork, forking, onEditContent, onCancelEdit, isEditing, showTimestamp, prevTimestamp, sessionId, writtenFiles, turnDurationMs, onCompact, isCompacting, compactError }: Props) { +export const MessageView = memo(function MessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, onOpenSubagent, entryId, searchBlock, onFork, forking, onEditContent, onCancelEdit, isEditing, showTimestamp, prevTimestamp, sessionId, writtenFiles, turnDurationMs, usageSummary, onShowUsageDetails, onCompact, isCompacting, compactError }: Props) { if (message.role === "user") { return ; } if (message.role === "assistant") { - return ; + return ; } if (message.role === "toolResult") { // Rendered inline under its toolCall — skip standalone rendering if paired @@ -327,6 +332,8 @@ export const MessageView = memo(function MessageView({ message, isStreaming, too && prev.prevTimestamp === next.prevTimestamp && prev.writtenFiles === next.writtenFiles && prev.turnDurationMs === next.turnDurationMs + && sameUsageSummary(prev.usageSummary, next.usageSummary) + && prev.onShowUsageDetails === next.onShowUsageDetails && prev.sessionId === next.sessionId && prev.onCompact === next.onCompact && prev.isCompacting === next.isCompacting @@ -658,6 +665,8 @@ function AssistantMessageView({ searchBlock, writtenFiles, turnDurationMs, + usageSummary, + onShowUsageDetails, onCompact, isCompacting, compactError, @@ -676,6 +685,8 @@ function AssistantMessageView({ searchBlock?: AssistantContentBlock; writtenFiles?: WrittenFile[]; turnDurationMs?: number; + usageSummary?: MessageUsageSummary; + onShowUsageDetails?: (message: string) => void; onCompact?: () => void; isCompacting?: boolean; compactError?: string | null; @@ -843,9 +854,7 @@ function AssistantMessageView({ {est > 0 && ( - - - + {est} {tps !== null && (() => { @@ -943,14 +952,11 @@ function AssistantMessageView({
- {(message.usage || turnDurationMs !== undefined) && !isStreaming && ( -
- {[ - message.usage ? formatUsage(message.usage) : null, - turnDurationMs !== undefined ? t("chat.turnDuration", { time: formatTurnDuration(turnDurationMs) }) : null, - ].filter(Boolean).join(" \u00b7 ")} -
- )} + {!isStreaming && (usageSummary || message.usage) ? ( + + ) : !isStreaming && turnDurationMs !== undefined ? ( + {t("chat.turnDuration", { time: formatTurnDuration(turnDurationMs) })} + ) : null} {textContent && !isStreaming && (