diff --git a/CHANGELOG.md b/CHANGELOG.md
index b0e65a07..73fa1c76 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -40,6 +40,8 @@
- PR 头部展示 reviewer 头像栈:在标题右侧、动作按钮行之上垂直居中展示评审者头像(Bitbucket 风格略重叠、灰色细描边环),按 needsWork > approved > 待评审 优先排序并过滤掉当前用户自己;approved / needsWork 的头像右上角带决断角标(圆环内绿勾 / 琥珀叹号)。至多展示 4 个,超出则显示 3 个 + 「+n」,点击「+n」下拉展示其余评审者(头像 + 名 + 决断 chip);直接展示的头像 hover 出名字。
+- run 卡片 / 思考步骤展示「模型实际交互规模」(缓存命中量 + 轮次):本机 CLI(claude / codex)接管 LLM 时,token 用量是 agentic 多轮累加、且每轮的 `cache_read` 反复计入,累计值远超模型单请求窗口,易被误读为超限或计量出错。现采集层从 CLI 返回的 usage 补充真实的**提示缓存命中量(`cache_read`)**与**模型交互轮次(`num_turns`)**,运行卡片与思考步骤统一呈现为「↑输入 (⛁缓存命中) / ↓输出 · ↻N」——蓝色数据库柱体图标标缓存命中量(属输入的一部分,无命中则整段不显示)、循环箭头图标标多轮次(单轮不显示),输入 / 输出各自独立悬浮提示。litellm / API 路径同步补 `cache_read` 采集(Anthropic `cache_read_input_tokens` / OpenAI `prompt_tokens_details.cached_tokens`),与 CLI 路径一致;`TokenUsage` 新增 `cacheReadTokens` / `turns` 字段(缺失向后兼容)。
+
### Changed
- ChatPane 评审结果交互打磨(一批小优化):
diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/install.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/install.py
index 30e3a300..3f7fd4b0 100644
--- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/install.py
+++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/install.py
@@ -88,13 +88,21 @@ async def chat_completion(self, model, system, user, temperature=0.2, img_path=N
)
text, usage = spec["parser"]((out or b"").decode("utf-8", "replace"))
if usage:
- # input_tokens(+cache_*) ≈ prompt,output_tokens ≈ completion(两家 usage 同字段名)
+ # input_tokens(+cache_*) ≈ prompt(输入侧总规模),output_tokens ≈ completion(两家 usage
+ # 同字段名)。cache_read 既计入 prompt 总量、又单列上抛供 UI 拆分展示「↑总量 (cache N)」。
prompt_tokens = usage.get("input_tokens")
for k in ("cache_read_input_tokens", "cache_creation_input_tokens"):
v = usage.get(k)
if isinstance(v, int):
prompt_tokens = (prompt_tokens or 0) + v
- _emit_usage_tokens(prompt_tokens, usage.get("output_tokens"))
+ cache_read = usage.get("cache_read_input_tokens")
+ turns = usage.get("num_turns")
+ _emit_usage_tokens(
+ prompt_tokens,
+ usage.get("output_tokens"),
+ cache_read_tokens=cache_read if isinstance(cache_read, int) else None,
+ turns=turns if isinstance(turns, int) else None,
+ )
return text, "stop"
handler_cls.chat_completion = chat_completion
diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/parsers.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/parsers.py
index 85b4968f..13d89d12 100644
--- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/parsers.py
+++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/cli/parsers.py
@@ -4,8 +4,12 @@
def _parse_claude_output(stdout):
"""解析 `claude -p --output-format json` 的 stdout,返回 (text, usage_dict_or_None)。
- 成功形如 {"result": "...", "usage": {"input_tokens":..,"output_tokens":..}, "is_error": false}。
- 非 JSON / 缺字段退化为「整段 stdout 当文本、usage=None」;仅 is_error=True 时抛错。"""
+ 成功形如 {"result": "...", "num_turns": N, "usage": {"input_tokens":..,"output_tokens":..,
+ "cache_read_input_tokens":..}, "is_error": false}。非 JSON / 缺字段退化为「整段 stdout 当文本、
+ usage=None」;仅 is_error=True 时抛错。
+
+ claude -p 是 agentic 多轮:顶层 num_turns 为本次会话内部的模型轮次(可远大于 1),把它并入
+ usage dict 的 num_turns 字段一并上抛(usage 同字段名供采集层统一读取,见 install.py)。"""
import json
s = (stdout or "").strip()
@@ -23,18 +27,25 @@ def _parse_claude_output(stdout):
if not isinstance(text, str):
text = s
usage = obj.get("usage")
- return text, (usage if isinstance(usage, dict) else None)
+ if not isinstance(usage, dict):
+ return text, None
+ nt = obj.get("num_turns")
+ if isinstance(nt, int):
+ usage["num_turns"] = nt
+ return text, usage
def _parse_codex_output(stdout):
"""解析 `codex exec --json` 的 JSONL 事件流,返回 (text, usage_dict_or_None):
- type==item.completed 且 item.type==agent_message → item.text 为模型回复,取最后一条;
- - type==turn.completed → usage {input_tokens, output_tokens} 为 token。
+ - type==turn.completed → usage {input_tokens, output_tokens} 为 token,并计一轮。
+ turn.completed 出现次数作模型轮次 num_turns(并入 usage dict,与 claude 路径同字段名)。
逐行容错:非 JSON 行跳过、事件缺字段不致命;text 缺失退到空串(让上层 load_yaml 兜底)。"""
import json
text = None
usage = None
+ turns = 0
for line in (stdout or "").splitlines():
line = line.strip()
if not line:
@@ -53,7 +64,10 @@ def _parse_codex_output(stdout):
if isinstance(txt, str):
text = txt # 取最后一条 agent_message 作最终回复
elif etype == "turn.completed":
+ turns += 1
u = ev.get("usage")
if isinstance(u, dict):
usage = u
+ if isinstance(usage, dict) and turns:
+ usage["num_turns"] = turns
return (text if isinstance(text, str) else ""), usage
diff --git a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/usage.py b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/usage.py
index 3f453728..9f8de2ab 100644
--- a/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/usage.py
+++ b/apps/desktop/scripts/pragent-shim/meebox_pragent_shim/usage.py
@@ -27,14 +27,29 @@ def _g(key):
}
if rec["prompt_tokens"] is None and rec["completion_tokens"] is None:
return # 没有任何可用数字(如流式 MockResponse)→ 不打
+ # 提示缓存读取量:Anthropic 走 cache_read_input_tokens;OpenAI 兼容走
+ # prompt_tokens_details.cached_tokens。两路尽力采集、缺失则不带(UI 据有无决定是否展示)。
+ cache_read = _g("cache_read_input_tokens")
+ if not isinstance(cache_read, int):
+ details = _g("prompt_tokens_details")
+ if details is not None:
+ cache_read = (
+ details.get("cached_tokens")
+ if isinstance(details, dict)
+ else getattr(details, "cached_tokens", None)
+ )
+ if isinstance(cache_read, int):
+ rec["cache_read_tokens"] = cache_read
print(f"@@MEEBOX_USAGE@@ {json.dumps(rec)}", file=sys.stderr, flush=True)
except Exception as exc: # noqa: BLE001
_debug(f"emit usage failed (ignored): {exc}")
-def _emit_usage_tokens(prompt_tokens, completion_tokens) -> None:
+def _emit_usage_tokens(
+ prompt_tokens, completion_tokens, cache_read_tokens=None, turns=None
+) -> None:
"""CLI 模式下从 CLI 返回的 JSON usage 直接构造哨兵(与 _emit_usage 同格式,主进程同一套
- 累加逻辑)。两个数都为 None 则不打。"""
+ 累加逻辑)。两个 token 数都为 None 则不打;cache_read / turns 仅在有值时附带。"""
try:
if prompt_tokens is None and completion_tokens is None:
return
@@ -45,6 +60,10 @@ def _emit_usage_tokens(prompt_tokens, completion_tokens) -> None:
"completion_tokens": completion_tokens,
"total_tokens": (prompt_tokens or 0) + (completion_tokens or 0),
}
+ if cache_read_tokens is not None:
+ rec["cache_read_tokens"] = cache_read_tokens
+ if turns is not None:
+ rec["turns"] = turns
print(f"@@MEEBOX_USAGE@@ {json.dumps(rec)}", file=sys.stderr, flush=True)
except Exception as exc: # noqa: BLE001
_debug(f"emit cli usage failed (ignored): {exc}")
diff --git a/apps/desktop/src/main/services/pr-agent/usage.ts b/apps/desktop/src/main/services/pr-agent/usage.ts
index 5fc6123a..7b48bc63 100644
--- a/apps/desktop/src/main/services/pr-agent/usage.ts
+++ b/apps/desktop/src/main/services/pr-agent/usage.ts
@@ -8,12 +8,16 @@ export interface UsageAcc {
completion: number;
total: number;
calls: number;
+ /** 累计提示缓存读取 token(cache_read),是 prompt 的一部分 */
+ cacheRead: number;
+ /** 累计模型交互轮次:CLI agentic 模式来自各次哨兵的 num_turns(一次 run 内可累加多段) */
+ turns: number;
any: boolean;
}
/** 新建一个空 usage 累加器。 */
export function newUsageAcc(): UsageAcc {
- return { prompt: 0, completion: 0, total: 0, calls: 0, any: false };
+ return { prompt: 0, completion: 0, total: 0, calls: 0, cacheRead: 0, turns: 0, any: false };
}
/**
@@ -29,6 +33,8 @@ export function accumulateUsageSentinel(line: string, acc: UsageAcc): boolean {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
+ cache_read_tokens?: number;
+ turns?: number;
};
acc.calls += 1;
if (typeof r.prompt_tokens === 'number') {
@@ -43,6 +49,8 @@ export function accumulateUsageSentinel(line: string, acc: UsageAcc): boolean {
acc.total += r.total_tokens;
acc.any = true;
}
+ if (typeof r.cache_read_tokens === 'number') acc.cacheRead += r.cache_read_tokens;
+ if (typeof r.turns === 'number') acc.turns += r.turns;
} catch {
// 坏哨兵行:仍吞掉,不计数
}
@@ -58,6 +66,9 @@ export function finalizeUsage(acc: UsageAcc): TokenUsage | undefined {
// 优先各次 total 累加;个别次缺 total 时用 prompt+completion 兜底
totalTokens: acc.total || acc.prompt + acc.completion,
calls: acc.calls,
+ // cache_read 无命中(0)则不带;turns 优先 CLI 上报的轮次,缺失回退为调用次数
+ cacheReadTokens: acc.cacheRead || undefined,
+ turns: acc.turns || acc.calls,
};
}
diff --git a/apps/desktop/src/renderer/src/components/common/icons.tsx b/apps/desktop/src/renderer/src/components/common/icons.tsx
index 4d029f11..64982eb8 100644
--- a/apps/desktop/src/renderer/src/components/common/icons.tsx
+++ b/apps/desktop/src/renderer/src/components/common/icons.tsx
@@ -632,6 +632,51 @@ export function SyncIcon({ size = 12 }: IconProps) {
);
}
+/** 数据库柱体(Lucide database,viewBox 24):表示提示缓存命中量(cache_read) */
+export function DatabaseIcon({ size = 12, className }: IconProps) {
+ return (
+
+ );
+}
+
+/** 循环箭头(Lucide repeat,viewBox 24):表示模型交互轮次(agentic 多轮) */
+export function RepeatIcon({ size = 12, className }: IconProps) {
+ return (
+
+ );
+}
+
/** 齿轮(Lucide settings,viewBox 24):设置按钮 */
export function SettingsIcon({ size = 14 }: IconProps) {
return (
diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/AgentStep.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/AgentStep.tsx
index 5c418dee..09cfef31 100644
--- a/apps/desktop/src/renderer/src/components/features/chat/components/AgentStep.tsx
+++ b/apps/desktop/src/renderer/src/components/features/chat/components/AgentStep.tsx
@@ -2,8 +2,8 @@ import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { AgentStep } from '@meebox/shared';
import { RobotIcon } from '../../../common';
-import { formatElapsed, formatTokens } from '../utils/format';
-import { Spinner } from './shared';
+import { formatElapsed } from '../utils/format';
+import { Spinner, TokenStat } from './shared';
/**
* 内联思考步骤(类 Claude Code「先思考→定步骤→执行步骤」):穿插在时间线里、排在所选工具的 run
@@ -33,31 +33,17 @@ export function AgentStepRow({ step }: { step: AgentStep }) {
)}
{headText && {headText}}
{/* 本步**单独**的 token 用量(不累计):judge / 总结 / 规划等经独立 LLM 通道的推理步带值;
- 与 run 卡片同款 ↑输入(绿) / ↓输出(红) 计法,靠行尾对齐。describe/review/ask 的开销在各自 run 卡片上。 */}
+ 与 run 卡片同款 ↑输入(绿)[⛁缓存]/↓输出(红),输入输出各自独立 hover、靠行尾对齐。
+ describe/review/ask 的开销在各自 run 卡片上。 */}
{step.usage &&
(step.usage.promptTokens !== undefined || step.usage.completionTokens !== undefined) ? (
-
- {step.usage.promptTokens !== undefined && (
- <>
- ↑
- {formatTokens(step.usage.promptTokens)}
- >
- )}
- {step.usage.promptTokens !== undefined && step.usage.completionTokens !== undefined
- ? ' '
- : ''}
- {step.usage.completionTokens !== undefined && (
- <>
- ↓
- {formatTokens(step.usage.completionTokens)}
- >
- )}
+
+
) : null}
diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/RunResultView.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/RunResultView.tsx
index be89a32a..2bc1f90c 100644
--- a/apps/desktop/src/renderer/src/components/features/chat/components/RunResultView.tsx
+++ b/apps/desktop/src/renderer/src/components/features/chat/components/RunResultView.tsx
@@ -1,11 +1,11 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { Finding, FindingClosure, ReviewDraft, ReviewRun } from '@meebox/shared';
-import { RetryIcon, ShareIcon, TrashIcon } from '../../../common';
+import { RepeatIcon, RetryIcon, ShareIcon, TrashIcon } from '../../../common';
import { orderFindings } from '../utils/findings';
-import { formatStartTime, formatTokens, runStatusLabel } from '../utils/format';
+import { formatStartTime, runStatusLabel } from '../utils/format';
import { extractTokenUsage, type TokenUsage } from '../utils/tokens';
-import { AnsiPre, AskQuestion, BreakablePath } from './shared';
+import { AnsiPre, AskQuestion, BreakablePath, TokenStat } from './shared';
import { FindingCard } from './FindingCard';
function RunMeta({ run, onDelete }: { run: ReviewRun; onDelete: () => void }) {
@@ -18,6 +18,8 @@ function RunMeta({ run, onDelete }: { run: ReviewRun; onDelete: () => void }) {
prompt: run.tokenUsage.promptTokens,
completion: run.tokenUsage.completionTokens,
total: run.tokenUsage.totalTokens,
+ cacheRead: run.tokenUsage.cacheReadTokens,
+ turns: run.tokenUsage.turns,
}
: run.stdout
? extractTokenUsage(run.stdout)
@@ -38,28 +40,24 @@ function RunMeta({ run, onDelete }: { run: ReviewRun; onDelete: () => void }) {
{run.model}
)}
- {/* 只分别展示输入(↑prompt,绿) / 输出(↓completion,红),不显示总数。旧 run 可能只有 prompt */}
+ {/* 输入(↑绿)[⛁缓存]/输出(↓红):输入输出各自独立 hover;缓存为输入一部分、无命中不显示。旧 run 可能只有 prompt */}
{usage.prompt !== undefined || usage.completion !== undefined ? (
+
+
+
+ ) : null}
+ {/* 模型交互轮次:循环箭头图标 + 次数(取代「N 轮」文案,省空间 / 免复数);仅多轮(agentic) 时展示 */}
+ {usage.turns !== undefined && usage.turns > 1 ? (
- {usage.prompt !== undefined && (
- <>
- ↑
- {formatTokens(usage.prompt)}
- >
- )}
- {usage.prompt !== undefined && usage.completion !== undefined ? ' / ' : ''}
- {usage.completion !== undefined && (
- <>
- ↓
- {formatTokens(usage.completion)}
- >
- )}
+
+ {usage.turns}
) : null}
diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/shared.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/shared.tsx
index d6d9b02d..0fedc631 100644
--- a/apps/desktop/src/renderer/src/components/features/chat/components/shared.tsx
+++ b/apps/desktop/src/renderer/src/components/features/chat/components/shared.tsx
@@ -3,7 +3,8 @@ import { useTranslation } from 'react-i18next';
import ReactMarkdown, { type Components } from 'react-markdown';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
-import { QuestionIcon, mermaidComponents } from '../../../common';
+import { DatabaseIcon, QuestionIcon, mermaidComponents } from '../../../common';
+import { formatTokens } from '../utils/format';
import { REMOTE_REHYPE_PLUGINS } from '../../../../lib/markdown';
import { parseAnsi, segmentStyle } from '../../../../utils/ansi';
@@ -122,6 +123,52 @@ export function AnsiPre({
);
}
+/**
+ * Token 用量内联展示:↑输入(绿) [⛁缓存命中] / ↓输出(红)。输入、输出**各自独立 hover 提示**;
+ * 缓存命中(cache_read)为输入的一部分,柱体图标拆分展示(间距在 cache 前,无命中时整段不渲染、不留空),
+ * 悬浮另给说明。run 卡片(RunMeta) 与思考步骤(AgentStep) 共用;分隔符按上下文传入(chip 内 ` / `、步骤行空格)。
+ */
+export function TokenStat({
+ prompt,
+ completion,
+ cacheRead,
+ separator = ' / ',
+}: {
+ prompt?: number;
+ completion?: number;
+ cacheRead?: number;
+ separator?: string;
+}) {
+ const { t } = useTranslation();
+ if (prompt === undefined && completion === undefined) return null;
+ return (
+ <>
+ {prompt !== undefined && (
+
+ ↑
+ {formatTokens(prompt)}
+ {cacheRead !== undefined && cacheRead > 0 && (
+
+
+ {formatTokens(cacheRead)}
+
+ )}
+
+ )}
+ {prompt !== undefined && completion !== undefined ? separator : ''}
+ {completion !== undefined && (
+
+ ↓
+ {formatTokens(completion)}
+
+ )}
+ >
+ );
+}
+
/** /ask 提问行:问号图标 + markdown 渲染的提问内容(Agent 自拟的追问常含内联代码 / 列表)。 */
export function AskQuestion({ text }: { text: string }) {
const { t } = useTranslation();
diff --git a/apps/desktop/src/renderer/src/components/features/chat/utils/tokens.ts b/apps/desktop/src/renderer/src/components/features/chat/utils/tokens.ts
index 5df78095..e1c7fb6d 100644
--- a/apps/desktop/src/renderer/src/components/features/chat/utils/tokens.ts
+++ b/apps/desktop/src/renderer/src/components/features/chat/utils/tokens.ts
@@ -6,6 +6,10 @@ export interface TokenUsage {
completion?: number;
/** 总 token;优先用 litellm 给的,缺时算 prompt+completion */
total?: number;
+ /** 提示缓存读取量(cache_read),是 prompt 的一部分;缺/0 时不展示「(cache N)」括号 */
+ cacheRead?: number;
+ /** 模型交互轮次;≤1 时不单独展示 */
+ turns?: number;
}
/**
diff --git a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json
index fc29fd37..f04dd454 100644
--- a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json
+++ b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json
@@ -47,6 +47,7 @@
"bulletDescribe": "PR-Zusammenfassung / Labels automatisch generieren",
"bulletImprove": "zeilenweise Vorschläge zur Codeverbesserung (mit Wichtigkeitsbewertung)",
"bulletReview": "einen KI-Review ausführen; Ergebnisse erscheinen in der Findings-Liste",
+ "cacheInline": "Cache {{n}}",
"cancelQueuedTitle": "Eingereihte Aufgabe abbrechen",
"clearConfirmLabel": "Leeren",
"clearConfirmMessage": "Den gesamten PR-Agent-Ausführungsverlauf für den aktuellen PR löschen? Dies betrifft nur diesen PR und kann nicht rückgängig gemacht werden.",
@@ -163,7 +164,9 @@
"statusSucceeded": "Fertig",
"stopAria": "Stoppen",
"stopTitle": "Ausführung stoppen",
- "tokensTitle": "Eingabe (prompt) {{prompt}} · Ausgabe (completion) {{completion}} Tokens",
+ "tokensInTitle": "Eingabe (prompt) {{n}} Tokens",
+ "tokensOutTitle": "Ausgabe (completion) {{n}} Tokens",
+ "turnsTitle": "Modell-Interaktionsrunden (ein Modellaufruf pro Runde)",
"unknownCommand": "Unbekannter Befehl {{head}}; unterstützt: {{cmds}}",
"userQuestionAria": "Benutzerfrage",
"waitingOutput": "(Warte auf PR-Agent-Ausgabe…)"
diff --git a/apps/desktop/src/renderer/src/i18n/locales/en-US.json b/apps/desktop/src/renderer/src/i18n/locales/en-US.json
index 607a9594..48b89e92 100644
--- a/apps/desktop/src/renderer/src/i18n/locales/en-US.json
+++ b/apps/desktop/src/renderer/src/i18n/locales/en-US.json
@@ -47,6 +47,7 @@
"bulletDescribe": "auto-generate PR summary / labels",
"bulletImprove": "line-by-line code improvement suggestions (with importance scores)",
"bulletReview": "run an AI review; results land in the findings list",
+ "cacheInline": "cache {{n}}",
"cancelQueuedTitle": "Cancel queued task",
"clearConfirmLabel": "Clear",
"clearConfirmMessage": "Clear all PR Agent run history for the current PR? This affects only this PR and cannot be undone.",
@@ -163,7 +164,9 @@
"statusSucceeded": "Done",
"stopAria": "Stop",
"stopTitle": "Stop execution",
- "tokensTitle": "Input (prompt) {{prompt}} · Output (completion) {{completion}} tokens",
+ "tokensInTitle": "Input (prompt) {{n}} tokens",
+ "tokensOutTitle": "Output (completion) {{n}} tokens",
+ "turnsTitle": "Model interaction turns (one model call per turn)",
"unknownCommand": "Unknown command {{head}}; supported: {{cmds}}",
"userQuestionAria": "User question",
"waitingOutput": "(Waiting for PR Agent output…)"
diff --git a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json
index 49a10ae3..2fc80f39 100644
--- a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json
+++ b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json
@@ -47,6 +47,7 @@
"bulletDescribe": "PR の概要 / ラベルを自動生成",
"bulletImprove": "行単位のコード改善提案(重要度スコア付き)",
"bulletReview": "AI レビューを実行し、結果は 指摘リストに表示されます",
+ "cacheInline": "キャッシュ {{n}}",
"cancelQueuedTitle": "待機中のタスクをキャンセル",
"clearConfirmLabel": "クリア",
"clearConfirmMessage": "現在の PR の PR Agent 実行履歴をすべてクリアしますか? この PR にのみ影響し、元に戻せません。",
@@ -163,7 +164,9 @@
"statusSucceeded": "完了",
"stopAria": "停止",
"stopTitle": "実行を停止",
- "tokensTitle": "入力(prompt) {{prompt}} · 出力(completion) {{completion}} トークン",
+ "tokensInTitle": "入力(prompt) {{n}} トークン",
+ "tokensOutTitle": "出力(completion) {{n}} トークン",
+ "turnsTitle": "モデルとのやり取りの回数(1 回につきモデル呼び出し 1 回)",
"unknownCommand": "不明なコマンド {{head}}。サポート対象:{{cmds}}",
"userQuestionAria": "ユーザーの質問",
"waitingOutput": "(PR Agent の出力を待機中…)"
diff --git a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json
index 30b4cac8..f16d2fbc 100644
--- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json
+++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json
@@ -47,6 +47,7 @@
"bulletDescribe": "自动生成 PR 摘要 / labels",
"bulletImprove": "逐行代码改进建议(带重要度评分)",
"bulletReview": "跑一次 AI review,结果落到 findings 列表",
+ "cacheInline": "缓存 {{n}}",
"cancelQueuedTitle": "取消排队任务",
"clearConfirmLabel": "清空",
"clearConfirmMessage": "确定清空当前 PR 的全部 PR Agent 执行历史记录?仅影响该 PR,不可恢复。",
@@ -163,7 +164,9 @@
"statusSucceeded": "完成",
"stopAria": "停止",
"stopTitle": "停止执行",
- "tokensTitle": "输入(prompt) {{prompt}} · 输出(completion) {{completion}} tokens",
+ "tokensInTitle": "输入(prompt) {{n}} tokens",
+ "tokensOutTitle": "输出(completion) {{n}} tokens",
+ "turnsTitle": "模型交互轮次(每轮一次模型调用)",
"unknownCommand": "未知命令 {{head}};支持:{{cmds}}",
"userQuestionAria": "用户提问",
"waitingOutput": "(等待 PR Agent 输出…)"
diff --git a/apps/desktop/src/renderer/src/styles/_tokens.scss b/apps/desktop/src/renderer/src/styles/_tokens.scss
index a4eb69f3..a27f53c3 100644
--- a/apps/desktop/src/renderer/src/styles/_tokens.scss
+++ b/apps/desktop/src/renderer/src/styles/_tokens.scss
@@ -48,6 +48,7 @@ $color-danger-strong: #e8512e;
$color-danger-strong-fade: rgba(232, 81, 46, 0.12); // ghost 危险按钮 hover 浅红底
$color-token-in: #22c55e; // token 用量 ↑输入 (Tailwind green-500)
$color-token-out: #ef4444; // token 用量 ↓输出 (Tailwind red-500)
+$color-token-cache: #6cb6ff; // token 用量 缓存命中(cache_read) 柱体图标,浅蓝
// 语义色浅底 (chip / banner 背景,与对应主色同色相;chat 卡片大量复用)
$color-accent-bg-hover: rgba(14, 99, 156, 0.28); // 蓝调 chip hover 加深 (rule-chip)
diff --git a/apps/desktop/src/renderer/src/styles/features/chat/run.scss b/apps/desktop/src/renderer/src/styles/features/chat/run.scss
index 814529c8..19524484 100644
--- a/apps/desktop/src/renderer/src/styles/features/chat/run.scss
+++ b/apps/desktop/src/renderer/src/styles/features/chat/run.scss
@@ -146,10 +146,22 @@
// 中性胶囊:模型 / token 用量 / 耗时共用 chat-chip + chat-chip-quiet + chat-chip-neutral;
// 各自只补字体差异(下方 mono / 截断)。
.chat-run-tokens,
+.chat-run-turns,
.chat-run-duration {
font-family: $font-mono;
font-variant-numeric: tabular-nums;
}
+// 轮次胶囊:循环箭头图标 + 次数,图标与数字对齐
+.chat-run-turns {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+}
+// 输入 / 输出各自一组(箭头 + 数字[+ 缓存]):独立 hover 提示的悬浮目标,内部行内对齐
+.chat-token-grp {
+ display: inline-flex;
+ align-items: center;
+}
// token 用量箭头:↑输入(绿) / ↓输出(红),run 卡片与思考步骤共用
.chat-token-in {
color: $color-token-in;
@@ -157,6 +169,17 @@
.chat-token-out {
color: $color-token-out;
}
+// cache_read 拆分(↑总量 后的缓存命中量):与输入数字拉开间隔,数字同色、柱体图标浅蓝并与数字对齐
+.chat-token-cache {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ margin-left: 5px;
+
+ svg {
+ color: $color-token-cache;
+ }
+}
// 模型名可能很长:单行截断 + 省略号,避免一个 chip 撑爆整行
.chat-run-model {
max-width: 100%;
diff --git a/packages/shared/src/poller-contract.ts b/packages/shared/src/poller-contract.ts
index fdde9db7..ded9b84b 100644
--- a/packages/shared/src/poller-contract.ts
+++ b/packages/shared/src/poller-contract.ts
@@ -239,6 +239,13 @@ export interface TokenUsage {
totalTokens?: number;
/** 本次 run 捕获到的 LLM 调用次数(累加来源) */
calls?: number;
+ /** 提示缓存读取(cache_read)token 数:promptTokens 的一部分,供 UI 拆分展示「↑总量 (cache N)」。
+ * CLI 路径取自 claude/codex usage,API 路径取自 litellm(Anthropic cache_read / OpenAI cached_tokens)。
+ * 缺失或为 0 = 无缓存命中信息(UI 不展示该括号)。 */
+ cacheReadTokens?: number;
+ /** 模型实际交互轮次:CLI agentic 模式为本次 run 内部累计的 num_turns(可远大于 calls);
+ * 其它情况回退为 LLM 调用次数(calls)。≤1 时 UI 不单独展示。 */
+ turns?: number;
}
export interface ReviewRun {