From ec878149c52fc4c52eeeba7e58231597935df8cb Mon Sep 17 00:00:00 2001 From: omartelo Date: Mon, 26 Oct 2026 22:51:00 -0300 Subject: [PATCH 1/4] feat(footer): show the last turn's prompt cache hit in the context tooltip The context window tooltip gains a line with the share of the last turn's input read back from the prompt cache, in green from 50% and amber below, over a bar splitting that input into cached, written and uncached. The session-usage event carries the split as api.SessionCache, read from the assistant line's usage on Claude Code and from last_token_usage on Codex. It is nil for every other provider and for a Claude conversation whose last event is a compaction. Tests: TestSessionUsageReportsTheTranscriptTail, TestSessionUsageReportsCodexRollout and TestScanCodexContextUsage expect the new Cache field because the contract changed. --- CHANGELOG.md | 4 ++ README.es.md | 1 + README.ja.md | 1 + README.ko.md | 1 + README.md | 1 + README.pt-BR.md | 1 + README.ru.md | 1 + README.zh-CN.md | 1 + api/terminal.go | 30 ++++++--- api/testdata/contract.golden | 7 ++ docs/ceilings.md | 5 +- docs/providers/claude.md | 4 +- docs/providers/codex.md | 5 +- internal/terminal/usage.go | 1 + internal/terminal/usage_claude.go | 10 ++- internal/terminal/usage_claude_test.go | 19 ++++++ internal/terminal/usage_codex.go | 11 ++++ internal/terminal/usage_codex_test.go | 22 ++++++- internal/terminal/usage_test.go | 11 +++- native/cmd/lich-native/footer.go | 3 + native/cmd/lich-native/footer_cache.go | 71 +++++++++++++++++++++ native/cmd/lich-native/footer_cache_test.go | 54 ++++++++++++++++ native/cmd/lich-native/footer_usage.go | 10 ++- native/i18n/locales/en/shell.json | 1 + native/i18n/locales/es/shell.json | 1 + native/i18n/locales/ja/shell.json | 1 + native/i18n/locales/ko/shell.json | 1 + native/i18n/locales/pt-br/shell.json | 1 + native/i18n/locales/ru/shell.json | 1 + native/i18n/locales/zh-cn/shell.json | 1 + 30 files changed, 260 insertions(+), 21 deletions(-) create mode 100644 native/cmd/lich-native/footer_cache.go create mode 100644 native/cmd/lich-native/footer_cache_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aacc689b..a1e9f386d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- The context window tooltip in the footer shows how much of the last turn was read back from the prompt cache, with a bar splitting the turn's input into cached, written to the cache and uncached. A turn after the cache expired reads 0% in amber, which is the turn billed at the full rate. Claude Code and Codex only: the other agents don't record that split next to a context window. + ### Changed - An agent can change the model or the reasoning effort of the session it runs in through `control_session`, and close that session when you ask it to (`self` on `close_session`, `lich close --self`). Both used to be refused outright. diff --git a/README.es.md b/README.es.md index 8a39cd786..bbed66bd4 100644 --- a/README.es.md +++ b/README.es.md @@ -109,6 +109,7 @@ porque lee lo que cada CLI deja escrito, y no hay dos que registren lo mismo. | Lo que obtienes | Claude Code | Codex | Antigravity | opencode | oh-my-pi | Crush | Cursor CLI | Kiro CLI | | --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | | Ventana de contexto en el pie de ventana | sí | sí | no | no | no | no | no | sí | +| Cuánto del último turno salió de la caché de prompts, en el tooltip de la ventana de contexto | sí | sí | no | no | no | no | no | no | | El tooltip de la tarjeta nombra el modelo en uso, y con cuál se abrió si cambió | sí | sí | no | no | no | no | no | sí | | Costo en el pie de ventana | sí | sí | no | sí | sí | sí | no | créditos | | Cuánto queda de tu plan | sí | sí | sí | solo OpenCode Go | no | no | sí | no | diff --git a/README.ja.md b/README.ja.md index 2f9ed6319..83996d776 100644 --- a/README.ja.md +++ b/README.ja.md @@ -98,6 +98,7 @@ lich は各 CLI が書き出す情報を読んでいて、何を書き出すか | 得られるもの | Claude Code | Codex | Antigravity | opencode | oh-my-pi | Crush | Cursor CLI | Kiro CLI | | --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | | フッターにコンテキストウィンドウを表示 | あり | あり | なし | なし | なし | なし | なし | あり | +| 直前のターンのうちプロンプトキャッシュから読んだ割合を、コンテキストウィンドウのツールチップに表示 | あり | あり | なし | なし | なし | なし | なし | なし | | カードのツールチップに実行中のモデルを表示(開始時から変わっていれば開始時のモデルも) | あり | あり | なし | なし | なし | なし | なし | あり | | フッターに費用を表示 | あり | あり | なし | あり | あり | あり | なし | クレジット | | プランの残量を表示 | あり | あり | あり | OpenCode Go のみ | なし | なし | あり | なし | diff --git a/README.ko.md b/README.ko.md index 488614bcd..a4693492d 100644 --- a/README.ko.md +++ b/README.ko.md @@ -98,6 +98,7 @@ lich로 할 수 있는 일: | 기능 | Claude Code | Codex | Antigravity | opencode | oh-my-pi | Crush | Cursor CLI | Kiro CLI | | --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | | 하단 바에 컨텍스트 윈도우 표시 | 예 | 예 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | 예 | +| 직전 턴 중 프롬프트 캐시에서 읽은 비율을 컨텍스트 윈도우 툴팁에 표시 | 예 | 예 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | | 카드 툴팁에 실행 중인 모델 표시 (시작할 때와 바뀌었으면 시작 모델도) | 예 | 예 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | 예 | | 하단 바에 비용 표시 | 예 | 예 | 아니요 | 예 | 예 | 예 | 아니요 | 크레딧 | | 남은 플랜 사용량 | 예 | 예 | 예 | OpenCode Go만 | 아니요 | 아니요 | 예 | 아니요 | diff --git a/README.md b/README.md index 7a7935ebb..a4034d5f3 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ reads what each CLI writes down and no two of them write down the same things. | What you get | Claude Code | Codex | Antigravity | opencode | oh-my-pi | Crush | Cursor CLI | Kiro CLI | | --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | | Context window in the footer | yes | yes | no | no | no | no | no | yes | +| How much of the last turn came from the prompt cache, in the context window tooltip | yes | yes | no | no | no | no | no | no | | The card's tooltip names the model running, and the one it opened on if it changed | yes | yes | no | no | no | no | no | yes | | Cost in the footer | yes | yes | no | yes | yes | yes | no | credits | | How much of your plan is left | yes | yes | yes | OpenCode Go only | no | no | yes | no | diff --git a/README.pt-BR.md b/README.pt-BR.md index e38c0cfaf..5b75d6b57 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -110,6 +110,7 @@ mesmas coisas. | Recurso | Claude Code | Codex | Antigravity | opencode | oh-my-pi | Crush | Cursor CLI | Kiro CLI | | --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | | Janela de contexto no rodapé | sim | sim | não | não | não | não | não | sim | +| Quanto do último turno veio do cache de prompt, no tooltip da janela de contexto | sim | sim | não | não | não | não | não | não | | O tooltip do card mostra o modelo em uso, e com qual abriu se ele mudou | sim | sim | não | não | não | não | não | sim | | Custo no rodapé | sim | sim | não | sim | sim | sim | não | créditos | | Quanto ainda resta do seu plano | sim | sim | sim | só OpenCode Go | não | não | sim | não | diff --git a/README.ru.md b/README.ru.md index 5269a6fc8..64d0963e3 100644 --- a/README.ru.md +++ b/README.ru.md @@ -109,6 +109,7 @@ | Что вы получаете | Claude Code | Codex | Antigravity | opencode | oh-my-pi | Crush | Cursor CLI | Kiro CLI | | --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | | Окно контекста в нижней панели | да | да | нет | нет | нет | нет | нет | да | +| Какая доля последнего хода пришла из кэша промптов, во всплывающей подсказке окна контекста | да | да | нет | нет | нет | нет | нет | нет | | Подсказка карточки называет работающую модель и ту, на которой сессия открылась, если она сменилась | да | да | нет | нет | нет | нет | нет | да | | Стоимость в нижней панели | да | да | нет | да | да | да | нет | кредиты | | Сколько осталось от лимитов подписки | да | да | да | только OpenCode Go | нет | нет | да | нет | diff --git a/README.zh-CN.md b/README.zh-CN.md index e3516a816..f0ed90678 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -86,6 +86,7 @@ | 功能 | Claude Code | Codex | Antigravity | opencode | oh-my-pi | Crush | Cursor CLI | Kiro CLI | | --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | | 底栏显示上下文窗口 | 有 | 有 | 无 | 无 | 无 | 无 | 无 | 有 | +| 在上下文窗口的提示框中显示上一轮有多少来自提示缓存 | 有 | 有 | 无 | 无 | 无 | 无 | 无 | 无 | | 卡片提示显示正在运行的模型,若与打开时不同也显示打开时的模型 | 有 | 有 | 无 | 无 | 无 | 无 | 无 | 有 | | 底栏显示花费 | 有 | 有 | 无 | 有 | 有 | 有 | 无 | 按点数计 | | 套餐剩余额度 | 有 | 有 | 有 | 仅 OpenCode Go | 无 | 无 | 有 | 无 | diff --git a/api/terminal.go b/api/terminal.go index 32084581a..88349f884 100644 --- a/api/terminal.go +++ b/api/terminal.go @@ -104,15 +104,29 @@ type ResumeAvailableOptions struct { // off, which is not a failure but the design above, and for a miss the next turn // heals. Without it a session that cannot be priced and a session that is not // being priced look identical, and an empty footer reads as zero spend. +// +// Cache is where the last turn's input came from, and is nil whenever the +// provider files no split of it: every provider but Claude Code and Codex, and a +// Claude conversation whose last event is a compaction, since the turn before it +// no longer describes the window. type SessionUsage struct { - ID string `json:"id"` - Percent int `json:"percent"` - Tokens int `json:"tokens"` - Window int `json:"window"` - Model string `json:"model"` - Effort string `json:"effort"` - CostUSD *float64 `json:"costUsd,omitempty"` - CostMiss string `json:"costMiss,omitempty"` + ID string `json:"id"` + Percent int `json:"percent"` + Tokens int `json:"tokens"` + Window int `json:"window"` + Model string `json:"model"` + Effort string `json:"effort"` + CostUSD *float64 `json:"costUsd,omitempty"` + CostMiss string `json:"costMiss,omitempty"` + Cache *SessionCache `json:"cache,omitempty"` +} + +// SessionCache splits one turn's input tokens three ways: Read came from the +// prompt cache, Write was written into it, and Uncached was neither. +type SessionCache struct { + Read int `json:"read"` + Write int `json:"write"` + Uncached int `json:"uncached"` } // TurnState is what the window missed about one live session while it was not diff --git a/api/testdata/contract.golden b/api/testdata/contract.golden index c76887c40..c7c37f234 100644 --- a/api/testdata/contract.golden +++ b/api/testdata/contract.golden @@ -1547,6 +1547,7 @@ SessionUsage { effort string costUsd,omitempty *float64 costMiss,omitempty string + cache,omitempty *SessionCache } SessionsColored { @@ -1739,6 +1740,12 @@ PluginProfileFailure { error string } +SessionCache { + read int + write int + uncached int +} + PRThreadComment { id int64 author string diff --git a/docs/ceilings.md b/docs/ceilings.md index a02e28d55..ff9f942f5 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -165,7 +165,10 @@ work when nobody knows it and that the call site never shows. The mechanism and mind, the footer is silently wrong in one direction or the other, with nothing on screen saying so. Their usage event carries a zero window, which is what tells the footer to drop the ring (`contextReadout`, `native/cmd/lich-native/footer_readout.go`). A zero from any of the three is an answer and not a gap: it is what - they record for a free model and for a subscription. Codex is the one provider lich prices itself, from a slice of + they record for a free model and for a subscription. The cache line in the context tooltip (`api.SessionCache`) is Claude Code's + and Codex's alone: opencode and oh-my-pi record a cache split but no window, so there is no context tooltip to + put it in; Crush folds its cache counts into the prompt and completion totals; Kiro files every token count as + zero; Cursor and Antigravity have no usage reader. Codex is the one provider lich prices itself, from a slice of rates baked into the release (`internal/pricing/prices.json`), so an offline Codex session shows a cost at the rate that shipped — **stale by however long it has been since the release**, until the refresh (`internal/pricing/pricing.go`) lands and overrides it. What is left with no price at all is a model newer than diff --git a/docs/providers/claude.md b/docs/providers/claude.md index 5714b6dce..7276529fa 100644 --- a/docs/providers/claude.md +++ b/docs/providers/claude.md @@ -43,7 +43,9 @@ two-cards-one-conversation warning arrives at once. ## Transcript & cost The full readout: per-turn token counts, and the window its mod reports, else one named from the model -(`windowForModel`). The running model the card's tooltip names is the assistant's `message.model`. +(`windowForModel`). The running model the card's tooltip names is the assistant's `message.model`. The cache line +under the window splits the same line's input into `cache_read_input_tokens`, `cache_creation_input_tokens` and +`input_tokens`, and goes away after a compaction until the next assistant line. A conversation lich spawned is told from a plain `claude` run by the name lich spawns it under (`relay.IsRosterName` on the first `custom-title`), which covers the conversations whose rows were deleted before diff --git a/docs/providers/codex.md b/docs/providers/codex.md index 7855e5b26..4fd7ce274 100644 --- a/docs/providers/codex.md +++ b/docs/providers/codex.md @@ -34,7 +34,10 @@ cards before the two-cards-one-conversation warning arrives. ## Transcript & cost The full readout: the window is 95% of the rollout's default or configured `model_context_window`, and the -tokens a running `total_token_usage`. The running model the card's tooltip names is `turn_context.model`. +tokens a running `total_token_usage`. The running model the card's tooltip names is `turn_context.model`. The +cache line under the window reads `last_token_usage`: `cached_input_tokens` out of `input_tokens`, with +`cache_write_input_tokens` taken as outside it, the way the cost is priced. That last reading is not measured, +since every rollout seen so far files the write as zero. Codex is the one provider lich prices itself. `internal/pricing/prices.json` bakes a fixed slice of OpenAI rates beside the Claude ones, read from the same LiteLLM table the refresh (`internal/pricing/pricing.go`) reads. diff --git a/internal/terminal/usage.go b/internal/terminal/usage.go index 133db3699..1bb1cca1b 100644 --- a/internal/terminal/usage.go +++ b/internal/terminal/usage.go @@ -95,6 +95,7 @@ func (s *Service) usageFrom(id string, src usageSource) (usageEvent, bool) { Window: u.window, Model: u.model, Effort: u.effort, + Cache: u.cache, } cost, miss, priced := s.sessionCost(id, src) switch { diff --git a/internal/terminal/usage_claude.go b/internal/terminal/usage_claude.go index 7ed81bff6..40bd27dcb 100644 --- a/internal/terminal/usage_claude.go +++ b/internal/terminal/usage_claude.go @@ -7,6 +7,8 @@ import ( "os" "slices" "strings" + + "github.com/lichdotdev/lich/api" ) const ( @@ -44,6 +46,7 @@ type contextUsage struct { window int model string effort string + cache *api.SessionCache } // claudeContextUsage reads the context-window usage of a Claude conversation @@ -135,11 +138,12 @@ func parseContextUsage(tail []byte) (contextUsage, bool) { continue } u := entry.Message.Usage - tokens := u.Input + u.CacheRead + u.CacheCreate if compacted >= 0 { - tokens = compacted + return usageFor(compacted, entry.Message.Model, entry.Effort), true } - return usageFor(tokens, entry.Message.Model, entry.Effort), true + usage := usageFor(u.Input+u.CacheRead+u.CacheCreate, entry.Message.Model, entry.Effort) + usage.cache = &api.SessionCache{Read: u.CacheRead, Write: u.CacheCreate, Uncached: u.Input} + return usage, true } if compacted >= 0 { // A compaction with no assistant line left in the tail to name the diff --git a/internal/terminal/usage_claude_test.go b/internal/terminal/usage_claude_test.go index 963df166f..3909c6a7c 100644 --- a/internal/terminal/usage_claude_test.go +++ b/internal/terminal/usage_claude_test.go @@ -6,6 +6,7 @@ import ( "strconv" "testing" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/providers" ) @@ -195,6 +196,24 @@ func TestParseContextUsage(t *testing.T) { } } +func TestParseContextUsageCache(t *testing.T) { + t.Parallel() + got, ok := parseContextUsage([]byte(assistantLine(modelOpus, 412, 118_020, 2_310) + "\n")) + if !ok || got.cache == nil { + t.Fatalf("parseContextUsage = %+v, %v; want a cache split", got, ok) + } + if want := (api.SessionCache{Read: 118_020, Write: 2_310, Uncached: 412}); *got.cache != want { + t.Errorf("cache = %+v, want %+v", *got.cache, want) + } + + // The turn before a compaction describes a window that no longer exists. + got, ok = parseContextUsage([]byte(assistantLine(modelOpus, 2, 236_082, 0) + "\n" + + boundaryLine("manual", 236_084, 12_642) + "\n")) + if !ok || got.cache != nil { + t.Errorf("after a compaction: cache = %+v, ok %v; want nil", got.cache, ok) + } +} + func TestParseContextUsageEffort(t *testing.T) { t.Parallel() // effort is a top-level field on the entry, beside type/isSidechain. diff --git a/internal/terminal/usage_codex.go b/internal/terminal/usage_codex.go index f579e0e8d..b13a8dc2b 100644 --- a/internal/terminal/usage_codex.go +++ b/internal/terminal/usage_codex.go @@ -6,6 +6,7 @@ import ( "io" "os" + "github.com/lichdotdev/lich/api" "github.com/lichdotdev/lich/internal/pricing" ) @@ -34,6 +35,7 @@ type codexRolloutEntry struct { Effort string `json:"effort"` Info *struct { Last *struct { + codexTokenUsage Total int `json:"total_tokens"` } `json:"last_token_usage"` Total *codexTokenUsage `json:"total_token_usage"` @@ -110,6 +112,7 @@ func consumeCodexUsageLine(line []byte, usage *contextUsage, haveTokens *bool) b usage.tokens = entry.Payload.Info.Last.Total usage.window = entry.Payload.Info.Window usage.percent = min(usage.tokens*100/usage.window, 100) + usage.cache = codexCache(entry.Payload.Info.Last.codexTokenUsage) *haveTokens = true return false } @@ -121,6 +124,14 @@ func consumeCodexUsageLine(line []byte, usage *contextUsage, haveTokens *bool) b return true } +// codexCache splits a turn's input the way the cost readout prices it +// (codexCostScan): cached_input_tokens is part of input_tokens and +// cache_write_input_tokens is not. The second half is that reading's, not a +// measurement: every rollout on hand files the write as zero. +func codexCache(t codexTokenUsage) *api.SessionCache { + return &api.SessionCache{Read: t.Cached, Write: t.CacheWrite, Uncached: max(t.Input-t.Cached, 0)} +} + // codexTranscriptCost prices a Codex rollout's running total in USD. Unlike a // Claude transcript's per-turn deltas, total_token_usage is already the // conversation's cumulative cost, so the last line is the whole answer — no diff --git a/internal/terminal/usage_codex_test.go b/internal/terminal/usage_codex_test.go index 73e6308a4..e31754ae3 100644 --- a/internal/terminal/usage_codex_test.go +++ b/internal/terminal/usage_codex_test.go @@ -3,8 +3,11 @@ package terminal import ( "os" "path/filepath" + "reflect" "strings" "testing" + + "github.com/lichdotdev/lich/api" ) func TestScanCodexContextUsage(t *testing.T) { @@ -13,7 +16,7 @@ func TestScanCodexContextUsage(t *testing.T) { body := `{"type":"turn_context","payload":{"model":"gpt-5.6-sol","effort":"high"}}` + "\n" + `{"type":"response_item","payload":{"text":"` + strings.Repeat("x", 2*int(codexScanBlockBytes)) + `"}}` + "\n" + `not json` + "\n" + - `{"type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"total_tokens":300000},"model_context_window":258400}}}` + "\n" + + `{"type":"event_msg","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":299000,"cached_input_tokens":250000,"cache_write_input_tokens":0,"output_tokens":1000,"total_tokens":300000},"model_context_window":258400}}}` + "\n" + `{"type":"event_msg","payload":{"type":"token_count","info":null}}` + "\n" if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatal(err) @@ -25,12 +28,27 @@ func TestScanCodexContextUsage(t *testing.T) { } want := contextUsage{ tokens: 300000, percent: 100, window: 258400, model: "gpt-5.6-sol", effort: "high", + cache: &api.SessionCache{Read: 250000, Write: 0, Uncached: 49000}, } - if got != want { + if !reflect.DeepEqual(got, want) { t.Errorf("usage = %+v, want %+v", got, want) } } +// A cache write is read as outside input_tokens, the way the cost readout +// prices it, so it does not come off the uncached share. +func TestCodexCacheSplit(t *testing.T) { + t.Parallel() + got := codexCache(codexTokenUsage{Input: 1000, Cached: 600, CacheWrite: 300}) + want := &api.SessionCache{Read: 600, Write: 300, Uncached: 400} + if *got != *want { + t.Errorf("cache = %+v, want %+v", *got, *want) + } + if got := codexCache(codexTokenUsage{Input: 10, Cached: 20}); got.Uncached != 0 { + t.Errorf("cached past input: uncached = %d, want 0", got.Uncached) + } +} + func TestCodexContextUsageMisses(t *testing.T) { t.Parallel() for _, body := range []string{ diff --git a/internal/terminal/usage_test.go b/internal/terminal/usage_test.go index 5fab6a57b..fde44bfcb 100644 --- a/internal/terminal/usage_test.go +++ b/internal/terminal/usage_test.go @@ -9,6 +9,7 @@ import ( "errors" "os" "path/filepath" + "reflect" "strings" "testing" @@ -76,8 +77,11 @@ func TestSessionUsageReportsTheTranscriptTail(t *testing.T) { if !ok { t.Fatal("sessionUsage: want ok, got false") } - want := usageEvent{ID: "s1", Percent: 6, Tokens: 64800, Window: 1_000_000, Model: modelOpus} - if got != want { + want := usageEvent{ + ID: "s1", Percent: 6, Tokens: 64800, Window: 1_000_000, Model: modelOpus, + Cache: &api.SessionCache{Read: 64798, Uncached: 2}, + } + if !reflect.DeepEqual(got, want) { t.Errorf("sessionUsage = %+v, want %+v", got, want) } } @@ -94,8 +98,9 @@ func TestSessionUsageReportsCodexRollout(t *testing.T) { } want := usageEvent{ ID: "s1", Percent: 25, Tokens: 65111, Window: 258400, Model: "gpt-5.6-sol", Effort: "xhigh", + Cache: &api.SessionCache{}, } - if got != want { + if !reflect.DeepEqual(got, want) { t.Errorf("sessionUsage = %+v, want %+v", got, want) } } diff --git a/native/cmd/lich-native/footer.go b/native/cmd/lich-native/footer.go index 64568029d..f2b337fd2 100644 --- a/native/cmd/lich-native/footer.go +++ b/native/cmd/lich-native/footer.go @@ -283,6 +283,9 @@ func (f *footer) readout(gtx C, th *ui.Theme, w *footerWidgets, s footerSlot, in switch s.ID { case "context": detail := para(pop.Text(ui.TextXS, r.Detail[0]).Muted().Mono()).Layout + if hit, ok := cacheHit(in.usage.Cache); ok { + detail = cacheDetail(pop, detail, *in.usage.Cache, hit) + } return footerReadoutView(gtx, th, f.root, w, r, detail, ringContent(in.usage.Percent, r.Text)) case "plan": return footerReadoutView(gtx, th, f.root, w, r, planDetails(pop, s.Plan, in.now), glyphText(icGauge, r.Text)) diff --git a/native/cmd/lich-native/footer_cache.go b/native/cmd/lich-native/footer_cache.go new file mode 100644 index 000000000..4dbe42e59 --- /dev/null +++ b/native/cmd/lich-native/footer_cache.go @@ -0,0 +1,71 @@ +package main + +import ( + "image" + "image/color" + + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/op/paint" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/i18n" + "github.com/lichdotdev/lich/native/ui" +) + +// cacheWarmPercent is the hit rate the cache line turns from amber to green +// at: below it, most of the turn's input was paid at the full rate. +const cacheWarmPercent = 50 + +// cacheHit is the rounded share of the last turn's input read back from the +// prompt cache. false when the provider filed no split, or an empty one. +func cacheHit(c *api.SessionCache) (int, bool) { + if c == nil { + return 0, false + } + total := c.Read + c.Write + c.Uncached + if total <= 0 { + return 0, false + } + return (c.Read*100 + total/2) / total, true +} + +// cacheDetail is the context tooltip's body with a cache reading: the token +// line, then the hit rate in its tone over the read / written / uncached bar. +func cacheDetail(th *ui.Theme, tokens layout.Widget, c api.SessionCache, hit int) layout.Widget { + tone := th.ToneWait + if hit >= cacheWarmPercent { + tone = th.TonePass + } + line := para(th.Text(ui.TextXS, i18n.T("shell.footerSession.cacheHit", i18n.Args{"percent": hit})).In(tone)).Layout + bar := func(gtx C) D { return cacheBar(gtx, th, c) } + return func(gtx C) D { + gtx.Constraints.Min.X = min(gtx.Dp(planMinWide), gtx.Constraints.Max.X) + return stack(gtx, 1, []layout.Widget{tokens, line, bar}) + } +} + +// cacheBar is gaugeBar split three ways, each part as wide as its share. +func cacheBar(gtx C, th *ui.Theme, c api.SessionCache) D { + size := image.Pt(gtx.Constraints.Max.X, gtx.Dp(ui.Space(1.5))) + defer clip.UniformRRect(image.Rectangle{Max: size}, gtx.Dp(ui.Space(0.75))).Push(gtx.Ops).Pop() + paint.FillShape(gtx.Ops, th.Over(th.Border), clip.Rect{Max: size}.Op()) + total := c.Read + c.Write + c.Uncached + parts := []struct { + tokens int + color color.NRGBA + }{{c.Read, th.MutedForeground}, {c.Write, th.ToneWait}, {c.Uncached, th.Foreground}} + x, sum := 0, 0 + for _, p := range parts { + sum += p.tokens + // Each edge comes from the running sum, so rounding never leaves the + // bar a pixel short of full. + end := size.X * sum / total + stackOp := op.Offset(image.Pt(x, 0)).Push(gtx.Ops) + paint.FillShape(gtx.Ops, p.color, clip.Rect{Max: image.Pt(end-x, size.Y)}.Op()) + stackOp.Pop() + x = end + } + return D{Size: size} +} diff --git a/native/cmd/lich-native/footer_cache_test.go b/native/cmd/lich-native/footer_cache_test.go new file mode 100644 index 000000000..c0632897b --- /dev/null +++ b/native/cmd/lich-native/footer_cache_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "testing" + + "github.com/lichdotdev/lich/api" +) + +func TestFooterUsageCache(t *testing.T) { + base := `"id":"s1","percent":10,"tokens":1,"window":2,"model":"m","effort":""` + cases := []struct { + extra string + want *api.SessionCache + }{ + {`,"cache":{"read":90,"write":6,"uncached":4}`, &api.SessionCache{Read: 90, Write: 6, Uncached: 4}}, + {``, nil}, + {`,"cache":null`, nil}, + {`,"cache":"90"`, nil}, // unusable reads as absent + {`,"cache":{"read":-1,"write":0,"uncached":4}`, nil}, + } + for _, c := range cases { + f := newFooterUsage() + if _, err := f.apply(footerEvent("session-usage", "{"+base+c.extra+"}")); err != nil { + t.Fatal(err) + } + u, ok := f.get("s1") + if !ok { + t.Fatalf("%s: the report was dropped", c.extra) + } + if (u.Cache == nil) != (c.want == nil) || (u.Cache != nil && *u.Cache != *c.want) { + t.Errorf("%s: cache %+v, want %+v", c.extra, u.Cache, c.want) + } + } +} + +func TestCacheHit(t *testing.T) { + cases := []struct { + cache *api.SessionCache + want int + wantOK bool + }{ + {&api.SessionCache{Read: 118_020, Write: 2_310, Uncached: 412}, 98, true}, + {&api.SessionCache{Write: 120_739, Uncached: 3}, 0, true}, + {&api.SessionCache{Read: 1, Uncached: 1}, 50, true}, + {&api.SessionCache{}, 0, false}, + {nil, 0, false}, + } + for _, c := range cases { + got, ok := cacheHit(c.cache) + if got != c.want || ok != c.wantOK { + t.Errorf("cacheHit(%+v) = %d, %v; want %d, %v", c.cache, got, ok, c.want, c.wantOK) + } + } +} diff --git a/native/cmd/lich-native/footer_usage.go b/native/cmd/lich-native/footer_usage.go index 5138ca868..f012e0c67 100644 --- a/native/cmd/lich-native/footer_usage.go +++ b/native/cmd/lich-native/footer_usage.go @@ -26,11 +26,12 @@ type sessionUsage struct { Effort string CostUSD *float64 CostMiss string + Cache *api.SessionCache } // usageEvent is the session-usage payload (internal/terminal usageEvent). -// Pointers tell a missing context field apart from a zero one; the cost -// fields stay raw so a malformed one reads as absent, not as a bad payload. +// Pointers tell a missing context field apart from a zero one; the cost and +// cache fields stay raw so a malformed one reads as absent, not as a bad payload. type usageEvent struct { ID string `json:"id"` Percent *int `json:"percent"` @@ -40,6 +41,7 @@ type usageEvent struct { Effort *string `json:"effort"` CostUSD json.RawMessage `json:"costUsd"` CostMiss json.RawMessage `json:"costMiss"` + Cache json.RawMessage `json:"cache"` } // footerUsage keeps the last usage report of every session: retained while the @@ -146,5 +148,9 @@ func decodeUsage(data json.RawMessage) (id string, u sessionUsage, ok bool, err if json.Unmarshal(e.CostMiss, &miss) == nil && slices.Contains(costMisses, miss) { u.CostMiss = miss } + var cache *api.SessionCache + if json.Unmarshal(e.Cache, &cache) == nil && cache != nil && cache.Read >= 0 && cache.Write >= 0 && cache.Uncached >= 0 { + u.Cache = cache + } return e.ID, u, true, nil } diff --git a/native/i18n/locales/en/shell.json b/native/i18n/locales/en/shell.json index 835acb558..6c8a5848b 100644 --- a/native/i18n/locales/en/shell.json +++ b/native/i18n/locales/en/shell.json @@ -44,6 +44,7 @@ "footerSession": { "contextWindow": "Context window", "contextTokens": "{tokens} / {window} tokens", + "cacheHit": "Cache: {percent}% hit", "costUnavailable": "Cost unavailable", "sessionCost": "Session cost", "costOfBudget": "{cost} of {budget} budget", diff --git a/native/i18n/locales/es/shell.json b/native/i18n/locales/es/shell.json index a5b226bea..01033745d 100644 --- a/native/i18n/locales/es/shell.json +++ b/native/i18n/locales/es/shell.json @@ -44,6 +44,7 @@ "footerSession": { "contextWindow": "Ventana de contexto", "contextTokens": "{tokens} / {window} tokens", + "cacheHit": "Caché: {percent}% de aciertos", "costUnavailable": "Costo no disponible", "sessionCost": "Costo de la sesión", "costOfBudget": "{cost} de {budget} de presupuesto", diff --git a/native/i18n/locales/ja/shell.json b/native/i18n/locales/ja/shell.json index fe3c048ef..657dea829 100644 --- a/native/i18n/locales/ja/shell.json +++ b/native/i18n/locales/ja/shell.json @@ -44,6 +44,7 @@ "footerSession": { "contextWindow": "コンテキストウィンドウ", "contextTokens": "{tokens} / {window} トークン", + "cacheHit": "キャッシュ: ヒット率 {percent}%", "costUnavailable": "コストは取得できません", "sessionCost": "セッションのコスト", "costOfBudget": "予算 {budget} のうち {cost}", diff --git a/native/i18n/locales/ko/shell.json b/native/i18n/locales/ko/shell.json index 0b3ce6e9e..d2a0111e7 100644 --- a/native/i18n/locales/ko/shell.json +++ b/native/i18n/locales/ko/shell.json @@ -44,6 +44,7 @@ "footerSession": { "contextWindow": "컨텍스트 창", "contextTokens": "{tokens} / {window} 토큰", + "cacheHit": "캐시: 적중률 {percent}%", "costUnavailable": "비용 정보 없음", "sessionCost": "세션 비용", "costOfBudget": "예산 {budget} 중 {cost}", diff --git a/native/i18n/locales/pt-br/shell.json b/native/i18n/locales/pt-br/shell.json index 7d618db82..767cf8bac 100644 --- a/native/i18n/locales/pt-br/shell.json +++ b/native/i18n/locales/pt-br/shell.json @@ -44,6 +44,7 @@ "footerSession": { "contextWindow": "Janela de contexto", "contextTokens": "{tokens} / {window} tokens", + "cacheHit": "Cache: {percent}% de acerto", "costUnavailable": "Custo indisponível", "sessionCost": "Custo da sessão", "costOfBudget": "{cost} de {budget} de orçamento", diff --git a/native/i18n/locales/ru/shell.json b/native/i18n/locales/ru/shell.json index e22234d8e..a409925b5 100644 --- a/native/i18n/locales/ru/shell.json +++ b/native/i18n/locales/ru/shell.json @@ -44,6 +44,7 @@ "footerSession": { "contextWindow": "Контекстное окно", "contextTokens": "{tokens} / {window} токенов", + "cacheHit": "Кэш: {percent}% попаданий", "costUnavailable": "Стоимость недоступна", "sessionCost": "Стоимость сессии", "costOfBudget": "{cost} из бюджета {budget}", diff --git a/native/i18n/locales/zh-cn/shell.json b/native/i18n/locales/zh-cn/shell.json index a4c96b77a..11f1d1552 100644 --- a/native/i18n/locales/zh-cn/shell.json +++ b/native/i18n/locales/zh-cn/shell.json @@ -44,6 +44,7 @@ "footerSession": { "contextWindow": "上下文窗口", "contextTokens": "{tokens} / {window} 个 token", + "cacheHit": "缓存:命中率 {percent}%", "costUnavailable": "费用不可用", "sessionCost": "会话费用", "costOfBudget": "预算 {budget} 中的 {cost}", From 49021d93a29f4058e8b9cd458dc6e8124806cd6b Mon Sep 17 00:00:00 2001 From: omartelo Date: Mon, 26 Oct 2026 22:58:29 -0300 Subject: [PATCH 2/4] fix(cost): stop billing a Codex cache write twice cache_write_input_tokens is part of input_tokens: Codex fills it from the Responses API's input_tokens_details (its parses_cache_write_token_usage test has 40 cached + 60 written of 100 input). The cost priced it once at the cache-write rate and again inside the uncached input. Cost and the context tooltip's cache bar now share one split, codexCache. TestCodexTranscriptCostPricesTheRunningTotal asserted the double count, which Codex never promised: its uncached share is now 575, not 600. --- docs/providers/codex.md | 6 +++--- internal/terminal/usage_codex.go | 30 +++++++++++++-------------- internal/terminal/usage_codex_test.go | 12 +++++++---- internal/terminal/usage_cost_test.go | 9 +++++--- 4 files changed, 32 insertions(+), 25 deletions(-) diff --git a/docs/providers/codex.md b/docs/providers/codex.md index 4fd7ce274..9ec4b04e2 100644 --- a/docs/providers/codex.md +++ b/docs/providers/codex.md @@ -35,9 +35,9 @@ cards before the two-cards-one-conversation warning arrives. The full readout: the window is 95% of the rollout's default or configured `model_context_window`, and the tokens a running `total_token_usage`. The running model the card's tooltip names is `turn_context.model`. The -cache line under the window reads `last_token_usage`: `cached_input_tokens` out of `input_tokens`, with -`cache_write_input_tokens` taken as outside it, the way the cost is priced. That last reading is not measured, -since every rollout seen so far files the write as zero. +cache line under the window reads `last_token_usage`: `cached_input_tokens` and `cache_write_input_tokens` are +both part of `input_tokens` (Codex fills them from the Responses API's `input_tokens_details`), and the cost is +priced from the same split. Codex is the one provider lich prices itself. `internal/pricing/prices.json` bakes a fixed slice of OpenAI rates beside the Claude ones, read from the same LiteLLM table the refresh (`internal/pricing/pricing.go`) reads. diff --git a/internal/terminal/usage_codex.go b/internal/terminal/usage_codex.go index b13a8dc2b..05dbe59b9 100644 --- a/internal/terminal/usage_codex.go +++ b/internal/terminal/usage_codex.go @@ -15,11 +15,11 @@ import ( // tiny reads; lines spanning blocks are carried into the next iteration. const codexScanBlockBytes int64 = 64 * 1024 -// codexTokenUsage is one info block's total_token_usage: the running total -// for the whole conversation, unlike a Claude transcript's per-turn counters. -// cached_input_tokens is a subset of Input and reasoning_output_tokens a -// subset of Output, so only the four fields a rate needs are kept — the other -// two would double-count if added in. +// codexTokenUsage is one info block's token counters: total_token_usage is +// the running total for the whole conversation, unlike a Claude transcript's +// per-turn counters, and last_token_usage the latest turn's. Both cache counters +// are subsets of Input (codexCache) and reasoning_output_tokens a subset of +// Output, so only the four fields a rate needs are kept. type codexTokenUsage struct { Input int `json:"input_tokens"` Cached int `json:"cached_input_tokens"` @@ -124,12 +124,12 @@ func consumeCodexUsageLine(line []byte, usage *contextUsage, haveTokens *bool) b return true } -// codexCache splits a turn's input the way the cost readout prices it -// (codexCostScan): cached_input_tokens is part of input_tokens and -// cache_write_input_tokens is not. The second half is that reading's, not a -// measurement: every rollout on hand files the write as zero. +// codexCache splits a turn's input three ways. Codex fills cached_input_tokens +// and cache_write_input_tokens from the Responses API's input_tokens_details, +// so both are part of input_tokens and the uncached share is what is left +// (codex-rs/codex-api/src/sse/responses.rs, parses_cache_write_token_usage). func codexCache(t codexTokenUsage) *api.SessionCache { - return &api.SessionCache{Read: t.Cached, Write: t.CacheWrite, Uncached: max(t.Input-t.Cached, 0)} + return &api.SessionCache{Read: t.Cached, Write: t.CacheWrite, Uncached: max(t.Input-t.Cached-t.CacheWrite, 0)} } // codexTranscriptCost prices a Codex rollout's running total in USD. Unlike a @@ -205,12 +205,12 @@ func (c *codexCostScan) consume(line []byte) bool { entry.Payload.Info == nil || entry.Payload.Info.Total == nil { return false } - t := entry.Payload.Info.Total + split := codexCache(*entry.Payload.Info.Total) c.tokens = pricing.Tokens{ - Input: t.Input - t.Cached, - CacheRead: t.Cached, - CacheWrite: t.CacheWrite, - Output: t.Output, + Input: split.Uncached, + CacheRead: split.Read, + CacheWrite: split.Write, + Output: entry.Payload.Info.Total.Output, } c.haveTokens = true return false diff --git a/internal/terminal/usage_codex_test.go b/internal/terminal/usage_codex_test.go index e31754ae3..afa57114f 100644 --- a/internal/terminal/usage_codex_test.go +++ b/internal/terminal/usage_codex_test.go @@ -35,15 +35,19 @@ func TestScanCodexContextUsage(t *testing.T) { } } -// A cache write is read as outside input_tokens, the way the cost readout -// prices it, so it does not come off the uncached share. +// Both cache counters are part of input_tokens, so the uncached share is what +// is left of it after the two. func TestCodexCacheSplit(t *testing.T) { t.Parallel() - got := codexCache(codexTokenUsage{Input: 1000, Cached: 600, CacheWrite: 300}) - want := &api.SessionCache{Read: 600, Write: 300, Uncached: 400} + got := codexCache(codexTokenUsage{Input: 100, Cached: 40, CacheWrite: 60}) + want := &api.SessionCache{Read: 40, Write: 60, Uncached: 0} if *got != *want { t.Errorf("cache = %+v, want %+v", *got, *want) } + got = codexCache(codexTokenUsage{Input: 1000, Cached: 400, CacheWrite: 25}) + if got.Uncached != 575 { + t.Errorf("uncached = %d, want 575", got.Uncached) + } if got := codexCache(codexTokenUsage{Input: 10, Cached: 20}); got.Uncached != 0 { t.Errorf("cached past input: uncached = %d, want 0", got.Uncached) } diff --git a/internal/terminal/usage_cost_test.go b/internal/terminal/usage_cost_test.go index 3e38f42bd..ec5087225 100644 --- a/internal/terminal/usage_cost_test.go +++ b/internal/terminal/usage_cost_test.go @@ -502,8 +502,11 @@ func codexTurnContextJSON(model string) string { // TestCodexTranscriptCostPricesTheRunningTotal is the shape a Codex rollout // reports: turn_context (the model) precedes the token_count line that closes -// the turn, and cached_input_tokens is a subset of input_tokens, so only the -// uncached share prices at the input rate — summing both would double-count. +// the turn, and both cached_input_tokens and cache_write_input_tokens are +// subsets of input_tokens (Codex fills them from the Responses API's +// input_tokens_details; its parses_cache_write_token_usage test in +// codex-rs/codex-api/src/sse/responses.rs has 40 cached + 60 written of 100 +// input), so only the rest prices at the input rate. func TestCodexTranscriptCostPricesTheRunningTotal(t *testing.T) { t.Parallel() path := writeFile(t, @@ -514,7 +517,7 @@ func TestCodexTranscriptCostPricesTheRunningTotal(t *testing.T) { if !ok { t.Fatal("codexTranscriptCost: want ok") } - want := 600*0.001 + 400*0.0005 + 25*0.002 + 10*0.01 + want := 575*0.001 + 400*0.0005 + 25*0.002 + 10*0.01 if !nearly(cost, want) { t.Errorf("cost = %v, want %v", cost, want) } From 978c6f526aed7a3d1baffc7a772f55296a74d44b Mon Sep 17 00:00:00 2001 From: omartelo Date: Mon, 26 Oct 2026 23:01:36 -0300 Subject: [PATCH 3/4] feat(footer): put the cache line in the cost tooltip on oh-my-pi and opencode Both record the last turn's cache split but no window, so the line rides on the cost tooltip instead of a context one. oh-my-pi's comes from the newest assistant line's usage (input, cacheRead, cacheWrite), opencode's from the newest finished assistant message row (tokens.input, tokens.cache.*); both already leave the cache out of input. --- CHANGELOG.md | 3 +- README.es.md | 2 +- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- README.pt-BR.md | 2 +- README.ru.md | 2 +- README.zh-CN.md | 2 +- api/terminal.go | 5 +- docs/ceilings.md | 8 +- docs/providers/omp.md | 4 +- docs/providers/opencode.md | 5 +- internal/terminal/usage.go | 3 + internal/terminal/usage_cache.go | 87 +++++++++++++++++ internal/terminal/usage_cache_test.go | 129 +++++++++++++++++++++++++ native/cmd/lich-native/footer.go | 9 ++ native/cmd/lich-native/footer_cache.go | 10 +- 17 files changed, 257 insertions(+), 20 deletions(-) create mode 100644 internal/terminal/usage_cache.go create mode 100644 internal/terminal/usage_cache_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a1e9f386d..5bd886573 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- The context window tooltip in the footer shows how much of the last turn was read back from the prompt cache, with a bar splitting the turn's input into cached, written to the cache and uncached. A turn after the cache expired reads 0% in amber, which is the turn billed at the full rate. Claude Code and Codex only: the other agents don't record that split next to a context window. +- The footer shows how much of the last turn was read back from the prompt cache, with a bar splitting the turn's input into cached, written to the cache and uncached. A turn after the cache expired reads 0% in amber, which is the turn billed at the full rate. It sits in the context window tooltip on Claude Code and Codex, and in the cost tooltip on oh-my-pi and opencode. ### Changed @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A Codex session's cost no longer bills the tokens written to the prompt cache twice. They were priced at the cache-write rate and again as uncached input. - The mouse wheel in a terminal scrolls smoothly and as far as in other terminals. A notch moves three lines of the shell's history, or a full-screen program such as `less`, eased over a few frames instead of jumping some eight lines at once. A program that takes the mouse, such as nvim, Claude Code or Codex, gets one wheel step a notch and scrolls its own distance, where it used to get about eight and race through the conversation. ## [0.79.0] - 2026-10-24 diff --git a/README.es.md b/README.es.md index bbed66bd4..1bcccf83c 100644 --- a/README.es.md +++ b/README.es.md @@ -109,7 +109,7 @@ porque lee lo que cada CLI deja escrito, y no hay dos que registren lo mismo. | Lo que obtienes | Claude Code | Codex | Antigravity | opencode | oh-my-pi | Crush | Cursor CLI | Kiro CLI | | --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | | Ventana de contexto en el pie de ventana | sí | sí | no | no | no | no | no | sí | -| Cuánto del último turno salió de la caché de prompts, en el tooltip de la ventana de contexto | sí | sí | no | no | no | no | no | no | +| Cuánto del último turno salió de la caché de prompts, en el pie de ventana | sí | sí | no | en el tooltip del costo | en el tooltip del costo | no | no | no | | El tooltip de la tarjeta nombra el modelo en uso, y con cuál se abrió si cambió | sí | sí | no | no | no | no | no | sí | | Costo en el pie de ventana | sí | sí | no | sí | sí | sí | no | créditos | | Cuánto queda de tu plan | sí | sí | sí | solo OpenCode Go | no | no | sí | no | diff --git a/README.ja.md b/README.ja.md index 83996d776..04d55433e 100644 --- a/README.ja.md +++ b/README.ja.md @@ -98,7 +98,7 @@ lich は各 CLI が書き出す情報を読んでいて、何を書き出すか | 得られるもの | Claude Code | Codex | Antigravity | opencode | oh-my-pi | Crush | Cursor CLI | Kiro CLI | | --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | | フッターにコンテキストウィンドウを表示 | あり | あり | なし | なし | なし | なし | なし | あり | -| 直前のターンのうちプロンプトキャッシュから読んだ割合を、コンテキストウィンドウのツールチップに表示 | あり | あり | なし | なし | なし | なし | なし | なし | +| 直前のターンのうちプロンプトキャッシュから読んだ割合をフッターに表示 | あり | あり | なし | コストのツールチップ | コストのツールチップ | なし | なし | なし | | カードのツールチップに実行中のモデルを表示(開始時から変わっていれば開始時のモデルも) | あり | あり | なし | なし | なし | なし | なし | あり | | フッターに費用を表示 | あり | あり | なし | あり | あり | あり | なし | クレジット | | プランの残量を表示 | あり | あり | あり | OpenCode Go のみ | なし | なし | あり | なし | diff --git a/README.ko.md b/README.ko.md index a4693492d..7926c7cc5 100644 --- a/README.ko.md +++ b/README.ko.md @@ -98,7 +98,7 @@ lich로 할 수 있는 일: | 기능 | Claude Code | Codex | Antigravity | opencode | oh-my-pi | Crush | Cursor CLI | Kiro CLI | | --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | | 하단 바에 컨텍스트 윈도우 표시 | 예 | 예 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | 예 | -| 직전 턴 중 프롬프트 캐시에서 읽은 비율을 컨텍스트 윈도우 툴팁에 표시 | 예 | 예 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | +| 직전 턴 중 프롬프트 캐시에서 읽은 비율을 하단 바에 표시 | 예 | 예 | 아니요 | 비용 툴팁에 | 비용 툴팁에 | 아니요 | 아니요 | 아니요 | | 카드 툴팁에 실행 중인 모델 표시 (시작할 때와 바뀌었으면 시작 모델도) | 예 | 예 | 아니요 | 아니요 | 아니요 | 아니요 | 아니요 | 예 | | 하단 바에 비용 표시 | 예 | 예 | 아니요 | 예 | 예 | 예 | 아니요 | 크레딧 | | 남은 플랜 사용량 | 예 | 예 | 예 | OpenCode Go만 | 아니요 | 아니요 | 예 | 아니요 | diff --git a/README.md b/README.md index a4034d5f3..3160493cc 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ reads what each CLI writes down and no two of them write down the same things. | What you get | Claude Code | Codex | Antigravity | opencode | oh-my-pi | Crush | Cursor CLI | Kiro CLI | | --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | | Context window in the footer | yes | yes | no | no | no | no | no | yes | -| How much of the last turn came from the prompt cache, in the context window tooltip | yes | yes | no | no | no | no | no | no | +| How much of the last turn came from the prompt cache, in the footer | yes | yes | no | in the cost tooltip | in the cost tooltip | no | no | no | | The card's tooltip names the model running, and the one it opened on if it changed | yes | yes | no | no | no | no | no | yes | | Cost in the footer | yes | yes | no | yes | yes | yes | no | credits | | How much of your plan is left | yes | yes | yes | OpenCode Go only | no | no | yes | no | diff --git a/README.pt-BR.md b/README.pt-BR.md index 5b75d6b57..36f0ba35e 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -110,7 +110,7 @@ mesmas coisas. | Recurso | Claude Code | Codex | Antigravity | opencode | oh-my-pi | Crush | Cursor CLI | Kiro CLI | | --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | | Janela de contexto no rodapé | sim | sim | não | não | não | não | não | sim | -| Quanto do último turno veio do cache de prompt, no tooltip da janela de contexto | sim | sim | não | não | não | não | não | não | +| Quanto do último turno veio do cache de prompt, no rodapé | sim | sim | não | no tooltip de custo | no tooltip de custo | não | não | não | | O tooltip do card mostra o modelo em uso, e com qual abriu se ele mudou | sim | sim | não | não | não | não | não | sim | | Custo no rodapé | sim | sim | não | sim | sim | sim | não | créditos | | Quanto ainda resta do seu plano | sim | sim | sim | só OpenCode Go | não | não | sim | não | diff --git a/README.ru.md b/README.ru.md index 64d0963e3..001d6cc99 100644 --- a/README.ru.md +++ b/README.ru.md @@ -109,7 +109,7 @@ | Что вы получаете | Claude Code | Codex | Antigravity | opencode | oh-my-pi | Crush | Cursor CLI | Kiro CLI | | --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | | Окно контекста в нижней панели | да | да | нет | нет | нет | нет | нет | да | -| Какая доля последнего хода пришла из кэша промптов, во всплывающей подсказке окна контекста | да | да | нет | нет | нет | нет | нет | нет | +| Какая доля последнего хода пришла из кэша промптов, в нижней панели | да | да | нет | в подсказке стоимости | в подсказке стоимости | нет | нет | нет | | Подсказка карточки называет работающую модель и ту, на которой сессия открылась, если она сменилась | да | да | нет | нет | нет | нет | нет | да | | Стоимость в нижней панели | да | да | нет | да | да | да | нет | кредиты | | Сколько осталось от лимитов подписки | да | да | да | только OpenCode Go | нет | нет | да | нет | diff --git a/README.zh-CN.md b/README.zh-CN.md index f0ed90678..b05f220df 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -86,7 +86,7 @@ | 功能 | Claude Code | Codex | Antigravity | opencode | oh-my-pi | Crush | Cursor CLI | Kiro CLI | | --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | | 底栏显示上下文窗口 | 有 | 有 | 无 | 无 | 无 | 无 | 无 | 有 | -| 在上下文窗口的提示框中显示上一轮有多少来自提示缓存 | 有 | 有 | 无 | 无 | 无 | 无 | 无 | 无 | +| 在底栏显示上一轮有多少来自提示缓存 | 有 | 有 | 无 | 在费用提示框中 | 在费用提示框中 | 无 | 无 | 无 | | 卡片提示显示正在运行的模型,若与打开时不同也显示打开时的模型 | 有 | 有 | 无 | 无 | 无 | 无 | 无 | 有 | | 底栏显示花费 | 有 | 有 | 无 | 有 | 有 | 有 | 无 | 按点数计 | | 套餐剩余额度 | 有 | 有 | 有 | 仅 OpenCode Go | 无 | 无 | 有 | 无 | diff --git a/api/terminal.go b/api/terminal.go index 88349f884..4362ed907 100644 --- a/api/terminal.go +++ b/api/terminal.go @@ -106,9 +106,10 @@ type ResumeAvailableOptions struct { // being priced look identical, and an empty footer reads as zero spend. // // Cache is where the last turn's input came from, and is nil whenever the -// provider files no split of it: every provider but Claude Code and Codex, and a +// provider files no split of it: Crush, Kiro, Cursor and Antigravity, and a // Claude conversation whose last event is a compaction, since the turn before it -// no longer describes the window. +// no longer describes the window. On oh-my-pi and opencode it rides beside the +// cost with no window, so the footer puts it in the cost tooltip. type SessionUsage struct { ID string `json:"id"` Percent int `json:"percent"` diff --git a/docs/ceilings.md b/docs/ceilings.md index ff9f942f5..86784f613 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -165,10 +165,10 @@ work when nobody knows it and that the call site never shows. The mechanism and mind, the footer is silently wrong in one direction or the other, with nothing on screen saying so. Their usage event carries a zero window, which is what tells the footer to drop the ring (`contextReadout`, `native/cmd/lich-native/footer_readout.go`). A zero from any of the three is an answer and not a gap: it is what - they record for a free model and for a subscription. The cache line in the context tooltip (`api.SessionCache`) is Claude Code's - and Codex's alone: opencode and oh-my-pi record a cache split but no window, so there is no context tooltip to - put it in; Crush folds its cache counts into the prompt and completion totals; Kiro files every token count as - zero; Cursor and Antigravity have no usage reader. Codex is the one provider lich prices itself, from a slice of + they record for a free model and for a subscription. The last turn's cache line (`api.SessionCache`) sits in the context + tooltip on Claude Code and Codex, and in the cost tooltip on oh-my-pi and opencode, which record the split but + no window. Crush folds its cache counts into the prompt and completion totals, Kiro files every token count as + zero, and Cursor and Antigravity have no usage reader, so the four show none. Codex is the one provider lich prices itself, from a slice of rates baked into the release (`internal/pricing/prices.json`), so an offline Codex session shows a cost at the rate that shipped — **stale by however long it has been since the release**, until the refresh (`internal/pricing/pricing.go`) lands and overrides it. What is left with no price at all is a model newer than diff --git a/docs/providers/omp.md b/docs/providers/omp.md index 0756a63b5..034549405 100644 --- a/docs/providers/omp.md +++ b/docs/providers/omp.md @@ -29,7 +29,9 @@ two-cards-one-conversation warning arrives at the switch. ## Transcript & cost Cost only: a USD total on every assistant turn, and no line carries a context window, so the footer shows the -figure and no ring. The figure is omp's own arithmetic, never re-priced here: the sum of its assistant turns, the +figure and no ring. The cost tooltip also carries the newest assistant turn's cache line, from its +`usage.input`, `cacheRead` and `cacheWrite`: omp takes the cache out of `input` before writing it +(`calculateOpenAIUsageAccounting`), so the three add up to the turn's input. The figure is omp's own arithmetic, never re-priced here: the sum of its assistant turns, the same walk its own status line makes, so a `task` sub-agent's spend is missing from lich's figure exactly as it is from omp's. A turn written with no total at all withholds the session's whole number and borrows the `unpriced-model` marker, whose tooltip then blames a network that would not have helped. That branch has never diff --git a/docs/providers/opencode.md b/docs/providers/opencode.md index c8e4731e7..2e84dd1e8 100644 --- a/docs/providers/opencode.md +++ b/docs/providers/opencode.md @@ -42,7 +42,10 @@ the card's row keeps the old id and a restart resumes the conversation the card Cost only: `session.cost` per conversation, with no window recorded beside it, so the footer shows the figure and no ring. opencode files each sub-agent as a session of its own and does *not* roll it into the parent, so the read -walks the `parent_id` chain. That is measured behaviour of opencode's schema, not a promise it made. +walks the `parent_id` chain. That is measured behaviour of opencode's schema, not a promise it made. The cost +tooltip also carries the newest finished assistant message's cache line, from `tokens.input`, `tokens.cache.read` +and `tokens.cache.write` on that `message` row, whose input already leaves the cache out. A sub-agent's turns are +its own session's and are not read. ## Plugin install diff --git a/internal/terminal/usage.go b/internal/terminal/usage.go index 1bb1cca1b..b0ffbb2d3 100644 --- a/internal/terminal/usage.go +++ b/internal/terminal/usage.go @@ -97,6 +97,9 @@ func (s *Service) usageFrom(id string, src usageSource) (usageEvent, bool) { Effort: u.effort, Cache: u.cache, } + if !supported { + event.Cache = lastTurnCache(src) + } cost, miss, priced := s.sessionCost(id, src) switch { case priced: diff --git a/internal/terminal/usage_cache.go b/internal/terminal/usage_cache.go new file mode 100644 index 000000000..e2b585522 --- /dev/null +++ b/internal/terminal/usage_cache.go @@ -0,0 +1,87 @@ +package terminal + +import ( + "bytes" + "database/sql" + "encoding/json" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/providers" +) + +// lastTurnCache is the newest assistant turn's cache split for the two +// cost-only providers that record one, oh-my-pi and opencode. Both file the +// input with the cache already taken out of it, so the three counters add up +// to the turn's whole input as they stand (measured against omp 18.0.10's +// calculateOpenAIUsageAccounting and an opencode 1.18.23 row whose total is +// input + output + cache.read + cache.write). nil on any miss, which the +// footer reads as no cache line. +func lastTurnCache(src usageSource) *api.SessionCache { + switch src.kind { + case providers.OMP: + return ompLastTurnCache(src.path) + case providers.OpenCode: + return opencodeLastTurnCache(src.path, src.id) + } + return nil +} + +// ompLastTurnCache reads the newest assistant line of an oh-my-pi session +// file, from the end, so a long conversation costs no more than a short one. +func ompLastTurnCache(path string) *api.SessionCache { + var cache *api.SessionCache + codexReverseScan(path, func(line []byte) bool { + if !bytes.Contains(line, ompAssistantMark) { + return false + } + cache = parseOMPCacheLine(line) + return cache != nil + }) + return cache +} + +func parseOMPCacheLine(line []byte) *api.SessionCache { + var entry struct { + Type string `json:"type"` + Message struct { + Role string `json:"role"` + Usage *struct { + Input int `json:"input"` + CacheRead int `json:"cacheRead"` + CacheWrite int `json:"cacheWrite"` + } `json:"usage"` + } `json:"message"` + } + if json.Unmarshal(line, &entry) != nil || entry.Type != "message" || + entry.Message.Role != "assistant" || entry.Message.Usage == nil { + return nil + } + u := entry.Message.Usage + return &api.SessionCache{Read: u.CacheRead, Write: u.CacheWrite, Uncached: u.Input} +} + +// opencodeLastTurnCacheQuery is the newest finished assistant message of one +// session: a message still streaming carries zero tokens until it completes. +// A sub-agent is a session of its own, and its turns are not this window's. +const opencodeLastTurnCacheQuery = `SELECT json_extract(data, '$.tokens.input'), + json_extract(data, '$.tokens.cache.read'), json_extract(data, '$.tokens.cache.write') + FROM message WHERE session_id = ? AND json_extract(data, '$.role') = 'assistant' + AND json_extract(data, '$.time.completed') IS NOT NULL + ORDER BY time_created DESC LIMIT 1` + +func opencodeLastTurnCache(path, id string) *api.SessionCache { + if id == "" { + return nil + } + db, ok := openSessionDB(path) + if !ok { + return nil + } + defer func() { _ = db.Close() }() + + var input, read, write sql.NullInt64 + if db.QueryRow(opencodeLastTurnCacheQuery, id).Scan(&input, &read, &write) != nil || !input.Valid { + return nil + } + return &api.SessionCache{Read: int(read.Int64), Write: int(write.Int64), Uncached: int(input.Int64)} +} diff --git a/internal/terminal/usage_cache_test.go b/internal/terminal/usage_cache_test.go new file mode 100644 index 000000000..254b5b4c1 --- /dev/null +++ b/internal/terminal/usage_cache_test.go @@ -0,0 +1,129 @@ +// Same tag as usage_tokenonly_test.go, whose fixtures and stubBins this reuses. +//go:build !windows + +package terminal + +import ( + "database/sql" + "fmt" + "testing" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/internal/events" + "github.com/lichdotdev/lich/internal/providers" +) + +// ompCachedAssistant is an oh-my-pi assistant line with a cache split and a cost. +func ompCachedAssistant(id string, input, read, write int) string { + return fmt.Sprintf( + `{"type":"message","id":%q,"message":{"role":"assistant","model":"rig/one",`+ + `"usage":{"input":%d,"output":26,"cacheRead":%d,"cacheWrite":%d,"cost":{"total":0.01}}}}`, + id, input, read, write, + ) +} + +func TestOMPLastTurnCacheReadsTheNewestAssistantLine(t *testing.T) { + writeOMPSession(t, "omp-1", + ompCachedAssistant("a1", 9, 9, 9), + ompCachedAssistant("a2", 621, 36352, 12), + `{"type":"message","id":"u1","message":{"role":"user","content":"next"}}`, + ) + src, ok := usageSourceFor("omp-1", "") + if !ok || src.kind != providers.OMP { + t.Fatalf("usageSourceFor = %+v, %v", src, ok) + } + got := lastTurnCache(src) + want := api.SessionCache{Read: 36352, Write: 12, Uncached: 621} + if got == nil || *got != want { + t.Errorf("cache = %+v, want %+v", got, want) + } +} + +func TestOMPLastTurnCacheMisses(t *testing.T) { + writeOMPSession(t, "omp-1", `{"type":"message","id":"u1","message":{"role":"user","content":"hi"}}`) + src, _ := usageSourceFor("omp-1", "") + if got := lastTurnCache(src); got != nil { + t.Errorf("no assistant line: cache = %+v, want nil", got) + } + if got := ompLastTurnCache("/does/not/exist.jsonl"); got != nil { + t.Errorf("missing file: cache = %+v, want nil", got) + } +} + +// writeOpenCodeMessages adds opencode's message table beside the session rows, +// each message as the JSON opencode keeps in its data column. +func writeOpenCodeMessages(t *testing.T, rows ...[3]string) { + t.Helper() + path, _ := opencodeSessionDB() + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + defer func() { _ = db.Close() }() + if _, err := db.Exec(`CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT)`); err != nil { + t.Fatal(err) + } + for i, r := range rows { + if _, err := db.Exec(`INSERT INTO message VALUES (?, ?, ?, ?)`, r[0], r[1], i, r[2]); err != nil { + t.Fatal(err) + } + } +} + +func opencodeAssistant(input, read, write int, completed bool) string { + done := "" + if completed { + done = `,"completed":2` + } + return fmt.Sprintf(`{"role":"assistant","time":{"created":1%s},`+ + `"tokens":{"input":%d,"output":26,"cache":{"read":%d,"write":%d}}}`, done, input, read, write) +} + +func TestOpenCodeLastTurnCacheReadsTheNewestFinishedTurn(t *testing.T) { + writeOpenCodeDB(t, sessionRow{id: "ses_one", cost: 0.02}, sessionRow{id: "ses_two", parent: "ses_one"}) + writeOpenCodeMessages(t, + [3]string{"m1", "ses_one", opencodeAssistant(1, 1, 1, true)}, + [3]string{"m2", "ses_one", opencodeAssistant(621, 36352, 0, true)}, + [3]string{"m3", "ses_one", `{"role":"user","time":{"created":3}}`}, + [3]string{"m4", "ses_two", opencodeAssistant(5, 5, 5, true)}, // a sub-agent's own window + [3]string{"m5", "ses_one", opencodeAssistant(0, 0, 0, false)}, + ) + src, ok := usageSourceFor("ses_one", "") + if !ok || src.kind != providers.OpenCode { + t.Fatalf("usageSourceFor = %+v, %v", src, ok) + } + got := lastTurnCache(src) + want := api.SessionCache{Read: 36352, Uncached: 621} + if got == nil || *got != want { + t.Errorf("cache = %+v, want %+v", got, want) + } + if got := opencodeLastTurnCache(src.path, ""); got != nil { + t.Errorf("no id: cache = %+v, want nil", got) + } +} + +// A database without the message table is a schema that moved: no line, and +// the cost beside it still reports. +func TestOpenCodeLastTurnCacheWithoutAMessageTable(t *testing.T) { + writeOpenCodeDB(t, sessionRow{id: "ses_one", cost: 0.02}) + src, _ := usageSourceFor("ses_one", "") + if got := lastTurnCache(src); got != nil { + t.Errorf("cache = %+v, want nil", got) + } +} + +func TestTheCostOnlyRungCarriesTheCache(t *testing.T) { + writeOMPSession(t, "omp-1", ompCachedAssistant("a1", 412, 118020, 2310)) + svc := New(newCostStore("omp-1"), nil, events.New()) + svc.prices = testRate + svc.spawns.Store("s1", spawn{kind: providers.OMP}) + + got, ok := svc.sessionUsage("s1") + if !ok || got.Window != 0 { + t.Fatalf("sessionUsage = %+v, %v; want a cost-only report", got, ok) + } + want := api.SessionCache{Read: 118020, Write: 2310, Uncached: 412} + if got.Cache == nil || *got.Cache != want { + t.Errorf("Cache = %+v, want %+v", got.Cache, want) + } +} diff --git a/native/cmd/lich-native/footer.go b/native/cmd/lich-native/footer.go index f2b337fd2..b3886daa6 100644 --- a/native/cmd/lich-native/footer.go +++ b/native/cmd/lich-native/footer.go @@ -289,6 +289,15 @@ func (f *footer) readout(gtx C, th *ui.Theme, w *footerWidgets, s footerSlot, in return footerReadoutView(gtx, th, f.root, w, r, detail, ringContent(in.usage.Percent, r.Text)) case "plan": return footerReadoutView(gtx, th, f.root, w, r, planDetails(pop, s.Plan, in.now), glyphText(icGauge, r.Text)) + case "cost": + // A provider with no window has no context tooltip, so its cache + // reading rides on the cost one. + hit, ok := cacheHit(in.usage.Cache) + if !ok || in.usage.Window > 0 { + break + } + detail := cacheDetail(pop, readoutDetail(pop, r.Detail), *in.usage.Cache, hit) + return footerReadoutView(gtx, th, f.root, w, r, detail, footerText(r.Text)) case "handsOn": content := glyphText(icTimer, r.Text) if in.handsOnErr != nil { diff --git a/native/cmd/lich-native/footer_cache.go b/native/cmd/lich-native/footer_cache.go index 4dbe42e59..f87672717 100644 --- a/native/cmd/lich-native/footer_cache.go +++ b/native/cmd/lich-native/footer_cache.go @@ -31,9 +31,11 @@ func cacheHit(c *api.SessionCache) (int, bool) { return (c.Read*100 + total/2) / total, true } -// cacheDetail is the context tooltip's body with a cache reading: the token -// line, then the hit rate in its tone over the read / written / uncached bar. -func cacheDetail(th *ui.Theme, tokens layout.Widget, c api.SessionCache, hit int) layout.Widget { +// cacheDetail is a tooltip body with a cache reading under it: head (the +// context tooltip's token line, or the cost tooltip's lines on a provider with +// no window), then the hit rate in its tone over the read / written / uncached +// bar. +func cacheDetail(th *ui.Theme, head layout.Widget, c api.SessionCache, hit int) layout.Widget { tone := th.ToneWait if hit >= cacheWarmPercent { tone = th.TonePass @@ -42,7 +44,7 @@ func cacheDetail(th *ui.Theme, tokens layout.Widget, c api.SessionCache, hit int bar := func(gtx C) D { return cacheBar(gtx, th, c) } return func(gtx C) D { gtx.Constraints.Min.X = min(gtx.Dp(planMinWide), gtx.Constraints.Max.X) - return stack(gtx, 1, []layout.Widget{tokens, line, bar}) + return stack(gtx, 1, []layout.Widget{head, line, bar}) } } From bcbff4b05d415fb326708aae1a8063740ac1d09c Mon Sep 17 00:00:00 2001 From: omartelo Date: Mon, 26 Oct 2026 23:14:51 -0300 Subject: [PATCH 4/4] fix(browser): give a cold Chrome start 60s to come up chromedp gives a launching Chrome 20s to print its DevTools URL. On a GitHub Ubuntu runner the suite's first launch passed that, failing TestChromeClearReachesTheFieldsListener with "websocket url timeout reached" on two runs; in the run that predates the sandbox sysctl the second launch took 6s and the third 1s to reach Chrome's sandbox check, so the time goes to loading the browser off a cold disk, not to lich. --- internal/browser/chrome.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/browser/chrome.go b/internal/browser/chrome.go index 2c0ce5d47..a75b0a026 100644 --- a/internal/browser/chrome.go +++ b/internal/browser/chrome.go @@ -27,6 +27,13 @@ const domSettle = 1500 * time.Millisecond // the profile a promotion reopens, short enough that a hung one is killed. const closeWait = 5 * time.Second +// launchWait bounds how long a starting Chrome may take to print its DevTools +// URL. chromedp's default of 20s is shorter than a cold start: on a GitHub +// Ubuntu runner the first launch of the suite passed 20s, the second took 6s +// and the third 1s, each before Chrome's own sandbox check, so the time goes to +// loading the browser off a cold disk, which a slow machine pays too. +const launchWait = 60 * time.Second + // chromeLaunch starts a Chromium-family browser via CDP. ExecPath is // chromium.FindBrowser (system Chrome/Edge/… — never lich-shell); UserDataDir // is this ephemeral dir, never lich/chromium-profile. Attaching to the UI @@ -38,7 +45,7 @@ func chromeLaunch(dir string, headed bool) (Page, error) { return nil, err } opts := append([]chromedp.ExecAllocatorOption{}, chromedp.DefaultExecAllocatorOptions[:]...) - opts = append(opts, chromedp.ExecPath(exe), chromedp.UserDataDir(dir)) + opts = append(opts, chromedp.ExecPath(exe), chromedp.UserDataDir(dir), chromedp.WSURLReadTimeout(launchWait)) if headed { opts = append(opts, chromedp.Flag("headless", false)) }