diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a9b8c4e3..b222fda33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - A session card's menu opens a browser for that session, above Open in, in a tab beside the session: its terminal on one side and the page on the other, with the divider, rotate and zoom of a split. The session's agent drives the same page: open a URL, read it, click, type, press a key, go back and forward, scroll and screenshot, from `browser_*` tools or from `lich browser`. The agent's own cursor shows where it acts: lich's arrow, in the session's card color or the theme's, with a badge naming the provider, glides to each click, field and scroll on the page and to back, forward, reload and the address on the pane's toolbar, playing even an agent's parallel calls in turn. You click, type and copy and paste with your own clipboard in the page while it does. Each click and key the agent sends answers with the page it left, in a compact form, so a long session spends about half the tokens it would reading the page back as JSON. When the agent opens a browser on its own, a toast in the bottom-right corner offers to show it. Open in a window moves the page to a Chromium window of its own, cookies and all. Closing the card closes the browser. The browser is the Chrome, Edge, Chromium, Brave, Vivaldi or Helium already installed. opencode's agent reaches it through `lich browser`; its own tool list does not include `browser_*`. +- 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 @@ -18,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - A session browser's open, reload, back and forward return once the page is interactive, instead of waiting for every image and tracker to finish. That wait held the agent's next action for as long as the load event took, up to 30 seconds, after the page was already on screen. A screenshot is the viewport being watched. +- 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 3759e89a9..669c8de39 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 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 46557754b..9fb4cacc8 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 4ba40259d..0e542b058 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 dceadb73e..e1187208d 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 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 2a080c7aa..7403d06fd 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 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 087dbf51d..505a50b5c 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 9c14599be..08c12b8a7 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..4362ed907 100644 --- a/api/terminal.go +++ b/api/terminal.go @@ -104,15 +104,30 @@ 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: 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. 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"` - 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 9ca011c6f..0715941fe 100644 --- a/api/testdata/contract.golden +++ b/api/testdata/contract.golden @@ -1679,6 +1679,7 @@ SessionUsage { effort string costUsd,omitempty *float64 costMiss,omitempty string + cache,omitempty *SessionCache } SessionsColored { @@ -1890,6 +1891,12 @@ PluginProfileFailure { error string } +SessionCache { + read int + write int + uncached int +} + BrowserRect { x int y int diff --git a/docs/ceilings.md b/docs/ceilings.md index 6f5406b06..1a23edc29 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 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/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..9ec4b04e2 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` 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/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/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)) } diff --git a/internal/terminal/usage.go b/internal/terminal/usage.go index 133db3699..b0ffbb2d3 100644 --- a/internal/terminal/usage.go +++ b/internal/terminal/usage.go @@ -95,6 +95,10 @@ func (s *Service) usageFrom(id string, src usageSource) (usageEvent, bool) { Window: u.window, Model: u.model, Effort: u.effort, + Cache: u.cache, + } + if !supported { + event.Cache = lastTurnCache(src) } cost, miss, priced := s.sessionCost(id, src) switch { 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/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..05dbe59b9 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" ) @@ -14,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"` @@ -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 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-t.CacheWrite, 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 @@ -194,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 73e6308a4..afa57114f 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,31 @@ 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) } } +// 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: 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) + } +} + func TestCodexContextUsageMisses(t *testing.T) { t.Parallel() for _, body := range []string{ 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) } 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..b3886daa6 100644 --- a/native/cmd/lich-native/footer.go +++ b/native/cmd/lich-native/footer.go @@ -283,9 +283,21 @@ 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)) + 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 new file mode 100644 index 000000000..f87672717 --- /dev/null +++ b/native/cmd/lich-native/footer_cache.go @@ -0,0 +1,73 @@ +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 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 + } + 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{head, 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}",