Skip to content
Merged
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`PORT=$LICH_WORKTREE_PORT pnpm dev` in the setup script (Settings › Project),
or in the terminal, and both worktrees come up. The number is the same every
time for a given checkout, so a bookmark to it keeps working across restarts.
- **Settings › Providers › Claude can show what a session has cost.** Off by
default, and deliberately so: on a subscription plan the number is noise, so
with the setting off the footer shows nothing at all — no zero, no greyed-out
figure — and lich never even reads the transcript for it. Turned on, the
footer carries the session's spend at API prices beside the context ring,
summed across every turn it has run, including the conversations it cleared
and the sub-agents it spawned. The total survives a restart, and a session
parked with its worktree keeps it when you reopen it. Prices ship with the
binary and refresh themselves from the published price table the first time a
session runs a model that binary never heard of; while a model has no known
price, the readout stays absent rather than quoting a total that is missing a
turn.

### Fixed

Expand Down
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ nobody knows it and that the call site never shows. The mechanism and the histor
land on the same number and only the dev server started second finds out. Stability is the trade — a probe
would move the port every time the previous server still held it. The main checkout is assigned one like any
worktree; the range is fixed, not per-project.
- **The cost readout is priced from a table, not from the provider**: no provider publishes an API for what a
turn cost, so `internal/pricing` bills the transcript's token counts against a baked table that refreshes
itself from LiteLLM's published one when it meets an unknown model. Two consequences: a model nobody has
priced yet makes the readout go *absent* (the scan stops at that line — a total missing a turn is worse than
no total), and the accounting is per `(session, transcript)` in `session_costs`, so a conversation forked
inside the PTY — which copies its history into a new transcript — bills that history twice. lich's own
resume continues the same transcript and is unaffected.
- **git status is polled** — one shared poller per repository path (`frontend/src/lib/git/git-status-store.ts`); the
lich plugin's `session-touched` hook nudges an immediate refresh. An fs watcher is the upgrade path.
- **Persistence is hybrid**: UI prefs in the page's localStorage (`lich.*` keys — the reason the listener port is
Expand Down
30 changes: 28 additions & 2 deletions frontend/src/components/FooterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type { DockTab } from "@/components/dock/RightDock"
import { useActiveSession } from "@/lib/session/use-active-session"
import { useSessionCwd } from "@/lib/session/use-session-cwd"
import { useSessionUsage } from "@/lib/session/use-session-usage"
import { useCostReadout } from "@/lib/use-cost-readout"
import { formatCost } from "@/lib/session/session-cost"
import { displayPath } from "@/lib/paths"
import { useGitStatus } from "@/lib/git/use-git-status"
import { usePullRequest } from "@/lib/pulls/use-pull-request"
Expand Down Expand Up @@ -92,8 +94,10 @@ export function FooterBar({ dock, onDock }: FooterBarProps) {
// Context-window occupancy of the active session, read off its transcript at
// each turn's end (null until the first turn of a Claude session lands).
const usage = useSessionUsage(sessionId)
// The footer context readout is opt-out (Settings › Providers).
// The footer context readout is opt-out (Settings › Providers); the cost
// beside it is opt-in, and off for everyone not billed per token.
const { showContextUsage } = useSettings()
const showCost = useCostReadout()
const status = useGitStatus(path)
const pr = usePullRequest(path, status?.branch ?? "", status?.head ?? "")
const now = useNow()
Expand All @@ -109,6 +113,27 @@ export function FooterBar({ dock, onDock }: FooterBarProps) {
}
}

// What the session has cost, beside the ring. Rendered only when the backend
// sent a number: on a subscription — and for a model no price table knows —
// there is nothing here at all, which is the point of the setting.
const costReadout =
usage && showCost && usage.costUsd !== null ? (
<Tooltip>
<TooltipTrigger render={<span className="tabular-nums" />}>
{formatCost(usage.costUsd)}
</TooltipTrigger>
<TooltipContent side="top" className="border border-border bg-card text-foreground">
<div className="flex flex-col gap-1">
<span className="font-medium">Session cost</span>
<span className="text-xs text-muted-foreground">
API pricing for every turn this session has run, this conversation and the ones it
cleared.
</span>
</div>
</TooltipContent>
</Tooltip>
) : null

// The context-window readout — the ring plus percent, with a detailed
// tooltip. Null when the user turned it off (Settings › Providers).
const contextReadout =
Expand Down Expand Up @@ -195,8 +220,9 @@ export function FooterBar({ dock, onDock }: FooterBarProps) {

<span className="ml-auto flex items-center gap-4">
{showContextUsage && <SessionModel sessionId={sessionId} />}
{costReadout}
{contextReadout}
{contextReadout && (status?.branch || path) && (
{(contextReadout || costReadout) && (status?.branch || path) && (
<Separator orientation="vertical" className="h-4" />
)}
{status?.branch && (
Expand Down
38 changes: 27 additions & 11 deletions frontend/src/components/settings/ProviderBinSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { Store } from "@/lib/rpc"
import { useProjects } from "@/providers/projects"
import { binKey } from "@/lib/providers-store"
import { useSettings } from "@/providers/settings"
import { setCostReadout } from "@/lib/cost-readout-store"
import { useCostReadout } from "@/lib/use-cost-readout"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import { SettingBlock } from "./SettingBlock"
Expand All @@ -22,6 +24,7 @@ export function ProviderBinSettings({
}) {
const { projects } = useProjects()
const { showContextUsage, setShowContextUsage } = useSettings()
const showCost = useCostReadout()
const project = projects.find((p) => p.id === projectId)
const key = binKey(providerId)
const [globalBin, setGlobalBin] = useState("")
Expand Down Expand Up @@ -84,18 +87,31 @@ export function ProviderBinSettings({
)}

{/* Only Claude Code reports context-window usage (via the lich plugin), so
the footer readout toggle lives in its section, not the generic hub. */}
the footer readout toggles live in its section, not the generic hub. */}
{providerId === "claude" && (
<SettingBlock
title="Model & context in the footer"
description="Show this session's model and context-window usage in the footer — the model name plus a ring with the percent, read from the transcript."
>
<Switch
checked={showContextUsage}
onCheckedChange={setShowContextUsage}
aria-label="Show model and context usage in the footer"
/>
</SettingBlock>
<>
<SettingBlock
title="Model & context in the footer"
description="Show this session's model and context-window usage in the footer — the model name plus a ring with the percent, read from the transcript."
>
<Switch
checked={showContextUsage}
onCheckedChange={setShowContextUsage}
aria-label="Show model and context usage in the footer"
/>
</SettingBlock>

<SettingBlock
title="Session cost in the footer"
description="Show what each session has cost at API prices, summed from its transcript. Leave this off on a subscription plan — the figure only means something when you are billed per token."
>
<Switch
checked={showCost}
onCheckedChange={setCostReadout}
aria-label="Show session cost in the footer"
/>
</SettingBlock>
</>
)}
</>
)
Expand Down
93 changes: 93 additions & 0 deletions frontend/src/lib/cost-readout-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { costReadoutStore, resetCostReadoutStore, setCostReadout } from "./cost-readout-store"

// The stored setting is the boundary; stub it so the test can assert both what
// the store reads and what a toggle writes back.
const getSetting = vi.fn()
const setSetting = vi.fn()
vi.mock("@/lib/rpc", () => ({
Store: {
GetSetting: (key: string, projectID: string) => getSetting(key, projectID),
SetSetting: (key: string, projectID: string, value: string) =>
setSetting(key, projectID, value),
},
}))

// flush lets the store's one load settle.
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))

beforeEach(() => {
resetCostReadoutStore()
getSetting.mockReset().mockResolvedValue("")
setSetting.mockReset().mockResolvedValue(null)
})

describe("costReadoutStore", () => {
// Off is what a subscription plan must see, and it is also what the page
// shows for the instant before the stored value arrives.
it("reads off before anything is loaded", () => {
expect(costReadoutStore.get()).toBe(false)
})

it("loads the stored flag on the first subscriber", async () => {
getSetting.mockResolvedValue("true")
let notified = 0
costReadoutStore.subscribe(() => notified++)

await flush()

expect(getSetting).toHaveBeenCalledWith("usage.cost", "")
expect(costReadoutStore.get()).toBe(true)
expect(notified).toBe(1)
})

it("loads once however many subscribers mount", async () => {
costReadoutStore.subscribe(() => {})
costReadoutStore.subscribe(() => {})
costReadoutStore.subscribe(() => {})

await flush()

expect(getSetting).toHaveBeenCalledTimes(1)
})

it("treats anything but the stored true as off", async () => {
getSetting.mockResolvedValue("1")
costReadoutStore.subscribe(() => {})

await flush()

expect(costReadoutStore.get()).toBe(false)
})

// An unreachable backend must not turn a money readout on by accident.
it("stays off when the setting cannot be read", async () => {
getSetting.mockRejectedValue(new Error("no backend"))
costReadoutStore.subscribe(() => {})

await flush()

expect(costReadoutStore.get()).toBe(false)
})

it("writes a toggle through and updates the page at once", () => {
let notified = 0
costReadoutStore.subscribe(() => notified++)

setCostReadout(true)

expect(costReadoutStore.get()).toBe(true)
expect(notified).toBe(1)
expect(setSetting).toHaveBeenCalledWith("usage.cost", "", "true")
})

it("stops notifying an unsubscribed listener", () => {
let notified = 0
const off = costReadoutStore.subscribe(() => notified++)
off()

setCostReadout(true)

expect(notified).toBe(0)
})
})
72 changes: 72 additions & 0 deletions frontend/src/lib/cost-readout-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Store } from "@/lib/rpc"

// Whether the footer shows what a session has cost. The truth lives in the
// backend settings table, not in localStorage, because the flag gates work and
// not just paint: with it off no transcript is summed and no price is ever
// fetched (see internal/store settings.CostReadout). This store is the page's
// copy of it — loaded once, written through on every toggle — so turning the
// readout off clears the number immediately instead of at the next turn.
//
// Off is the honest default while the value is still loading: the figure only
// means something on API billing, so nobody should see one appear and vanish.

// SETTING_KEY and GLOBAL_SCOPE mirror the Go side's costReadoutKey and its
// global (project-less) scope.
const SETTING_KEY = "usage.cost"
const GLOBAL_SCOPE = ""

let enabled = false
let loaded = false
const listeners = new Set<() => void>()

const notify = () => {
for (const listener of listeners) {
listener()
}
}

// load reads the stored flag once per page. A failed read leaves it off, which
// is what an unreachable backend should show for a number about money.
const load = async () => {
try {
const value = await Store.GetSetting(SETTING_KEY, GLOBAL_SCOPE)
const next = value === "true"
if (next !== enabled) {
enabled = next
notify()
}
} catch {
// Keep the default; the toggle in Settings still works and will write.
}
}

export const costReadoutStore = {
get: (): boolean => enabled,
subscribe(listener: () => void): () => void {
listeners.add(listener)
if (!loaded) {
loaded = true
void load()
}
return () => {
listeners.delete(listener)
}
},
}

// setCostReadout flips the flag for good: the page updates at once and the
// backend stops (or starts) counting from its next turn.
export function setCostReadout(on: boolean): void {
if (on !== enabled) {
enabled = on
notify()
}
void Store.SetSetting(SETTING_KEY, GLOBAL_SCOPE, String(on))
}

// resetCostReadoutStore drops the module state between tests.
export function resetCostReadoutStore(): void {
enabled = false
loaded = false
listeners.clear()
}
31 changes: 31 additions & 0 deletions frontend/src/lib/session/session-cost.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest"
import { formatCost } from "./session-cost"

describe("formatCost", () => {
it("shows cents once there are dollars to anchor them", () => {
expect(formatCost(12.476)).toBe("$12.48")
expect(formatCost(1)).toBe("$1.00")
})

it("keeps three decimals below a dollar, where the early signal lives", () => {
expect(formatCost(0.42)).toBe("$0.420")
expect(formatCost(0.004)).toBe("$0.004")
})

// A row of zeros would read as free. The floor says "small", not "nothing".
it("floors a fraction of a cent instead of rounding it away", () => {
expect(formatCost(0.0004)).toBe("<$0.001")
})

it("shows a true zero as zero", () => {
expect(formatCost(0)).toBe("$0")
})

// The value crosses a process boundary; a negative or non-finite total is a
// bug upstream, and rendering nothing beats rendering nonsense about money.
it("renders nothing for a value that cannot be a cost", () => {
expect(formatCost(-1)).toBe("")
expect(formatCost(Number.NaN)).toBe("")
expect(formatCost(Number.POSITIVE_INFINITY)).toBe("")
})
})
20 changes: 20 additions & 0 deletions frontend/src/lib/session/session-cost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// formatCost renders a session's spend for the footer, where it sits beside the
// context percent at 12px and has to stay readable at a glance.
//
// The scale it spans is wide — a one-question session costs fractions of a
// cent, a long one runs to dollars — so the precision follows the number: cents
// once there are dollars to anchor them, three decimals below that (the
// difference between $0.004 and $0.02 is the whole signal early in a session),
// and a floor of "<$0.001" rather than a row of zeros, which would read as free.
export function formatCost(usd: number): string {
if (!Number.isFinite(usd) || usd < 0) {
return ""
}
if (usd === 0) {
return "$0"
}
if (usd < 0.001) {
return "<$0.001"
}
return usd >= 1 ? `$${usd.toFixed(2)}` : `$${usd.toFixed(3)}`
}
Loading