fix: clarify Claude CLI billing and compact status copy

This commit is contained in:
amosblomqvist
2026-10-28 21:13:43 +02:00
parent 074ba7afe4
commit ab76e4ff50
6 changed files with 40 additions and 9 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ export function ClaudeCodeInstallationStatus() {
return () => request.current?.abort();
}, [refresh]);
return <div className="space-y-2" data-testid="claude-code-installation">
<ConnectionRow label="Claude Code" labelClassName="font-prose text-(length:--lh-prose-size) text-foreground" action="Check again" disabled={busy} onAction={() => void refresh()} actionTestid="claude-code-check-again">
<ConnectionRow label="Claude Code" labelClassName="font-prose text-(length:--lh-prose-size) text-foreground" action="Check again" actionVariant="outline" disabled={busy} onAction={() => void refresh()} actionTestid="claude-code-check-again">
<span role="status" className="text-xs text-muted-foreground">{busy ? "Checking…" : claudeCodeStatusLabel(status)}</span>
</ConnectionRow>
<p className="text-xs leading-relaxed text-muted-foreground">Install and authenticate in Claude Code outside LearnHarness. Authentication is not checked. Your Claude Code configuration determines billing.</p>
+3 -2
View File
@@ -4,11 +4,12 @@ import { createPortal } from "react-dom";
import { useBackdropDismiss } from "@/components/backdrop-dismiss";
import { Button } from "@/components/ui/button";
export function ConnectionRow({ label, action, disabled, onAction, children, testid, actionTestid, labelClassName }: { label: string; action: string; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string; labelClassName?: string }) {
export function ConnectionRow({ label, action, actionVariant, disabled, onAction, children, testid, actionTestid, labelClassName }: { label: string; action: string; actionVariant?: "outline" | "ghost"; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string; labelClassName?: string }) {
const variant = actionVariant ?? (action === "Connect" ? "outline" : "ghost");
return <div className="flex flex-wrap items-center gap-3" data-testid={testid}>
<span className={labelClassName ?? "font-ui text-xs text-foreground"}>{label}</span>
{children}
<Button type="button" size="xs" variant={action === "Connect" ? "outline" : "ghost"} className={`cursor-pointer disabled:cursor-not-allowed ${action === "Connect" ? "" : "text-muted-foreground hover:text-foreground"}`} data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button>
<Button type="button" size="xs" variant={variant} className={`cursor-pointer disabled:cursor-not-allowed ${variant === "outline" ? "" : "text-muted-foreground hover:text-foreground"}`} data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button>
</div>;
}
+4 -3
View File
@@ -109,7 +109,8 @@ export function StatsBar({
// otherwise "—" (never a fake $0.0000).
const pricedCount = turns.length - unpriced - subscription;
const providers = pricing?.subscriptionProviders ?? subscriptionLabels(turns.map((turn) => turn.model));
const costLabel = planCostLabel(pricedCount > 0 ? cost : null, providers);
const cliBilled = turns.some((turn) => turn.model.startsWith(`${CLAUDE_CODE_PROVIDER}:`));
const costLabel = planCostLabel(pricedCount > 0 ? cost : null, cliBilled ? [...providers, "CLI"] : providers);
return (
<div
@@ -164,7 +165,7 @@ export function StatsBar({
</span>
</div>
{selectedModel?.startsWith(`${CLAUDE_CODE_PROVIDER}:`) && (
<p>The window is reported by Claude Code’s local summary and may reflect its auto-compaction budget rather than the model’s full maximum. Unavailable windows are omitted.</p>
<p>Window reported by Claude Code; may be lower for auto-compaction.</p>
)}
<div className="flex justify-between gap-2">
<span>{subscription ? "Usage" : "Total cost"}</span>
@@ -193,7 +194,7 @@ export function StatsBar({
</span>
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground">
<span className="text-muted-foreground">{turn.steps.length} steps</span>
<span>{subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))}</span>
<span>{turn.model.startsWith(`${CLAUDE_CODE_PROVIDER}:`) ? "CLI" : subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))}</span>
</span>
</li>
);
+6 -3
View File
@@ -7,7 +7,7 @@ import { parseHTML } from "linkedom";
import { StatsBar } from "../components/stats-bar.tsx";
import { CLAUDE_CODE_MODELS } from "../src/agent/claude-code/provider.ts";
import { toModelDto } from "../src/server/model-catalog.ts";
import type { ModelCatalogResponse } from "../src/core/index.ts";
import type { ModelCatalogResponse, TurnUsage } from "../src/core/index.ts";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement,
@@ -27,14 +27,17 @@ test("compact CLI context is numeric only; effective-window explanation appears
throw new Error(`unexpected request ${url}`);
}) as typeof fetch;
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
const render = (window: { model: string; tokens: number } | null) => root!.render(<StatsBar chatId="cli-session" turns={[]} contextTokens={42000} cliContextWindow={window} />);
const turn: TurnUsage = { id: "cli-turn", messageId: "cli-message", model: "claude-code:opus", at: "2026-10-28T00:00:00Z", steps: [{ inputTokens: 42000, outputTokens: 1, cacheReadTokens: undefined, cacheWriteTokens: undefined, reasoningTokens: undefined }] };
const render = (window: { model: string; tokens: number } | null) => root!.render(<StatsBar chatId="cli-session" turns={[turn]} contextTokens={42000} cliContextWindow={window} />);
await act(async () => render({ model: "claude-code:opus", tokens: 200000 }));
assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k / 200k");
assert(!document.body.textContent?.includes("auto-compaction"));
assert.equal(document.querySelector('[data-testid="stats-cost"]')?.textContent, "CLI");
const summary = document.querySelector<HTMLButtonElement>('[data-testid="stats-summary"]')!;
assert(!summary.textContent?.includes("window")); assert(!summary.textContent?.includes("budget"));
await act(async () => summary.click());
assert.match(document.querySelector('[data-testid="stats-detail"]')?.textContent ?? "", /auto-compaction budget rather than the model’s full maximum/);
assert.match(document.querySelector('[data-testid="stats-detail"]')?.textContent ?? "", /Window reported by Claude Code; may be lower for auto-compaction\./);
assert.equal(document.querySelector('[data-testid="stats-detail"] li > span:last-child > span:last-child')?.textContent, "CLI");
assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k / 200k");
await act(async () => render(null));
assert.equal(document.querySelector('[data-testid="stats-context"]')?.textContent, "42k");
+13
View File
@@ -6,6 +6,7 @@ import { createRoot, type Root } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
import { parseHTML } from "linkedom";
import { ClaudeCodeInstallationStatus, claudeCodeStatusLabel } from "../components/settings/claude-code-status.tsx";
import { ConnectionRow } from "../components/settings/connection-controls.tsx";
import type { ClaudeCodeStatus } from "../src/core/contracts/claude-code.ts";
const dom = parseHTML("<html><body></body></html>");
@@ -33,6 +34,18 @@ test("row is informational, disclaims auth/billing readiness, and links only to
assert.equal((html.match(/<button/g) ?? []).length, 1);
assert(!html.includes("Connect</button>"));
assert(!html.includes("Sign in</button>"));
assert.match(html, /data-variant="outline"/);
assert.match(html, /data-size="xs"/);
});
test("recheck matches the existing Connect button styling without restyling Manage actions", () => {
const row = (action: string) => renderToStaticMarkup(<ConnectionRow label="Fixture" action={action} disabled={false} onAction={() => {}} />);
const connect = parseHTML(row("Connect")).document.querySelector("button")!;
const manage = parseHTML(row("Manage")).document.querySelector("button")!;
const check = parseHTML(renderToStaticMarkup(<ClaudeCodeInstallationStatus />)).document.querySelector("button")!;
assert.equal(check.getAttribute("class"), connect.getAttribute("class"));
assert.equal(check.getAttribute("data-variant"), "outline");
assert.equal(manage.getAttribute("data-variant"), "ghost");
});
for (const [value, text] of [
+13
View File
@@ -25,6 +25,7 @@ for (const model of CLAUDE_CODE_MODELS) test(`${model.id}: token usage is retain
assert.equal(jobCost({ usage: accumulateJobUsage(undefined, id, step) }), null);
const html = renderToStaticMarkup(<StatsBar turns={[turn]} pricing={pricing} />);
assert(!html.includes("$0"));
assert.match(html, /data-testid="stats-cost">CLI<\/span>/);
const summary = graphCostSummary({ chats: [{ title: "CLI fixture", extractions: [{ startedAt: 1, at: turn.at, durationMs: 1,
stages: { extractor: { model: id, steps: [step] }, auditor: { model: id, steps: [step] } },
}] }], sources: [] }, callCost);
@@ -32,6 +33,18 @@ for (const model of CLAUDE_CODE_MODELS) test(`${model.id}: token usage is retain
assert.equal(summary.runs[0]!.dollars, null);
});
test("mixed session totals retain known API dollars and subscription labels alongside CLI billing", () => {
const cli: TurnUsage = { id: "cli", messageId: "cli", at: "2026-10-28T00:00:00Z", model: "claude-code:sonnet", steps: [step] };
const api = { ...cli, id: "api", messageId: "api", model: "openai:fixture" };
const pricing = { cost: 0.12, unpriced: 1, turnCosts: { cli: null, api: 0.12 } };
const html = renderToStaticMarkup(<StatsBar turns={[api, cli]} pricing={pricing} />);
assert.match(html, /data-testid="stats-cost">\$0\.1200 \+ CLI<\/span>/);
const subscription = { ...cli, id: "plan", messageId: "plan", model: "chatgpt:fixture" };
const mixed = renderToStaticMarkup(<StatsBar turns={[api, cli, subscription]} pricing={{ ...pricing, subscription: 1, subscriptionProviders: ["ChatGPT"] }} />);
assert.match(mixed, /data-testid="stats-cost">\$0\.1200 \+ ChatGPT \+ CLI<\/span>/);
assert.equal(callCost(step, cli.model), null, "CLI stays unpriced in accounting");
});
test("model-catalog API and menus never advertise the CLI adapter's placeholder zero rates", () => {
const models = CLAUDE_CODE_MODELS.map(toModelDto);
assert(models.every((model) => model.cost === null));