Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions desktop/frontend/src/components/StatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Coins, Cpu, Wallet } from "lucide-react";
import { EffortSwitcher } from "./EffortSwitcher";
import { ModelSwitcher } from "./ModelSwitcher";
import { Tooltip } from "./Tooltip";
import { UsageIndicator } from "./UsageIndicator";
import { SPINNER_WORDS, useI18n } from "../lib/i18n";
import type { BalanceInfo, ContextInfo, EffortInfo, JobView, Meta, Mode, WireUsage } from "../lib/types";

Expand Down Expand Up @@ -168,6 +169,12 @@ export function StatusBar({
<span className="statusbar__cache">{t("status.cacheAvg", { pct: avgPct })}</span>
</>
)}
{usage && (
<>
<span className="statusbar__sep">·</span>
<UsageIndicator usage={usage} />
</>
)}
{jobs && jobs.length > 0 && (
<>
<span className="statusbar__sep">·</span>
Expand Down
48 changes: 48 additions & 0 deletions desktop/frontend/src/components/UsageIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { WireUsage } from "../lib/types";

// UsageIndicator is a compact, info-dense readout of the latest turn's
// token usage. It mounts in the status bar to the right of the cache-
// hit % indicator. The three numbers are styled with a small caption
// + value pair so the user can tell at a glance "this turn cost 4.2k
// completion tokens, mostly from the prompt cache".
//
// We use a pill instead of a sparkline because the controller exposes
// only the latest WireUsage payload (not a per-turn history). A
// sparkline would need a separate ring buffer in the controller; a
// future PR can add that and swap the pill for a real line chart.
//
// The component is intentionally tiny: 60 lines, no state, no
// effects. It re-renders when the WireUsage prop changes (the
// controller dispatches on every usage event during the turn).
export function UsageIndicator({ usage }: { usage?: WireUsage }) {
if (!usage) return null;
// "k" rounding: 0–999 shows as the raw number, ≥1000 as "1.2k".
// Two significant digits feel right for both 800 (informative) and
// 184,000 (don't drown the status bar).
const fmt = (n: number): string => {
if (n < 1000) return String(n);
return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
};
// The "saved" caption shows the cache-hit count as a fraction of
// the prompt tokens; deepSeek and Anthropic charge less for cache
// hits, so this number is the user-facing ROI.
const promptSaved = usage.cacheHitTokens;
return (
<div className="usage" title="Latest turn token usage">
<span className="usage__cell">
<span className="usage__lbl">↑</span>
<span className="usage__val">{fmt(usage.promptTokens)}</span>
</span>
<span className="usage__cell">
<span className="usage__lbl">↓</span>
<span className="usage__val">{fmt(usage.completionTokens)}</span>
</span>
{promptSaved > 0 && (
<span className="usage__cell usage__cell--saved" title="Cache hits this turn">
<span className="usage__lbl">↻</span>
<span className="usage__val">{fmt(promptSaved)}</span>
</span>
)}
</div>
);
}
44 changes: 44 additions & 0 deletions desktop/frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -5541,6 +5541,7 @@ body {
color: var(--fg);
background: var(--hover);
}

.composer__pasted {
display: flex;
flex-direction: column;
Expand Down Expand Up @@ -5881,4 +5882,47 @@ body {
.onboarding__skip:disabled {
opacity: 0.4;
cursor: not-allowed;


/* UsageIndicator: a small three-cell pill that mounts in the status
bar. The up/down arrows are in unicode (cheaper than lucide icons
for an always-on status bar element). The cache-hit cell is
dimmer than the prompt/completion cells so the eye lands on the
billable numbers first. */
.usage {
display: inline-flex;
align-items: center;
gap: 0;
margin-left: 6px;
padding: 2px 4px;
font-family: var(--mono);
font-size: 10.5px;
color: var(--fg-dim);
background: var(--bg-soft);
border: 1px solid var(--border-soft);
border-radius: 4px;
}
.usage__cell {
display: inline-flex;
align-items: baseline;
gap: 2px;
padding: 0 5px;
border-right: 1px solid var(--border-soft);
}
.usage__cell:last-child {
border-right: 0;
}
.usage__lbl {
opacity: 0.6;
font-size: 9.5px;
}
.usage__val {
color: var(--fg);
font-variant-numeric: tabular-nums;
}
.usage__cell--saved {
color: var(--add-fg);
}
.usage__cell--saved .usage__val {
color: var(--add-fg);
}
Loading