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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **A model newer than your lich no longer reads its context window as 200k.**
Opus 5 sessions showed five times their real usage in the footer, because the
window came from a table of the 1M models and anything missing from it fell
back to the smallest window that fit — so every model launch needed a release
here. The rule is now the other way round: Haiku is the 200k exception and
everything else is 1M, which is what a model released after your build reads
without a new version.

## [0.18.0] - 2026-07-25

### Added
Expand Down
11 changes: 0 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,17 +97,6 @@ Deliberate limits and shortcuts — one line of *what and where*; the mechanism
failed read degrades to the session's start directory. Tracks the direct child only, not nested shells.
- **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.
- **Context-window usage is read off the transcript** (`internal/terminal/usage_claude.go`): on a turn-boundary
status hook, the tail of `~/.claude/projects/<slug>/<id>.jsonl` is parsed for the last main-thread assistant
`usage` (sidechain sub-agent lines skipped), shown in the footer for the active session. The JSONL layout is
Claude-internal and unstable across releases — the read fails soft (the readout keeps its last number). The
percent is taken against the model's native window from a small `model → window` table (`modelWindows`: current
Opus/Sonnet/Fable are 1M, Haiku and pre-4.6 are 200k) — the transcript records the model but not the window; an
unlisted model falls back to inferring the window from the token count. Two deliberate ceilings: the table goes
stale as models ship (Models API `max_input_tokens` is the upgrade path), and it assumes the model's *native*
window — a session that runs a 1M-capable model at 200k reads double its true percent, because the exact window
lives only in the statusLine JSON (`context_window.context_window_size`), never in the transcript. The reader is
Claude-only (the sole provider reporting a session id today), isolated for a per-provider selection later.
- **Persistence is hybrid**: UI prefs in the page's localStorage (`lich.*` keys — the reason the listener port is
pinned at 47821; `LICH_LISTEN_PORT` overrides it, `LICH_PORT` is the distinct per-session hook variable), the
workspace in SQLite (`<config-dir>/lich/lich.db`, `internal/store`). Closing a session deletes its row; keeping a
Expand Down
59 changes: 13 additions & 46 deletions internal/terminal/usage_claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,55 +9,22 @@ import (
"strings"
)

// modelWindows maps a Claude model to its native context window — the transcript
// records the model but not the window, so this is what the percent is taken
// against. Current Opus/Sonnet/Fable are natively 1M (no beta); Haiku and pre-4.6
// models are 200k. Matched by substring so a dated id ("…-4-5-20251001") still
// hits. ponytail: this table goes stale as models ship — the Models API's
// `max_input_tokens` is the authoritative upgrade path; an unlisted model falls
// back to inferring the window from the token count (contextWindowFor).
var modelWindows = []struct {
match string
tokens int
}{
{"opus-4-6", 1_000_000},
{"opus-4-7", 1_000_000},
{"opus-4-8", 1_000_000},
{"sonnet-4-6", 1_000_000},
{"sonnet-5", 1_000_000},
{"fable-5", 1_000_000},
{"mythos-5", 1_000_000},
{"haiku-4-5", 200_000},
// Older models (opus-4-5, sonnet-4-5, haiku-3-5, …) are unlisted and take
// the 200k fallback below — their native window.
}

// contextWindows are the standard sizes the fallback picks between when a model
// is unlisted, smallest first.
var contextWindows = []int{200_000, 1_000_000}

// contextWindowFor returns the window for a token count when the model is
// unknown: the smallest standard window it still fits inside, the largest once
// it exceeds them all — exact the moment a session passes 200k (a 200k window
// cannot hold more than that).
func contextWindowFor(tokens int) int {
for _, w := range contextWindows {
if tokens <= w {
return w
}
}
return contextWindows[len(contextWindows)-1]
}
const (
smallWindow = 200_000
defaultWindow = 1_000_000
)

// windowForModel returns a model's native context window from modelWindows, or
// falls back to inferring it from the token count for an unlisted model.
// windowForModel resolves the context window the percent is taken against. The
// transcript records the model but not its window, so it is named: Haiku is the
// only Claude model still on 200k, everything else is 1M — naming the exception
// rather than the rule is what lets a model released after this build read right.
// A count past 200k disproves the guess (a window cannot hold more than its size),
// so it widens. Matched by substring: a dated id ("…-4-5-20251001") still hits.
func windowForModel(model string, tokens int) int {
for _, w := range modelWindows {
if strings.Contains(model, w.match) {
return w.tokens
}
if strings.Contains(model, "haiku") && tokens <= smallWindow {
return smallWindow
}
return contextWindowFor(tokens)
return defaultWindow
}

// usageTailBytes bounds how much of a transcript's end is scanned for the last
Expand Down
32 changes: 19 additions & 13 deletions internal/terminal/usage_claude_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,21 @@ func TestParseContextUsage(t *testing.T) {
wantPercent: 20,
},
{
name: "an unlisted model past 200k infers a 1M window",
tail: assistantLine(modelNew, 0, 300000, 0) + "\n",
// Contract change: an unlisted model takes the 1M window at any
// token count. It used to infer the smallest window that fit, which
// read a new 1M model as five times fuller than it was.
name: "an unlisted model defaults to the 1M window",
tail: assistantLine(modelNew, 2, 40000, 0) + "\n",
wantOK: true,
wantTokens: 300000,
wantPercent: 30, // 300000 * 100 / 1_000_000
wantTokens: 40002,
wantPercent: 4, // 40002 * 100 / 1_000_000
},
{
name: "an unlisted model under 200k infers a 200k window",
tail: assistantLine(modelNew, 2, 40000, 0) + "\n",
name: "a small-window model past 200k widens to fit",
tail: assistantLine(modelHaiku, 0, 300000, 0) + "\n",
wantOK: true,
wantTokens: 40002,
wantPercent: 20,
wantTokens: 300000,
wantPercent: 30, // 300000 * 100 / 1_000_000
},
{
name: "caps percent at 100 past a full window",
Expand Down Expand Up @@ -148,11 +151,14 @@ func TestWindowForModel(t *testing.T) {
want int
}{
{modelOpus, 50_000, 1_000_000},
{modelHaiku, 50_000, 200_000},
{"claude-sonnet-5", 50_000, 1_000_000},
{"claude-fable-5", 50_000, 1_000_000},
{modelNew, 50_000, 200_000}, // unlisted, fits 200k
{modelNew, 500_000, 1_000_000}, // unlisted, exceeds 200k
{"claude-opus-5", 50_000, 1_000_000},
{modelHaiku, 50_000, 200_000}, // dated id, matched by substring
// Contract change: a model this build has never heard of now takes the
// 1M window instead of the smallest one that fits, so a model released
// later reads right without a new release.
{modelNew, 50_000, 1_000_000},
// A count past 200k disproves the Haiku guess — it widens.
{modelHaiku, 500_000, 1_000_000},
}
for _, tt := range tests {
if got := windowForModel(tt.model, tt.tokens); got != tt.want {
Expand Down