Compare commits

...
18 changed files with 362 additions and 57 deletions
+77 -21
View File
@@ -65,6 +65,8 @@ interface MessageBubbleProps {
temporary?: boolean; temporary?: boolean;
/** When false, hide this message's copy button. Default true. */ /** When false, hide this message's copy button. Default true. */
showCopyAction?: boolean; showCopyAction?: boolean;
/** Show opt-in per-turn token usage for completed assistant messages. */
showTurnUsage?: boolean;
cliApps?: CliAppInfo[]; cliApps?: CliAppInfo[];
mcpPresets?: McpPresetInfo[]; mcpPresets?: McpPresetInfo[];
slashCommands?: SlashCommand[]; slashCommands?: SlashCommand[];
@@ -189,31 +191,65 @@ function compactDuration(milliseconds: number): string {
return `${minutes}m ${Math.round(seconds % 60)}s`; return `${minutes}m ${Math.round(seconds % 60)}s`;
} }
function TurnUsageMeta({ function TurnUsageMeta({ usage }: { usage: TurnUsage }) {
usage,
latencyMs,
}: {
usage: TurnUsage;
latencyMs?: number;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const context = usage.context_tokens;
const prompt = usage.prompt_tokens; const prompt = usage.prompt_tokens;
const completion = usage.completion_tokens; const completion = usage.completion_tokens;
const approximate = (usage.estimated_tokens ?? 0) > 0 ? "~" : ""; const approximate = (usage.estimated_tokens ?? 0) > 0 ? "~" : "";
const formatTokens = (tokens: number) => `${approximate}${formatCompactTokenCount(tokens)}`;
const parts: string[] = []; const parts: string[] = [];
if (typeof prompt === "number") parts.push(`${approximate}${formatCompactTokenCount(prompt)} in`); const details: string[] = [];
if (typeof completion === "number") parts.push(`${approximate}${formatCompactTokenCount(completion)} out`);
if (typeof context === "number" && context >= 0) {
parts.push(t("message.usage.context", {
tokens: formatTokens(context),
defaultValue: "{{tokens}} context",
}));
if (typeof prompt === "number") {
details.push(t("message.usage.turnInput", {
tokens: formatTokens(prompt),
defaultValue: "{{tokens}} turn input",
}));
}
} else if (typeof prompt === "number") {
parts.push(t("message.usage.turnInput", {
tokens: formatTokens(prompt),
defaultValue: "{{tokens}} turn input",
}));
}
if (typeof usage.request_count === "number" && usage.request_count > 0) {
const requestKey = usage.request_count === 1
? "message.usage.request"
: "message.usage.requests";
details.push(t(requestKey, {
count: usage.request_count,
defaultValue: usage.request_count === 1 ? "{{count}} request" : "{{count}} requests",
}));
}
if (typeof completion === "number") {
const output = t("message.usage.output", {
tokens: formatTokens(completion),
defaultValue: "{{tokens}} out",
});
if (parts.length > 0) details.push(output);
else parts.push(output);
}
if ( if (
typeof usage.cached_tokens === "number" typeof usage.cached_tokens === "number"
&& typeof prompt === "number" && typeof prompt === "number"
&& prompt > 0 && prompt > 0
) { ) {
parts.push(`${Math.round(Math.min(1, usage.cached_tokens / prompt) * 100)}% cached`); details.push(t("message.usage.cached", {
percent: Math.round(Math.min(1, usage.cached_tokens / prompt) * 100),
defaultValue: "{{percent}}% cached",
}));
} }
if (typeof latencyMs === "number" && latencyMs >= 0) parts.push(compactDuration(latencyMs));
if (parts.length === 0) return null;
const details: string[] = []; if (parts.length === 0) return null;
if (approximate) { if (approximate) {
details.push(t("message.usage.estimated", { defaultValue: "Includes estimated usage" })); details.push(t("message.usage.estimated", { defaultValue: "Includes estimated usage" }));
} }
@@ -246,6 +282,17 @@ function TurnUsageMeta({
); );
} }
function TurnLatencyMeta({ latencyMs }: { latencyMs: number }) {
return (
<span
data-turn-latency
className="text-[11px] leading-none text-muted-foreground/70 tabular-nums"
>
{compactDuration(latencyMs)}
</span>
);
}
function deliveryErrorCopy( function deliveryErrorCopy(
kind: MessageDeliveryErrorKind | undefined, kind: MessageDeliveryErrorKind | undefined,
t: (key: string) => string, t: (key: string) => string,
@@ -394,6 +441,7 @@ export function MessageBubble({
isTurnStreaming = false, isTurnStreaming = false,
temporary = false, temporary = false,
showCopyAction = true, showCopyAction = true,
showTurnUsage = false,
cliApps = [], cliApps = [],
mcpPresets = [], mcpPresets = [],
slashCommands = [], slashCommands = [],
@@ -549,9 +597,21 @@ export function MessageBubble({
&& (!empty || hasReasoning || media.length > 0); && (!empty || hasReasoning || media.length > 0);
const assistantTimestampTitle = showAssistantTimestamp ? fmtDateTime(assistantTimestamp) : ""; const assistantTimestampTitle = showAssistantTimestamp ? fmtDateTime(assistantTimestamp) : "";
const showAutomationTrigger = showAssistantTimestamp && automationSourceLabel.length > 0; const showAutomationTrigger = showAssistantTimestamp && automationSourceLabel.length > 0;
const showUsage = message.role === "assistant" && !!message.usage && !message.isStreaming; const showUsage = (
showTurnUsage
&& message.role === "assistant"
&& !!message.usage
&& !message.isStreaming
);
const showLatency = (
message.role === "assistant"
&& !!message.usage
&& !message.isStreaming
&& typeof message.latencyMs === "number"
&& message.latencyMs >= 0
);
const showAssistantFooterRow = const showAssistantFooterRow =
showCopyButton || showForkButton || showAssistantTimestamp || showUsage; showCopyButton || showForkButton || showAssistantTimestamp || showUsage || showLatency;
const showAssistantFooterSlot = const showAssistantFooterSlot =
message.role === "assistant" message.role === "assistant"
&& (!empty || hasReasoning || media.length > 0); && (!empty || hasReasoning || media.length > 0);
@@ -617,12 +677,8 @@ export function MessageBubble({
<TooltipContent side="top" align="center">{forkLabel}</TooltipContent> <TooltipContent side="top" align="center">{forkLabel}</TooltipContent>
</Tooltip> </Tooltip>
) : null} ) : null}
{showUsage ? ( {showUsage ? <TurnUsageMeta usage={message.usage!} /> : null}
<TurnUsageMeta {showLatency ? <TurnLatencyMeta latencyMs={message.latencyMs!} /> : null}
usage={message.usage!}
latencyMs={message.latencyMs}
/>
) : null}
{showAssistantTimestamp ? ( {showAssistantTimestamp ? (
<MessageTimestamp <MessageTimestamp
{...(showCompletedAt ? { "data-assistant-completed-at": true } : {})} {...(showCompletedAt ? { "data-assistant-completed-at": true } : {})}
@@ -407,6 +407,22 @@ export function AppearanceSettings({
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")} label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/> />
</SettingsRow> </SettingsRow>
<SettingsRow
title={tx("settings.rows.showTurnUsage", "Show per-turn token usage")}
description={tx(
"settings.help.showTurnUsage",
"Show context, aggregate input, output, cache rate, and model calls under assistant messages. Stored only in this browser.",
)}
>
<ToggleButton
checked={localPrefs.showTurnUsage}
onChange={(showTurnUsage) => (
onChangeLocalPrefs((prev) => ({ ...prev, showTurnUsage }))
)}
ariaLabel={tx("settings.rows.showTurnUsage", "Show per-turn token usage")}
label={localPrefs.showTurnUsage ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow <SettingsRow
title={tx("settings.rows.brandLogos", "Brand logos")} title={tx("settings.rows.brandLogos", "Brand logos")}
description={tx( description={tx(
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { MessageBubble } from "@/components/MessageBubble"; import { MessageBubble } from "@/components/MessageBubble";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster"; import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
import { AssistantSelectionAction } from "@/components/thread/AssistantSelectionAction"; import { AssistantSelectionAction } from "@/components/thread/AssistantSelectionAction";
import { useShowTurnUsage } from "@/hooks/useShowTurnUsage";
import { projectActivityTimeline, type TurnUnit } from "@/lib/activity-timeline"; import { projectActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types"; import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
@@ -67,6 +68,7 @@ export function ThreadMessages({
onQuoteSelection, onQuoteSelection,
}: ThreadMessagesProps) { }: ThreadMessagesProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const showTurnUsage = useShowTurnUsage();
const messageListRef = useRef<HTMLDivElement>(null); const messageListRef = useRef<HTMLDivElement>(null);
const units = useMemo( const units = useMemo(
() => buildDisplayUnits(messages, isStreaming, activeTurnId), () => buildDisplayUnits(messages, isStreaming, activeTurnId),
@@ -158,6 +160,7 @@ export function ThreadMessages({
showForkBoundary={index === forkBoundaryAfterUnitIndex} showForkBoundary={index === forkBoundaryAfterUnitIndex}
forkBoundaryLabel={t("thread.forkedFromHistory")} forkBoundaryLabel={t("thread.forkedFromHistory")}
temporary={temporary} temporary={temporary}
showTurnUsage={showTurnUsage}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
slashCommands={slashCommands} slashCommands={slashCommands}
@@ -239,6 +242,7 @@ interface ThreadDisplayUnitProps {
showForkBoundary: boolean; showForkBoundary: boolean;
forkBoundaryLabel: string; forkBoundaryLabel: string;
temporary: boolean; temporary: boolean;
showTurnUsage: boolean;
cliApps: CliAppInfo[]; cliApps: CliAppInfo[];
mcpPresets: McpPresetInfo[]; mcpPresets: McpPresetInfo[];
slashCommands: SlashCommand[]; slashCommands: SlashCommand[];
@@ -257,6 +261,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
showForkBoundary, showForkBoundary,
forkBoundaryLabel, forkBoundaryLabel,
temporary, temporary,
showTurnUsage,
cliApps, cliApps,
mcpPresets, mcpPresets,
slashCommands, slashCommands,
@@ -295,6 +300,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
message={unit.message} message={unit.message}
isTurnStreaming={isTurnStreaming} isTurnStreaming={isTurnStreaming}
temporary={temporary} temporary={temporary}
showTurnUsage={showTurnUsage}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
slashCommands={slashCommands} slashCommands={slashCommands}
@@ -323,6 +329,7 @@ function threadDisplayUnitPropsEqual(
&& previous.showForkBoundary === next.showForkBoundary && previous.showForkBoundary === next.showForkBoundary
&& previous.forkBoundaryLabel === next.forkBoundaryLabel && previous.forkBoundaryLabel === next.forkBoundaryLabel
&& previous.temporary === next.temporary && previous.temporary === next.temporary
&& previous.showTurnUsage === next.showTurnUsage
&& previous.cliApps === next.cliApps && previous.cliApps === next.cliApps
&& previous.mcpPresets === next.mcpPresets && previous.mcpPresets === next.mcpPresets
&& previous.slashCommands === next.slashCommands && previous.slashCommands === next.slashCommands
+33
View File
@@ -0,0 +1,33 @@
import { useEffect, useState } from "react";
import {
LOCAL_PREFS_CHANGED_EVENT,
readLocalPreferences,
type LocalPreferences,
} from "@/lib/local-preferences";
export function useShowTurnUsage(): boolean {
const [showTurnUsage, setShowTurnUsage] = useState(
() => readLocalPreferences().showTurnUsage,
);
useEffect(() => {
const refresh = () => setShowTurnUsage(readLocalPreferences().showTurnUsage);
const refreshFromLocalPreferenceEvent = (event: Event) => {
const detail = (event as CustomEvent<Partial<LocalPreferences> | undefined>).detail;
setShowTurnUsage(
detail ? detail.showTurnUsage === true : readLocalPreferences().showTurnUsage,
);
};
window.addEventListener("storage", refresh);
window.addEventListener("focus", refresh);
window.addEventListener(LOCAL_PREFS_CHANGED_EVENT, refreshFromLocalPreferenceEvent);
return () => {
window.removeEventListener("storage", refresh);
window.removeEventListener("focus", refresh);
window.removeEventListener(LOCAL_PREFS_CHANGED_EVENT, refreshFromLocalPreferenceEvent);
};
}, []);
return showTurnUsage;
}
+8 -2
View File
@@ -196,6 +196,7 @@
"density": "Density", "density": "Density",
"activityMode": "Activity detail", "activityMode": "Activity detail",
"fileEditDisplay": "File edit display", "fileEditDisplay": "File edit display",
"showTurnUsage": "Show per-turn token usage",
"codeWrap": "Code wrapping", "codeWrap": "Code wrapping",
"brandLogos": "Brand logos", "brandLogos": "Brand logos",
"maxResults": "Max results", "maxResults": "Max results",
@@ -241,6 +242,7 @@
"density": "Stored only in this browser.", "density": "Stored only in this browser.",
"activityMode": "Choose how much agent activity chrome to show by default.", "activityMode": "Choose how much agent activity chrome to show by default.",
"fileEditDisplay": "Choose whether file edit activity opens as line counts or a diff.", "fileEditDisplay": "Choose whether file edit activity opens as line counts or a diff.",
"showTurnUsage": "Show context, aggregate input, output, cache rate, and model calls under assistant messages. Stored only in this browser.",
"codeWrap": "Keep long code lines readable on smaller screens.", "codeWrap": "Keep long code lines readable on smaller screens.",
"brandLogos": "Show third-party provider and CLI logos in Settings.", "brandLogos": "Show third-party provider and CLI logos in Settings.",
"maxResults": "Results returned by each web_search call.", "maxResults": "Results returned by each web_search call.",
@@ -1431,8 +1433,12 @@
"fileEditOpenFile": "Open file", "fileEditOpenFile": "Open file",
"fileEditDiffTruncated": "Diff truncated. Open the file for the full change.", "fileEditDiffTruncated": "Diff truncated. Open the file for the full change.",
"usage": { "usage": {
"context": "Context now: {{tokens}}{{capacity}}", "context": "{{tokens}} context",
"requests": "{{count}} calls this turn", "turnInput": "{{tokens}} turn input",
"request": "{{count}} request",
"requests": "{{count}} requests",
"output": "{{tokens}} out",
"cached": "{{percent}}% cached",
"estimated": "Includes estimated usage" "estimated": "Includes estimated usage"
} }
}, },
+8 -2
View File
@@ -142,6 +142,7 @@
"density": "Densidad", "density": "Densidad",
"activityMode": "Detalle de actividad", "activityMode": "Detalle de actividad",
"fileEditDisplay": "Vista de edición de archivos", "fileEditDisplay": "Vista de edición de archivos",
"showTurnUsage": "Mostrar uso de tokens por turno",
"codeWrap": "Ajuste de código", "codeWrap": "Ajuste de código",
"maxResults": "Resultados máximos", "maxResults": "Resultados máximos",
"timeout": "Tiempo de espera", "timeout": "Tiempo de espera",
@@ -185,6 +186,7 @@
"density": "Solo se guarda en este navegador.", "density": "Solo se guarda en este navegador.",
"activityMode": "Elige cuánto detalle de actividad del agente se muestra por defecto.", "activityMode": "Elige cuánto detalle de actividad del agente se muestra por defecto.",
"fileEditDisplay": "Elige si la actividad de edición muestra recuentos de líneas o diferencias.", "fileEditDisplay": "Elige si la actividad de edición muestra recuentos de líneas o diferencias.",
"showTurnUsage": "Muestra el contexto, la entrada acumulada, la salida, la tasa de caché y las llamadas al modelo debajo de los mensajes del asistente. Solo se guarda en este navegador.",
"codeWrap": "Mantiene legibles las líneas largas de código en pantallas pequeñas.", "codeWrap": "Mantiene legibles las líneas largas de código en pantallas pequeñas.",
"maxResults": "Resultados devueltos por cada llamada web_search.", "maxResults": "Resultados devueltos por cada llamada web_search.",
"timeout": "Segundos antes de que una solicitud de búsqueda expire.", "timeout": "Segundos antes de que una solicitud de búsqueda expire.",
@@ -1401,8 +1403,12 @@
"fileEditOpenFile": "Abrir archivo", "fileEditOpenFile": "Abrir archivo",
"fileEditDiffTruncated": "Diferencias truncadas. Abre el archivo para ver el cambio completo.", "fileEditDiffTruncated": "Diferencias truncadas. Abre el archivo para ver el cambio completo.",
"usage": { "usage": {
"context": "Contexto más reciente: {{tokens}}{{capacity}} tokens", "context": "{{tokens}} de contexto",
"requests": "{{count}} solicitudes al modelo", "turnInput": "{{tokens}} de entrada en el turno",
"request": "{{count}} solicitud",
"requests": "{{count}} solicitudes",
"output": "{{tokens}} de salida",
"cached": "{{percent}}% en caché",
"estimated": "Incluye uso estimado" "estimated": "Incluye uso estimado"
}, },
"activityThinkingFor": "Pensando durante {{duration}}", "activityThinkingFor": "Pensando durante {{duration}}",
+8 -2
View File
@@ -142,6 +142,7 @@
"density": "Densité", "density": "Densité",
"activityMode": "Détail dactivité", "activityMode": "Détail dactivité",
"fileEditDisplay": "Affichage des modifications de fichiers", "fileEditDisplay": "Affichage des modifications de fichiers",
"showTurnUsage": "Afficher lutilisation des tokens par tour",
"codeWrap": "Retour à la ligne du code", "codeWrap": "Retour à la ligne du code",
"maxResults": "Résultats max.", "maxResults": "Résultats max.",
"timeout": "Délai dattente", "timeout": "Délai dattente",
@@ -185,6 +186,7 @@
"density": "Enregistré seulement dans ce navigateur.", "density": "Enregistré seulement dans ce navigateur.",
"activityMode": "Choisissez le niveau de détail de lactivité de lagent affiché par défaut.", "activityMode": "Choisissez le niveau de détail de lactivité de lagent affiché par défaut.",
"fileEditDisplay": "Choisissez si lactivité de modification affiche le nombre de lignes ou les différences.", "fileEditDisplay": "Choisissez si lactivité de modification affiche le nombre de lignes ou les différences.",
"showTurnUsage": "Affiche le contexte, lentrée cumulée, la sortie, le taux de cache et le nombre dappels au modèle sous les messages de lassistant. Enregistré uniquement dans ce navigateur.",
"codeWrap": "Garde les longues lignes de code lisibles sur les petits écrans.", "codeWrap": "Garde les longues lignes de code lisibles sur les petits écrans.",
"maxResults": "Résultats renvoyés par chaque appel web_search.", "maxResults": "Résultats renvoyés par chaque appel web_search.",
"timeout": "Nombre de secondes avant lexpiration dune requête de recherche.", "timeout": "Nombre de secondes avant lexpiration dune requête de recherche.",
@@ -1400,8 +1402,12 @@
"fileEditOpenFile": "Ouvrir le fichier", "fileEditOpenFile": "Ouvrir le fichier",
"fileEditDiffTruncated": "Différences tronquées. Ouvrez le fichier pour voir la modification complète.", "fileEditDiffTruncated": "Différences tronquées. Ouvrez le fichier pour voir la modification complète.",
"usage": { "usage": {
"context": "Dernier contexte : {{tokens}}{{capacity}} tokens", "context": "{{tokens}} de contexte",
"requests": "{{count}} requêtes au modèle", "turnInput": "{{tokens}} en entrée sur ce tour",
"request": "{{count}} requête",
"requests": "{{count}} requêtes",
"output": "{{tokens}} en sortie",
"cached": "{{percent}} % en cache",
"estimated": "Inclut une estimation de lutilisation" "estimated": "Inclut une estimation de lutilisation"
}, },
"activityThinkingFor": "Réflexion pendant {{duration}}", "activityThinkingFor": "Réflexion pendant {{duration}}",
+8 -2
View File
@@ -142,6 +142,7 @@
"density": "Kerapatan", "density": "Kerapatan",
"activityMode": "Detail aktivitas", "activityMode": "Detail aktivitas",
"fileEditDisplay": "Tampilan perubahan file", "fileEditDisplay": "Tampilan perubahan file",
"showTurnUsage": "Tampilkan penggunaan token per giliran",
"codeWrap": "Bungkus kode", "codeWrap": "Bungkus kode",
"maxResults": "Hasil maksimum", "maxResults": "Hasil maksimum",
"timeout": "Batas waktu", "timeout": "Batas waktu",
@@ -185,6 +186,7 @@
"density": "Hanya disimpan di browser ini.", "density": "Hanya disimpan di browser ini.",
"activityMode": "Pilih seberapa banyak detail aktivitas agen yang ditampilkan secara default.", "activityMode": "Pilih seberapa banyak detail aktivitas agen yang ditampilkan secara default.",
"fileEditDisplay": "Pilih apakah aktivitas perubahan file ditampilkan sebagai jumlah baris atau perbedaan.", "fileEditDisplay": "Pilih apakah aktivitas perubahan file ditampilkan sebagai jumlah baris atau perbedaan.",
"showTurnUsage": "Tampilkan konteks, input kumulatif, output, rasio cache, dan jumlah panggilan model di bawah pesan asisten. Hanya disimpan di browser ini.",
"codeWrap": "Menjaga baris kode panjang tetap terbaca di layar kecil.", "codeWrap": "Menjaga baris kode panjang tetap terbaca di layar kecil.",
"maxResults": "Hasil yang dikembalikan oleh setiap panggilan web_search.", "maxResults": "Hasil yang dikembalikan oleh setiap panggilan web_search.",
"timeout": "Detik sebelum permintaan penyedia pencarian mencapai batas waktu.", "timeout": "Detik sebelum permintaan penyedia pencarian mencapai batas waktu.",
@@ -1400,8 +1402,12 @@
"fileEditOpenFile": "Buka file", "fileEditOpenFile": "Buka file",
"fileEditDiffTruncated": "Perbedaan dipotong. Buka file untuk melihat perubahan lengkap.", "fileEditDiffTruncated": "Perbedaan dipotong. Buka file untuk melihat perubahan lengkap.",
"usage": { "usage": {
"context": "Konteks terbaru: {{tokens}}{{capacity}} token", "context": "Konteks {{tokens}}",
"requests": "{{count}} permintaan model", "turnInput": "Input giliran {{tokens}}",
"request": "{{count}} permintaan",
"requests": "{{count}} permintaan",
"output": "Output {{tokens}}",
"cached": "{{percent}}% di-cache",
"estimated": "Termasuk penggunaan perkiraan" "estimated": "Termasuk penggunaan perkiraan"
}, },
"activityThinkingFor": "Berpikir selama {{duration}}", "activityThinkingFor": "Berpikir selama {{duration}}",
+8 -2
View File
@@ -142,6 +142,7 @@
"density": "表示密度", "density": "表示密度",
"activityMode": "アクティビティ詳細", "activityMode": "アクティビティ詳細",
"fileEditDisplay": "ファイル編集表示", "fileEditDisplay": "ファイル編集表示",
"showTurnUsage": "ターンごとのトークン使用量を表示",
"codeWrap": "コードの折り返し", "codeWrap": "コードの折り返し",
"maxResults": "最大結果数", "maxResults": "最大結果数",
"timeout": "タイムアウト", "timeout": "タイムアウト",
@@ -185,6 +186,7 @@
"density": "このブラウザーにのみ保存されます。", "density": "このブラウザーにのみ保存されます。",
"activityMode": "既定で表示するエージェントアクティビティの詳細量を選択します。", "activityMode": "既定で表示するエージェントアクティビティの詳細量を選択します。",
"fileEditDisplay": "ファイル編集アクティビティを行数または差分で表示します。", "fileEditDisplay": "ファイル編集アクティビティを行数または差分で表示します。",
"showTurnUsage": "アシスタントのメッセージの下に、コンテキスト、累積入力、出力、キャッシュ率、モデル呼び出し回数を表示します。このブラウザーにのみ保存されます。",
"codeWrap": "小さな画面でも長いコード行を読みやすくします。", "codeWrap": "小さな画面でも長いコード行を読みやすくします。",
"maxResults": "各 web_search 呼び出しで返す結果数です。", "maxResults": "各 web_search 呼び出しで返す結果数です。",
"timeout": "検索プロバイダーのリクエストがタイムアウトするまでの秒数です。", "timeout": "検索プロバイダーのリクエストがタイムアウトするまでの秒数です。",
@@ -1400,8 +1402,12 @@
"fileEditOpenFile": "ファイルを開く", "fileEditOpenFile": "ファイルを開く",
"fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。", "fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。",
"usage": { "usage": {
"context": "最新のコンテキスト: {{tokens}}{{capacity}} トークン", "context": "コンテキスト {{tokens}}",
"requests": "モデルリクエスト {{count}} 回", "turnInput": "ターン入力 {{tokens}}",
"request": "リクエスト {{count}} 回",
"requests": "リクエスト {{count}} 回",
"output": "出力 {{tokens}}",
"cached": "キャッシュ {{percent}}%",
"estimated": "推定使用量を含みます" "estimated": "推定使用量を含みます"
}, },
"activityThinkingFor": "{{duration}}考えています", "activityThinkingFor": "{{duration}}考えています",
+8 -2
View File
@@ -142,6 +142,7 @@
"density": "밀도", "density": "밀도",
"activityMode": "활동 상세", "activityMode": "활동 상세",
"fileEditDisplay": "파일 편집 표시", "fileEditDisplay": "파일 편집 표시",
"showTurnUsage": "턴별 토큰 사용량 표시",
"codeWrap": "코드 줄바꿈", "codeWrap": "코드 줄바꿈",
"maxResults": "최대 결과 수", "maxResults": "최대 결과 수",
"timeout": "타임아웃", "timeout": "타임아웃",
@@ -185,6 +186,7 @@
"density": "이 브라우저에만 저장됩니다.", "density": "이 브라우저에만 저장됩니다.",
"activityMode": "기본으로 표시할 에이전트 활동 세부 수준을 선택합니다.", "activityMode": "기본으로 표시할 에이전트 활동 세부 수준을 선택합니다.",
"fileEditDisplay": "파일 편집 활동을 줄 수 또는 변경 사항으로 표시할지 선택합니다.", "fileEditDisplay": "파일 편집 활동을 줄 수 또는 변경 사항으로 표시할지 선택합니다.",
"showTurnUsage": "어시스턴트 메시지 아래에 컨텍스트, 누적 입력, 출력, 캐시 비율, 모델 호출 횟수를 표시합니다. 이 브라우저에만 저장됩니다.",
"codeWrap": "작은 화면에서도 긴 코드 줄을 읽기 쉽게 유지합니다.", "codeWrap": "작은 화면에서도 긴 코드 줄을 읽기 쉽게 유지합니다.",
"maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.", "maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.",
"timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.", "timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.",
@@ -1400,8 +1402,12 @@
"fileEditOpenFile": "파일 열기", "fileEditOpenFile": "파일 열기",
"fileEditDiffTruncated": "변경 사항이 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.", "fileEditDiffTruncated": "변경 사항이 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.",
"usage": { "usage": {
"context": "최근 컨텍스트: {{tokens}}{{capacity}} 토큰", "context": "컨텍스트 {{tokens}}",
"requests": "모델 요청 {{count}}", "turnInput": "턴 입력 {{tokens}}",
"request": "요청 {{count}}회",
"requests": "요청 {{count}}회",
"output": "출력 {{tokens}}",
"cached": "캐시됨 {{percent}}%",
"estimated": "예상 사용량 포함" "estimated": "예상 사용량 포함"
}, },
"activityThinkingFor": "{{duration}} 동안 생각 중", "activityThinkingFor": "{{duration}} 동안 생각 중",
+8 -2
View File
@@ -196,6 +196,7 @@
"density": "Densidade", "density": "Densidade",
"activityMode": "Detalhe de atividade", "activityMode": "Detalhe de atividade",
"fileEditDisplay": "Exibição de edição de arquivo", "fileEditDisplay": "Exibição de edição de arquivo",
"showTurnUsage": "Mostrar uso de tokens por turno",
"codeWrap": "Quebra de linha no código", "codeWrap": "Quebra de linha no código",
"brandLogos": "Logos de marca", "brandLogos": "Logos de marca",
"maxResults": "Máx. de resultados", "maxResults": "Máx. de resultados",
@@ -241,6 +242,7 @@
"density": "Armazenado apenas neste navegador.", "density": "Armazenado apenas neste navegador.",
"activityMode": "Escolha quanto detalhe de atividade do agente é exibido por padrão.", "activityMode": "Escolha quanto detalhe de atividade do agente é exibido por padrão.",
"fileEditDisplay": "Escolha se a atividade de edição de arquivo é exibida como contagem de linhas ou como diferenças.", "fileEditDisplay": "Escolha se a atividade de edição de arquivo é exibida como contagem de linhas ou como diferenças.",
"showTurnUsage": "Mostra contexto, entrada acumulada, saída, taxa de cache e chamadas do modelo abaixo das mensagens do assistente. Salvo somente neste navegador.",
"codeWrap": "Mantém linhas longas de código legíveis em telas menores.", "codeWrap": "Mantém linhas longas de código legíveis em telas menores.",
"brandLogos": "Mostra logotipos de provedores terceiros e de CLIs em Configurações.", "brandLogos": "Mostra logotipos de provedores terceiros e de CLIs em Configurações.",
"maxResults": "Resultados retornados por cada chamada de web_search.", "maxResults": "Resultados retornados por cada chamada de web_search.",
@@ -1431,8 +1433,12 @@
"fileEditOpenFile": "Abrir arquivo", "fileEditOpenFile": "Abrir arquivo",
"fileEditDiffTruncated": "Diferenças truncadas. Abra o arquivo para ver a alteração completa.", "fileEditDiffTruncated": "Diferenças truncadas. Abra o arquivo para ver a alteração completa.",
"usage": { "usage": {
"context": "Contexto atual: {{tokens}}{{capacity}}", "context": "{{tokens}} de contexto",
"requests": "{{count}} chamadas nesta rodada", "turnInput": "{{tokens}} de entrada na rodada",
"request": "{{count}} chamada",
"requests": "{{count}} chamadas",
"output": "{{tokens}} de saída",
"cached": "{{percent}}% em cache",
"estimated": "Inclui uso estimado" "estimated": "Inclui uso estimado"
} }
}, },
+8 -2
View File
@@ -142,6 +142,7 @@
"density": "Mật độ", "density": "Mật độ",
"activityMode": "Chi tiết hoạt động", "activityMode": "Chi tiết hoạt động",
"fileEditDisplay": "Hiển thị sửa tệp", "fileEditDisplay": "Hiển thị sửa tệp",
"showTurnUsage": "Hiển thị mức dùng token theo lượt",
"codeWrap": "Xuống dòng mã", "codeWrap": "Xuống dòng mã",
"maxResults": "Kết quả tối đa", "maxResults": "Kết quả tối đa",
"timeout": "Thời gian chờ", "timeout": "Thời gian chờ",
@@ -185,6 +186,7 @@
"density": "Chỉ lưu trong trình duyệt này.", "density": "Chỉ lưu trong trình duyệt này.",
"activityMode": "Chọn mức chi tiết hoạt động của tác nhân hiển thị mặc định.", "activityMode": "Chọn mức chi tiết hoạt động của tác nhân hiển thị mặc định.",
"fileEditDisplay": "Chọn hoạt động sửa tệp hiển thị số dòng hay khác biệt.", "fileEditDisplay": "Chọn hoạt động sửa tệp hiển thị số dòng hay khác biệt.",
"showTurnUsage": "Hiển thị ngữ cảnh, đầu vào tích lũy, đầu ra, tỷ lệ bộ nhớ đệm và số lần gọi mô hình bên dưới tin nhắn của trợ lý. Chỉ được lưu trong trình duyệt này.",
"codeWrap": "Giữ các dòng mã dài dễ đọc trên màn hình nhỏ.", "codeWrap": "Giữ các dòng mã dài dễ đọc trên màn hình nhỏ.",
"maxResults": "Số kết quả được trả về sau mỗi lần gọi web_search.", "maxResults": "Số kết quả được trả về sau mỗi lần gọi web_search.",
"timeout": "Số giây trước khi yêu cầu của nhà cung cấp tìm kiếm hết thời gian.", "timeout": "Số giây trước khi yêu cầu của nhà cung cấp tìm kiếm hết thời gian.",
@@ -1400,8 +1402,12 @@
"fileEditOpenFile": "Mở tệp", "fileEditOpenFile": "Mở tệp",
"fileEditDiffTruncated": "Khác biệt đã bị cắt bớt. Mở tệp để xem toàn bộ thay đổi.", "fileEditDiffTruncated": "Khác biệt đã bị cắt bớt. Mở tệp để xem toàn bộ thay đổi.",
"usage": { "usage": {
"context": "Ngữ cảnh gần nhất: {{tokens}}{{capacity}} token", "context": "Ngữ cảnh {{tokens}}",
"requests": "{{count}} yêu cầu mô hình", "turnInput": "Đầu vào lượt {{tokens}}",
"request": "{{count}} yêu cầu",
"requests": "{{count}} yêu cầu",
"output": "Đầu ra {{tokens}}",
"cached": "{{percent}}% đã lưu đệm",
"estimated": "Bao gồm mức sử dụng ước tính" "estimated": "Bao gồm mức sử dụng ước tính"
}, },
"activityThinkingFor": "Đang suy nghĩ trong {{duration}}", "activityThinkingFor": "Đang suy nghĩ trong {{duration}}",
+8 -2
View File
@@ -196,6 +196,7 @@
"density": "密度", "density": "密度",
"activityMode": "活动详情", "activityMode": "活动详情",
"fileEditDisplay": "文件编辑显示", "fileEditDisplay": "文件编辑显示",
"showTurnUsage": "显示每轮 Token 用量",
"codeWrap": "代码换行", "codeWrap": "代码换行",
"brandLogos": "品牌 Logo", "brandLogos": "品牌 Logo",
"maxResults": "最大结果数", "maxResults": "最大结果数",
@@ -241,6 +242,7 @@
"density": "只保存在此浏览器中。", "density": "只保存在此浏览器中。",
"activityMode": "选择默认显示多少智能体活动详情。", "activityMode": "选择默认显示多少智能体活动详情。",
"fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。", "fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。",
"showTurnUsage": "在助手消息下显示上下文、累计输入、输出、缓存率和模型调用次数;仅保存在此浏览器。",
"codeWrap": "让长代码行在小屏幕上也易读。", "codeWrap": "让长代码行在小屏幕上也易读。",
"brandLogos": "在设置中显示第三方提供商和 CLI 图标。", "brandLogos": "在设置中显示第三方提供商和 CLI 图标。",
"maxResults": "每次 web_search 调用返回的结果数。", "maxResults": "每次 web_search 调用返回的结果数。",
@@ -1431,8 +1433,12 @@
"fileEditOpenFile": "打开文件", "fileEditOpenFile": "打开文件",
"fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。", "fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。",
"usage": { "usage": {
"context": "当前上下文{{tokens}}{{capacity}}", "context": "上下文 {{tokens}}",
"requests": "本轮 {{count}} 次模型调用", "turnInput": "本轮输入 {{tokens}}",
"request": "{{count}} 次模型调用",
"requests": "{{count}} 次模型调用",
"output": "输出 {{tokens}}",
"cached": "缓存 {{percent}}%",
"estimated": "包含估算用量" "estimated": "包含估算用量"
} }
}, },
+7 -1
View File
@@ -142,6 +142,7 @@
"density": "密度", "density": "密度",
"activityMode": "活動細節", "activityMode": "活動細節",
"fileEditDisplay": "檔案編輯顯示", "fileEditDisplay": "檔案編輯顯示",
"showTurnUsage": "顯示每輪 Token 用量",
"codeWrap": "程式碼換行", "codeWrap": "程式碼換行",
"maxResults": "最大結果數", "maxResults": "最大結果數",
"timeout": "逾時", "timeout": "逾時",
@@ -185,6 +186,7 @@
"density": "只儲存在此瀏覽器中。", "density": "只儲存在此瀏覽器中。",
"activityMode": "選擇預設顯示多少智能體活動細節。", "activityMode": "選擇預設顯示多少智能體活動細節。",
"fileEditDisplay": "選擇檔案編輯活動預設顯示行數或差異。", "fileEditDisplay": "選擇檔案編輯活動預設顯示行數或差異。",
"showTurnUsage": "在助理訊息下顯示上下文、累計輸入、輸出、快取率和模型呼叫次數;僅儲存在此瀏覽器。",
"codeWrap": "讓長程式碼行在小螢幕上也易讀。", "codeWrap": "讓長程式碼行在小螢幕上也易讀。",
"maxResults": "每次呼叫 web_search 所回傳的結果數。", "maxResults": "每次呼叫 web_search 所回傳的結果數。",
"timeout": "搜尋供應商請求逾時前的秒數。", "timeout": "搜尋供應商請求逾時前的秒數。",
@@ -1399,8 +1401,12 @@
"fileEditOpenFile": "開啟檔案", "fileEditOpenFile": "開啟檔案",
"fileEditDiffTruncated": "差異內容已截斷。請開啟檔案以檢視完整變更。", "fileEditDiffTruncated": "差異內容已截斷。請開啟檔案以檢視完整變更。",
"usage": { "usage": {
"context": "最近一次上下文{{tokens}}{{capacity}} tokens", "context": "上下文 {{tokens}}",
"turnInput": "本輪輸入 {{tokens}}",
"request": "{{count}} 次模型請求",
"requests": "{{count}} 次模型請求", "requests": "{{count}} 次模型請求",
"output": "輸出 {{tokens}}",
"cached": "快取 {{percent}}%",
"estimated": "包含估算用量" "estimated": "包含估算用量"
}, },
"activityThinkingFor": "思考中,已 {{duration}}", "activityThinkingFor": "思考中,已 {{duration}}",
+3
View File
@@ -8,6 +8,7 @@ export interface LocalPreferences {
codeWrap: boolean; codeWrap: boolean;
brandLogos: boolean; brandLogos: boolean;
fileEditDisplayMode: FileEditDisplayMode; fileEditDisplayMode: FileEditDisplayMode;
showTurnUsage: boolean;
} }
export const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences"; export const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences";
@@ -19,6 +20,7 @@ export const DEFAULT_LOCAL_PREFS: LocalPreferences = {
codeWrap: true, codeWrap: true,
brandLogos: false, brandLogos: false,
fileEditDisplayMode: "summary", fileEditDisplayMode: "summary",
showTurnUsage: false,
}; };
export function normalizeFileEditDisplayMode(value: unknown): FileEditDisplayMode { export function normalizeFileEditDisplayMode(value: unknown): FileEditDisplayMode {
@@ -36,6 +38,7 @@ export function readLocalPreferences(): LocalPreferences {
codeWrap: parsed.codeWrap !== false, codeWrap: parsed.codeWrap !== false,
brandLogos: parsed.brandLogos === true, brandLogos: parsed.brandLogos === true,
fileEditDisplayMode: normalizeFileEditDisplayMode(parsed.fileEditDisplayMode), fileEditDisplayMode: normalizeFileEditDisplayMode(parsed.fileEditDisplayMode),
showTurnUsage: parsed.showTurnUsage === true,
}; };
} catch { } catch {
return DEFAULT_LOCAL_PREFS; return DEFAULT_LOCAL_PREFS;
+9
View File
@@ -160,6 +160,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.rows.activityMode", "settings.rows.activityMode",
"settings.rows.fileEditDisplay", "settings.rows.fileEditDisplay",
"settings.rows.codeWrap", "settings.rows.codeWrap",
"settings.rows.showTurnUsage",
"settings.rows.brandLogos", "settings.rows.brandLogos",
"settings.rows.currentModel", "settings.rows.currentModel",
"settings.rows.localServiceAccess", "settings.rows.localServiceAccess",
@@ -171,6 +172,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.help.activityMode", "settings.help.activityMode",
"settings.help.fileEditDisplay", "settings.help.fileEditDisplay",
"settings.help.codeWrap", "settings.help.codeWrap",
"settings.help.showTurnUsage",
"settings.help.brandLogos", "settings.help.brandLogos",
"settings.help.currentModel", "settings.help.currentModel",
"settings.help.localServiceAccess", "settings.help.localServiceAccess",
@@ -291,6 +293,13 @@ const LOCALIZED_NEW_SURFACE_KEYS = [
"message.openLink", "message.openLink",
"message.openAttachment", "message.openAttachment",
"message.skill", "message.skill",
"message.usage.context",
"message.usage.turnInput",
"message.usage.request",
"message.usage.requests",
"message.usage.output",
"message.usage.cached",
"message.usage.estimated",
"settings.channels.connectionChecks", "settings.channels.connectionChecks",
"settings.channels.open", "settings.channels.open",
]; ];
+98 -16
View File
@@ -1,8 +1,14 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { MessageBubble } from "@/components/MessageBubble"; import { MessageBubble } from "@/components/MessageBubble";
import { useShowTurnUsage } from "@/hooks/useShowTurnUsage";
import { fmtDateTime, formatMessageEndTime } from "@/lib/format"; import { fmtDateTime, formatMessageEndTime } from "@/lib/format";
import {
DEFAULT_LOCAL_PREFS,
LOCAL_PREFS_STORAGE_KEY,
writeLocalPreferences,
} from "@/lib/local-preferences";
import type { import type {
CliAppInfo, CliAppInfo,
McpPresetInfo, McpPresetInfo,
@@ -67,6 +73,11 @@ const MCP_PRESETS: McpPresetInfo[] = [
}, },
]; ];
function PreferenceAwareMessageBubble({ message }: { message: UIMessage }) {
const showTurnUsage = useShowTurnUsage();
return <MessageBubble message={message} showTurnUsage={showTurnUsage} />;
}
const SLASH_COMMANDS: SlashCommand[] = [ const SLASH_COMMANDS: SlashCommand[] = [
{ {
command: "/model", command: "/model",
@@ -95,6 +106,10 @@ const SLASH_COMMANDS: SlashCommand[] = [
]; ];
describe("MessageBubble", () => { describe("MessageBubble", () => {
afterEach(() => {
localStorage.removeItem(LOCAL_PREFS_STORAGE_KEY);
});
it("renders user messages as right-aligned pills", () => { it("renders user messages as right-aligned pills", () => {
const message: UIMessage = { const message: UIMessage = {
id: "u1", id: "u1",
@@ -1013,32 +1028,95 @@ describe("MessageBubble", () => {
expect(screen.queryByLabelText("File attachment")).not.toBeInTheDocument(); expect(screen.queryByLabelText("File attachment")).not.toBeInTheDocument();
}); });
it("keeps turn usage focused on the completed reply", () => { it("hides turn usage by default and leaves latency visible when the preference changes", async () => {
const message: UIMessage = { const message: UIMessage = {
id: "a-usage", id: "turn-usage-preference",
role: "assistant", role: "assistant",
content: "done", content: "done",
createdAt: Date.now(), createdAt: Date.now(),
latencyMs: 18_200, latencyMs: 18_200,
contextWindowTokens: 128_000,
usage: { usage: {
prompt_tokens: 12_400, prompt_tokens: 12_400,
completion_tokens: 823, completion_tokens: 823,
cached_tokens: 9_672,
context_tokens: 8_100, context_tokens: 8_100,
request_count: 3, request_count: 1,
}, },
}; };
render(<MessageBubble message={message} />); const { container } = render(<PreferenceAwareMessageBubble message={message} />);
const usage = screen.getByText("12.4K in · 823 out · 78% cached · 18s"); expect(container.querySelector("[data-turn-usage]")).not.toBeInTheDocument();
expect(usage).toHaveAttribute("data-turn-usage"); expect(container.querySelector("[data-turn-latency]")).toHaveTextContent("18s");
expect(usage).not.toHaveAttribute("tabindex");
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); act(() => {
writeLocalPreferences({ ...DEFAULT_LOCAL_PREFS, showTurnUsage: true });
});
expect(await screen.findByText("8.1K context")).toHaveAttribute("data-turn-usage");
act(() => {
writeLocalPreferences({ ...DEFAULT_LOCAL_PREFS, showTurnUsage: false });
});
await waitFor(() => {
expect(container.querySelector("[data-turn-usage]")).not.toBeInTheDocument();
});
expect(container.querySelector("[data-turn-latency]")).toHaveTextContent("18s");
}); });
it("marks estimated usage and omits cache when the provider did not report it", () => { it("shows consecutive contexts while keeping each turn's aggregate cost in details", async () => {
const turns: UIMessage[] = [
{
id: "turn-a",
role: "assistant",
content: "first reply",
createdAt: Date.now(),
usage: {
prompt_tokens: 111_000,
completion_tokens: 333,
cached_tokens: 55_500,
context_tokens: 56_000,
request_count: 2,
},
},
{
id: "turn-b",
role: "assistant",
content: "second reply",
createdAt: Date.now(),
usage: {
prompt_tokens: 57_000,
completion_tokens: 192,
cached_tokens: 55_290,
context_tokens: 57_000,
request_count: 1,
},
},
];
render(<>{turns.map((message) => (
<MessageBubble key={message.id} message={message} showTurnUsage />
))}</>);
const turnAUsage = screen.getByText("56K context");
const turnBUsage = screen.getByText("57K context");
expect(turnAUsage).toHaveAttribute("data-turn-usage");
expect(turnBUsage).toHaveAttribute("data-turn-usage");
expect(screen.queryByText(/111K turn input/)).not.toBeInTheDocument();
fireEvent.focus(turnAUsage);
expect(screen.getByRole("tooltip")).toHaveTextContent(
"111K turn input · 2 requests · 333 out · 50% cached",
);
fireEvent.blur(turnAUsage);
await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument());
fireEvent.focus(turnBUsage);
expect(screen.getByRole("tooltip")).toHaveTextContent(
"57K turn input · 1 request · 192 out · 97% cached",
);
});
it("falls back to clearly labeled estimated turn input when context is unavailable", () => {
const message: UIMessage = { const message: UIMessage = {
id: "a-estimated-usage", id: "a-estimated-usage",
role: "assistant", role: "assistant",
@@ -1047,15 +1125,19 @@ describe("MessageBubble", () => {
usage: { usage: {
prompt_tokens: 1_250, prompt_tokens: 1_250,
completion_tokens: 90, completion_tokens: 90,
request_count: 1,
estimated_tokens: 1_340, estimated_tokens: 1_340,
}, },
}; };
render(<MessageBubble message={message} />); render(<MessageBubble message={message} showTurnUsage />);
const usage = screen.getByText("~1.3K in · ~90 out"); const usage = screen.getByText("~1.3K turn input");
expect(usage).not.toHaveTextContent("cached"); expect(usage).not.toHaveTextContent("context");
fireEvent.focus(usage); fireEvent.focus(usage);
expect(screen.getByRole("tooltip")).toHaveTextContent("Includes estimated usage"); expect(screen.getByRole("tooltip")).toHaveTextContent(
"1 request · ~90 out · Includes estimated usage",
);
expect(screen.getByRole("tooltip")).not.toHaveTextContent("cached");
}); });
}); });
+40 -1
View File
@@ -1,5 +1,6 @@
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { expect, it, vi } from "vitest"; import { expect, it, vi } from "vitest";
import { LOCAL_PREFS_STORAGE_KEY } from "@/lib/local-preferences";
import type { SettingsPayload } from "@/lib/types"; import type { SettingsPayload } from "@/lib/types";
import { jsonResponse, settingsPayload, renderSettingsView, installSettingsViewTestHooks } from "@/tests/settings-test-utils"; import { jsonResponse, settingsPayload, renderSettingsView, installSettingsViewTestHooks } from "@/tests/settings-test-utils";
@@ -10,6 +11,44 @@ describe("Settings overview and appearance", () => {
installSettingsViewTestHooks(); installSettingsViewTestHooks();
it("defaults per-turn token usage off and persists changes across remounts", async () => {
const renderAppearance = () => renderSettingsView({
initialSection: "appearance",
initialSettings: settingsPayload(),
showSidebar: true,
});
renderAppearance();
const title = "Show per-turn token usage";
const description = (
"Show context, aggregate input, output, cache rate, and model calls under assistant messages. "
+ "Stored only in this browser."
);
expect(screen.getByText(description)).toBeInTheDocument();
expect(screen.getByRole("switch", { name: title })).toHaveAttribute("aria-checked", "false");
fireEvent.click(screen.getByRole("switch", { name: title }));
await waitFor(() => {
const saved = JSON.parse(localStorage.getItem(LOCAL_PREFS_STORAGE_KEY) || "{}");
expect(saved.showTurnUsage).toBe(true);
});
cleanup();
renderAppearance();
expect(screen.getByRole("switch", { name: title })).toHaveAttribute("aria-checked", "true");
fireEvent.click(screen.getByRole("switch", { name: title }));
await waitFor(() => {
const saved = JSON.parse(localStorage.getItem(LOCAL_PREFS_STORAGE_KEY) || "{}");
expect(saved.showTurnUsage).toBe(false);
});
cleanup();
renderAppearance();
expect(screen.getByRole("switch", { name: title })).toHaveAttribute("aria-checked", "false");
});
it("persists the file edit display local preference", async () => { it("persists the file edit display local preference", async () => {
renderSettingsView({ renderSettingsView({
initialSection: "appearance", initialSection: "appearance",