Skip to content

Commit a845311

Browse files
committed
feat(chat): 会话详情显示 model/上下文占用 + 切回 rounds 计数
- 新增 get_session_usage Tauri 命令,按需读取 .jsonl 解析当前 session 真实用量 - SessionUsage 新增 model 与 context_tokens(peak 单回合 input+cache_read+cache_creation) - 前端 inferModelInfo 推断 provider/上下文窗口(Anthropic/OpenAI/Google/未知) - SessionDetail 底栏替换 cwd 为 "provider · model · ctx X (Y%)" 信息 - "messages" 计数改为 "rounds"(仅统计用户 prompt,不含工具/meta)
1 parent 89c60ec commit a845311

4 files changed

Lines changed: 115 additions & 10 deletions

File tree

src-tauri/src/lib.rs

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,15 @@ pub struct SessionUsage {
172172
pub cache_creation_tokens: u64,
173173
pub cache_read_tokens: u64,
174174
pub cost_usd: f64, // estimated cost in USD
175+
/// Last model identifier seen in the session (e.g. "claude-opus-4-7"). Populated from
176+
/// assistant message metadata; None for older sessions or non-Claude sources.
177+
#[serde(default, skip_serializing_if = "Option::is_none")]
178+
pub model: Option<String>,
179+
/// Peak total input footprint across any single assistant turn — best proxy for "context
180+
/// window occupancy" since each turn ships the full live transcript. = input + cache_read +
181+
/// cache_creation tokens at that turn. 0 when unknown.
182+
#[serde(default)]
183+
pub context_tokens: u64,
175184
}
176185

177186
#[derive(Debug, Serialize, Deserialize)]
@@ -268,6 +277,7 @@ struct RawMessage {
268277
role: Option<String>,
269278
content: Option<serde_json::Value>,
270279
usage: Option<RawUsage>,
280+
model: Option<String>,
271281
}
272282

273283
/// Entry from history.jsonl - used as fast session index
@@ -768,10 +778,20 @@ fn read_session_usage(path: &Path) -> SessionUsage {
768778
if is_assistant {
769779
if let Some(msg) = &parsed.message {
770780
if let Some(u) = &msg.usage {
771-
usage.input_tokens += u.input_tokens.unwrap_or(0);
781+
let inp = u.input_tokens.unwrap_or(0);
782+
let cw = u.cache_creation_input_tokens.unwrap_or(0);
783+
let cr = u.cache_read_input_tokens.unwrap_or(0);
784+
usage.input_tokens += inp;
772785
usage.output_tokens += u.output_tokens.unwrap_or(0);
773-
usage.cache_creation_tokens += u.cache_creation_input_tokens.unwrap_or(0);
774-
usage.cache_read_tokens += u.cache_read_input_tokens.unwrap_or(0);
786+
usage.cache_creation_tokens += cw;
787+
usage.cache_read_tokens += cr;
788+
let turn_ctx = inp + cw + cr;
789+
if turn_ctx > usage.context_tokens {
790+
usage.context_tokens = turn_ctx;
791+
}
792+
}
793+
if let Some(m) = msg.model.as_ref().filter(|s| !s.is_empty()) {
794+
usage.model = Some(m.clone());
775795
}
776796
}
777797
}
@@ -788,6 +808,22 @@ pub struct SessionUsageEntry {
788808
pub usage: SessionUsage,
789809
}
790810

811+
#[tauri::command]
812+
async fn get_session_usage(project_id: String, session_id: String) -> Result<SessionUsage, String> {
813+
tauri::async_runtime::spawn_blocking(move || {
814+
let path = get_claude_dir()
815+
.join("projects")
816+
.join(&project_id)
817+
.join(format!("{}.jsonl", session_id));
818+
if !path.exists() {
819+
return Err("Session not found".to_string());
820+
}
821+
Ok(read_session_usage(&path))
822+
})
823+
.await
824+
.map_err(|e| e.to_string())?
825+
}
826+
791827
#[tauri::command]
792828
async fn get_sessions_usage(project_id: String) -> Result<Vec<SessionUsageEntry>, String> {
793829
tauri::async_runtime::spawn_blocking(move || {
@@ -9282,6 +9318,7 @@ pub fn run() {
92829318
list_projects,
92839319
list_sessions,
92849320
get_sessions_usage,
9321+
get_session_usage,
92859322
list_all_sessions,
92869323
get_app_starred_session_ids,
92879324
list_all_chats,

src/types/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export interface SessionUsage {
5252
cache_creation_tokens: number;
5353
cache_read_tokens: number;
5454
cost_usd: number;
55+
model?: string;
56+
context_tokens: number;
5557
}
5658

5759
export interface Session {

src/views/Chat/ProjectList.tsx

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
importCollapsedAtom,
2222
} from "../../store";
2323
import { useAppConfig } from "../../context";
24-
import { useReadableText } from "./utils";
24+
import { useReadableText, formatTokens, inferModelInfo } from "./utils";
2525
import { useInvokeQuery, useQueryClient } from "../../hooks";
2626
import { CollapsibleContent } from "./CollapsibleContent";
2727
import { ContentBlockRenderer } from "./ContentBlockRenderer";
@@ -1270,6 +1270,13 @@ function SessionDetail({ session, onClose, highlight, scrollRef }: { session: Se
12701270

12711271
const displaySummary = session.title || toReadable(session.summary) || "Untitled";
12721272

1273+
const { data: liveUsage } = useInvokeQuery<import("../../types").SessionUsage>(
1274+
["session-usage", session.project_id, session.id],
1275+
"get_session_usage",
1276+
{ projectId: session.project_id, sessionId: session.id },
1277+
);
1278+
const usage = liveUsage ?? session.usage;
1279+
12731280
useEffect(() => {
12741281
let cancelled = false;
12751282
setLoading(true);
@@ -1290,6 +1297,13 @@ function SessionDetail({ session, onClose, highlight, scrollRef }: { session: Se
12901297
return result;
12911298
}, [messages, originalChat, userPromptsOnly]);
12921299

1300+
// A round = one user prompt (plus its following assistant turn). Count user messages from the
1301+
// chat-only view so meta/tool entries don't inflate the number.
1302+
const roundCount = useMemo(
1303+
() => messages.filter((m) => !m.is_meta && !m.is_tool && m.role === "user").length,
1304+
[messages],
1305+
);
1306+
12931307
const handleCopyContent = (content: string) => {
12941308
invoke("copy_to_clipboard", { text: content });
12951309
};
@@ -1432,9 +1446,7 @@ function SessionDetail({ session, onClose, highlight, scrollRef }: { session: Se
14321446
<p className="text-xs text-muted-foreground truncate">
14331447
{session.project_path ? formatPath(session.project_path) : session.project_id}
14341448
{" · "}
1435-
{userPromptsOnly
1436-
? `${filteredMessages.length} prompts`
1437-
: `${session.message_count} messages`}
1449+
{`${roundCount} rounds`}
14381450
</p>
14391451
</div>
14401452
<div className="flex items-center gap-3 shrink-0">
@@ -1646,9 +1658,30 @@ function SessionDetail({ session, onClose, highlight, scrollRef }: { session: Se
16461658
))}
16471659
</DropdownMenuContent>
16481660
</DropdownMenu>
1649-
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[60%]" title={session.project_path ?? undefined}>
1650-
cwd: {session.project_path ? formatPath(session.project_path) : "—"}
1651-
</span>
1661+
{(() => {
1662+
const info = inferModelInfo(usage?.model);
1663+
const ctx = usage?.context_tokens ?? 0;
1664+
if (!info && ctx === 0) return null;
1665+
const pct = info?.contextWindow && ctx > 0 ? Math.min(100, Math.round((ctx / info.contextWindow) * 100)) : null;
1666+
const parts = [
1667+
info?.provider,
1668+
info?.name,
1669+
ctx > 0 ? `ctx ${formatTokens(ctx)}${pct !== null ? ` (${pct}%)` : ""}` : null,
1670+
].filter(Boolean) as string[];
1671+
if (parts.length === 0) return null;
1672+
return (
1673+
<div
1674+
className="text-[10px] text-muted-foreground font-mono truncate min-w-0"
1675+
title={[
1676+
info?.provider && `Provider: ${info.provider}`,
1677+
info?.name && `Model: ${info.name}`,
1678+
ctx > 0 && `Peak context: ${ctx.toLocaleString()} tokens${info?.contextWindow ? ` / ${info.contextWindow.toLocaleString()} (${pct}%)` : ""}`,
1679+
].filter(Boolean).join("\n")}
1680+
>
1681+
{parts.join(" · ")}
1682+
</div>
1683+
);
1684+
})()}
16521685
</div>
16531686
</div>
16541687
)}

src/views/Chat/utils.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,39 @@ export function formatDate(ts: number): string {
2424
return new Date(ts * 1000).toLocaleString();
2525
}
2626

27+
export function formatTokens(n: number): string {
28+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
29+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
30+
return n.toString();
31+
}
32+
33+
// Best-effort provider/family/window inference from a raw model id seen in session usage.
34+
// Only covers the model families this app cares about today (Anthropic Claude + OpenAI/Codex).
35+
// Returns null fields when unknown rather than guessing.
36+
export interface ModelInfo {
37+
provider: string | null;
38+
name: string;
39+
contextWindow: number | null;
40+
}
41+
42+
export function inferModelInfo(model: string | undefined | null): ModelInfo | null {
43+
if (!model) return null;
44+
const m = model.toLowerCase();
45+
if (m.includes("claude")) {
46+
return { provider: "Anthropic", name: model, contextWindow: 200_000 };
47+
}
48+
if (m.startsWith("gpt-5") || m.startsWith("o3") || m.startsWith("o1") || m.includes("codex")) {
49+
return { provider: "OpenAI", name: model, contextWindow: 128_000 };
50+
}
51+
if (m.startsWith("gpt-4")) {
52+
return { provider: "OpenAI", name: model, contextWindow: 128_000 };
53+
}
54+
if (m.includes("gemini")) {
55+
return { provider: "Google", name: model, contextWindow: 1_000_000 };
56+
}
57+
return { provider: null, name: model, contextWindow: null };
58+
}
59+
2760
/** Hook that returns a function to convert text based on global readable setting */
2861
export function useReadableText(): (text: string | null | undefined) => string {
2962
const readable = useAtomValue(originalChatAtom);

0 commit comments

Comments
 (0)