Compare commits

...

2 commits

Author SHA1 Message Date
b23f3d1ecb memory: log usage-panel scope/metric refinements + double-count investigation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:26:33 +01:00
ebbf8db407 Usage panel: scope to open panes, lead with tokens, label $ as API estimate
Addresses feedback on the usage panel:
- It was summing every recent session on the distro (all projects, mounted
  + home dirs), not the open panes' work — which read as inflated/double-
  counted. (Verified there's no literal double count: every transcript is
  read once and no two project dirs share a cwd, since claude resolves
  symlinks/mounts to the real path before mangling.) Now the panel + the
  titlebar chip default to sessions whose cwd matches an open pane, with an
  'open panes / all recent' toggle to see the full per-distro list.
- Token volume is now the headline figure; the API-cost estimate is shown
  as a clearly-labeled '~$' secondary, with a footer note that it's n/a on
  a Pro/Max subscription and can't reflect /usage quota. Kept visible (not
  hidden) so it can be validated against real API billing.

Frontend-only; backend still returns the full recent set for the toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 22:26:15 +01:00
4 changed files with 81 additions and 11 deletions

View file

@ -57,6 +57,7 @@ Four-agent research pass (terminal-landscape, AI-orchestration, xterm/Tauri ecos
**→ Exploring first (user-selected 2026-05-28):** **→ Exploring first (user-selected 2026-05-28):**
- [x] ~~**Per-session cost / token tracking.**~~ Done (code) 2026-05-28 — **WSL-only v1, pending Windows runtime verify.** Backend `src-tauri/src/usage.rs` (`get_claude_usage(distros)` command): probes `$HOME` per distro via `wsl.exe`, reads `~/.claude/projects/*/*.jsonl` over the `\\wsl.localhost\<distro>` UNC share, tallies `message.usage` **per model per assistant line** (sessions can switch models). Cached by `(path,size,mtime)`; recency-capped 30d/50 sessions. Frontend: `src/lib/usage.ts` holds the editable pricing table (per-MTok, matched by opus/sonnet/haiku substring) + cost/format helpers; `UsagePanel.tsx` (MCP-panel modal pattern) lists sessions, highlights those whose transcript `cwd` matches an open pane (`[pane: label]`); titlebar 💰 total chip; App polls 20s (visible) / 5s (panel open); **Ctrl+Shift+U** opens it. **Design choice:** session-list attribution (not 1:1 pane binding) — avoids the unsolvable "2 claudes in one cwd" ambiguity. **Caveats:** cost is an estimate (cache-creation priced at 5m rate; rates hardcoded, may drift); panes with no explicit cwd (`~`) won't highlight; PowerShell/SSH show nothing. Plan: `~/.claude/plans/greedy-cooking-flask.md`. - [x] ~~**Per-session cost / token tracking.**~~ Done (code) 2026-05-28 — **WSL-only v1, pending Windows runtime verify.** Backend `src-tauri/src/usage.rs` (`get_claude_usage(distros)` command): probes `$HOME` per distro via `wsl.exe`, reads `~/.claude/projects/*/*.jsonl` over the `\\wsl.localhost\<distro>` UNC share, tallies `message.usage` **per model per assistant line** (sessions can switch models). Cached by `(path,size,mtime)`; recency-capped 30d/50 sessions. Frontend: `src/lib/usage.ts` holds the editable pricing table (per-MTok, matched by opus/sonnet/haiku substring) + cost/format helpers; `UsagePanel.tsx` (MCP-panel modal pattern) lists sessions, highlights those whose transcript `cwd` matches an open pane (`[pane: label]`); titlebar 💰 total chip; App polls 20s (visible) / 5s (panel open); **Ctrl+Shift+U** opens it. **Design choice:** session-list attribution (not 1:1 pane binding) — avoids the unsolvable "2 claudes in one cwd" ambiguity. **Caveats:** cost is an estimate (cache-creation priced at 5m rate; rates hardcoded, may drift); panes with no explicit cwd (`~`) won't highlight; PowerShell/SSH show nothing. Plan: `~/.claude/plans/greedy-cooking-flask.md`.
- **Refined same day after user feedback:** (1) **Scope** — panel + titlebar chip now default to sessions matching open panes ("this workspace"), with an "open panes / all recent" toggle. The first cut summed *every* recent session on the distro (all projects, `/mnt` + home), which read as inflated. **Investigated the "double counting mounted folders + projects" report: NOT a real double count** — every transcript file is read exactly once, and no two project dirs share a cwd because claude resolves symlinks/mounts to the real path before mangling the project-dir name (e.g. the `~/claude/projects/tiletopia → /mnt/d/dev/tiletopia` symlink yields only `-mnt-d-dev-tiletopia`). The inflation was purely the global scope. (2) **Metric framing** — user is on a Pro/Max subscription where $ is meaningless (and `/usage` rate-limit quota can't be derived from transcripts); **tokens are now the headline**, the API-cost estimate is a labeled secondary `~$` kept visible so the user can validate it against real API billing at work. **Open question:** accuracy of the $ estimate vs actual API billing — user will check at work.
- [ ] **Smart link providers.** `terminal.registerLinkProvider()` to make file paths (`src/foo.ts:12:3`), `localhost:PORT`, and error locations clickable — more flexible than the regex-only web-links addon already loaded. Open file in editor / browser. Difficulty: medium. - [ ] **Smart link providers.** `terminal.registerLinkProvider()` to make file paths (`src/foo.ts:12:3`), `localhost:PORT`, and error locations clickable — more flexible than the regex-only web-links addon already loaded. Open file in editor / browser. Difficulty: medium.
- [x] ~~**Find in scrollback.**~~ Done + **verified on Windows 2026-05-28**`@xterm/addon-search` + new `src/components/SearchBar.tsx`/`.css` overlay, Ctrl+Shift+F open / Enter / Shift+Enter / Esc, regex + case toggles, decoration highlight. - [x] ~~**Find in scrollback.**~~ Done + **verified on Windows 2026-05-28**`@xterm/addon-search` + new `src/components/SearchBar.tsx`/`.css` overlay, Ctrl+Shift+F open / Enter / Shift+Enter / Esc, regex + case toggles, decoration highlight.
- [x] ~~**Unicode 11 + grapheme width.**~~ Done + **verified on Windows 2026-05-28**`@xterm/addon-unicode11` loaded after CanvasAddon, `term.unicode.activeVersion = '11'`. (Skipped the separate `addon-unicode-graphemes` for now.) - [x] ~~**Unicode 11 + grapheme width.**~~ Done + **verified on Windows 2026-05-28**`@xterm/addon-unicode11` loaded after CanvasAddon, `term.unicode.activeVersion = '11'`. (Skipped the separate `addon-unicode-graphemes` for now.)

View file

@ -804,6 +804,13 @@ export default function App() {
[tree], [tree],
); );
// Titlebar chip total — scoped to the open panes ("this workspace"), matching
// the usage panel's default view, so it isn't inflated by unrelated projects.
const workspaceUsageTotal = useMemo(() => {
const cwds = new Set(openPanes.map((p) => p.cwd).filter(Boolean));
return totalCost(usageSessions.filter((s) => cwds.has(s.cwd)));
}, [openPanes, usageSessions]);
// Outside-click dismissal for the titlebar dropdowns. Mirrors the // Outside-click dismissal for the titlebar dropdowns. Mirrors the
// per-pane shell-picker pattern in LeafPane.tsx. // per-pane shell-picker pattern in LeafPane.tsx.
useEffect(() => { useEffect(() => {
@ -2153,7 +2160,7 @@ export default function App() {
title="claude token usage & estimated cost (Ctrl+Shift+U)" title="claude token usage & estimated cost (Ctrl+Shift+U)"
aria-label="Usage" aria-label="Usage"
> >
💰{usageSessions.length > 0 ? ` ${formatUsd(totalCost(usageSessions))}` : ""} 💰{workspaceUsageTotal > 0 ? ` ~${formatUsd(workspaceUsageTotal)}` : ""}
</button> </button>
<button <button
className="palette-btn" className="palette-btn"

View file

@ -32,11 +32,38 @@
font-weight: 600; font-weight: 600;
font-size: 13px; font-size: 13px;
} }
.usage-scope {
font: inherit;
font-size: 11px;
background: transparent;
border: 1px solid #2a2a2a;
border-radius: 3px;
color: #9aa0a6;
padding: 2px 8px;
cursor: pointer;
margin-right: auto;
}
.usage-scope:hover {
background: #2a2a2a;
color: #ddd;
}
.usage-total { .usage-total {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #e6e6e6;
}
.usage-total-usd {
color: #6cc04a; color: #6cc04a;
margin-right: auto; font-weight: 600;
}
.usage-link {
font: inherit;
background: transparent;
border: none;
color: #6ca0d8;
cursor: pointer;
text-decoration: underline;
padding: 0;
} }
.usage-refresh, .usage-refresh,
.usage-close { .usage-close {

View file

@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import type { SessionUsage } from "../ipc"; import type { SessionUsage } from "../ipc";
import { import {
sessionCost, sessionCost,
@ -32,6 +32,10 @@ export default function UsagePanel({
onClose, onClose,
openPanes, openPanes,
}: UsagePanelProps) { }: UsagePanelProps) {
// Default to the open panes' sessions ("this workspace"); toggle to see
// every recent session on the distros.
const [showAll, setShowAll] = useState(false);
// Refresh on open and on a light interval while open. // Refresh on open and on a light interval while open.
useEffect(() => { useEffect(() => {
onRefresh(); onRefresh();
@ -46,8 +50,11 @@ export default function UsagePanel({
if (p.cwd && !paneByCwd.has(p.cwd)) paneByCwd.set(p.cwd, p.label); if (p.cwd && !paneByCwd.has(p.cwd)) paneByCwd.set(p.cwd, p.label);
} }
const matched = sessions.filter((s) => paneByCwd.has(s.cwd));
const shown = showAll ? sessions : matched;
const nowMs = Date.now(); const nowMs = Date.now();
const total = totalCost(sessions); const total = totalCost(shown);
return ( return (
<> <>
@ -55,8 +62,26 @@ export default function UsagePanel({
<div className="usage-panel" role="dialog" aria-label="Token usage"> <div className="usage-panel" role="dialog" aria-label="Token usage">
<header className="usage-header"> <header className="usage-header">
<span className="usage-title">Usage</span> <span className="usage-title">Usage</span>
<span className="usage-total" title="Estimated total across listed sessions"> <button
{formatUsd(total)} className="usage-scope"
onClick={() => setShowAll((v) => !v)}
title={
showAll
? "Showing every recent session on the open panes' distros"
: "Showing only sessions for currently-open panes"
}
>
{showAll ? `all recent (${sessions.length})` : `open panes (${matched.length})`}
</button>
<span
className="usage-total"
title="Total tokens across the listed sessions"
>
{formatTokens(shown.reduce((a, s) => a + sessionTokens(s), 0))} tok
<span className="usage-total-usd" title="API-pricing estimate — n/a on a Pro/Max subscription">
{" · ~"}
{formatUsd(total)}
</span>
</span> </span>
<button <button
className="usage-refresh" className="usage-refresh"
@ -73,15 +98,25 @@ export default function UsagePanel({
</header> </header>
<div className="usage-body"> <div className="usage-body">
{sessions.length === 0 ? ( {shown.length === 0 ? (
<p className="usage-empty"> <p className="usage-empty">
{loading {loading
? "Reading transcripts…" ? "Reading transcripts…"
: "No recent claude sessions found in the open panes' WSL distros."} : sessions.length > 0 && !showAll
? "No open pane has a matching claude session yet."
: "No recent claude sessions found in the open panes' WSL distros."}
{sessions.length > 0 && !showAll && (
<>
{" "}
<button className="usage-link" onClick={() => setShowAll(true)}>
Show all recent ({sessions.length})
</button>
</>
)}
</p> </p>
) : ( ) : (
<ul className="usage-list"> <ul className="usage-list">
{sessions.map((s) => { {shown.map((s) => {
const paneLabel = paneByCwd.get(s.cwd); const paneLabel = paneByCwd.get(s.cwd);
const open = paneLabel !== undefined; const open = paneLabel !== undefined;
return ( return (
@ -121,8 +156,8 @@ export default function UsagePanel({
</div> </div>
<footer className="usage-foot"> <footer className="usage-foot">
= open pane &nbsp;·&nbsp; estimate (rates may drift) &nbsp;·&nbsp; recent = open pane &nbsp;·&nbsp; ~$ is an API-pricing estimate (n/a on Pro/Max;
sessions only can't reflect <code>/usage</code> quota) &nbsp;·&nbsp; recent sessions only
</footer> </footer>
</div> </div>
</> </>