diff --git a/AGENTS.md b/AGENTS.md index 69a70b1d83..7f4ec402c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,6 +145,7 @@ lib/ draft-store.ts local draft persistence extension-ui-queue.ts FIFO queues for extension dialogs and custom panels, by request id; isBlockingExtensionUiRequest() (which method a run waits on) session-stats-format.ts client-safe compact counts, costs and active durations of the stats toolbar and agent switcher + money.ts client-safe money display: USD amounts converted to yuan for zh-CN at a hardcoded rate, dollars otherwise turn-duration.ts getTurnDurationMs() / formatTurnDuration(): a finished turn's length from its anchor to its last response's end, on pi's timestamps transcript-notices.ts client-safe: showCacheMissNotices notices on chat messages, TUI number formats, a fold's cache-miss sum transcript-notices-server.ts the notices derived from session entries (cache-stats port), live ones for message_end, the setting @@ -247,7 +248,7 @@ hooks/ Design decisions and traps live in `docs/agents/`, one note per area. Read every note whose files a change touches before making it. Add new notes to the area's file, not here. -- [sessions.md](docs/agents/sessions.md): AgentSession lifecycle and shutdown, fork vs in-session branching, the sidebar's on-disk Fork, session file rewrites, toolCall normalization, a run's message keys across the file read, SSE reconnect and tool events, a tool card's flat arguments as rows, transcript system / usage / context-edit entries, running-state polling, custom-message collapsing, streamed chunks re-rendering only the reply, streaming markdown block by block, hand-written anchors in rendered Markdown landing after the sanitizer's id prefix, long thinking rendered in pieces, a finished turn's duration, history caches (tool-result images, subagent runs, session views) and deferred code highlighting with Prism languages loaded on demand, a patch shown in one column or two, the session sidebar (toolbar row and its brand, project groups, their order and display names, pins, archive, new-session project adoption, the files below the sessions or in a tab), the project/worktree bar above a fresh composer (the header: brand, then the bar) and what it carries across the remount, exported HTML, the extension status bar and its `command:` buttons, cache and cost notices (`showCacheMissNotices`). Files: `lib/rpc-manager.ts`, `lib/session-reader.ts`, `lib/session-fork*.ts`, `lib/session-delete.ts`, `lib/normalize.ts`, `lib/message-keys.ts`, `lib/session-tree.ts`, `lib/session-ui-state*.ts`, `lib/sidebar-prefs.ts`, `lib/sidebar-actions.ts`, `lib/session-links.ts`, `lib/new-session-context.ts`, `hooks/useAgentSession.ts`, `hooks/useSessionUiState.ts`, `hooks/useSidebarMotion.ts`, `hooks/useGroupDrag.ts`, `hooks/useFilesPlacement.ts`, `hooks/useResizablePanel.ts`, `app/api/agent/**`, `app/api/sessions/**`, `components/SessionSidebar.tsx`, `components/PiWebTitle.tsx`, `components/SessionTree.tsx`, `components/SidebarMenu.tsx`, `components/SidebarToast.tsx`, `components/SidebarIcons.tsx`, `components/ProjectWorktreePicker.tsx`, `components/NewSessionContextBar.tsx`, `components/WorktreeCreateForm.tsx`, `handleNewSession`, `handleSelectSession` and the bar's handlers in `components/AppShell.tsx`, `components/BranchNavigator.tsx`, `components/MessageView.tsx`, `lib/message-display.ts`, `lib/patch.ts`, `components/FileViewer.tsx`, `components/MarkdownBody.tsx`, `lib/markdown.ts`, `lib/markdown-blocks.ts`, `lib/streaming-message.ts`, `hooks/useStreamingSelector.ts`, `lib/turn-duration.ts`, `components/MermaidBlock.tsx`, `lib/highlight-queue.ts`, `lib/prism-languages.ts`, `hooks/usePrismLanguages.ts`, `lib/session-view-cache.ts`, `components/CodemodeToolView.tsx`, `components/ExtensionStatusBar.tsx`, `components/ExtensionWidgets.tsx`, `lib/transcript-notices*.ts`, `components/TranscriptNotices.tsx`, `app/api/cache-miss-notices/**`, `lib/tool-fields.ts`. +- [sessions.md](docs/agents/sessions.md): AgentSession lifecycle and shutdown, fork vs in-session branching, the sidebar's on-disk Fork, session file rewrites, toolCall normalization, a run's message keys across the file read, SSE reconnect and tool events, a tool card's flat arguments as rows, transcript system / usage / context-edit entries, running-state polling, custom-message collapsing, streamed chunks re-rendering only the reply, streaming markdown block by block, hand-written anchors in rendered Markdown landing after the sanitizer's id prefix, long thinking rendered in pieces, a finished turn's duration, history caches (tool-result images, subagent runs, session views) and deferred code highlighting with Prism languages loaded on demand, a patch shown in one column or two, the session sidebar (toolbar row and its brand, project groups, their order and display names, pins, archive, new-session project adoption, the files below the sessions or in a tab), the project/worktree bar above a fresh composer (the header: brand, then the bar) and what it carries across the remount, exported HTML, the extension status bar and its `command:` buttons, cache and cost notices (`showCacheMissNotices`), the currency display of those costs (`zh-CN` in yuan). Files: `lib/rpc-manager.ts`, `lib/session-reader.ts`, `lib/session-fork*.ts`, `lib/session-delete.ts`, `lib/normalize.ts`, `lib/message-keys.ts`, `lib/session-tree.ts`, `lib/session-ui-state*.ts`, `lib/sidebar-prefs.ts`, `lib/sidebar-actions.ts`, `lib/session-links.ts`, `lib/new-session-context.ts`, `hooks/useAgentSession.ts`, `hooks/useSessionUiState.ts`, `hooks/useSidebarMotion.ts`, `hooks/useGroupDrag.ts`, `hooks/useFilesPlacement.ts`, `hooks/useResizablePanel.ts`, `app/api/agent/**`, `app/api/sessions/**`, `components/SessionSidebar.tsx`, `components/PiWebTitle.tsx`, `components/SessionTree.tsx`, `components/SidebarMenu.tsx`, `components/SidebarToast.tsx`, `components/SidebarIcons.tsx`, `components/ProjectWorktreePicker.tsx`, `components/NewSessionContextBar.tsx`, `components/WorktreeCreateForm.tsx`, `handleNewSession`, `handleSelectSession` and the bar's handlers in `components/AppShell.tsx`, `components/BranchNavigator.tsx`, `components/MessageView.tsx`, `lib/message-display.ts`, `lib/patch.ts`, `components/FileViewer.tsx`, `components/MarkdownBody.tsx`, `lib/markdown.ts`, `lib/markdown-blocks.ts`, `lib/streaming-message.ts`, `hooks/useStreamingSelector.ts`, `lib/turn-duration.ts`, `lib/money.ts`, `components/MermaidBlock.tsx`, `lib/highlight-queue.ts`, `lib/prism-languages.ts`, `hooks/usePrismLanguages.ts`, `lib/session-view-cache.ts`, `components/CodemodeToolView.tsx`, `components/ExtensionStatusBar.tsx`, `components/ExtensionWidgets.tsx`, `lib/transcript-notices*.ts`, `components/TranscriptNotices.tsx`, `app/api/cache-miss-notices/**`, `lib/tool-fields.ts`. - [tools.md](docs/agents/tools.md): tool presets and Chat only, exact system prompts, tool exposure, the codemode / tool-search / mcp built-ins, the read-only MCP policy, the Code mode and PowerShell `defaultTools` switches. Files: `lib/tool-presets.ts`, `lib/tool-preset-preference.ts`, `lib/chat-only.ts`, `lib/exact-system-prompt.ts`, `lib/builtin-extensions.ts`, `lib/mcp-read-only-policy.ts`, `lib/codemode-settings.ts`, `lib/powershell-settings.ts`, `lib/global-settings-file.ts`, `app/api/agent/new/route.ts`, `app/api/tools/settings/route.ts`, tool selection in `lib/rpc-manager.ts`. - [mcp-runtime.md](docs/agents/mcp-runtime.md): the per-session MCP host (when servers register and connect, reported states, trust read on every sync, idle release); `/mcp` in the composer. Files: `lib/mcp-host.ts`, `lib/mcp-transport.ts`, `lib/mcp-status.ts`, `lib/mcp-command.ts`, `lib/mcp-config-key.ts`, MCP wiring in `lib/rpc-manager.ts` and `lib/builtin-extensions.ts`, `/mcp` handling in `hooks/useAgentSession.ts`. - [mcp-settings.md](docs/agents/mcp-settings.md): Settings › MCP reads without running anything, masking, the trust dialog's server list, row states, notices, Code mode choice, trust from Settings, Escape stacking, every `mcp.json` write and undo. Files: `app/api/mcp/route.ts`, `app/api/project-trust/route.ts`, `lib/mcp-config-read.ts`, `lib/mcp-config-file.ts`, `lib/mcp-override.ts`, `lib/mcp-undo.ts`, `lib/mcp-secrets.ts`, `lib/mcp-server-display.ts`, `lib/mcp-json-error.ts`, `lib/project-trust.ts`, `lib/regular-file.ts`, `lib/stacked-dialog.ts`, `lib/settings-navigation.ts`, `components/McpConfig.tsx`, `components/mcp-config-helpers.ts`, `components/ProjectTrustDialog.tsx`, `components/SettingsPanel.tsx`. diff --git a/components/AgentSessionPanel.tsx b/components/AgentSessionPanel.tsx index e7756215c3..a65cb3519f 100644 --- a/components/AgentSessionPanel.tsx +++ b/components/AgentSessionPanel.tsx @@ -5,6 +5,7 @@ import { useI18n } from "@/hooks/useI18n"; import { countRunningSubagents } from "@/lib/session-family"; import type { SessionUsageSummary } from "@/lib/session-stats"; import { formatActiveDuration, formatCompactCount, formatCostShort } from "@/lib/session-stats-format"; +import { formatMoney } from "@/lib/money"; import type { SessionInfo, SubagentSessionStatus } from "@/lib/types"; interface Props { @@ -76,14 +77,14 @@ function usageTooltip(usage: SessionUsageSummary, t: (key: string) => string, lo ...(tokens.cacheRead > 0 ? [`${t("session.cacheRead")}: ${tokens.cacheRead.toLocaleString(locale)}`] : []), ...(tokens.cacheWrite > 0 ? [`${t("session.cacheWrite")}: ${tokens.cacheWrite.toLocaleString(locale)}`] : []), `${t("session.total")}: ${tokens.total.toLocaleString(locale)}`, - ...(usage.cost > 0 ? [`${t("session.cost")}: $${usage.cost.toFixed(4)}`] : []), + ...(usage.cost > 0 ? [`${t("session.cost")}: ${formatMoney(usage.cost, locale, 4)}`] : []), `${t("session.toolCalls")}: ${usage.toolCalls.toLocaleString(locale)}`, ].join("\n"); } function UsageLine({ usage, style }: { usage: SessionUsageSummary; style?: React.CSSProperties }) { const { locale, t } = useI18n(); - const cost = formatCostShort(usage.cost); + const cost = formatCostShort(usage.cost, locale); return ( 0) tooltipParts.push(`cost: $${cost.toFixed(4)}`); + if (cost > 0) tooltipParts.push(`cost: ${formatMoney(cost, locale, 4)}`); } if (contextUsage?.contextWindow) { const percent = contextUsage.percent; @@ -2398,7 +2399,7 @@ export function AppShell() { ]; const ctx = contextUsage ?? sessionStats.contextUsage; const extraTokenRows = [ - ...(sessionStats.cost > 0 ? [[translate("session.cost"), `$${sessionStats.cost.toFixed(4)}`]] : []), + ...(sessionStats.cost > 0 ? [[translate("session.cost"), formatMoney(sessionStats.cost, locale, 4)]] : []), ...(ctx?.contextWindow ? [[translate("session.context"), `${ctx.percent !== null ? `${ctx.percent.toFixed(1)}%` : "?"} / ${formatCompactCount(ctx.contextWindow)}`]] : []), // Cache hit rate = cache reads / (input + cache writes + cache reads) — the denominator covers all input-class tokens. ...(sessionStats.tokens.cacheRead + sessionStats.tokens.cacheWrite > 0 && sessionStats.tokens.cacheRead + sessionStats.tokens.cacheWrite + sessionStats.tokens.input > 0 diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 788e10f4b3..244565154f 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -33,6 +33,7 @@ import type { AppUpdateResponse } from "@/lib/api-types"; import type { ToolEntry } from "@/lib/tool-presets"; import type { SettingsSection } from "@/lib/settings-navigation"; import { getTranscriptNotices, summarizeCacheMisses, type CacheMissSummary } from "@/lib/transcript-notices"; +import type { Locale } from "@/lib/i18n/types"; import { cacheMissSummaryText, TRANSCRIPT_NOTICE_WARNING_COLOR, TranscriptNoticeLines } from "@/components/TranscriptNotices"; import { findChatScrollAnchor, type ChatScrollPosition } from "@/lib/chat-scroll-position"; import { @@ -194,7 +195,7 @@ function getUserInputText(message: AgentMessage): string | null { return text.length > 0 ? text : null; } -function ProcessDetailsGroup({ messageCount, toolCallCount, durationMs, cacheMisses, defaultExpanded = false, reveal = false, children, t }: { messageCount: number; toolCallCount: number; durationMs?: number; cacheMisses?: CacheMissSummary; defaultExpanded?: boolean; reveal?: boolean; children: ReactNode; t: (key: string, params?: Record) => string }) { +function ProcessDetailsGroup({ messageCount, toolCallCount, durationMs, cacheMisses, defaultExpanded = false, reveal = false, children, t, locale }: { messageCount: number; toolCallCount: number; durationMs?: number; cacheMisses?: CacheMissSummary; defaultExpanded?: boolean; reveal?: boolean; children: ReactNode; t: (key: string, params?: Record) => string; locale: Locale }) { const [expanded, setExpanded] = useState(defaultExpanded); useLayoutEffect(() => { if (reveal) setExpanded(true); @@ -204,7 +205,7 @@ function ProcessDetailsGroup({ messageCount, toolCallCount, durationMs, cacheMis if (toolCallCount > 0) parts.push(`${toolCallCount} ${t(toolCallCount === 1 ? "chat.toolCall" : "chat.toolCalls")}`); if (durationMs !== undefined) parts.push(t("chat.turnDuration", { time: formatTurnDuration(durationMs) })); // Folded, the cache misses inside are added up here (#1265); open, each shows under its step. - const cacheMissText = cacheMisses && !(expanded || reveal) ? cacheMissSummaryText(t, cacheMisses) : undefined; + const cacheMissText = cacheMisses && !(expanded || reveal) ? cacheMissSummaryText(t, cacheMisses, locale) : undefined; return (
@@ -248,7 +249,7 @@ function ProcessDetailsGroup({ messageCount, toolCallCount, durationMs, cacheMis } export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initialScrollPosition, onScrollPositionChange, sessionRunning, newSessionCwd, newSessionDraftKey, newSessionContextBar, initialNewSessionChoices, onNewSessionChoicesChange, onAgentEnd, onToolEnd, onAttentionNeeded, onSessionCreated, onSessionForked, modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSystemToolsChange, onSystemInfoLoaderChange, onSessionStatsChange, onSessionStatsPanelOpen, onOpenSettings, onContextUsageChange, onOpenFile, onFilesUploaded, onOpenSubagent, onAskInNewChat, quoteSelectionEnabled = false, initialPrompt, onInitialPromptConsumed, soundEnabled = true, onSoundToggle, playDoneSound = () => {}, unlockAudio, holdAudio }: Props) { - const { t } = useI18n(); + const { locale, t } = useI18n(); const isMobile = useIsMobile(); const completionNotificationsEnabled = session?.relation?.kind !== "subagent"; @@ -1284,7 +1285,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}
, diff --git a/components/CodemodeToolView.tsx b/components/CodemodeToolView.tsx index 51fa7fa019..9a301893af 100644 --- a/components/CodemodeToolView.tsx +++ b/components/CodemodeToolView.tsx @@ -25,7 +25,7 @@ const STATUS_STYLE: Record }; function CallRow({ call }: { call: CodemodeCallView }) { - const { t } = useI18n(); + const { locale, t } = useI18n(); const status = STATUS_STYLE[call.status]; const duration = formatCodemodeDuration(call.durationMs); return ( @@ -49,7 +49,7 @@ function CallRow({ call }: { call: CodemodeCallView }) { {duration} )} {call.cost !== undefined && ( - {formatCodemodeCost(call.cost)} + {formatCodemodeCost(call.cost, locale)} )} {call.error && ( @@ -65,7 +65,7 @@ export function CodemodeCallList({ calls, omitted, isError }: { omitted: number; isError: boolean; }) { - const { t } = useI18n(); + const { locale, t } = useI18n(); const [showAll, setShowAll] = useState(false); if (calls.length === 0 && omitted === 0) return null; const hidden = showAll ? 0 : Math.max(0, calls.length - CODEMODE_VISIBLE_CALLS); @@ -106,7 +106,7 @@ export function CodemodeCallList({ calls, omitted, isError }: { ))} {totalCost !== null && ( -
{t("codemode.modelCost", { cost: formatCodemodeCost(totalCost) })}
+
{t("codemode.modelCost", { cost: formatCodemodeCost(totalCost, locale) })}
)} ); diff --git a/components/MessageView.tsx b/components/MessageView.tsx index 78ff10777b..8861fa7a96 100644 --- a/components/MessageView.tsx +++ b/components/MessageView.tsx @@ -26,6 +26,8 @@ import { toolFieldsOfInput, type ToolField } from "@/lib/tool-fields"; import { mcpToolLabel, prettyMcpResultText } from "@/lib/mcp-tool-display"; import { streamRateKey, streamRateStart, streamTokensPerSecond, type StreamRateStart } from "@/lib/stream-token-rate"; import { getTranscriptNotices } from "@/lib/transcript-notices"; +import { formatMoney } from "@/lib/money"; +import type { Locale } from "@/lib/i18n/types"; import { TRANSCRIPT_NOTICE_WARNING_COLOR, transcriptNoticeText } from "@/components/TranscriptNotices"; import type { AgentMessage, @@ -680,7 +682,7 @@ function AssistantMessageView({ isCompacting?: boolean; compactError?: string | null; }) { - const { t } = useI18n(); + const { locale, t } = useI18n(); const time = showTimestamp ? formatTime(message.timestamp) : null; const blockItems = useMemo(() => (message.content ?? []) .map((block, originalIndex) => ({ block, originalIndex })) @@ -946,7 +948,7 @@ function AssistantMessageView({ {(message.usage || turnDurationMs !== undefined) && !isStreaming && (
{[ - message.usage ? formatUsage(message.usage) : null, + message.usage ? formatUsage(message.usage, locale) : null, turnDurationMs !== undefined ? t("chat.turnDuration", { time: formatTurnDuration(turnDurationMs) }) : null, ].filter(Boolean).join(" \u00b7 ")}
@@ -1799,7 +1801,7 @@ function PairedResult({ text, isEmpty, isError }: { } function CompactionMessageView({ message }: { message: CustomMessage }) { - const { t } = useI18n(); + const { locale, t } = useI18n(); // Collapsed by default, as in pi's TUI: a summary can run to pages. const [expanded, setExpanded] = useState(false); const summary = getMessageText(message.content); @@ -1869,7 +1871,7 @@ function CompactionMessageView({ message }: { message: CustomMessage }) { )} {costNotice && ( - {transcriptNoticeText(t, costNotice)} + {transcriptNoticeText(t, costNotice, locale)} )}
@@ -2225,13 +2227,13 @@ function formatUsage(usage: { cacheRead: number; cacheWrite: number; cost: { total: number }; -}): string { +}, locale: Locale | string | undefined): string { const parts = []; if (usage.input) parts.push(`${usage.input.toLocaleString()} in`); if (usage.output) parts.push(`${usage.output.toLocaleString()} out`); if (usage.cacheRead) parts.push(`${usage.cacheRead.toLocaleString()} cache R`); if (usage.cacheWrite) parts.push(`${usage.cacheWrite.toLocaleString()} cache W`); - if (usage.cost?.total) parts.push(`$${usage.cost.total.toFixed(4)}`); + if (usage.cost?.total) parts.push(formatMoney(usage.cost.total, locale, 4)); return parts.join(" · "); } diff --git a/components/TranscriptNotices.tsx b/components/TranscriptNotices.tsx index db81aaa294..f1cec670ad 100644 --- a/components/TranscriptNotices.tsx +++ b/components/TranscriptNotices.tsx @@ -9,21 +9,22 @@ import { type CacheMissSummary, type TranscriptNotice, } from "@/lib/transcript-notices"; +import type { Locale } from "@/lib/i18n/types"; // pi's TUI draws these lines in its warning color, cache warming dim (#1265). export const TRANSCRIPT_NOTICE_WARNING_COLOR = "#d97706"; type Translate = (key: string, params?: Record) => string; -function costSuffix(t: Translate, cost: number): string { - const formatted = formatNoticeCost(cost); +function costSuffix(t: Translate, cost: number, locale: Locale | string | undefined): string { + const formatted = formatNoticeCost(cost, locale); return formatted ? t("transcriptNotice.costSuffix", { cost: formatted }) : ""; } -export function transcriptNoticeText(t: Translate, notice: TranscriptNotice): string { +export function transcriptNoticeText(t: Translate, notice: TranscriptNotice, locale: Locale | string | undefined): string { switch (notice.kind) { case "cache_miss": { - const params = { tokens: formatNoticeTokens(notice.missedTokens), cost: costSuffix(t, notice.missedCost) }; + const params = { tokens: formatNoticeTokens(notice.missedTokens), cost: costSuffix(t, notice.missedCost, locale) }; if (notice.modelChanged) return t("transcriptNotice.cacheMissModelSwitch", params); if (notice.idleMinutes !== undefined) return t("transcriptNotice.cacheMissIdle", { ...params, minutes: notice.idleMinutes }); return t("transcriptNotice.cacheMiss", params); @@ -31,12 +32,12 @@ export function transcriptNoticeText(t: Translate, notice: TranscriptNotice): st case "cache_warm": return t("transcriptNotice.cacheWarmed", { note: notice.note ? t("transcriptNotice.cacheWarmedNote", { note: notice.note }) : "", - cost: formatCacheWarmCost(notice.cost), + cost: formatCacheWarmCost(notice.cost, locale), }); case "summary_cost": return t(notice.source === "compaction" ? "transcriptNotice.compactionCost" : "transcriptNotice.branchSummaryCost", { tokens: formatNoticeTokens(notice.tokens), - cost: costSuffix(t, notice.cost), + cost: costSuffix(t, notice.cost, locale), }); case "thinking_dropped": return t(notice.count === 1 ? "transcriptNotice.thinkingDroppedOne" : "transcriptNotice.thinkingDropped", { count: notice.count }); @@ -44,18 +45,18 @@ export function transcriptNoticeText(t: Translate, notice: TranscriptNotice): st } /** The folded Process details header's part for the cache misses inside it. */ -export function cacheMissSummaryText(t: Translate, summary: CacheMissSummary): string { +export function cacheMissSummaryText(t: Translate, summary: CacheMissSummary, locale: Locale | string | undefined): string { const params = { count: summary.count, tokens: formatNoticeTokens(summary.missedTokens), - cost: costSuffix(t, summary.missedCost), + cost: costSuffix(t, summary.missedCost, locale), }; return t(summary.count === 1 ? "transcriptNotice.foldCacheMiss" : "transcriptNotice.foldCacheMisses", params); } /** One line per notice, under the message they follow. A compaction's cost is drawn in its card. */ export const TranscriptNoticeLines = memo(function TranscriptNoticeLines({ notices }: { notices: readonly TranscriptNotice[] }) { - const { t } = useI18n(); + const { locale, t } = useI18n(); const shown = notices.filter((notice) => !(notice.kind === "summary_cost" && notice.source === "compaction")); if (shown.length === 0) return null; return ( @@ -82,7 +83,7 @@ export const TranscriptNoticeLines = memo(function TranscriptNoticeLines({ notic )} - {transcriptNoticeText(t, notice)} + {transcriptNoticeText(t, notice, locale)} ); })} diff --git a/docs/agents/sessions.md b/docs/agents/sessions.md index e99fbb77b6..494a9db58a 100644 --- a/docs/agents/sessions.md +++ b/docs/agents/sessions.md @@ -90,6 +90,12 @@ A displayed custom message starts expanded: pi's TUI draws its default custom me - Live, the wrapper adds `transcriptNotices` beside an assistant `message_end` (never on the message: pi writes that object to the file), skipping the message if pi already appended it; `useAgentSession` hangs them on the message it appends. Cache warming and summary costs arrive with the reload after `agent_end` or the compaction; an idle-mode warming with no run shows on the next read. - `ChatWindow` draws each message's notices under it (`TranscriptNoticeLines`; amber, cache warming dim) from the stored message, not a turn view's copy, so the final answer's go under the answer and not again inside Process details. A step that shows nothing else keeps its notice lines, not counted as a message. Folded, Process details adds the cache misses inside it to its header (`summarizeCacheMisses()`); open, the header drops that part and each line shows under its step. A compaction's cost is a line in its card. +## Currency display (`lib/money.ts`) +- Costs are stored in US dollars everywhere — pi's `usage.cost`, the models.dev catalog and models.json's per-million prices — and only the display converts. `lib/money.ts` is the single place that does: `localAmount()`, `currencySymbol()`, `formatMoney()` and `formatMoneyFromCent()` read the locale, multiply by `USD_TO_CNY` for `zh-CN` and pass the amount through for `zh-TW` and `en`. The rate is hardcoded there with the date it was taken and its sources; moving it is a one-line edit, and no bill or export depends on it. +- Every money-rendering helper takes the locale as its last argument rather than reading context, so they stay pure: `formatCostShort()` (stats toolbar, agent switcher), `formatNoticeCost()` / `formatCacheWarmCost()` (cache and cost notices), `formatCodemodeCost()` (codemode card), `transcriptNoticeText()` / `cacheMissSummaryText()` and `ProcessDetailsGroup` (which passes `locale` down), and `formatMoney(…, 4)` in the tooltips of `AppShell`, `AgentSessionPanel` and `MessageView`'s usage line. Components hand in `useI18n().locale`; a test asserts both branches for each helper. +- The one-cent threshold is read in the displayed currency: convert first, then compare with `SMALLEST_UNIT`. `$0.002` therefore shows as `< $0.01` in English but as `¥0.01` in Simplified Chinese, and `<$0.01` becomes `<¥0.01`. +- Two amounts deliberately stay as stored: Settings › Models' price fields are models.json's USD unit prices (converting them would suggest the file holds yuan — the inputs write the raw value back), and `ProviderUsageSummary` shows a provider's real balance in its own currency (`lib/provider-usage.ts`'s `CURRENCY`, plus DeepSeek's per-account `CNY`/`USD`), which is not a converted figure. + ## Turn grouping and custom messages in the chat (`ChatWindow`, `lib/message-display.ts`) - A turn runs from one anchor (`isMessageGroupAnchor()`) to the next; its last assistant answer shows, everything before it folds into Process details. Anchors: user messages, the compaction summary, and background subagent completions, which arrive as a follow-up that triggers a new run after the answer: pi-web's `pi-web:subagent-notification` and `@tintinweb/pi-subagents`' bare `subagent-notification` (#891, #1075). Without the anchor, the reply to the notification becomes the turn's answer and the real one folds away. - A custom message without `display` (`display: false` keeps it for the model alone) stays in `messages` and `entryIds`, from the reader and from `message_end`, but renders nothing, as in pi's TUI: `isHiddenCustomMessage()` makes `renderMessage()` return null, keeps it out of the Process details count, and it never anchors a turn. An extension may send one on every `session_start`, i.e. on each idle restart (#1043). diff --git a/lib/codemode-view.test.mjs b/lib/codemode-view.test.mjs index 4bf62448eb..ba68096772 100644 --- a/lib/codemode-view.test.mjs +++ b/lib/codemode-view.test.mjs @@ -65,8 +65,12 @@ test("formats durations and costs as pi's TUI does", () => { assert.equal(formatCodemodeDuration(undefined), ""); assert.equal(formatCodemodeDuration(12.4), "12ms"); assert.equal(formatCodemodeDuration(1530), "1.5s"); - assert.equal(formatCodemodeCost(0.25), "$0.25"); - assert.equal(formatCodemodeCost(0.000123), "$0.00012"); + assert.equal(formatCodemodeCost(0.25, "en"), "$0.25"); + assert.equal(formatCodemodeCost(0.000123, "en"), "$0.00012"); + assert.equal(formatCodemodeCost(0.25, "zh-TW"), "$0.25"); + // Simplified Chinese converts first, then the same two-decimal / two-significant-digit rule applies. + assert.equal(formatCodemodeCost(0.25, "zh-CN"), "¥1.67"); + assert.equal(formatCodemodeCost(0.000123, "zh-CN"), "¥0.00082"); }); test("totals model costs only when more than one call reported one", () => { diff --git a/lib/codemode-view.ts b/lib/codemode-view.ts index fa02fed671..d0aa16d038 100644 --- a/lib/codemode-view.ts +++ b/lib/codemode-view.ts @@ -3,6 +3,9 @@ // mirror the SDK's `CodemodeToolDetails` / `CodemodeNestedCall`, read // defensively because a third-party tool may also be called `codemode`. +import { currencySymbol, localAmount } from "./money"; +import type { Locale } from "./i18n/types"; + export const CODEMODE_TOOL_NAME = "codemode"; export type CodemodeCallStatus = "running" | "ok" | "error" | "cancelled"; @@ -89,8 +92,9 @@ export function formatCodemodeDuration(ms: number | undefined): string { } /** Cents for larger amounts, two significant digits for the fractions of a cent classifier calls cost. */ -export function formatCodemodeCost(cost: number): string { - return `$${cost >= 0.01 ? cost.toFixed(2) : cost.toPrecision(2)}`; +export function formatCodemodeCost(cost: number, locale: Locale | string | undefined): string { + const amount = localAmount(cost, locale); + return `${currencySymbol(locale)}${amount >= 0.01 ? amount.toFixed(2) : amount.toPrecision(2)}`; } /** The total cost of the script's model calls, when more than one reported one. */ diff --git a/lib/money.test.mjs b/lib/money.test.mjs new file mode 100644 index 0000000000..539836f810 --- /dev/null +++ b/lib/money.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url); +const { + USD_TO_CNY, + SMALLEST_UNIT, + currencySymbol, + displaysCny, + formatMoney, + formatMoneyFromCent, + localAmount, +} = await jiti.import("./money.ts"); + +test("only Simplified Chinese converts dollar amounts into yuan", () => { + assert.equal(displaysCny("zh-CN"), true); + assert.equal(displaysCny("zh-TW"), false); + assert.equal(displaysCny("en"), false); + assert.equal(displaysCny(undefined), false); + assert.equal(USD_TO_CNY > 0, true); + assert.equal(SMALLEST_UNIT, 0.01); +}); + +test("converts and formats amounts with the locale's currency symbol", () => { + assert.equal(localAmount(2, "en"), 2); + assert.equal(localAmount(2, "zh-TW"), 2); + assert.equal(localAmount(2, "zh-CN"), 2 * USD_TO_CNY); + assert.equal(currencySymbol("zh-CN"), "¥"); + assert.equal(currencySymbol("zh-TW"), "$"); + assert.equal(currencySymbol(undefined), "$"); + assert.equal(formatMoney(0.5, "en", 2), "$0.50"); + assert.equal(formatMoney(0.5, "zh-TW", 2), "$0.50"); + assert.equal(formatMoney(0.5, "zh-CN", 2), `¥${(0.5 * USD_TO_CNY).toFixed(2)}`); + assert.equal(formatMoney(0.456123, "zh-CN", 4), `¥${(0.456123 * USD_TO_CNY).toFixed(4)}`); +}); + +test("shows nothing below zero and a floored unit below one cent", () => { + assert.equal(formatMoneyFromCent(0, "en"), null); + assert.equal(formatMoneyFromCent(-1, "zh-CN"), null); + assert.equal(formatMoneyFromCent(0.004, "en"), "<$0.01"); + assert.equal(formatMoneyFromCent(0.0001, "zh-CN"), "<¥0.01"); + // Converted, it reaches a cent: the threshold is read in the displayed currency, not in USD. + assert.equal(formatMoneyFromCent(0.002, "en"), "<$0.01"); + assert.equal(formatMoneyFromCent(0.002, "zh-CN"), "¥0.01"); + assert.equal(formatMoneyFromCent(0.5, "zh-CN"), `¥${(0.5 * USD_TO_CNY).toFixed(2)}`); +}); diff --git a/lib/money.ts b/lib/money.ts new file mode 100644 index 0000000000..e34b7dd2c0 --- /dev/null +++ b/lib/money.ts @@ -0,0 +1,52 @@ +// Client-safe money display. Amounts are always stored in US dollars (pi's +// `usage.cost` and the models.dev / models.json unit prices are USD); only the +// display converts. Simplified Chinese shows yuan, Traditional Chinese and +// English keep dollars. Nothing that is written back to a config file goes +// through here: models.json keeps its USD prices. + +import type { Locale } from "./i18n/types"; + +/** + * Hardcoded rate: 1 USD = 6.69 CNY. + * Taken on 2026-10-23 (exchangerate-api 6.689327, frankfurter 6.6743); this + * is the only place to edit when the rate moves. + */ +export const USD_TO_CNY = 6.69; + +/** The smallest currency unit: one cent (¥0.01 and $0.01). */ +export const SMALLEST_UNIT = 0.01; + +/** The one locale that converts dollars into yuan; every other keeps dollars. */ +const CNY_LOCALES: readonly string[] = ["zh-CN"]; + +/** Whether this UI language shows dollar amounts converted into yuan. */ +export function displaysCny(locale: Locale | string | undefined): boolean { + return locale !== undefined && CNY_LOCALES.includes(locale); +} + +/** A USD amount in the unit the locale displays. */ +export function localAmount(usd: number, locale: Locale | string | undefined): number { + return displaysCny(locale) ? usd * USD_TO_CNY : usd; +} + +/** The locale's currency symbol. */ +export function currencySymbol(locale: Locale | string | undefined): "¥" | "$" { + return displaysCny(locale) ? "¥" : "$"; +} + +/** An amount with a fixed number of decimals, such as ¥12.34 or $0.1234. */ +export function formatMoney(usd: number, locale: Locale | string | undefined, digits: number): string { + return `${currencySymbol(locale)}${localAmount(usd, locale).toFixed(digits)}`; +} + +/** + * An amount shown from one cent up: two decimals once it reaches a cent, + * `<¥0.01` below that, and null when nothing was billed. + */ +export function formatMoneyFromCent(usd: number, locale: Locale | string | undefined): string | null { + const amount = localAmount(usd, locale); + if (!(amount > 0)) return null; + return amount >= SMALLEST_UNIT + ? formatMoney(usd, locale, 2) + : `<${currencySymbol(locale)}${SMALLEST_UNIT.toFixed(2)}`; +} diff --git a/lib/session-stats-format.test.mjs b/lib/session-stats-format.test.mjs index aa55d14d4b..df489d8548 100644 --- a/lib/session-stats-format.test.mjs +++ b/lib/session-stats-format.test.mjs @@ -9,9 +9,13 @@ test("formats counts, costs and durations as the stats toolbar shows them", () = assert.equal(formatCompactCount(950), "950"); assert.equal(formatCompactCount(18_400), "18k"); assert.equal(formatCompactCount(1_240_000), "1.2M"); - assert.equal(formatCostShort(0), null); - assert.equal(formatCostShort(0.004), "<$0.01"); - assert.equal(formatCostShort(0.456), "$0.46"); + assert.equal(formatCostShort(0, "en"), null); + assert.equal(formatCostShort(0.004, "en"), "<$0.01"); + assert.equal(formatCostShort(0.456, "en"), "$0.46"); + // Simplified Chinese converts at the hardcoded rate; Traditional Chinese and English keep dollars. + assert.equal(formatCostShort(0.456, "zh-CN"), "¥3.05"); + assert.equal(formatCostShort(0.0001, "zh-CN"), "<¥0.01"); + assert.equal(formatCostShort(0.456, "zh-TW"), "$0.46"); assert.equal(formatActiveDuration(0), "0s"); assert.equal(formatActiveDuration(42_000), "42s"); assert.equal(formatActiveDuration(185_000), "3m 5s"); diff --git a/lib/session-stats-format.ts b/lib/session-stats-format.ts index 3e08c3f844..14d2187769 100644 --- a/lib/session-stats-format.ts +++ b/lib/session-stats-format.ts @@ -1,6 +1,9 @@ // Client-safe display of session usage: the toolbar's stats button and // popover, and the agent switcher's per-agent rows. +import { formatMoneyFromCent } from "./money"; +import type { Locale } from "./i18n/types"; + /** 950 · 18k · 1.2M */ export function formatCompactCount(value: number): string { if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; @@ -8,10 +11,9 @@ export function formatCompactCount(value: number): string { return String(value); } -/** $0.12 · <$0.01 · null when nothing was billed. */ -export function formatCostShort(cost: number): string | null { - if (!(cost > 0)) return null; - return cost >= 0.01 ? `$${cost.toFixed(2)}` : "<$0.01"; +/** $0.12 · <$0.01 (¥ in Simplified Chinese) · null when nothing was billed. */ +export function formatCostShort(cost: number, locale: Locale | string | undefined): string | null { + return formatMoneyFromCent(cost, locale); } /** 0s · 42s · 3m 5s · 1h 20m */ diff --git a/lib/transcript-notices.test.mjs b/lib/transcript-notices.test.mjs index 33b9f92035..02acc7174a 100644 --- a/lib/transcript-notices.test.mjs +++ b/lib/transcript-notices.test.mjs @@ -290,9 +290,13 @@ test("client helpers format as the TUI and add up a fold's misses", () => { for (const count of [0, 999, 1_000, 8_499, 9_999, 10_000, 85_312, 999_999, 1_000_000, 1_250_000, 12_000_000]) { assert.equal(formatNoticeTokens(count), sdkFooter.formatTokens(count)); } - assert.equal(formatCacheWarmCost(0.0123), "$0.0123"); - assert.equal(formatCacheWarmCost(0.5), "$0.500"); - assert.equal(formatCacheWarmCost(0.000123), "$0.000123"); + assert.equal(formatCacheWarmCost(0.0123, "en"), "$0.0123"); + assert.equal(formatCacheWarmCost(0.5, "en"), "$0.500"); + assert.equal(formatCacheWarmCost(0.000123, "en"), "$0.000123"); + assert.equal(formatCacheWarmCost(0.5, "zh-TW"), "$0.500"); + // Simplified Chinese converts first, then keeps the same six decimals and trailing-zero rule. + assert.equal(formatCacheWarmCost(0.5, "zh-CN"), "¥3.345"); + assert.equal(formatCacheWarmCost(0.0123, "zh-CN"), "¥0.082287"); const miss = (missedTokens, missedCost) => ({ transcriptNotices: [{ kind: "cache_miss", missedTokens, missedCost }] }); assert.equal(summarizeCacheMisses([{ role: "user" }]), undefined); diff --git a/lib/transcript-notices.ts b/lib/transcript-notices.ts index 465924b46d..9e3b1010d2 100644 --- a/lib/transcript-notices.ts +++ b/lib/transcript-notices.ts @@ -3,6 +3,9 @@ // (lib/transcript-notices-server.ts) and hangs them on the chat message they // follow; the chat renders them as one line each. +import { formatMoney, localAmount, SMALLEST_UNIT } from "./money"; +import type { Locale } from "./i18n/types"; + export type TranscriptNotice = /** An assistant response re-billed prompt tokens a cache should have served. */ | { kind: "cache_miss"; missedTokens: number; missedCost: number; idleMinutes?: number; modelChanged?: boolean } @@ -40,14 +43,14 @@ export function formatNoticeTokens(count: number): string { return `${Math.round(count / 1_000_000)}M`; } -/** The TUI's cost suffix: shown from a cent up, two decimals. */ -export function formatNoticeCost(cost: number): string | undefined { - return cost >= 0.01 ? `$${cost.toFixed(2)}` : undefined; +/** The TUI's cost suffix: shown from a cent up, two decimals (¥ instead of $ in Simplified Chinese). */ +export function formatNoticeCost(cost: number, locale: Locale | string | undefined): string | undefined { + return localAmount(cost, locale) >= SMALLEST_UNIT ? formatMoney(cost, locale, 2) : undefined; } /** The TUI's cache-warming cost: six decimals, trailing zeros past the third dropped. */ -export function formatCacheWarmCost(cost: number): string { - return `$${cost.toFixed(6).replace(/(\.\d{3}\d*?)0+$/, "$1")}`; +export function formatCacheWarmCost(cost: number, locale: Locale | string | undefined): string { + return formatMoney(cost, locale, 6).replace(/(\.\d{3}\d*?)0+$/, "$1"); } export interface CacheMissSummary {