From ab76e4ff50681ca7959ab3cf58c56c5cc0f6ca22 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 28 Oct 2026 21:13:43 +0200 Subject: [PATCH] fix: clarify Claude CLI billing and compact status copy --- components/settings/claude-code-status.tsx | 2 +- components/settings/connection-controls.tsx | 5 +++-- components/stats-bar.tsx | 7 ++++--- tests/claude-code-context-ui.test.tsx | 9 ++++++--- tests/claude-code-status-ui.test.tsx | 13 +++++++++++++ tests/claude-code-usage.test.tsx | 13 +++++++++++++ 6 files changed, 40 insertions(+), 9 deletions(-) diff --git a/components/settings/claude-code-status.tsx b/components/settings/claude-code-status.tsx index cc5fd33..65c600a 100644 --- a/components/settings/claude-code-status.tsx +++ b/components/settings/claude-code-status.tsx @@ -40,7 +40,7 @@ export function ClaudeCodeInstallationStatus() { return () => request.current?.abort(); }, [refresh]); return
- void refresh()} actionTestid="claude-code-check-again"> + void refresh()} actionTestid="claude-code-check-again"> {busy ? "Checking…" : claudeCodeStatusLabel(status)}

Install and authenticate in Claude Code outside LearnHarness. Authentication is not checked. Your Claude Code configuration determines billing.

diff --git a/components/settings/connection-controls.tsx b/components/settings/connection-controls.tsx index 5a92319..cd67f47 100644 --- a/components/settings/connection-controls.tsx +++ b/components/settings/connection-controls.tsx @@ -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
{label} {children} - +
; } diff --git a/components/stats-bar.tsx b/components/stats-bar.tsx index 1abbc2b..d3e9811 100644 --- a/components/stats-bar.tsx +++ b/components/stats-bar.tsx @@ -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 (
{selectedModel?.startsWith(`${CLAUDE_CODE_PROVIDER}:`) && ( -

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.

+

Window reported by Claude Code; may be lower for auto-compaction.

)}
{subscription ? "Usage" : "Total cost"} @@ -193,7 +194,7 @@ export function StatsBar({ {turn.steps.length} steps - {subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))} + {turn.model.startsWith(`${CLAUDE_CODE_PROVIDER}:`) ? "CLI" : subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))} ); diff --git a/tests/claude-code-context-ui.test.tsx b/tests/claude-code-context-ui.test.tsx index 3b026ac..9aa62f3 100644 --- a/tests/claude-code-context-ui.test.tsx +++ b/tests/claude-code-context-ui.test.tsx @@ -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(""); 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(); + 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(); 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('[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"); diff --git a/tests/claude-code-status-ui.test.tsx b/tests/claude-code-status-ui.test.tsx index 015fe66..1afbf0d 100644 --- a/tests/claude-code-status-ui.test.tsx +++ b/tests/claude-code-status-ui.test.tsx @@ -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(""); @@ -33,6 +34,18 @@ test("row is informational, disclaims auth/billing readiness, and links only to assert.equal((html.match(/")); + 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( {}} />); + const connect = parseHTML(row("Connect")).document.querySelector("button")!; + const manage = parseHTML(row("Manage")).document.querySelector("button")!; + const check = parseHTML(renderToStaticMarkup()).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 [ diff --git a/tests/claude-code-usage.test.tsx b/tests/claude-code-usage.test.tsx index 7c781fc..8415d2f 100644 --- a/tests/claude-code-usage.test.tsx +++ b/tests/claude-code-usage.test.tsx @@ -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(); 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(); + assert.match(html, /data-testid="stats-cost">\$0\.1200 \+ CLI<\/span>/); + const subscription = { ...cli, id: "plan", messageId: "plan", model: "chatgpt:fixture" }; + const mixed = renderToStaticMarkup(); + 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));