SLOPSHOPPER

cost-ledger

Cost ledger across all chats: after every answer it records the chat cost (as /cost), tokens per model and model calls made by other mods. /ledger shows today…

newrowscommand
★ 3v0.6.0MITupdated 2026-10-09FynnXland/fynn-mods/mods/cost-ledger
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cost-ledger
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /ledger ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ cost-ledger since Oct 9, 2025 · as of Oct 9 08:53 · reload: /ledger │ │ │ │ Today 7 days 30 days All time │ │ $0.00 $0.00 $0.00 $0.00 │ │ 0 chats · Mods $0.00 0 chats · Mods $0.00 0 chats · Mods $0.00 0 chats · Mods $0.00 │ │ │ │ Last 14 days │ │ Oct 9 – │ │ Oct 8 – │ │ Oct 7 – │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Command output
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ cost-ledger since Oct 9, 2025 · as of Oct 9 08:53 · reload: /ledger │ │ │ │ Today 7 days 30 days All time │ │ $0.00 $0.00 $0.00 $0.00 │ │ 0 chats · Mods $0.00 0 chats · Mods $0.00 0 chats · Mods $0.00 0 chats · Mods $0.00 │ │ │ │ Last 14 days │ │ Oct 9 – │ │ Oct 8 – │ │ Oct 7 – │ │ Oct 6 – │ │ Oct 5 – │ │ Oct 4 – │ │ Oct 3 – │ │ Oct 2 – │ │ Oct 1 – │ │ Sep 30 – │ │ Sep 29 – │ │ Sep 28 – │ │ Sep 27 – │ │ Sep 26 – │ │ │ │ Projects · 30 days Most expensive chats · 30 days │ │ no costs in this period no costs in this period │ │ │ │ Mods · 30 days │ │ no model calls by other mods in this period │ │ adds to the chat costs, not included in /cost │ │ │ │ API value; on a subscription it counts against your usage limits · /ledger weeks · chats · │ │ projects · models · help │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

cost-ledger

A cost ledger across all your chats. After every answer, cost-ledger records what the chat has cost so far (the same value as /cost), plus the model calls made by other mods (sidekick, quick-replies …). /ledger shows today, 7 days, 30 days and all time, the history of the last 14 days, your projects and your most expensive chats in an overview styled like Claude Code (theme colors). The history shows 14 days or 14 weeks, each bar split by model; model calls by other mods appear as their own parts (e.g. “Sonnet 5.5 · mods”). On a subscription, /ledger limits shows how much API value went into the current 5-hour and weekly window and into your subscription month. It is a pure observer: no budget, no intervention, no model calls of its own.

Texts are English by default; set language to de for German.

Tested with Claude Code v2.1.295 · Plugin version 0.6.0 · Requires Claude Code v2.1.271 or later (setting options)

Usage

InputEffect
/ledgerOverview: today, 7 days, 30 days, all time; history of the last 14 days, each bar split by model (with legend, chat and mod calls separate); projects, most expensive chats, mods
/ledger weeksThe same overview with the last 14 calendar weeks instead of days
`/ledger chats [7\30\all]`The 20 most expensive chats in the period (default 30 days; any number of days from 1 to 3650 works)
`/ledger projects [7\30\all]`All projects in the period
`/ledger models [7\30\all]`Models: share of the estimated API value, answers, input, output and cache tokens (including subagents and mod calls)
/ledger limitsSubscription limits: the current 5-hour and weekly window (% used, API value, reset time with countdown, projection “100 % ≈ $X”), your subscription month (API value against the plan price, factor, next renewal) and the last 10 five-hour windows with their average
`/ledger plan <plan> <day\today> [price]`Set your plan and billing day: pro, max5 (5x), max20 (20x), team or enterprise; day 1–31, or today if your plan renewed today; monthly price in $ (comma or dot). Without a price, the list price is used: Pro $20, Max 5x $100, Max 20x $200; Team and Enterprise have none. Example: /ledger plan max20 14
/ledger planShow the plan setting and the syntax; /ledger plan off deletes it
/ledger resetDelete everything, after a confirmation (Cancel is recommended and listed first). The plan setting stays
/ledger helpHelp as a table: all commands, the current state (plan, storage used, recording since) and your settings with their values, plus how to change them and how to turn the mod off. Also /ledger ?

Older German arguments still work as aliases: hilfe for help, alle for all, heute for today, aus for off. An unknown command word (the first word after /ledger) answers with a pointer to /ledger help. /ledger days is gone; it only showed the overview.

Help: /ledger help draws a table in the terminal and the desktop app (accent green), with the state as of the moment you ran it. Run it again to see a change. Elsewhere (claude -p, VS Code) you get a short Markdown version, which is also what Claude reads.

On a subscription, /ledger also shows a compact line at the top: 5 h 62 % · $18.40 · reset 18:00 │ week 98 % · $212.10 │ plan $812.40 = 4.1× · /ledger limits. It is left out when there is neither a limit window nor a plan.

Why /ledger plan? Claude Code does not tell mods which plan you have or when it renews, and cost-ledger never reads your login data. So you set it once. Until then the subscription month shows “Plan not set”.

What gets counted:

  • Chats: after every turn, the difference in usage().cost.usd, booked to today. Subagents are included (verified). Remaining costs are booked at session end and before /ledger.
  • Mods: every $.model.complete / fork / classify call by another mod, with count and amount, priced with cost-ledger's own price table (Haiku 5.5 and Sonnet 5.5 as of 2026-10-07, the other models as of 2026-09-25). classify only counts the number of calls. These costs are not part of /cost and are added on top (verified).
  • Project: the folder the chat was started in (Claude Code only reports a repository name for repositories on its own allowlist); worktrees under .claude/worktrees/ count toward the main folder. Runs with claude -p are listed as Script runs.
  • Chat name: the session title if Claude Code reports one (the desktop app does not), otherwise the start of your first message (at most 50 characters, without commands; not for -p script runs). These 50 characters are kept in the local plugin store. Chats without a message are called Chat from Oct 6 14:05 (date and time of the first booking).
  • Limit windows: every answer and every mod call is also credited to the current 5-hour and weekly window, as Claude Code reports them (usage().rateLimits: % used and reset time). A window is identified by its reset time. If the window has already reset and no new answer has reported the next one, the amount waits and goes into the next window. % used is the highest value seen in the window.
  • Subscription month: chat and mod costs since the last billing day (from /ledger plan), only from chats that saw subscription limits (runs with an API key are left out). Factor = API value ÷ plan price.
  • Collected in advance (not all shown yet, so later reports have data from today on): per chat and day the number of answers and subagent turns, working time, aborts and errors, most expensive answer, highest context fill, cost per hour, peak of the 5-hour and weekly limits; tokens per mod; the project's Git remote (only host/owner/repo).
  • Refreshing: there is no button (it would need additional rights). The top right shows the timestamp and the command to reload.
  • Models: tokens of every answer (including subagents) and every mod call, per model. The amount per model is an estimate from the price table; the chat totals above remain the real /cost value. Not included in the model view: compaction and Claude Code's internal calls, so the sum over models is slightly below /cost.

All amounts are API values. On a subscription they count toward your usage limits, not toward a bill.

Runs in: terminal and the desktop Code tab (its own rendering at the command line, colors from the theme). Terminal verified with 0.3.x; desktop verified with 0.3.1 on 2026-10-06 (0.3.0 fell back to the Markdown summary there, fixed in 0.3.1). The 0.5.0 views (/ledger limits, the limit line) are covered by rendering tests for both surfaces; a visual check is pending. The help table (0.6.0) was checked in the terminal. In claude -p and other surfaces you get the Markdown summary. Claude only reads that summary (at most 10 lines for the views, about 15 for the help), not the rendering.

Configuration

/plugin → cost-ledger → settings (userConfig):

KeyTitle in /configMeaningDefault
languageLanguage / SpracheLanguage of the overview, summary, help and confirmation: en or deen
keepDaysRetention (days)Chats with no new bookings for this long are deleted by cost-ledger on the next /ledger365
dayYellowDaily amount yellow from ($)Daily amount (chat + mods) from which the day's value in the history turns yellow3
dayRedDaily amount red from ($)Daily amount from which it turns red8

Language

language switches all texts the mod shows: the /ledger overview and its views, the Markdown summary Claude reads, the help, the reset confirmation, and the formats (en: $14.44, Oct 6, W41; de: 14,44 $, 06.10., KW 41). The default is en; set it to de for German. A change applies to the next /ledger. Stored data does not depend on the language.

Rights

claude plugin validate shows:

hooks: session.start, classic.SessionStart, classic.UserPromptSubmit, turn.complete, model.complete, model.fork, model.classify, session.end, command.run{command=ledger}, ui.render{component=CommandOutput, props has {command=ledger}}
calls: $.clock.now, $.command.register, $.session.repo, $.session.root, $.session.surfaces, $.session.usage, $.store.delete, $.store.get, $.store.keys, $.store.set, $.ui.ask, $.ui.log

In plain language, the hooks:

  • session.start: sets the starting point of the chat's cost counter, reads the project, registers /ledger.
  • classic.SessionStart: the chat's ID and title; /clear, /resume and branching switch the chat without a new start.
  • classic.UserPromptSubmit: names a chat after the start of your first own message when Claude Code reports no title (at most 50 characters, not for commands or -p runs), and finds the chat's ID again after the mod reloads. The prompt itself is neither changed nor stored beyond those 50 characters.
  • turn.complete: books the chat's cost after every answer.
  • model.complete, model.fork, model.classify: see the calls other mods make, to count them (below).
  • session.end: books the rest when the chat ends.
  • command.run{command=ledger}: the /ledger command.
  • ui.render{component=CommandOutput, props has {command=ledger}}: draws the overview, the other views and the help in place of the command's text line.

And the calls:

  • $.command.register: registers /ledger.
  • $.session.usage: reads the chat's cost, without breakdown, so no extra request; from the same call the context fill and the rate-limit windows (% used, reset time) for /ledger limits. Mod calls use the last reading and do not call it again.
  • $.session.surfaces: tells desktop, terminal and script runs apart.
  • $.session.repo, $.session.root: the chat's project name; also the Git remote, stored only as host/owner/repo, never with credentials.
  • $.store.get/set/keys/delete: the ledger in the mod's own plugin store, one entry per chat; cleanup and reset; the plan setting from /ledger plan (plan, billing day, price). /ledger help only reads it (storage used, plan, recording since).
  • $.ui.ask: confirmation before /ledger reset.
  • $.ui.log: errors go to the debug log ({to:'debug'}), never into the transcript.
  • $.clock.now: current date and timestamps.

The model.* hooks only read the result of a call (usage, origin) and pass it on unchanged. No model calls, no files, processes or network targets.

Installation

Add the marketplace once, then install the mod:

claude plugin marketplace add FynnXland/fynn-mods
claude plugin install cost-ledger@fynn-mods

Inside a session the same works with /plugin marketplace add FynnXland/fynn-mods and /plugin install cost-ledger@fynn-mods. The mod loads in the next session, or after /reload-plugins.

Check: /plugin shows … mod active · cost-ledger.

Update: claude plugin update cost-ledger@fynn-mods, or turn on auto-update for fynn-mods under Marketplaces in /plugin.

Remove: disable it under Installed in /plugin, or run claude plugin uninstall cost-ledger@fynn-mods.

Try it for one session without installing (from a clone of the repo):

claude --plugin-dir <path-to-clone>/mods/cost-ledger

Install in user scope (the default), so the mod records in every project. It only books chats in which it is loaded, and only chats started after installation.

The plugin store is tied to the plugin ID. What gets recorded while the mod is loaded via --plugin-dir (cost-ledger_inline-…) is not visible to the marketplace install (cost-ledger_fynn-mods-…), and vice versa.

Known limitations

  • Only numbers from installation on. Older chats are missing.
  • If the desktop app closes a chat hard (without session.end), the remainder since the last answer is missing. If the chat is resumed later, cost-ledger books that remainder then.
  • /resume in the middle of a session is only covered by a test, not verified in a real session (it cannot be triggered in -p). Assumption: usage().cost afterwards reflects the resumed chat (as when starting with --resume, which is verified). If the counter instead kept running per process, the previous chat's cost would be booked twice onto the resumed one.
  • After the mod reloads (a module file saved, settings changed), cost-ledger only recognizes the session again with the next prompt. Costs in between are booked then; mod calls from that window are missing.
  • Mod amounts are estimates: tokens × price table; cache writes are priced as 5-minute TTL. Calls that are denied (deny) do not count. Haiku 5.5 is priced by prompt length: above 100,000 prompt tokens (input + cache reads + cache writes) the whole call costs five times as much. cost-ledger applies this only to single mod calls (model.complete). A chat turn and a fork report their responses summed, so their per-model estimate always uses the lower Haiku 5.5 rate and can be too low when a single request in them was over 100,000 tokens.
  • The alias haiku: a mod that calls $.model.complete({ model: 'haiku' }) gets Claude Haiku 5.5 on Claude Code v2.1.293 and later (verified with v2.1.295), Haiku 4.5 before. The call's result does not say which model answered, so cost-ledger prices haiku as Haiku 5.5. On an older Claude Code such calls are booked about ten times too low, and if you point haiku elsewhere with ANTHROPIC_DEFAULT_HAIKU_MODEL, they are still priced as Haiku 5.5.
  • Haiku 5.5 as the chat model: the chat amount is Claude Code's own /cost value. Claude Code v2.1.291 still priced Haiku 5.5 like Haiku 4.5 (about ten times too much); v2.1.295 prices it correctly.
  • Retention needs use: Claude Code deletes a plugin's store if no session reads or writes it within cleanupPeriodDays. A 365-day retention assumes chats with the mod loaded keep running.
  • Storage: $.store has 4 MiB in total. With the data collected (including the limit windows since 0.5.0), a chat needs about 0.7–1.1 KB per day on which it runs. That is enough for roughly 4,000–6,000 chat-days; with very many -p runs, lower keepDays. /ledger warns from 75 % and reports a write error.
  • Old /ledger lines only show the Markdown summary after the session restarts (the rendering's data lives only in the process memory).
  • Two ledgers: if the same mod is loaded both by path (--plugin-dir) and from the marketplace, each copy keeps its own ledger, because the plugin store is tied to the plugin ID. Chats recorded by one copy do not appear in the other.
  • Chat names in the desktop app: the desktop app does not report the sidebar title to mods, so cost-ledger uses the start of the first message.
  • Model per turn: if a turn uses several models, its tokens count toward the model of the last request (that is how turn.complete reports it).
  • Limits are per account, dollars are per ledger. % used covers your whole account, including claude.ai, the mobile app and chats where cost-ledger is not loaded. The dollar amounts only come from chats with cost-ledger. The projection “100 % ≈ $X” (API value ÷ % used × 100) and the average over past windows are therefore estimates: they come out too low when you also use Claude elsewhere.
  • The first windows are incomplete. Windows that began before cost-ledger 0.5.0 recorded its first window are marked “from …”. They get no projection and do not count toward the average. The 5-hour history starts with 0.5.0; older days only kept the daily peak of % used.
  • Window start is derived. The start of a window (for labels like “13–18” and for “incomplete”) is the reset time minus 5 hours or 7 days, taken from the window names. The amounts do not depend on it.
  • Mod calls count toward the windows on the assumption that they use the same login and therefore the same limits. This is not documented.
  • The limits only update with an answer. Claude Code reports the windows with each API response. After a reset, /ledger limits shows “reset passed” until the next answer. Countdowns are as of the moment you ran the command; the view does not refresh by itself.
  • Waiting amounts live in memory. An amount that waits for the next window (the window had reset, no answer had reported the new one yet) is kept only while the chat runs. If the chat ends first, it is missing from the windows; the daily totals still have it.
  • Plan and price are what you set. cost-ledger does not know your real plan or price; list prices are from Anthropic's pricing pages as of 2026-10-08. Mobile app subscriptions can cost more.
Source 5 files
hooks/register.ts 572 lines
1// cost-ledger: Kostenbuch über alle Chats. Reiner Beobachter: bucht nach jedem Turn die Differenz von `usage().cost.usd`,
2// dazu die Modellaufrufe anderer Mods, und zeigt per /ledger eine Übersicht (Sprache aus userConfig `language`).
3// Fail-open: Jeder Erfassungs-Hook gibt das Ergebnis von `next` unverändert zurück, Fehler gehen nur ins Debug-Log; kein `.catch`.
4// Ein $.store-Schlüssel pro Session (`s:<sessionId>`), den nur diese Session schreibt: $.store ist nicht atomar (CHEATSHEET).
5import type { EngineInterface, On } from 'claude-code'
6import { helpMarkdown, helpTree } from './help.ts'
7import type { HelpData } from './help.ts'
8import { T, dateTime, shortDate, usd } from './i18n.ts'
9import {
10  HELP_WORDS,
11  NO_FOLDER,
12  OFF_WORDS,
13  PLANS,
14  VIEW_WORDS,
15  ledgerHelp,
16  aggregate,
17  baselineFor,
18  bookChat,
19  bookLim,
20  bookLimAt,
21  bookMod,
22  bookModel,
23  bookRate,
24  bookTurn,
25  cleanPlan,
26  cleanRemote,
27  callCost,
28  cleanRec,
29  cleanSettings,
30  dayKey,
31  DAY,
32  deltaOf,
33  limitsOf,
34  newRec,
35  parsePlan,
36  parseRange,
37  planLabel,
38  planMonth,
39  pluginName,
40  projectOf,
41  summaryText,
42  titleFromPrompt,
43  tokensOf,
44} from './logic.ts'
45import type { Carry, HelpInfo, Kind, Limit, Plan, Rec, Settings, TurnInfo, Usage } from './logic.ts'
46import { ledgerTree } from './view.ts'
47import type { View } from './view.ts'
48
49const SELF = 'cost-ledger'
50
51let settings: Settings = cleanSettings(undefined)
52let sessionId = '' // aus classic.SessionStart (kommt vor session.start), nach einem Modul-Reload aus UserPromptSubmit
53let rec: Rec | null = null // eigener Datensatz, im Speicher führend; geschrieben wird er immer ganz
54let seen = 0 // Baseline: letzter gebuchter Stand des Zählers in diesem Prozess
55let rebase = false // Sessionwechsel mitten im Prozess (Resume, Branch): Baseline beim nächsten Messen neu setzen
56let started = false
57let hasCost = true
58let lastModel = '' // Modell der Hauptschleife, für model.fork
59let title = ''
60let titleDirty = false
61let titleFromSession = false // Titel kam als session_title (gewinnt immer) statt aus der ersten Nachricht
62const meta: { project: string; root: string; kind: Kind; remote: string } = { project: NO_FOLDER, root: '', kind: 'terminal', remote: '' }
63// Kennung im Text → Daten der Zeichnung, höchstens 10: eine Ansicht von /ledger oder die Hilfe, dazu die Argumente des
64// Aufrufs. Der Render-Hook zeichnet nur, wenn die Zeile dieselben Argumente hat: `/ledger #<kennung>` gibt „Unbekannt: …“
65// mit der Kennung in der ersten Zeile zurück und darf trotzdem nicht als Tabelle erscheinen (Review worklist 0.7.0, K2).
66type Drawn = ({ kind: 'ledger'; view: View } | { kind: 'help'; data: HelpData }) & { args: string }
67const reports = new Map<string, Drawn>()
68let reportNo = 0
69// Akzent der Hilfe als Theme-Key: passt sich hell und dunkel an (docs/HELP-SPEC.md §4, Fynn 2026-10-09)
70const ACCENT = 'success'
71
72/** Argumente vergleichbar machen: ohne Rand, klein, einfache Leerzeichen. */
73const normArgs = (s: string | undefined) => String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' ')
74
75/** Neue, eindeutige Kennung `#…` für die erste Textzeile; ui.render findet darüber die Daten der Zeichnung. */
76function remember(now: number, d: Drawn): string {
77  const tag = `#${(++reportNo).toString(36)}${now.toString(36).slice(-5)}`
78  reports.set(tag, d)
79  while (reports.size > 10) reports.delete(reports.keys().next().value as string)
80  return tag
81}
82let chain: Promise<unknown> = Promise.resolve() // Messen und Schreiben dieser Session nacheinander
83let writeError = '' // letzter Schreibfehler (z. B. Speicher voll), /ledger zeigt ihn
84// Limit-Fenster der letzten Messung: Mod-Aufrufe buchen hierhin, ohne eigenes usage() (SPEC Nachtrag 0.5.0, Verhalten 2)
85let limits: Limit[] = []
86let carry: Carry = {} // Beträge, die auf das nächste gültige Fenster warten
87let carryAt = 0 // seit wann `carry` Beträge hält; ein späterer Reset (auch aus einem anderen Chat) verwirft sie
88let limSinceChecked = false // `meta:limSince` in diesem Prozess schon geprüft
89let pendingGap = 0 // Nachbuchung beim Start (Resume): gehört ins Fenster der letzten Buchung, nicht ins laufende
90let gapAt: number | undefined // Zeitpunkt der letzten Buchung beim Start; spätere Mod-Aufrufe verschieben ihn nicht
91
92/** `carryAt` nach einer Fenster-Buchung nachführen. */
93function noteCarry(now: number) {
94  if (!Object.keys(carry).length) carryAt = 0
95  else if (!carryAt) carryAt = now
96}
97
98/** `fn` nach allem, was schon in der Kette steht; ein Fehler bricht die Kette nicht. */
99function serial<T>(fn: () => Promise<T>): Promise<T> {
100  const p = chain.then(fn, fn)
101  chain = p.catch(() => undefined)
102  return p
103}
104
105const msg = (err: unknown) => String((err as Error)?.message ?? err).slice(0, 200)
106
107function log($: EngineInterface, text: string) {
108  try {
109    $.ui.log(`cost-ledger: ${text}`, { to: 'debug' })
110  } catch {
111    // Debug-Log ist Beiwerk
112  }
113}
114
115/** Desktop, Terminal oder Skript-Lauf (`-p`: keine Surface, API-DETAILS.md:303-304). */
116function kindOf(surfaces: readonly string[]): Kind {
117  if (surfaces.includes('desktop')) return 'desktop'
118  return surfaces.length ? 'terminal' : 'script'
119}
120
121async function load($: EngineInterface, id: string): Promise<Rec | null> {
122  return cleanRec(await $.store.get(`s:${id}`))
123}
124
125async function save($: EngineInterface) {
126  if (!rec || !sessionId) return
127  try {
128    await $.store.set(`s:${sessionId}`, rec)
129    writeError = ''
130  } catch (err) {
131    writeError = msg(err)
132    throw err
133  }
134}
135
136/**
137 * Hat eine andere Session `/ledger reset` ausgeführt, seit dieser Datensatz zuletzt gebucht wurde, beginnt er leer;
138 * sonst schriebe dieser Chat die gelöschten Beträge zurück ($.store ist geteilt, interface.md:821-826).
139 */
140async function honorReset($: EngineInterface) {
141  const held = Object.keys(carry).length > 0
142  if (!rec && !held) return
143  const at = await $.store.get('meta:resetAt')
144  if (typeof at !== 'number') return
145  // Wartende Fenster-Beträge von vor dem Reset, auch ohne Datensatz (z. B. nach /clear in eine neue Session)
146  if (held && carryAt < at) {
147    carry = {}
148    carryAt = 0
149  }
150  if (rec && rec.lastAt < at) {
151    rec.days = {}
152    rec.mods = {}
153    rec.models = {}
154    rec.modModels = {}
155    rec.act = {}
156    rec.hours = {}
157    rec.rl = {}
158    rec.lim = {}
159  }
160}
161
162/**
163 * An eine Session binden. Mitten im Prozess (`started`): bei /clear beginnt der Zähler bei 0 (types:11148-11151), sonst
164 * (Resume, Branch, Reload) wird die Baseline beim nächsten Messen aus dem Datensatz gesetzt.
165 */
166async function bind($: EngineInterface, id: string, source: string | undefined, newTitle: string | undefined) {
167  sessionId = id
168  rec = await load($, id)
169  pendingGap = 0 // gehört zur bisherigen Session
170  gapAt = undefined
171  title = newTitle || rec?.title || ''
172  titleDirty = !!newTitle && newTitle !== rec?.title
173  titleFromSession = !!newTitle
174  if (!started) return
175  if (source === 'clear') seen = (await $.session.usage()).cost?.usd ?? 0
176  else rebase = true
177}
178
179/**
180 * Messpunkt (SPEC Verhalten 4): Zähler lesen, Differenz auf heute buchen, dazu Tokens, Aktivität und Limits des Turns.
181 * `final`: am Session-Ende nur das Nötigste (Budget 1,5 s, CHEATSHEET Limits).
182 */
183async function measure($: EngineInterface, final = false, usage?: Usage & { model?: string }, turn?: TurnInfo): Promise<void> {
184  // Ein Aufruf ohne `breakdown` (kostenlos): Kosten, dazu vorsorglich Kontext-Füllung und Limit-Stände
185  const u = await $.session.usage()
186  const cost = u.cost?.usd
187  const counted = typeof cost === 'number' && Number.isFinite(cost)
188  hasCost = counted // Host ohne Kostenbuch: keine Chat-Buchung, /ledger nennt den Grund; Tokens je Modell trotzdem
189  // Fenster der letzten API-Antwort (types:11386-11390); leer ohne Abo oder vor der ersten Antwort des Prozesses
190  limits = limitsOf(u.rateLimits)
191  if (!sessionId) return
192  let delta = 0
193  if (counted) {
194    if (rebase) {
195      rebase = false
196      seen = baselineFor(rec, cost)
197    }
198    const d = deltaOf(seen, cost)
199    seen = d.seen
200    delta = d.delta
201  }
202  const hasUsage = tokensOf(usage) > 0
203  if (!(delta > 0) && !(titleDirty && rec) && !hasUsage && !turn) return
204  const now = await $.clock.now()
205  if (!final && meta.kind === 'script') meta.kind = kindOf(await $.session.surfaces()) // Desktop meldet sich evtl. erst später
206  await honorReset($)
207  const r = rec ?? newRec({ ...meta, title }, now, 0)
208  if (r.kind === 'script' && meta.kind !== 'script') r.kind = meta.kind
209  if (titleDirty) r.title = title
210  if (counted) bookChat(r, dayKey(now), delta, now, cost)
211  if (hasUsage) bookModel(r, usage?.model ?? '', dayKey(now), usage, now)
212  if (turn) bookTurn(r, dayKey(now), turn, delta, u.context?.percent)
213  let windowed = false
214  if (turn || delta > 0) {
215    bookRate(r, dayKey(now), u.rateLimits)
216    // Der Rest, der beim Start schon auf dem Zähler stand, fiel vor diesem Prozess an: ins Fenster der letzten Buchung
217    const gap = Math.min(pendingGap, delta)
218    pendingGap = 0
219    windowed = bookLim(r, limits, now, 'chat', delta - gap, carry)
220    if (gap > 0 && gapAt !== undefined) bookLimAt(r, gapAt, gap)
221    gapAt = undefined
222    noteCarry(now)
223  }
224  rec = r
225  titleDirty = false
226  await save($)
227  // Beginn der Fenster-Daten für „unvollständig“; einmal je Prozess, nicht im knappen Budget von session.end
228  if (windowed && !final && !limSinceChecked) {
229    limSinceChecked = true
230    if ((await $.store.get('meta:limSince')) === undefined) await $.store.set('meta:limSince', now)
231  }
232}
233
234/**
235 * Modellaufruf eines anderen Mods buchen (SPEC Verhalten 5). `usage` steht auf jedem Zweig des Ergebnisses (types ModelCompleteResult).
236 * `single`: `usage` ist genau eine Anfrage (nur `model.complete`), dann gilt eine Preisstufe nach Prompt-Länge (Haiku 5.5).
237 */
238async function bookCall($: EngineInterface, origin: string | undefined, model: string, usage: unknown, countOnly: boolean, single = false) {
239  const name = pluginName(origin ?? '')
240  if (!name || name === 'engine' || name === SELF || !sessionId) return
241  const now = await $.clock.now()
242  const amount = countOnly ? 0 : callCost(usage as Parameters<typeof callCost>[0], model, single)
243  await honorReset($)
244  rec ??= newRec({ ...meta, title }, now, seen)
245  bookMod(rec, name, dayKey(now), amount, now, countOnly ? undefined : (usage as Usage | undefined))
246  if (!countOnly) {
247    bookModel(rec, model, dayKey(now), usage as Usage | undefined, now, 'modModels', single)
248    // Fenster aus der letzten Messung, kein eigenes usage(): Mod-Aufrufe sollen nicht warten (Verhalten 5)
249    bookLim(rec, limits, now, 'mod', amount, carry)
250    noteCarry(now)
251  }
252  await save($)
253}
254
255/**
256 * Alle Datensätze lesen; dabei alte löschen (älter als `keepDays` seit der letzten Buchung). Das Aufräumen liegt hier und
257 * nicht im awaiteten session.start, weil `/ledger` ohnehin jeden Datensatz liest. `bytes` schätzt die Größe des Speichers
258 * (4 MiB JSON insgesamt, docs/raw/en/reference.md:294).
259 */
260async function collect($: EngineInterface, now: number) {
261  const recs: { id: string; rec: Rec }[] = []
262  let unreadable = 0
263  let bytes = 0
264  let plan: Plan | null = null
265  let limSince: number | null = null
266  const cutoff = now - settings.keepDays * DAY
267  await honorReset($) // Reset aus einem anderen Chat auch ohne neue Buchung beachten
268  for (const key of await $.store.keys()) {
269    const value = await $.store.get(key)
270    bytes += key.length + (JSON.stringify(value ?? null)?.length ?? 0)
271    if (key === 'meta:plan') plan = cleanPlan(value)
272    if (key === 'meta:limSince' && typeof value === 'number') limSince = value
273    if (!key.startsWith('s:')) continue
274    const id = key.slice(2)
275    // Eigener Datensatz aus dem Speicher: der ist mindestens so frisch wie der gespeicherte
276    const r = id === sessionId && rec ? rec : cleanRec(value)
277    if (!r) unreadable++
278    else if (id !== sessionId && r.lastAt < cutoff) await $.store.delete(key)
279    else recs.push({ id, rec: r })
280  }
281  const hasData = (r: Rec) => [r.days, r.mods, r.models, r.modModels, r.act, r.lim].some((x) => Object.keys(x).length > 0)
282  if (rec && sessionId && hasData(rec) && !recs.some((x) => x.id === sessionId)) recs.push({ id: sessionId, rec })
283  const since = await $.store.get('meta:since')
284  return { recs, unreadable, bytes, since: typeof since === 'number' ? since : null, plan, limSince }
285}
286
287/**
288 * `/ledger help` (docs/HELP-SPEC.md §3): Schnappschuss mit Plan, Speicherbelegung und Beginn der Erfassung, nur lesend
289 * (kein Aufräumen, kein Buchen). Scheitert das Lesen, zeigt die Hilfe trotzdem Befehle und Einstellungen.
290 */
291async function helpCommand($: EngineInterface, args: string): Promise<string> {
292  const now = await $.clock.now()
293  const info: HelpInfo = { plan: null, storeBytes: 0, since: null }
294  try {
295    for (const key of await $.store.keys()) {
296      const value = await $.store.get(key)
297      info.storeBytes += key.length + (JSON.stringify(value ?? null)?.length ?? 0)
298      if (key === 'meta:plan') info.plan = cleanPlan(value)
299      if (key === 'meta:since' && typeof value === 'number') info.since = value
300    }
301  } catch (err) {
302    log($, `/ledger help: ${msg(err)}`)
303  }
304  const data = ledgerHelp(settings, info)
305  return helpMarkdown(data, remember(now, { kind: 'help', data, args: normArgs(args) }))
306}
307
308/**
309 * `/ledger plan …`: Abo und Abrechnungstag in `meta:plan` (SPEC Nachtrag 0.5.0, Bedienung). Den Plan kann ein Mod nicht
310 * abfragen (kein Feld in der Mods-API, Login-Daten sind tabu: CLAUDE.md Grundregel 3), deshalb stellt der Nutzer ihn ein.
311 */
312async function planCommand($: EngineInterface, args: string[]): Promise<string> {
313  const t = T[settings.lang]
314  const lang = settings.lang
315  const now = await $.clock.now()
316  const price = (p: Plan) => {
317    const v = p.price ?? PLANS[p.plan]?.price
318    return v === undefined ? t.noPrice : `${t.perMonth(usd(v, lang))}${p.price === undefined ? ` (${t.listPrice})` : ''}`
319  }
320  const first = (args[0] ?? '').toLowerCase()
321  if (!first) {
322    const p = cleanPlan(await $.store.get('meta:plan'))
323    return `${p ? t.planCurrent(planLabel(p.plan), p.day, price(p), dateTime(p.at, lang)) : t.planNone}\n${t.planHelp}`
324  }
325  if (OFF_WORDS.includes(first)) {
326    await $.store.delete('meta:plan')
327    return t.planDeleted
328  }
329  const p = parsePlan(args, now)
330  if (!p) return `${t.planInvalid(args.join(' '))}\n${t.planHelp}`
331  await $.store.set('meta:plan', p)
332  const m = planMonth(p.day, now)
333  return t.planSaved(planLabel(p.plan), p.day, price(p), shortDate(m.start, lang), shortDate(m.next, lang))
334}
335
336async function reset($: EngineInterface): Promise<string> {
337  const t = T[settings.lang]
338  let answer: string
339  try {
340    // Empfohlene Antwort auf Platz 1 (Fynns Regel für Rückfragen); in -p wird ask abgelehnt (API-DETAILS.md:149-154)
341    answer = await $.ui.ask(t.askQuestion, [t.askCancel, t.askDelete])
342  } catch {
343    return t.notDeletedNoAsk
344  }
345  // Beide Sprachen gelten, falls die Einstellung zwischen Frage und Antwort wechselt
346  if (answer !== T.en.askDelete && answer !== T.de.askDelete) return t.notDeleted
347  const now = await $.clock.now()
348  // Zuerst die Marke: Wer ab jetzt bucht oder /ledger zeigt, verwirft seinen alten Stand (honorReset)
349  await $.store.set('meta:resetAt', now)
350  await serial(async () => {
351    rec = null // Die bisherigen Kosten dieser Session werden nicht neu gebucht: `seen` bleibt
352    carry = {}
353    carryAt = 0
354    pendingGap = 0
355    gapAt = undefined
356    limSinceChecked = false
357    reports.clear()
358  })
359  // Löschen außerhalb der Kette: Mod-Aufrufe warten nur auf das eigene Schreiben (SPEC Verhalten 5).
360  // `meta:plan` bleibt: eine Einstellung, keine Buchung (SPEC Nachtrag 0.5.0, Verhalten 5)
361  let n = 0
362  let hadLimits = false
363  for (const key of await $.store.keys()) {
364    if (key === 'meta:resetAt' || key === 'meta:plan' || (!key.startsWith('s:') && !key.startsWith('meta:'))) continue
365    if (key === 'meta:limSince') hadLimits = true
366    await $.store.delete(key)
367    if (key.startsWith('s:')) n++
368  }
369  await $.store.set('meta:since', now)
370  // Die Fenster-Daten beginnen mit dem Reset neu. Gleich setzen statt löschen: Sonst setzte der nächste Prozess, der ein
371  // Fenster bucht, einen späteren Beginn, und Fenster anderer Chats seit dem Reset gälten als unvollständig (Review 0.5.0)
372  if (hadLimits) await $.store.set('meta:limSince', now)
373  return t.cleared(n)
374}
375
376export function register(on: On, options: Readonly<Record<string, string | number | boolean | readonly string[]>>) {
377  settings = cleanSettings(options)
378
379  on('session.start', async ($, e, next) => {
380    try {
381      await serial(async () => {
382        const root = await $.session.root()
383        const repo = await $.session.repo()
384        meta.root = root
385        meta.project = projectOf(repo?.name, root)
386        meta.remote = cleanRemote(repo?.remote) // nur host/owner/repo, nie Zugangsdaten
387        meta.kind = kindOf(await $.session.surfaces())
388        if (sessionId && !rec) rec = await load($, sessionId)
389        const cost = (await $.session.usage()).cost?.usd
390        hasCost = typeof cost === 'number'
391        // Resume zählt `cost` weiter: Baseline = gespeicherter Stand, sonst der aktuelle → keine Doppelbuchung
392        seen = typeof cost === 'number' ? baselineFor(rec, cost) : 0
393        // Was zwischen letzter Buchung und Prozessende anfiel (hartes Ende), bucht der nächste Messpunkt nach
394        pendingGap = typeof cost === 'number' ? Math.max(0, cost - seen) : 0
395        gapAt = pendingGap > 0 ? rec?.lastAt : undefined
396        rebase = false
397        started = true
398        if ((await $.store.get('meta:since')) === undefined) await $.store.set('meta:since', await $.clock.now())
399      })
400    } catch (err) {
401      log($, `Start: ${msg(err)}`)
402    }
403    try {
404      await $.command.register({
405        name: 'ledger',
406        description: T[settings.lang].commandDescription,
407        argumentHint: '[weeks|chats|projects|models|limits|plan|reset|help]',
408      })
409    } catch (err) {
410      log($, `/ledger nicht registriert: ${msg(err)}`)
411    }
412    return next(e)
413  })
414
415  // Session-ID und Titel; /clear, /resume und /branch wechseln die Session ohne neues session.start (interface.md:786)
416  on('classic.SessionStart', async ($, e, next) => {
417    try {
418      await serial(async () => {
419        if (e.session_id && e.session_id !== sessionId) await bind($, e.session_id, e.source, e.session_title)
420        else if (e.session_title && e.session_title !== title) {
421          title = e.session_title
422          titleDirty = true
423          titleFromSession = true
424        }
425      })
426    } catch (err) {
427      log($, `SessionStart: ${msg(err)}`)
428    }
429    return next(e)
430  })
431
432  // Titel nur merken; geschrieben wird beim nächsten Buchen. Nach einem Modul-Reload feuert nur session.start, nicht
433  // classic.SessionStart (types:4173-4178, interface.md:789): dann bindet die Session-ID von hier (BaseHookInput)
434  on('classic.UserPromptSubmit', async ($, e, next) => {
435    try {
436      await serial(async () => {
437        if (e.session_id && e.session_id !== sessionId) await bind($, e.session_id, undefined, e.session_title)
438        else if (e.session_title && e.session_title !== title) {
439          title = e.session_title
440          titleDirty = true
441          titleFromSession = true
442        }
443        // Ohne Titel vom Host: die erste eigene Nachricht benennt den Chat (keine System- oder Weck-Prompts). Nicht bei
444        // Skript-Läufen (-p): Skripte schicken oft fremden Text, der sonst in /ledger bei Claude landet
445        const own = e.source === undefined || e.source === 'user' || e.source === 'sdk'
446        if (!title && !titleFromSession && own && meta.kind !== 'script') {
447          const t = titleFromPrompt(e.prompt)
448          if (t) {
449            title = t
450            titleDirty = true
451          }
452        }
453      })
454    } catch (err) {
455      log($, `UserPromptSubmit: ${msg(err)}`)
456    }
457    return next(e)
458  })
459
460  on('turn.complete', async ($, e, next) => {
461    const r = await next(e)
462    try {
463      if (!e.agentId && e.usage?.model) lastModel = e.usage.model
464      // Tokens je Modell aus der Usage des Turns (TurnUsage: Summe seiner Anfragen, Modell der letzten), auch Subagents
465      await serial(() => measure($, false, e.usage, { agentId: e.agentId, durationMs: e.durationMs, reason: e.reason, isAborted: e.isAborted }))
466    } catch (err) {
467      log($, `Buchen: ${msg(err)}`)
468    }
469    return r
470  })
471
472  // Modellaufrufe anderer Mods: `next(e)` liefert `{ value }` oder `{ deny }` (types OpEventResult). Nie ändern, nie verzögern
473  // außer um das eigene Schreiben; den eigenen Hook überspringt die Engine ohnehin (types OpEventOf).
474  on('model.complete', async ($, e, next) => {
475    const r = await next(e)
476    try {
477      if ('value' in r) await serial(() => bookCall($, next.origin?.plugin, e.model, r.value.usage, false, true))
478    } catch (err) {
479      log($, `Mod-Aufruf: ${msg(err)}`)
480    }
481    return r
482  })
483
484  on('model.fork', async ($, e, next) => {
485    const r = await next(e)
486    try {
487      // Der Fork läuft auf dem Modell der Hauptschleife; „nothing-to-fork“ hat keine Usage und kostet nichts
488      if ('value' in r && 'usage' in r.value) await serial(() => bookCall($, next.origin?.plugin, lastModel, r.value.usage, false))
489    } catch (err) {
490      log($, `Mod-Aufruf: ${msg(err)}`)
491    }
492    return r
493  })
494
495  // classify liefert nur einen String ohne Usage: nur die Anzahl zählt (types:6539-6546, :6871)
496  on('model.classify', async ($, e, next) => {
497    const r = await next(e)
498    try {
499      if ('value' in r) await serial(() => bookCall($, next.origin?.plugin, '', undefined, true))
500    } catch (err) {
501      log($, `Mod-Aufruf: ${msg(err)}`)
502    }
503    return r
504  })
505
506  // Letzte Buchung; ein Schreibvorgang, kein Warten auf anderes (Budget 1,5 s)
507  on('session.end', async ($, e, next) => {
508    try {
509      await serial(() => measure($, true))
510    } catch (err) {
511      log($, `Ende: ${msg(err)}`)
512    }
513    return next(e)
514  })
515
516  on('command.run', { command: 'ledger' }, async ($, e) => {
517    const t = T[settings.lang]
518    const [sub = '', ...rest] = e.args.trim().split(/\s+/)
519    const arg = rest[0]
520    const what = sub.toLowerCase()
521    // Hilfe: nur das erste Wort, ohne weitere Wörter (docs/HELP-SPEC.md §2)
522    if (HELP_WORDS.includes(what)) return { text: rest.length ? t.unknownArg(e.args.trim()) : await helpCommand($, e.args) }
523    // Speicher voll o. Ä.: ein lesbarer Hinweis statt einer Fehlerzeile (Review 0.5.0 K1)
524    if (what === 'reset' || what === 'plan') {
525      try {
526        return { text: what === 'reset' ? await reset($) : await planCommand($, rest) }
527      } catch (err) {
528        log($, `/ledger ${what}: ${msg(err)}`)
529        return { text: t.writeFailed(msg(err)) }
530      }
531    }
532    if (what && !VIEW_WORDS.includes(what)) return { text: t.unknownArg(sub) }
533    try {
534      await serial(() => measure($)) // den laufenden Chat mitzählen
535    } catch (err) {
536      log($, `Buchen vor /ledger: ${msg(err)}`)
537    }
538    const views: Record<string, View['view']> = { chats: 'chats', projects: 'projects', models: 'models', weeks: 'weeks', limits: 'limits' }
539    const view = views[what] ?? 'overview'
540    const range = view === 'overview' || view === 'weeks' || view === 'limits' ? 30 : parseRange(arg)
541    const now = await $.clock.now()
542    const data = await collect($, now)
543    const report = aggregate(data.recs, now, {
544      range,
545      unreadable: data.unreadable,
546      hasCost,
547      since: data.since,
548      storeBytes: data.bytes,
549      writeError,
550      plan: data.plan,
551      limSince: data.limSince,
552      live: limits,
553    })
554    // Eindeutige Kennung im Text: ui.render findet darüber die Daten der Zeichnung (SPEC Verhalten 7)
555    const tag = remember(now, { kind: 'ledger', view: { report, view, range }, args: normArgs(e.args) })
556    return { text: summaryText(report, view, range, tag, settings.lang) }
557  })
558
559  // Die Übersicht und die Hilfe: ein eigener Baum statt der Markdown-Zeile; „a hook's own tree draws in the row's place“
560  // (types CommandOutput). `command` gehört zu den Props, daher `props: { command }` im Matcher (oben auf `e` feuert er nie).
561  on('ui.render', { component: 'CommandOutput', props: { command: 'ledger' } }, async ($, e, next) => {
562    if (e.props.isErrored || (e.surface !== 'terminal' && e.surface !== 'desktop')) return next(e)
563    // Kennung irgendwo in der ersten Zeile: in -p steht davor „cost-ledger: “ (HELP-SPEC §8 Punkt 1)
564    const tag = /#[0-9a-z]{5,}/.exec(e.props.text.split('\n')[0] ?? '')?.[0]
565    const d = tag ? reports.get(tag) : undefined
566    // Unbekannt (nach Neustart, alte Zeile, reset, plan): die Engine zeichnet den Markdown-Text
567    if (!d || d.args !== normArgs(e.props.args)) return next(e)
568    const columns = e.viewport?.columns ?? 100
569    return d.kind === 'help' ? helpTree(d.data, columns, e.surface, ACCENT) : ledgerTree(d.view, settings, columns, e.surface)
570  })
571}
572
hooks/help.ts 205 lines
1// help.ts: die gezeichnete Hilfe-Tabelle für `/<befehl> help` (docs/HELP-SPEC.md §3-§4). Allgemein gehalten: Eine Mod
2// liefert nur `HelpData` und ihre Akzentfarbe; Aufbau, Spalten, Farben der Schalter und die Markdown-Fassung stehen hier.
3// Vorlage für alle Mods: templates/help/ (README dort). Ohne `$`, nur Daten → Baum bzw. Text, deshalb ohne Engine testbar.
4//
5// Baum aus reinen Daten {type, props, children} (types RenderElement), nur Box und Text mit erlaubten Props. Desktop:
6// Spaltenbreiten nur als ganzzahlige Prozent, sonst verwirft er den ganzen Baum (cost-ledger 0.3.1). Unter 60 Spalten
7// stehen die Spalten untereinander.
8import type { RenderElement, RenderNode } from 'claude-code'
9
10export type HelpLang = 'en' | 'de'
11export type HelpSurface = 'terminal' | 'desktop'
12
13/** Eine Zeile unter BEFEHLE bzw. BEDIENUNG: Befehl oder Bedienelement (Akzentfarbe) und seine Wirkung. */
14export type HelpCommand = { cmd: string; does: string }
15/**
16 * Zustand einer Funktion: Schalter (`on` → „● an“ in `success`, `off` → „○ aus“ in `inactive`) oder ein Wert als Text, mit
17 * „(Standard)“, wenn `isDefault`. `text` ersetzt „an“/„aus“; bei `on` steht er dann in der normalen Schriftfarbe (lange
18 * Texte bleiben lesbar), nur der Punkt ist grün.
19 */
20export type HelpState = { kind: 'on' | 'off'; text?: string } | { kind: 'value'; text: string; isDefault?: boolean }
21/** Eine Zeile unter FUNKTIONEN; `toggle` ist der Befehl, der den Zustand ändert, sonst z. B. „Einstellung“ oder „nur Info“. */
22export type HelpFeature = { name: string; state: HelpState; toggle: string }
23/** Eine Zeile unter EINSTELLUNGEN: Titel des userConfig-Felds (übersetzt) und sein aktueller Wert. */
24export type HelpSetting = { title: string; value: string; isDefault?: boolean }
25/** Schnappschuss beim Aufruf von `help`; die Zeichnung schreibt sich danach nicht um (HELP-SPEC §3 Punkt 3). */
26export type HelpData = {
27  /** Name der Mod im Titel */
28  mod: string
29  lang: HelpLang
30  /** Ein Satz, was die Mod macht */
31  intro: string
32  commands: HelpCommand[]
33  /** Gedimmte Zeilen unter den Befehlen (z. B. Aliase) */
34  notes?: string[]
35  /** BEDIENUNG: nur, wenn es Klicks oder Tasten gibt */
36  controls?: HelpCommand[]
37  features: HelpFeature[]
38  settings: HelpSetting[]
39  /** Weg zum Ändern der Einstellungen und zum Abschalten der Mod; Terminal und Desktop brauchen verschiedene Wege */
40  footer: { terminal: string; desktop: string }
41}
42
43const LABELS = {
44  en: {
45    help: 'Help',
46    commands: 'COMMANDS',
47    controls: 'CONTROLS',
48    features: 'FEATURES',
49    status: 'STATUS',
50    toggle: 'TOGGLE',
51    settings: 'SETTINGS (/plugin)',
52    value: 'VALUE',
53    on: 'on',
54    off: 'off',
55    isDefault: '(default)',
56  },
57  de: {
58    help: 'Hilfe',
59    commands: 'BEFEHLE',
60    controls: 'BEDIENUNG',
61    features: 'FUNKTIONEN',
62    status: 'STATUS',
63    toggle: 'UMSCHALTEN',
64    settings: 'EINSTELLUNGEN (/plugin)',
65    value: 'WERT',
66    on: 'an',
67    off: 'aus',
68    isDefault: '(Standard)',
69  },
70} as const
71
72export function helpLabels(lang: HelpLang) {
73  return LABELS[lang]
74}
75
76type Props = Record<string, string | number | boolean>
77const el = (type: 'Box' | 'Text', props: Props, children: RenderNode[]): RenderElement => ({ type, props, children })
78const text = (s: string, props: Props = {}) => el('Text', props, [s])
79const dim = (s: string) => text(s, { dimColor: true })
80const row = (props: Props, kids: RenderNode[]) => el('Box', { flexDirection: 'row', ...props }, kids)
81const col = (props: Props, kids: RenderNode[]) => el('Box', { flexDirection: 'column', ...props }, kids)
82const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
83
84/** Zustand als Text (für die Markdown-Fassung und zum Messen der Spaltenbreite). */
85export function stateText(s: HelpState, lang: HelpLang): string {
86  const L = LABELS[lang]
87  if (s.kind === 'value') return s.isDefault ? `${s.text} ${L.isDefault}` : s.text
88  return `${s.kind === 'on' ? '●' : '○'} ${s.text ?? (s.kind === 'on' ? L.on : L.off)}`
89}
90
91/**
92 * Zustand gezeichnet: „● an“ in `success`, „○ aus“ in `inactive`; mit eigenem Text bei `on` nur der Punkt grün. Werte in
93 * der normalen Schriftfarbe, „(Standard)“ gedimmt.
94 */
95function stateNode(s: HelpState, lang: HelpLang): RenderElement {
96  const L = LABELS[lang]
97  if (s.kind === 'value') return el('Text', {}, [text(s.text), ...(s.isDefault ? [dim(` ${L.isDefault}`)] : [])])
98  const on = s.kind === 'on'
99  const label = s.text ?? (on ? L.on : L.off)
100  const labelProps: Props = on ? (s.text === undefined ? { color: 'success' } : {}) : { color: 'inactive' }
101  return el('Text', {}, [text(on ? '● ' : '○ ', { color: on ? 'success' : 'inactive' }), text(label, labelProps)])
102}
103
104/**
105 * Spalten einer Zeile. Terminal: feste Zellen, die letzte füllt den Rest. Desktop: ganzzahlige Prozent mit Summe 100
106 * (`share` = gewünschte Breite in Zeichen, daraus die Anteile).
107 */
108function columns(sf: HelpSurface, inner: number, widths: number[], kids: RenderNode[], props: Props = {}): RenderElement {
109  if (sf === 'desktop') {
110    const pct = widths.slice(0, -1).map((w) => clamp(Math.round((w / inner) * 100), 10, 60))
111    const rest = Math.max(10, 100 - pct.reduce((a, b) => a + b, 0))
112    const all = [...pct, rest]
113    // Summe genau 100: Überhang vom größten Anteil abziehen
114    const over = all.reduce((a, b) => a + b, 0) - 100
115    if (over > 0) all[all.indexOf(Math.max(...all))]! -= over
116    return row(props, kids.map((k, i) => el('Box', { width: `${all[i]}%`, paddingRight: 1 }, [k])))
117  }
118  return row(
119    props,
120    kids.map((k, i) => (i < kids.length - 1 ? el('Box', { width: widths[i]!, flexShrink: 0 }, [k]) : el('Box', { flexGrow: 1, flexShrink: 1 }, [k]))),
121  )
122}
123
124const heading = (s: string, accent: string) => text(s, { color: accent, bold: true })
125
126/**
127 * Der ganze Baum für eine `CommandOutput`-Zeile. `columns` ist `e.viewport?.columns` (begrenzt auf 30-140), `accent` die
128 * Akzentfarbe der Mod (Hex oder Theme-Key), nur für Titel, Überschriften und Befehle.
129 */
130export function helpTree(d: HelpData, columnsHint: number, surface: HelpSurface, accent: string): RenderElement {
131  const L = LABELS[d.lang]
132  const cols = clamp(columnsHint || 100, 30, 140)
133  const inner = cols - 4 // Rahmen und paddingX
134  const narrow = cols < 60
135  const kids: RenderNode[] = [heading(`${d.mod} · ${L.help}`, accent), text(d.intro)]
136
137  const commandRows = (title: string, list: HelpCommand[]) => {
138    if (!list.length) return
139    kids.push(el('Box', { marginTop: 1 }, [heading(title, accent)]))
140    const cmdW = clamp(Math.max(...list.map((c) => c.cmd.length)) + 2, 12, Math.floor(inner * 0.5))
141    for (const c of list)
142      kids.push(
143        narrow
144          ? col({}, [text(c.cmd, { color: accent }), el('Box', { paddingLeft: 2 }, [text(c.does)])])
145          : columns(surface, inner, [cmdW, inner - cmdW], [text(c.cmd, { color: accent }), text(c.does)]),
146      )
147  }
148  commandRows(L.commands, d.commands)
149  for (const n of d.notes ?? []) kids.push(dim(n))
150  commandRows(L.controls, d.controls ?? [])
151
152  if (d.features.length) {
153    const nameW = clamp(Math.max(L.features.length, ...d.features.map((f) => f.name.length)) + 2, 12, Math.floor(inner * 0.34))
154    const stateW = clamp(Math.max(L.status.length, ...d.features.map((f) => stateText(f.state, d.lang).length)) + 2, 10, Math.floor(inner * 0.4))
155    if (narrow) {
156      kids.push(el('Box', { marginTop: 1 }, [heading(L.features, accent)]))
157      // Zustand und Umschalten in einem Text, damit sie als ein Absatz umbrechen statt als zwei schmale Spalten
158      for (const f of d.features)
159        kids.push(col({}, [text(f.name), el('Box', { paddingLeft: 2 }, [el('Text', {}, [stateNode(f.state, d.lang), dim(` · ${f.toggle}`)])])]))
160    } else {
161      const widths = [nameW, stateW, inner - nameW - stateW]
162      kids.push(columns(surface, inner, widths, [heading(L.features, accent), heading(L.status, accent), heading(L.toggle, accent)], { marginTop: 1 }))
163      for (const f of d.features) kids.push(columns(surface, inner, widths, [text(f.name), stateNode(f.state, d.lang), dim(f.toggle)]))
164    }
165  }
166
167  if (d.settings.length) {
168    const titleW = clamp(Math.max(L.settings.length, ...d.settings.map((s) => s.title.length)) + 2, 12, Math.floor(inner * 0.5))
169    const value = (s: HelpSetting) => stateNode({ kind: 'value', text: s.value, isDefault: s.isDefault }, d.lang)
170    if (narrow) {
171      kids.push(el('Box', { marginTop: 1 }, [heading(L.settings, accent)]))
172      for (const s of d.settings) kids.push(col({}, [text(s.title), el('Box', { paddingLeft: 2 }, [value(s)])]))
173    } else {
174      const widths = [titleW, inner - titleW]
175      kids.push(columns(surface, inner, widths, [heading(L.settings, accent), heading(L.value, accent)], { marginTop: 1 }))
176      for (const s of d.settings) kids.push(columns(surface, inner, widths, [text(s.title), value(s)]))
177    }
178  }
179
180  kids.push(el('Box', { marginTop: 1 }, [dim(surface === 'desktop' ? d.footer.desktop : d.footer.terminal)]))
181  return col({ borderStyle: 'round', borderDimColor: true, paddingX: 1, width: '100%', key: `${d.mod}-help` }, kids)
182}
183
184/**
185 * Kompakte Markdown-Fassung: was Claude mitliest und was `-p`, das SDK und VS Code zeigen. `tag` (Kennung `#…`) steht in
186 * der ersten Zeile, darüber findet der Render-Hook den Schnappschuss. Fußzeile mit dem Terminal-Weg. Leerzeilen zwischen
187 * den Blöcken: Sonst hängt Markdown (CommonMark) alles nach einer Liste an deren letzten Punkt.
188 */
189export function helpMarkdown(d: HelpData, tag: string): string {
190  const L = LABELS[d.lang]
191  const lines = [`**${d.mod} · ${L.help}**${tag ? ` · ${tag}` : ''}`, '', d.intro]
192  const list = (title: string, items: HelpCommand[]) => {
193    if (!items.length) return
194    lines.push('', `**${title}**`, ...items.map((c) => `- \`${c.cmd}\`: ${c.does}`))
195  }
196  list(L.commands, d.commands)
197  if (d.notes?.length) lines.push('', ...d.notes)
198  list(L.controls, d.controls ?? [])
199  if (d.features.length) lines.push('', `**${L.features}:** ${d.features.map((f) => `${f.name} ${stateText(f.state, d.lang)} (${f.toggle})`).join(' · ')}`)
200  if (d.settings.length)
201    lines.push('', `**${L.settings}:** ${d.settings.map((s) => `${s.title} ${s.value}${s.isDefault ? ` ${L.isDefault}` : ''}`).join(' · ')}`)
202  lines.push('', d.footer.terminal)
203  return lines.join('\n')
204}
205
hooks/i18n.ts 379 lines
1// cost-ledger: Texte und Formatierer für Englisch und Deutsch (release/I18N.md). Ohne `$`, ohne `Intl`: selbst formatiert.
2// Sprache aus userConfig `language`; Standard Englisch.
3
4export type Lang = 'en' | 'de'
5
6export function langOf(v: unknown): Lang {
7  return v === 'de' ? 'de' : 'en'
8}
9
10const pad = (n: number) => String(n).padStart(2, '0')
11const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
12
13/** en `$1,234.50`, `< $0.01`, `$0.00`; de `1.234,50 $`, `< 0,01 $`, `0,00 $`. */
14export function usd(v: number, lang: Lang): string {
15  if (lang === 'de') {
16    if (!(v > 0)) return '0,00 $'
17    if (v < 0.005) return '< 0,01 $'
18    return `${v.toFixed(2).replace('.', ',').replace(/\B(?=(\d{3})+(?!\d),)/g, '.')} $`
19  }
20  if (!(v > 0)) return '$0.00'
21  if (v < 0.005) return '< $0.01'
22  return `$${v.toFixed(2).replace(/\B(?=(\d{3})+(?!\d)\.)/g, ',')}`
23}
24
25/** Tag `YYYY-MM-DD` kurz: en `Oct 6`, de `06.10.` */
26export function shortDate(key: string, lang: Lang): string {
27  const [, m = '1', d = '1'] = key.split('-')
28  return lang === 'de' ? `${d}.${m}.` : `${MONTHS[Number(m) - 1]} ${Number(d)}`
29}
30
31/** Zeitpunkt mit Uhrzeit: en `Oct 6 14:05`, de `06.10. 14:05` */
32export function dateTime(ms: number, lang: Lang): string {
33  const d = new Date(ms)
34  const day = lang === 'de' ? `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.` : `${MONTHS[d.getMonth()]} ${d.getDate()}`
35  return `${day} ${pad(d.getHours())}:${pad(d.getMinutes())}`
36}
37
38/** Datum mit Jahr: en `Oct 6, 2026`, de `06.10.2026` */
39export function fullDate(ms: number, lang: Lang): string {
40  const d = new Date(ms)
41  return lang === 'de' ? `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()}` : `${MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`
42}
43
44/** Kalenderwoche: en `W41`, de `KW 41` */
45export function weekLabel(week: number, lang: Lang): string {
46  return lang === 'de' ? `KW ${week}` : `W${week}`
47}
48
49/** Tokens kurz: `412k`, en `1.2M`, de `1,2M` */
50export function tokens(n: number, lang: Lang): string {
51  const v = Math.max(0, Math.round(n || 0))
52  const dec = (x: number) => (lang === 'de' ? x.toFixed(1).replace('.', ',') : x.toFixed(1))
53  if (v >= 1e6) return `${dec(v / 1e6)}M`
54  if (v >= 1e4) return `${Math.round(v / 1e3)}k`
55  if (v >= 1e3) return `${dec(v / 1e3)}k`
56  return String(v)
57}
58
59const WEEKDAYS: Record<Lang, string[]> = { en: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], de: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'] }
60
61/** Uhrzeit `HH:MM` (lokal) */
62export function clock(ms: number): string {
63  const d = new Date(ms)
64  return `${pad(d.getHours())}:${pad(d.getMinutes())}`
65}
66
67/** Reset-Zeitpunkt: am selben Tag nur die Uhrzeit, sonst en `Mon Oct 12 04:00`, de `Mo 12.10. 04:00` */
68export function resetTime(ms: number, now: number, lang: Lang): string {
69  const d = new Date(ms)
70  const n = new Date(now)
71  if (d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth() && d.getDate() === n.getDate()) return clock(ms)
72  return `${WEEKDAYS[lang][d.getDay()]} ${dateTime(ms, lang)}`
73}
74
75/** Dauer, abgerundet: `3 h 50 min`, `12 min`, en `3 d 10 h`, de `3 T 10 h`; unter einer Minute `< 1 min` */
76export function duration(ms: number, lang: Lang): string {
77  const m = Math.floor(Math.max(0, ms) / 60000)
78  if (m < 1) return '< 1 min'
79  const d = Math.floor(m / 1440)
80  const h = Math.floor((m % 1440) / 60)
81  if (d > 0) return `${d} ${lang === 'de' ? 'T' : 'd'} ${h} h`
82  return h > 0 ? `${h} h ${m % 60} min` : `${m} min`
83}
84
85/** Auslastung ganzzahlig: `62 %` */
86export function pct(v: number): string {
87  return `${Math.round(v || 0)} %`
88}
89
90/** Faktor mit einer Stelle: en `4.1`, de `4,1` */
91export function factor(v: number, lang: Lang): string {
92  const s = (Number.isFinite(v) ? v : 0).toFixed(1)
93  return lang === 'de' ? s.replace('.', ',') : s
94}
95
96/**
97 * Fenster von–bis mit dem Datum des Beginns: de `08.10. 13–18`, en `Oct 8 16:10–21:10`; volle Stunden ohne Minuten,
98 * ein Ende um Mitternacht als `24`.
99 */
100export function span(start: number, end: number, lang: Lang): string {
101  const s = new Date(start)
102  const e = new Date(end)
103  const hours = s.getMinutes() === 0 && e.getMinutes() === 0
104  const endH = e.getHours() === 0 && e.getMinutes() === 0 && e.getDate() !== s.getDate() ? 24 : e.getHours()
105  const fmt = (h: number, m: number) => (hours ? pad(h) : `${pad(h)}:${pad(m)}`)
106  const day = lang === 'de' ? `${pad(s.getDate())}.${pad(s.getMonth() + 1)}.` : `${MONTHS[s.getMonth()]} ${s.getDate()}`
107  return `${day} ${fmt(s.getHours(), s.getMinutes())}–${fmt(endH, e.getMinutes())}`
108}
109
110/** Zeitraum in Tagen, 0 = alles: en `7 days`/`all time`, de `7 Tage`/`gesamt` */
111export function rangeLabel(range: number, lang: Lang): string {
112  if (range <= 0) return lang === 'de' ? 'gesamt' : 'all time'
113  return lang === 'de' ? `${range} Tage` : `${range} days`
114}
115
116const en = {
117  asOf: (t: string) => `as of ${t}`,
118  since: (d: string) => `since ${d}`,
119  reload: (cmd: string) => `reload: ${cmd}`,
120  empty: 'No entries yet. Counting starts now, after every answer.',
121  today: 'Today',
122  days7: '7 days',
123  days30: '30 days',
124  allTime: 'All time',
125  chats: (n: number) => `${n} ${n === 1 ? 'chat' : 'chats'}`,
126  last14Days: 'Last 14 days',
127  last14Weeks: 'Last 14 weeks',
128  highest: '← highest',
129  noData: 'no data',
130  modsPart: (model: string) => `${model} · mods`,
131  modsNoData: 'mods, no data',
132  unknownModel: 'Unknown',
133  projectsIn: (range: string) => `Projects · ${range}`,
134  chatsIn: (range: string) => `Most expensive chats · ${range}`,
135  modelsIn: (range: string) => `Models · ${range}`,
136  modsIn: (range: string) => `Mods · ${range}`,
137  noCosts: 'no costs in this period',
138  more: (n: number) => `+${n} more`,
139  noModCalls: 'no model calls by other mods in this period',
140  modsExtra: 'adds to the chat costs, not included in /cost',
141  tokenLine: (i: string, o: string, cr: string, cw: string) => `Input ${i} · Output ${o} · Cache ${cr} read, ${cw} written`,
142  noTokens: 'No answers with token data in this period yet.',
143  modelsNote: 'Amount estimated from tokens × price table; includes model calls by other mods.',
144  foot: 'API value; on a subscription it counts against your usage limits · /ledger weeks · chats · projects · models · help',
145  noCost: 'This host reports no chat costs; only model calls by mods are recorded.',
146  unreadable: (n: number) => `${n} ${n === 1 ? 'entry' : 'entries'} unreadable.`,
147  storeFull: (pct: number) => `Storage ${pct} % full (4 MiB): lower the retention (keepDays).`,
148  writeFailed: (err: string) => `Saving failed last time: ${err}`,
149  scriptRuns: 'Script runs',
150  noFolder: '(no folder)',
151  chatFrom: (t: string) => `Chat from ${t}`,
152  sumTitle: (t: string, tag: string) => `**Cost ledger** · as of ${t} · ${tag}`,
153  sumChats: (today: string, n: number, d7: string, d30: string, all: string) => `Chats: today ${today} (${n}) · 7 days ${d7} · 30 days ${d30} · all time ${all}`,
154  sumMods: (today: string, d30: string, all: string) => `Mods: today ${today} · 30 days ${d30} · all time ${all}`,
155  sumProjects: (list: string) => `Projects (30 days): ${list}`,
156  sumTopChats: (list: string) => `Most expensive chats (30 days): ${list}`,
157  sumModsList: (list: string) => `Mods (30 days): ${list}`,
158  sumWeeks: (list: string) => `Weeks (chat + mods): ${list}`,
159  sumChatsHead: (range: string, shown: number, total: number) => `Most expensive chats (${range}), ${shown} of ${total}:`,
160  sumModelsHead: (range: string) => `Models (${range}), estimated API value from tokens, incl. mod calls:`,
161  sumModelLine: (name: string, amt: string, pct: number, n: number, line: string) => `${name}: ${amt} (${pct} %) · ${n}× · ${line}`,
162  sumMoreModels: (n: number) => `+${n} more models`,
163  sumProjectsHead: (range: string) => `Projects (${range}):`,
164  sumProjectRow: (name: string, amt: string, n: number) => `${name} ${amt} (${n} ${n === 1 ? 'chat' : 'chats'})`,
165  sumFoot: 'Model calls by mods add to the chat costs (not included in /cost). API value; on a subscription it counts against your usage limits.',
166  unknownArg: (s: string) => `Unknown: “${s}”. All commands: /ledger help`,
167  // /ledger help (0.6.0, docs/HELP-SPEC.md)
168  helpIntro: 'Records what your chats and other mods cost (API value) after every answer and shows it by day, project, chat, model and limit window.',
169  helpOverview: 'Overview: today, 7 and 30 days, all time, last 14 days, projects, most expensive chats, mods',
170  helpWeeks: 'The overview with the last 14 calendar weeks',
171  helpLists: 'Most expensive chats · all projects · models with tokens; default 30 days, any number of days from 1 to 3650',
172  helpLimits: '5-hour and weekly window, subscription month, last 5-hour windows',
173  helpPlanShow: 'Show the plan setting and its syntax',
174  helpPlanSet: 'Set plan (pro, max5 or 5x/max5x, max20 or 20x/max20x, team, enterprise), billing day 1–31 and monthly price in $',
175  helpPlanOff: 'Delete the plan setting',
176  helpReset: 'Delete all entries (asks first; the plan stays)',
177  helpHelp: 'This help (also: ?)',
178  helpAliases: 'German words work too: hilfe, alle, heute, aus.',
179  helpPlanName: 'Subscription plan',
180  helpDay: (d: number) => `day ${d}`,
181  helpNoPlan: 'not set',
182  helpStorageName: 'Storage used',
183  helpStorage: (pct: number) => `${pct} % of 4 MiB`,
184  helpSinceName: 'Recording since',
185  helpInfo: 'info only',
186  setLanguage: 'Language',
187  setKeepDays: 'Retention (days)',
188  setDayYellow: 'Daily amount yellow from ($)',
189  setDayRed: 'Daily amount red from ($)',
190  helpFooterTerminal: 'Change settings: /plugin configure cost-ledger · Turn the mod off: /plugin disable cost-ledger',
191  // Desktop: Plugins nur an/aus über + → Plugins → Manage plugins (rel/desktop.md:478); Einstellungen gibt es dort nicht,
192  // Terminal-Dialogbefehle laufen im Desktop nicht (rel/desktop.md:1039-1041) → Einstellungen im Terminal
193  helpFooterDesktop: 'Turn the mod off: + → Plugins → Manage plugins · Change settings: /plugin configure cost-ledger in a terminal',
194  askQuestion: 'Delete all entries in the cost ledger?',
195  askCancel: 'Cancel (recommended)',
196  askDelete: 'Delete',
197  notDeletedNoAsk: 'Not deleted: the question cannot be asked here (e.g. in `-p`).',
198  notDeleted: 'Not deleted.',
199  cleared: (n: number) => `Cost ledger cleared (${n} ${n === 1 ? 'entry' : 'entries'}).`,
200  commandDescription: 'Cost ledger: what chats, mods and models have cost',
201  // Limits und Abo (0.5.0)
202  limitsHead: 'cost-ledger · limits',
203  fiveHours: '5 hours',
204  week: 'Week',
205  fiveShort: '5 h',
206  weekShort: 'week',
207  planShort: 'plan',
208  resetAt: (when: string) => `reset ${when}`,
209  inTime: (d: string) => `in ${d}`,
210  projection: (amt: string) => `100 % ≈ ${amt} (estimate)`,
211  projectionLater: 'projection from 5 %',
212  projectionPartial: 'projection from the next window',
213  projectionNone: 'no projection without a recorded amount',
214  partialFrom: (t: string) => `from ${t}`,
215  running: 'running',
216  resetPassed: 'reset passed, no new answer yet',
217  resetPassedShort: 'reset passed',
218  noWindowYet: 'no reading yet',
219  noLimitData: 'No limit data: no subscription detected, or no answer since the update yet.',
220  subMonth: 'Subscription month',
221  planNotSet: 'Plan not set: /ledger plan max20 14',
222  planValue: (amt: string, price: string, f: string) => `${amt} API value for a ${price} plan = ${f}×`,
223  planValueNoPrice: (amt: string) => `${amt} API value (no price set)`,
224  listPrice: 'list price',
225  renews: (d: string, n: number) => `renews ${d} (in ${n} ${n === 1 ? 'day' : 'days'})`,
226  lastWindows: 'Last 5-hour windows',
227  noWindows: 'No 5-hour window recorded yet.',
228  avg: (amt: string, n: number) => `Ø 100 % ≈ ${amt} from ${n} ${n === 1 ? 'window' : 'windows'} (≥ 20 %, estimate)`,
229  avgNone: 'Ø 100 %: no fully recorded, completed window with ≥ 20 % yet',
230  limitsFoot: '% applies to the whole account (incl. claude.ai) · $ only from chats with cost-ledger · projections are estimates · /ledger plan',
231  sumLimitsTitle: (t: string, tag: string) => `**Cost ledger · limits** · as of ${t} · ${tag}`,
232  sumLimitsLine: (list: string) => `Limits: ${list} · /ledger limits`,
233  sumLimitsFoot: '% applies to the whole account (incl. claude.ai); $ only counts chats with cost-ledger; projections are estimates.',
234  perMonth: (price: string) => `${price}/month`,
235  noPrice: 'no price',
236  planSaved: (label: string, day: number, price: string, start: string, next: string) =>
237    `Plan saved: ${label}, billing day ${day}, ${price}. Subscription month since ${start}, renews ${next} · details: /ledger limits`,
238  planCurrent: (label: string, day: number, price: string, at: string) => `Plan: ${label}, billing day ${day}, ${price} (set ${at}).`,
239  planNone: 'No plan set.',
240  planDeleted: 'Plan setting deleted.',
241  planInvalid: (s: string) => `Not saved: “${s}” is not valid.`,
242  planHelp: [
243    '**/ledger plan <plan> <day|today> [price]**: `pro`, `max5` (`5x`), `max20` (`20x`), `team` or `enterprise`; billing day 1–31 or `today`; monthly price in $, default list price (Pro $20, Max 5x $100, Max 20x $200).',
244    'Examples: `/ledger plan max20 14` · `/ledger plan max20 today 180` · `/ledger plan off` deletes the setting. Claude Code does not tell mods your plan, so it is set here.',
245  ].join('\n'),
246}
247
248export type Texts = typeof en
249
250const de: Texts = {
251  asOf: (t) => `Stand ${t}`,
252  since: (d) => `seit ${d}`,
253  reload: (cmd) => `neu laden: ${cmd}`,
254  empty: 'Noch keine Einträge. Gezählt wird ab jetzt, nach jeder Antwort.',
255  today: 'Heute',
256  days7: '7 Tage',
257  days30: '30 Tage',
258  allTime: 'Gesamt',
259  chats: (n) => `${n} ${n === 1 ? 'Chat' : 'Chats'}`,
260  last14Days: 'Letzte 14 Tage',
261  last14Weeks: 'Letzte 14 Wochen',
262  highest: '← höchster',
263  noData: 'ohne Angabe',
264  modsPart: (model) => `${model} · Mods`,
265  modsNoData: 'Mods ohne Angabe',
266  unknownModel: 'Unbekannt',
267  projectsIn: (range) => `Projekte · ${range}`,
268  chatsIn: (range) => `Teuerste Chats · ${range}`,
269  modelsIn: (range) => `Modelle · ${range}`,
270  modsIn: (range) => `Mods · ${range}`,
271  noCosts: 'keine Kosten im Zeitraum',
272  more: (n) => `+${n} weitere`,
273  noModCalls: 'keine Modellaufrufe anderer Mods im Zeitraum',
274  modsExtra: 'kommt zu den Chat-Kosten hinzu, steckt nicht in /cost',
275  tokenLine: (i, o, cr, cw) => `Input ${i} · Output ${o} · Cache ${cr} gelesen, ${cw} geschrieben`,
276  noTokens: 'Noch keine Antworten mit Token-Angaben im Zeitraum.',
277  modelsNote: 'Betrag geschätzt aus Tokens × Preistabelle; enthält die Modellaufrufe anderer Mods.',
278  foot: 'API-Wert, im Abo zählt es aufs Kontingent · /ledger weeks · chats · projects · models · help',
279  noCost: 'Dieser Host liefert keine Chat-Kosten; gebucht werden nur Mod-Aufrufe.',
280  unreadable: (n) => `${n} ${n === 1 ? 'Eintrag' : 'Einträge'} unlesbar.`,
281  storeFull: (pct) => `Speicher zu ${pct} % voll (4 MiB): Aufbewahrung (keepDays) senken.`,
282  writeFailed: (err) => `Speichern scheiterte zuletzt: ${err}`,
283  scriptRuns: 'Skript-Läufe',
284  noFolder: '(ohne Ordner)',
285  chatFrom: (t) => `Chat vom ${t}`,
286  sumTitle: (t, tag) => `**Kostenbuch** · Stand ${t} · ${tag}`,
287  sumChats: (today, n, d7, d30, all) => `Chats: heute ${today} (${n}) · 7 Tage ${d7} · 30 Tage ${d30} · gesamt ${all}`,
288  sumMods: (today, d30, all) => `Mods: heute ${today} · 30 Tage ${d30} · gesamt ${all}`,
289  sumProjects: (list) => `Projekte (30 Tage): ${list}`,
290  sumTopChats: (list) => `Teuerste Chats (30 Tage): ${list}`,
291  sumModsList: (list) => `Mods (30 Tage): ${list}`,
292  sumWeeks: (list) => `Wochen (Chat + Mods): ${list}`,
293  sumChatsHead: (range, shown, total) => `Teuerste Chats (${range}), ${shown} von ${total}:`,
294  sumModelsHead: (range) => `Modelle (${range}), geschätzter API-Wert nach Tokens, inkl. Mod-Aufrufe:`,
295  sumModelLine: (name, amt, pct, n, line) => `${name}: ${amt} (${pct} %) · ${n}× · ${line}`,
296  sumMoreModels: (n) => `+${n} weitere Modelle`,
297  sumProjectsHead: (range) => `Projekte (${range}):`,
298  sumProjectRow: (name, amt, n) => `${name} ${amt} (${n} Chats)`,
299  sumFoot: 'Mod-Aufrufe kommen zu den Chat-Kosten hinzu (sie stecken nicht in /cost). API-Wert; im Abo zählt es aufs Kontingent.',
300  unknownArg: (s) => `Unbekannt: „${s}“. Alle Befehle: /ledger help`,
301  helpIntro: 'Bucht nach jeder Antwort, was Chats und andere Mods kosten (API-Wert), und zeigt es nach Tag, Projekt, Chat, Modell und Limit-Fenster.',
302  helpOverview: 'Übersicht: heute, 7 und 30 Tage, gesamt, letzte 14 Tage, Projekte, teuerste Chats, Mods',
303  helpWeeks: 'Die Übersicht mit den letzten 14 Kalenderwochen',
304  helpLists: 'Teuerste Chats · alle Projekte · Modelle mit Tokens; Standard 30 Tage, beliebige Tage von 1 bis 3650',
305  helpLimits: '5-Stunden- und Wochenfenster, Abo-Monat, letzte 5-Stunden-Fenster',
306  helpPlanShow: 'Abo-Einstellung und Syntax anzeigen',
307  helpPlanSet: 'Abo einstellen (pro, max5 oder 5x/max5x, max20 oder 20x/max20x, team, enterprise), Abrechnungstag 1–31 und Monatspreis in $',
308  helpPlanOff: 'Abo-Einstellung löschen',
309  helpReset: 'Alle Einträge löschen (mit Rückfrage; das Abo bleibt)',
310  helpHelp: 'Diese Hilfe (auch: ?)',
311  helpAliases: 'Deutsche Wörter gehen auch: hilfe, alle, heute, aus.',
312  helpPlanName: 'Abo',
313  helpDay: (d) => `Tag ${d}`,
314  helpNoPlan: 'kein Abo',
315  helpStorageName: 'Speicher belegt',
316  helpStorage: (pct) => `${pct} % von 4 MiB`,
317  helpSinceName: 'Erfasst seit',
318  helpInfo: 'nur Info',
319  setLanguage: 'Sprache',
320  setKeepDays: 'Aufbewahrung (Tage)',
321  setDayYellow: 'Tagesbetrag gelb ab ($)',
322  setDayRed: 'Tagesbetrag rot ab ($)',
323  helpFooterTerminal: 'Einstellungen ändern: /plugin configure cost-ledger · Mod abschalten: /plugin disable cost-ledger',
324  helpFooterDesktop: 'Mod abschalten: + → Plugins → Manage plugins · Einstellungen ändern: im Terminal /plugin configure cost-ledger',
325  askQuestion: 'Alle Einträge im Kostenbuch löschen?',
326  askCancel: 'Abbrechen (empfohlen)',
327  askDelete: 'Löschen',
328  notDeletedNoAsk: 'Nicht gelöscht: Die Rückfrage ist hier nicht möglich (z. B. in `-p`).',
329  notDeleted: 'Nicht gelöscht.',
330  cleared: (n) => `Kostenbuch geleert (${n} ${n === 1 ? 'Eintrag' : 'Einträge'}).`,
331  commandDescription: 'Kostenbuch: was Chats, Mods und Modelle gekostet haben',
332  limitsHead: 'cost-ledger · Limits',
333  fiveHours: '5 Stunden',
334  week: 'Woche',
335  fiveShort: '5 Std.',
336  weekShort: 'Woche',
337  planShort: 'Abo',
338  resetAt: (when) => `Reset ${when}`,
339  inTime: (d) => `in ${d}`,
340  projection: (amt) => `100 % ≈ ${amt} (Schätzung)`,
341  projectionLater: 'Hochrechnung ab 5 %',
342  projectionPartial: 'Hochrechnung ab dem nächsten Fenster',
343  projectionNone: 'keine Hochrechnung ohne gebuchten Betrag',
344  partialFrom: (t) => `ab ${t}`,
345  running: 'läuft',
346  resetPassed: 'Reset vorbei, noch keine neue Antwort',
347  resetPassedShort: 'Reset vorbei',
348  noWindowYet: 'noch kein Messwert',
349  noLimitData: 'Keine Limit-Daten: kein Abo erkannt oder seit dem Update noch keine Antwort.',
350  subMonth: 'Abo-Monat',
351  planNotSet: 'Abo nicht eingestellt: /ledger plan max20 14',
352  planValue: (amt, price, f) => `${amt} API-Wert für ${price} Abo = ${f}×`,
353  planValueNoPrice: (amt) => `${amt} API-Wert (kein Preis eingestellt)`,
354  listPrice: 'Listenpreis',
355  renews: (d, n) => `erneuert ${d} (in ${n} ${n === 1 ? 'Tag' : 'Tagen'})`,
356  lastWindows: 'Letzte 5-Stunden-Fenster',
357  noWindows: 'Noch kein 5-Stunden-Fenster erfasst.',
358  avg: (amt, n) => `Ø 100 % ≈ ${amt} aus ${n} ${n === 1 ? 'Fenster' : 'Fenstern'} (≥ 20 %, Schätzung)`,
359  avgNone: 'Ø 100 %: noch kein vollständig erfasstes, abgeschlossenes Fenster mit ≥ 20 %',
360  limitsFoot: '% gilt fürs ganze Konto (auch claude.ai) · $ nur aus Chats mit cost-ledger · Hochrechnungen sind Schätzungen · /ledger plan',
361  sumLimitsTitle: (t, tag) => `**Kostenbuch · Limits** · Stand ${t} · ${tag}`,
362  sumLimitsLine: (list) => `Limits: ${list} · /ledger limits`,
363  sumLimitsFoot: '% gilt fürs ganze Konto (auch claude.ai); $ zählt nur Chats mit cost-ledger; Hochrechnungen sind Schätzungen.',
364  perMonth: (price) => `${price} im Monat`,
365  noPrice: 'ohne Preis',
366  planSaved: (label, day, price, start, next) =>
367    `Abo gespeichert: ${label}, Abrechnungstag ${day}, ${price}. Abo-Monat seit ${start}, erneuert ${next} · Details: /ledger limits`,
368  planCurrent: (label, day, price, at) => `Abo: ${label}, Abrechnungstag ${day}, ${price} (eingestellt ${at}).`,
369  planNone: 'Kein Abo eingestellt.',
370  planDeleted: 'Abo-Einstellung gelöscht.',
371  planInvalid: (s) => `Nicht gespeichert: „${s}“ ist ungültig.`,
372  planHelp: [
373    '**/ledger plan <plan> <tag|heute> [preis]**: `pro`, `max5` (`5x`), `max20` (`20x`), `team` oder `enterprise`; Abrechnungstag 1–31 oder `heute`; Monatspreis in $, Standard ist der Listenpreis (Pro 20 $, Max 5x 100 $, Max 20x 200 $).',
374    'Beispiele: `/ledger plan max20 14` · `/ledger plan max20 heute 180` · `/ledger plan off` löscht die Einstellung. Claude Code verrät Mods den Plan nicht, deshalb wird er hier eingestellt.',
375  ].join('\n'),
376}
377
378export const T: Record<Lang, Texts> = { en, de }
379
hooks/logic.ts 1151 lines
1// cost-ledger: reine Logik ohne $ (Buchen, Aggregieren, Preise, Kurzfassung). Alles hier ist ohne Engine testbar.
2import { T, clock, dateTime, duration, factor, fullDate, langOf, pct, rangeLabel, resetTime, shortDate, span, tokens, usd, weekLabel } from './i18n.ts'
3import type { Lang } from './i18n.ts'
4import type { HelpData } from './help.ts'
5
6export const DAY = 24 * 60 * 60 * 1000
7
8export type Kind = 'desktop' | 'terminal' | 'script'
9/** Je Mod und Tag; ältere Einträge haben nur usd/calls, keine Tokens. */
10export type ModDay = { usd: number; calls: number; in?: number; out?: number; cr?: number; cw?: number }
11/**
12 * Aktivität je Tag (vorsorglich gesammelt): Antworten der Hauptschleife, Subagent-Turns, Arbeitszeit in ms, Abbrüche,
13 * Fehler/Ablehnungen, teuerste Antwort in $, höchste Kontext-Füllung in %.
14 */
15export type ActDay = { turns: number; sub: number; ms: number; abort: number; err: number; maxTurn: number; ctx: number }
16/** Tokens und geschätzter API-Wert je Modell und Tag; `n` = Antworten bzw. Mod-Aufrufe. */
17export type ModelDay = { in: number; out: number; cr: number; cw: number; usd: number; n: number }
18/** Ein Datensatz pro Session unter `s:<sessionId>`; nur diese Session schreibt ihn (SPEC Zustand). */
19export type Rec = {
20  v: 1
21  project: string
22  root: string
23  title: string
24  kind: Kind
25  firstAt: number
26  lastAt: number
27  /** Letzter gelesener Stand von `usage().cost.usd`; Baseline nach Resume (Resume zählt weiter). */
28  c: number
29  days: Record<string, number>
30  mods: Record<string, { days: Record<string, ModDay> }>
31  /** Je Modell (normalisierte ID, z. B. `opus-5-5`) die Antworten des Chats, auch Subagents. */
32  models: Record<string, { days: Record<string, ModelDay> }>
33  /** Je Modell die Modellaufrufe anderer Mods. */
34  modModels: Record<string, { days: Record<string, ModelDay> }>
35  /** Vorsorglich gesammelt: Aktivität je Tag, Chat-Kosten je Tag und Stunde (`HH`), Limit-Stände je Tag. */
36  act: Record<string, ActDay>
37  hours: Record<string, Record<string, number>>
38  /** Höchster gesehener `percentUsed` je Limit-Fenster (`kind`, z. B. `five_hour`) und Tag. */
39  rl: Record<string, Record<string, number>>
40  /**
41   * Je Limit-Fenster (`five_hour`, `seven_day`) und Reset-Zeitpunkt (ms, auf die Minute gerundet, als String) der API-Wert,
42   * der in dieses Fenster fiel (SPEC Nachtrag 0.5.0).
43   */
44  lim: Record<string, Record<string, LimWin>>
45  /** Git-Remote des Projekts als `host/owner/repo`, ohne Zugangsdaten. */
46  remote: string
47}
48/** Ein Limit-Fenster im Datensatz: API-Wert aus Chat und Mods, höchster `percentUsed`, erste und letzte Buchung. */
49export type LimWin = { chat: number; mod: number; pct: number; first: number; last: number }
50
51export type Settings = { keepDays: number; dayYellow: number; dayRed: number; lang: Lang }
52const DEFAULTS: Settings = { keepDays: 365, dayYellow: 3, dayRed: 8, lang: 'en' }
53
54// Wörter, die `/ledger` annimmt. Parser, Hilfe und ein Test nutzen dieselben Listen: Was der Parser neu lernt, muss in
55// der Hilfe stehen, sonst scheitert der Test (docs/HELP-SPEC.md §6 Punkt 5). Deutsche Wörter sind Aliase (release/I18N.md §3).
56export const HELP_WORDS: readonly string[] = ['help', 'hilfe', '?']
57export const VIEW_WORDS: readonly string[] = ['weeks', 'chats', 'projects', 'models', 'limits']
58export const RANGE_WORDS: readonly string[] = ['all', 'alle']
59export const TODAY_WORDS: readonly string[] = ['today', 'heute']
60export const OFF_WORDS: readonly string[] = ['off', 'aus']
61
62function num(v: unknown, def: number, min: number): number {
63  const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() ? Number(v) : NaN
64  return Number.isFinite(n) && n >= min ? n : def
65}
66
67/** userConfig aus `register(on, options)`; unbrauchbare Werte fallen auf den Standard zurück. */
68export function cleanSettings(o: Readonly<Record<string, unknown>> | undefined): Settings {
69  const s = {
70    keepDays: Math.round(num(o?.keepDays, DEFAULTS.keepDays, 1)),
71    dayYellow: num(o?.dayYellow, DEFAULTS.dayYellow, 0),
72    dayRed: num(o?.dayRed, DEFAULTS.dayRed, 0),
73    lang: langOf(o?.language),
74  }
75  if (s.dayRed < s.dayYellow) s.dayRed = s.dayYellow
76  return s
77}
78
79const pad = (n: number) => String(n).padStart(2, '0')
80
81/** Lokales Datum `YYYY-MM-DD` zu einem Zeitpunkt aus `$.clock.now()`. */
82export function dayKey(ms: number): string {
83  const d = new Date(ms)
84  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
85}
86
87/** Tag `n` Tage vor `ms` (lokal, über Mittag gerechnet, damit Zeitumstellungen nicht verrutschen). */
88export function dayBefore(ms: number, n: number): string {
89  const d = new Date(ms)
90  return dayKey(new Date(d.getFullYear(), d.getMonth(), d.getDate() - n, 12).getTime())
91}
92
93// Dollar je Million Tokens. haiku-5-5 und sonnet-5-5: Stand 2026-10-07 (platform.claude.com/docs/en/about-claude/pricing,
94// SPEC Nachtrag 0.4.3); übrige Zeilen aus mods/sidekick/hooks/cache.ts, Stand 2026-09-25.
95// Längere IDs zuerst: 'opus-5' darf 'opus-5-5' nicht schlucken. `long`: ab `above` Prompt-Tokens gilt das `factor`-Fache für
96// den ganzen Aufruf (Haiku 5.5: zweite Preiszeile „for prompts over 100,000 tokens“, alle Spalten ×5).
97type Price = { input: number; output: number; read: number; long?: { above: number; factor: number } }
98const TABLE: readonly [string, Price][] = [
99  ['fable-5-1', { input: 10, output: 50, read: 0.25 }],
100  ['mythos-5-1', { input: 10, output: 50, read: 0.25 }],
101  ['fable-5', { input: 10, output: 50, read: 1 }],
102  ['opus-5-5', { input: 4, output: 20, read: 0.2 }],
103  ['opus-5', { input: 5, output: 25, read: 0.5 }],
104  ['opus-4-8', { input: 5, output: 25, read: 0.5 }],
105  ['opus-4-7', { input: 5, output: 25, read: 0.5 }],
106  ['opus-4-6', { input: 5, output: 25, read: 0.5 }],
107  ['sonnet-5-5', { input: 2, output: 10, read: 0.1 }],
108  ['sonnet-5', { input: 2, output: 10, read: 0.2 }],
109  ['sonnet-4-6', { input: 3, output: 15, read: 0.3 }],
110  ['haiku-5-5', { input: 0.1, output: 0.5, read: 0.01, long: { above: 100_000, factor: 5 } }],
111  ['haiku-4-5', { input: 1, output: 5, read: 0.1 }],
112]
113// Alias → Eintrag. `haiku` ist seit Claude Code 2.1.293 Haiku 5.5 (Probe 2.1.295: `claude -p --model haiku` →
114// `claude-haiku-5-5`, SPEC Nachtrag 0.6.0); bis 2.1.291 war es Haiku 4.5. Das Ergebnis eines Mod-Aufrufs nennt das
115// aufgelöste Modell nicht (types ModelUsage), deshalb gilt die Zuordnung der getesteten Version.
116const FAMILY: readonly [string, string][] = [
117  ['fable', 'fable-5-1'],
118  ['mythos', 'mythos-5-1'],
119  ['opus', 'opus-5-5'],
120  ['sonnet', 'sonnet-5-5'],
121  ['haiku', 'haiku-5-5'],
122]
123
124/** `claude-opus-5-5-20260101` → `opus-5-5` (ohne Präfix, Kontext-Zusatz und Datum). */
125function bareId(model: string): string {
126  return String(model || '')
127    .toLowerCase()
128    .replace(/^claude-/, '')
129    .replace(/\[.*?\]/g, '')
130    .replace(/-\d{8}$/, '')
131    .trim()
132}
133
134/** Preis je Million Tokens für eine Modell-ID oder einen Alias (`haiku`, `claude-opus-5-5`, `opus[1m]` …). */
135export function priceFor(model: string): { id: string } & Price {
136  const id = bareId(model)
137  for (const [key, p] of TABLE) if (id === key || id.startsWith(key)) return { id: key, ...p }
138  for (const [fam, key] of FAMILY) {
139    const hit = TABLE.find(([k]) => k === key)
140    if (id.includes(fam) && hit) return { id: key, ...hit[1] }
141  }
142  return { id: 'opus-5-5', input: 4, output: 20, read: 0.2 }
143}
144
145export type Usage = {
146  input_tokens?: number
147  output_tokens?: number
148  cache_read_input_tokens?: number
149  cache_creation_input_tokens?: number
150}
151
152/**
153 * API-Wert in $; Cache-Schreiben wie 5-min-TTL (1,25 × Input), wie sidekick completeCost.
154 * Preisstufe (`long`) nur bei `single`, also wenn `u` genau eine Anfrage ist (`model.complete`, types:2525-2528). Turn- und
155 * Fork-Usage sind Summen über mehrere Antworten (types:13262, :6079); dort wäre die Summe kein Prompt, also Faktor 1.
156 * Prompt = `input_tokens + cache_read_input_tokens + cache_creation_input_tokens`. Dass Cache-Tokens mitzählen, ist ein Schluss
157 * aus zwei Seiten, wörtlich steht es nirgends: die Preisseite (…/about-claude/pricing) nennt nur „prompt“, die Seite
158 * …/build-with-claude/context-windows sagt „all three count toward the window“.
159 */
160export function callCost(u: Usage | undefined, model: string, single = false): number {
161  if (!u) return 0
162  const p = priceFor(model)
163  const prompt = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
164  const factor = single && p.long && prompt > p.long.above ? p.long.factor : 1
165  return (
166    (factor *
167      ((u.input_tokens || 0) * p.input +
168        (u.cache_read_input_tokens || 0) * p.read +
169        (u.cache_creation_input_tokens || 0) * p.input * 1.25 +
170        (u.output_tokens || 0) * p.output)) /
171    1e6
172  )
173}
174
175export function tokensOf(u: Usage | undefined): number {
176  return u ? (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0) : 0
177}
178
179/** Schlüssel für Beträge ohne Modell-Daten (alte Tage). */
180export const UNKNOWN_MODEL = 'unbekannt'
181
182/**
183 * Modell-Schlüssel: `claude-opus-5-5-20260101` → `opus-5-5`. Ein Alias ohne Version (`haiku`, `opus[1m]`, wie ihn Mods
184 * übergeben) wird über die Preistabelle zur aktuellen Version (`haiku-5-5`, `FAMILY`), damit Turns und Mod-Aufrufe in einer Zeile
185 * landen; leer → `UNKNOWN_MODEL`.
186 */
187export function modelKey(model: string): string {
188  const id = bareId(model)
189  if (!id) return UNKNOWN_MODEL
190  return /^[a-z]+$/.test(id) && FAMILY.some(([fam]) => fam === id) ? priceFor(id).id : id
191}
192
193/** `opus-5-5` → `Opus 5.5`, `haiku` → `Haiku`; `UNKNOWN_MODEL` in der eingestellten Sprache. */
194export function modelName(key: string, lang: Lang): string {
195  if (key === UNKNOWN_MODEL) return T[lang].unknownModel
196  const [fam = '', ...ver] = key.split('-')
197  const name = fam ? fam[0]!.toUpperCase() + fam.slice(1) : key
198  return ver.length && ver.every((x) => /^\d+$/.test(x)) ? `${name} ${ver.join('.')}` : [name, ...ver].join(' ')
199}
200
201/** `sidekick@inline` → `sidekick` */
202export function pluginName(p: string): string {
203  return String(p || '').replace(/@.*$/, '')
204}
205
206/**
207 * Git-Remote ohne Zugangsdaten: nur `host/owner/repo`. `https://user:token@github.com/a/b.git` und `git@github.com:a/b.git`
208 * werden zu `github.com/a/b`; Query und Fragment fallen weg. Tokens werden nie gespeichert (CLAUDE.md Grundregel 3).
209 */
210export function cleanRemote(url: string | null | undefined): string {
211  let s = String(url ?? '').trim()
212  if (!s) return ''
213  s = s.replace(/[?#].*$/, '')
214  const scp = /^[^@\s/]+@([^:/\s]+):(.+)$/.exec(s)
215  if (scp) s = `${scp[1]}/${scp[2]}`
216  else s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/^[^@/]*@/, '')
217  return s.replace(/\.git$/, '').replace(/\/+$/, '').slice(0, 200)
218}
219
220/** Gespeicherter Projektname, wenn es weder Repo noch Ordner gibt (bleibt aus Kompatibilität deutsch, Anzeige übersetzt). */
221export const NO_FOLDER = '(ohne Ordner)'
222
223/** Projektname: Repo-Name, sonst letzter Ordner; Worktrees unter `.claude/worktrees/` zählen zum Hauptordner. */
224export function projectOf(repoName: string | null | undefined, root: string): string {
225  const parts = String(root || '').split(/[\\/]+/).filter(Boolean)
226  const wt = parts.findIndex((p, i) => p === '.claude' && parts[i + 1] === 'worktrees')
227  const folder = wt > 0 ? parts[wt - 1] : parts[parts.length - 1]
228  return (repoName && repoName.trim()) || folder || NO_FOLDER
229}
230
231/**
232 * Name eines Chats aus der ersten eigenen Nachricht (der Desktop meldet den Seitenleisten-Titel nicht). Keine Befehle
233 * (`/…`), keine eingespielten Blöcke (`<…>`), Leerraum zusammengezogen, höchstens 50 Zeichen.
234 */
235export function titleFromPrompt(prompt: string | undefined): string {
236  const t = String(prompt ?? '').replace(/\s+/g, ' ').trim()
237  if (!t || /^[/<!]/.test(t)) return ''
238  return t.length <= 50 ? t : `${t.slice(0, 49).trimEnd()}…`
239}
240
241export function newRec(meta: { project: string; root: string; title: string; kind: Kind; remote?: string }, now: number, c: number): Rec {
242  return {
243    v: 1,
244    project: meta.project,
245    root: meta.root,
246    title: meta.title,
247    kind: meta.kind,
248    firstAt: now,
249    lastAt: now,
250    c,
251    days: {},
252    mods: {},
253    models: {},
254    modModels: {},
255    act: {},
256    hours: {},
257    rl: {},
258    lim: {},
259    remote: meta.remote ?? '',
260  }
261}
262
263const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
264const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
265
266function numMap(src: unknown): Record<string, number> {
267  const out: Record<string, number> = {}
268  if (isObj(src)) for (const [k, x] of Object.entries(src)) if (isNum(x)) out[k] = x
269  return out
270}
271
272function mapOfMaps(src: unknown): Record<string, Record<string, number>> {
273  const out: Record<string, Record<string, number>> = {}
274  if (isObj(src)) for (const [k, x] of Object.entries(src)) if (isObj(x)) out[k] = numMap(x)
275  return out
276}
277
278function cleanModels(src: unknown): Rec['models'] {
279  const out: Rec['models'] = {}
280  if (isObj(src))
281    for (const [name, m] of Object.entries(src)) {
282      if (!isObj(m) || !isObj(m.days)) continue
283      const md: Record<string, ModelDay> = {}
284      for (const [k, x] of Object.entries(m.days))
285        if (isObj(x) && [x.in, x.out, x.cr, x.cw, x.usd, x.n].every(isNum))
286          md[k] = { in: x.in as number, out: x.out as number, cr: x.cr as number, cw: x.cw as number, usd: x.usd as number, n: x.n as number }
287      out[name] = { days: md }
288    }
289  return out
290}
291
292function cleanLim(src: unknown): Rec['lim'] {
293  const out: Rec['lim'] = {}
294  if (isObj(src))
295    for (const [kind, m] of Object.entries(src)) {
296      if (!LIMIT_KINDS.includes(kind) || !isObj(m)) continue
297      const wins: Record<string, LimWin> = {}
298      for (const [k, x] of Object.entries(m))
299        if (/^\d+$/.test(k) && isObj(x) && [x.chat, x.mod, x.pct, x.first, x.last].every(isNum))
300          wins[k] = { chat: Math.max(0, x.chat as number), mod: Math.max(0, x.mod as number), pct: x.pct as number, first: x.first as number, last: x.last as number }
301      out[kind] = wins
302    }
303  return out
304}
305
306/** Gelesenen Datensatz prüfen; `null` bei unbekannter Version oder kaputter Form. Fehlende Felder älterer Fassungen sind leer. */
307export function cleanRec(v: unknown): Rec | null {
308  if (!isObj(v) || v.v !== 1 || !isObj(v.days) || !isNum(v.firstAt) || !isNum(v.lastAt)) return null
309  const days: Record<string, number> = {}
310  for (const [k, x] of Object.entries(v.days)) if (/^\d{4}-\d\d-\d\d$/.test(k) && isNum(x) && x >= 0) days[k] = x
311  const mods: Rec['mods'] = {}
312  if (isObj(v.mods))
313    for (const [name, m] of Object.entries(v.mods)) {
314      if (!isObj(m) || !isObj(m.days)) continue
315      const md: Record<string, ModDay> = {}
316      for (const [k, x] of Object.entries(m.days))
317        if (isObj(x) && isNum(x.usd) && isNum(x.calls)) {
318          const t = numMap(x)
319          md[k] = { usd: Math.max(0, x.usd), calls: Math.max(0, x.calls), ...(isNum(t.in) ? { in: t.in, out: t.out ?? 0, cr: t.cr ?? 0, cw: t.cw ?? 0 } : {}) }
320        }
321      mods[name] = { days: md }
322    }
323  const act: Record<string, ActDay> = {}
324  if (isObj(v.act))
325    for (const [k, x] of Object.entries(v.act)) {
326      const n = numMap(x)
327      act[k] = { turns: n.turns ?? 0, sub: n.sub ?? 0, ms: n.ms ?? 0, abort: n.abort ?? 0, err: n.err ?? 0, maxTurn: n.maxTurn ?? 0, ctx: n.ctx ?? 0 }
328    }
329  return {
330    v: 1,
331    project: typeof v.project === 'string' && v.project ? v.project : NO_FOLDER,
332    root: typeof v.root === 'string' ? v.root : '',
333    title: typeof v.title === 'string' ? v.title : '',
334    kind: v.kind === 'desktop' || v.kind === 'script' ? v.kind : 'terminal',
335    firstAt: v.firstAt,
336    lastAt: v.lastAt,
337    c: isNum(v.c) ? v.c : 0,
338    days,
339    mods,
340    models: cleanModels(v.models),
341    modModels: cleanModels(v.modModels),
342    act,
343    hours: mapOfMaps(v.hours),
344    rl: mapOfMaps(v.rl),
345    lim: cleanLim(v.lim),
346    remote: typeof v.remote === 'string' ? cleanRemote(v.remote) : '',
347  }
348}
349
350// ---------- Buchen ----------
351
352/** Neue Baseline nach Start oder Resume: der gespeicherte Stand, wenn er zum Zähler passt, sonst der Zähler selbst. */
353export function baselineFor(rec: Rec | null, cost: number): number {
354  return rec && rec.c > 0 && rec.c <= cost ? rec.c : cost
355}
356
357/** Differenz zum letzten Stand; fällt der Zähler, beginnt er neu bei 0. */
358export function deltaOf(seen: number, cost: number): { delta: number; seen: number } {
359  const base = cost < seen ? 0 : seen
360  return { delta: Math.max(0, cost - base), seen: cost }
361}
362
363/** Auf 1e-8 $ runden: hält die Datensätze kurz (Gleitkomma-Reste wie 0.13945199999999998), ohne sichtbaren Fehler. */
364const r8 = (v: number) => Math.round(v * 1e8) / 1e8
365
366export function bookChat(rec: Rec, day: string, delta: number, now: number, cost: number): void {
367  if (delta > 0) {
368    rec.days[day] = r8((rec.days[day] ?? 0) + delta)
369    const h = (rec.hours[day] ??= {})
370    const hour = pad(new Date(now).getHours())
371    h[hour] = r8((h[hour] ?? 0) + delta)
372  }
373  rec.c = cost
374  rec.lastAt = now
375}
376
377export function bookMod(rec: Rec, name: string, day: string, amount: number, now: number, u?: Usage): void {
378  const m = (rec.mods[name] ??= { days: {} })
379  const d = (m.days[day] ??= { usd: 0, calls: 0 })
380  d.usd = r8(d.usd + Math.max(0, amount))
381  d.calls += 1
382  if (u) {
383    d.in = (d.in ?? 0) + (u.input_tokens || 0)
384    d.out = (d.out ?? 0) + (u.output_tokens || 0)
385    d.cr = (d.cr ?? 0) + (u.cache_read_input_tokens || 0)
386    d.cw = (d.cw ?? 0) + (u.cache_creation_input_tokens || 0)
387  }
388  rec.lastAt = now
389}
390
391/** Eine Antwort (`models`) oder einen Mod-Aufruf (`modModels`) je Modell buchen; Betrag geschätzt nach Preistabelle. */
392export function bookModel(rec: Rec, model: string, day: string, u: Usage | undefined, now: number, into: 'models' | 'modModels' = 'models', single = false): void {
393  if (!u) return
394  const m = (rec[into][modelKey(model)] ??= { days: {} })
395  const d = (m.days[day] ??= { in: 0, out: 0, cr: 0, cw: 0, usd: 0, n: 0 })
396  d.in += u.input_tokens || 0
397  d.out += u.output_tokens || 0
398  d.cr += u.cache_read_input_tokens || 0
399  d.cw += u.cache_creation_input_tokens || 0
400  d.usd = r8(d.usd + callCost(u, model, single))
401  d.n += 1
402  rec.lastAt = now
403}
404
405export type TurnInfo = { agentId?: string; durationMs?: number; reason?: string; isAborted?: boolean }
406
407/** Aktivität eines Turns: Antwort oder Subagent, Dauer, Abbruch/Fehler, teuerste Antwort, Kontext-Spitze. */
408export function bookTurn(rec: Rec, day: string, t: TurnInfo, delta: number, ctxPct: number | undefined): void {
409  const a = (rec.act[day] ??= { turns: 0, sub: 0, ms: 0, abort: 0, err: 0, maxTurn: 0, ctx: 0 })
410  if (t.agentId) a.sub += 1
411  else {
412    a.turns += 1
413    a.ms += Math.max(0, Math.round(t.durationMs || 0))
414    if (delta > a.maxTurn) a.maxTurn = r8(delta)
415  }
416  if (t.isAborted || t.reason === 'aborted') a.abort += 1
417  if (t.reason === 'error' || t.reason === 'refusal') a.err += 1
418  if (isNum(ctxPct) && ctxPct > a.ctx) a.ctx = Math.round(ctxPct)
419}
420
421/** Höchster Limit-Stand des Tages je Fenster (5 Stunden, Woche …), für spätere Auswertungen. */
422export function bookRate(rec: Rec, day: string, limits: readonly { kind: string; percentUsed: number }[] | undefined): void {
423  for (const l of limits ?? []) {
424    if (!l || typeof l.kind !== 'string' || !isNum(l.percentUsed)) continue
425    const d = (rec.rl[day] ??= {})
426    const v = Math.round(l.percentUsed * 10) / 10
427    if (!(d[l.kind]! >= v)) d[l.kind] = v
428  }
429}
430
431// ---------- Limit-Fenster und Abo (SPEC Nachtrag 0.5.0) ----------
432
433/** Fenster, die gebucht werden; `spend_limit` (Gateway) und Unbekanntes bleiben außen vor (types:10953). */
434export const LIMIT_KINDS: readonly string[] = ['five_hour', 'seven_day']
435const MINUTE = 60 * 1000
436const HOUR = 60 * MINUTE
437/**
438 * Je Fenster: Länge (aus dem Namen abgeleitet, nur für Beschriftung und „unvollständig“), Toleranz beim Zusammenlegen in
439 * der Aggregation (Absicherung, Phase 0: `resetsAt` war über Antworten und parallele Sessions gleich) und wie viele
440 * Einträge ein Datensatz höchstens behält.
441 */
442const LIM: Record<string, { span: number; tol: number; keep: number }> = {
443  five_hour: { span: 5 * HOUR, tol: 10 * MINUTE, keep: 60 },
444  seven_day: { span: 7 * DAY, tol: 60 * MINUTE, keep: 12 },
445}
446
447/** Ein Fenster, wie `usage().rateLimits` es zuletzt meldete; `resetsAt` in ms. */
448export type Limit = { kind: string; pct: number; resetsAt: number }
449/** Beträge, die auf das nächste gültige Fenster warten (Fenster abgelaufen oder noch unbekannt). */
450export type Carry = Record<string, { chat: number; mod: number }>
451
452/** `five_hour` und `seven_day` mit Zahl und parsebarem `resetsAt` (types:10948-10966); sonst nichts. */
453export function limitsOf(rl: readonly { kind: string; percentUsed: number; resetsAt?: string }[] | undefined): Limit[] {
454  const out: Limit[] = []
455  for (const l of rl ?? []) {
456    if (!l || !LIMIT_KINDS.includes(l.kind) || !isNum(l.percentUsed) || out.some((x) => x.kind === l.kind)) continue
457    const at = typeof l.resetsAt === 'string' ? Date.parse(l.resetsAt) : NaN
458    if (Number.isFinite(at)) out.push({ kind: l.kind, pct: l.percentUsed, resetsAt: at })
459  }
460  return out
461}
462
463/** Schlüssel eines Fensters: `resetsAt` auf die volle Minute gerundet, als String (ms). */
464export function limKey(resetsAt: number): string {
465  return String(Math.round(resetsAt / MINUTE) * MINUTE)
466}
467
468/**
469 * Betrag (`chat`: Delta wie in `bookChat`, `mod`: Mod-Aufruf) dem laufenden Fenster je Art gutschreiben und dessen
470 * `percentUsed` als Höchstwert merken. Ist das Fenster abgelaufen (`now ≥ resetsAt`) oder unbekannt, wartet der Betrag in
471 * `carry` und kommt beim nächsten gültigen Fenster dazu. Je Datensatz bleiben die neuesten 60 bzw. 12 Fenster.
472 * Gibt zurück, ob ein Fenster gebucht wurde.
473 */
474export function bookLim(rec: Rec, limits: readonly Limit[], now: number, part: 'chat' | 'mod', amount: number, carry: Carry): boolean {
475  const v = Math.max(0, amount || 0)
476  let booked = false
477  for (const kind of LIMIT_KINDS) {
478    const l = limits.find((x) => x.kind === kind)
479    if (!l || now >= l.resetsAt) {
480      if (v > 0) {
481        const c = (carry[kind] ??= { chat: 0, mod: 0 })
482        c[part] = r8(c[part] + v)
483      }
484      continue
485    }
486    const wins = (rec.lim[kind] ??= {})
487    const e = (wins[limKey(l.resetsAt)] ??= { chat: 0, mod: 0, pct: 0, first: now, last: now })
488    const c = carry[kind]
489    if (c) {
490      e.chat = r8(e.chat + c.chat)
491      e.mod = r8(e.mod + c.mod)
492      delete carry[kind]
493    }
494    e[part] = r8(e[part] + v)
495    if (l.pct > e.pct) e.pct = l.pct
496    e.last = now
497    booked = true
498    const keys = Object.keys(wins).sort((a, b) => Number(a) - Number(b))
499    for (const k of keys.slice(0, Math.max(0, keys.length - LIM[kind]!.keep))) delete wins[k]
500  }
501  return booked
502}
503
504/**
505 * Nachbuchung beim Start (Rest nach der letzten Buchung, z. B. nach hartem Ende und `--resume`): Sie gehört in das Fenster,
506 * in das die letzte Buchung `at` fiel, nicht ins laufende. Gibt es dort keinen Eintrag (Buchung vor 0.5.0, ohne Abo),
507 * bleibt der Betrag nur im Tag.
508 */
509export function bookLimAt(rec: Rec, at: number, amount: number): void {
510  if (!(amount > 0)) return
511  for (const kind of LIMIT_KINDS) {
512    const wins = rec.lim[kind]
513    if (!wins) continue
514    let hit: string | null = null
515    for (const [k, e] of Object.entries(wins)) if (Number(k) > at && e.first <= at && (hit === null || Number(k) < Number(hit))) hit = k
516    if (hit !== null) wins[hit]!.chat = r8(wins[hit]!.chat + amount)
517  }
518}
519
520/** Abo-Pläne mit Listenpreis in $ je Monat: Pro claude.com/pricing (monatlich), Max support.claude.com Artikel 11049741 (2026-10-08). */
521export const PLANS: Record<string, { label: string; price?: number }> = {
522  pro: { label: 'Pro', price: 20 },
523  max5: { label: 'Max 5x', price: 100 },
524  max20: { label: 'Max 20x', price: 200 },
525  team: { label: 'Team' },
526  enterprise: { label: 'Enterprise' },
527}
528const PLAN_ALIASES: Record<string, string> = { pro: 'pro', max5: 'max5', max5x: 'max5', '5x': 'max5', max20: 'max20', max20x: 'max20', '20x': 'max20', team: 'team', enterprise: 'enterprise' }
529/** Nur eigene Schlüssel: `constructor` oder `__proto__` sind kein Plan. */
530const own = (o: object, k: string) => Object.prototype.hasOwnProperty.call(o, k)
531
532/** Alle Schreibweisen eines Plans, die `/ledger plan` annimmt (für Hilfe und Vollständigkeitstest). */
533export function planWords(): string[] {
534  return Object.keys(PLAN_ALIASES)
535}
536
537/** `meta:plan`: Plan, Abrechnungstag, eigener Preis (ohne: Listenpreis), Zeitpunkt der Einstellung. */
538export type Plan = { v: 1; plan: string; day: number; price?: number; at: number }
539
540export function planLabel(plan: string): string {
541  return own(PLANS, plan) ? PLANS[plan]!.label : plan
542}
543
544/** Gespeicherte Einstellung prüfen; `null` bei fehlender oder kaputter. */
545export function cleanPlan(v: unknown): Plan | null {
546  if (!isObj(v) || v.v !== 1 || typeof v.plan !== 'string' || !own(PLANS, v.plan) || !isNum(v.day) || !isNum(v.at)) return null
547  if (!Number.isInteger(v.day) || v.day < 1 || v.day > 31) return null
548  return { v: 1, plan: v.plan, day: v.day, ...(isNum(v.price) && v.price > 0 ? { price: v.price } : {}), at: v.at }
549}
550
551/**
552 * `/ledger plan <plan> <tag|heute> [preis]` → Einstellung, sonst `null` (dann wird nichts gespeichert). Plan in beliebiger
553 * Schreibweise samt Aliasen, Tag 1–31 oder `today`/`heute`, Preis mit Komma oder Punkt, optional mit `$`.
554 */
555export function parsePlan(args: readonly string[], now: number): Plan | null {
556  const [p = '', d = '', price, ...rest] = args.map((a) => a.trim().toLowerCase())
557  const plan = own(PLAN_ALIASES, p) ? PLAN_ALIASES[p] : undefined
558  if (!plan || rest.length) return null
559  const day = TODAY_WORDS.includes(d) ? new Date(now).getDate() : /^\d{1,2}$/.test(d) ? Number(d) : NaN
560  if (!Number.isInteger(day) || day < 1 || day > 31) return null
561  if (price === undefined) return { v: 1, plan, day, at: now }
562  const s = price.replace(/^\$|\$$/g, '').replace(',', '.')
563  const n = /^\d{1,6}(\.\d{1,2})?$/.test(s) ? Number(s) : NaN
564  return n > 0 ? { v: 1, plan, day, price: n, at: now } : null
565}
566
567/** Tag `day` im Monat (Jahr, Monat 0–11), gekürzt auf die Länge des Monats (31. → 30.09.). */
568function billingDay(y: number, m: number, day: number): Date {
569  const len = new Date(y, m + 1, 0, 12).getDate()
570  return new Date(y, m, Math.min(day, len), 12)
571}
572
573/**
574 * Laufender Abo-Monat zum Abrechnungstag: Beginn = letzter Tag ≤ heute mit dem Tag `min(day, Länge des Monats)`,
575 * nächste Erneuerung einen Monat später, Resttage bis dahin (lokal, über Mittag gerechnet).
576 */
577export function planMonth(day: number, now: number): { start: string; next: string; daysLeft: number } {
578  const t = new Date(now)
579  let s = billingDay(t.getFullYear(), t.getMonth(), day)
580  if (t.getDate() < s.getDate()) s = billingDay(t.getFullYear(), t.getMonth() - 1, day)
581  const n = billingDay(s.getFullYear(), s.getMonth() + 1, day)
582  const noon = new Date(t.getFullYear(), t.getMonth(), t.getDate(), 12).getTime()
583  return { start: dayKey(s.getTime()), next: dayKey(n.getTime()), daysLeft: Math.round((n.getTime() - noon) / DAY) }
584}
585
586/** Ein Fenster in der Auswertung, über alle Datensätze summiert und zusammengelegt. */
587export type LimWindow = {
588  kind: string
589  resetsAt: number
590  /** `resetsAt` minus Fensterlänge (aus dem Namen abgeleitet) */
591  start: number
592  usd: number
593  pct: number
594  running: boolean
595  /** Begann vor der ersten Fenster-Buchung (`meta:limSince`): der Betrag ist unvollständig */
596  partial: boolean
597  /** Hochrechnung `usd ÷ pct × 100`, erst ab 5 % und nur für vollständige Fenster */
598  proj: number | null
599}
600export type PlanReport = {
601  plan: string
602  day: number
603  price: number | null
604  /** Kein eigener Preis: Listenpreis des Plans */
605  listPrice: boolean
606  start: string
607  next: string
608  daysLeft: number
609  /** API-Wert seit Beginn des Abo-Monats aus Datensätzen, die Abo-Limits gesehen haben */
610  usd: number
611  factor: number | null
612  /** Beginn liegt vor `meta:since`: gezählt ab diesem Tag */
613  from: string | null
614}
615export type LimitsReport = {
616  five: LimWindow | null
617  week: LimWindow | null
618  seenFive: boolean
619  seenWeek: boolean
620  /** Letzte 10 Fünf-Stunden-Fenster, neueste zuerst */
621  history: LimWindow[]
622  /** Ø „100 % ≈ usd“ über abgeschlossene, vollständige Fünf-Stunden-Fenster ab 20 % */
623  avg: { usd: number; n: number } | null
624  plan: PlanReport | null
625  limSince: number | null
626}
627
628/** Hat der Datensatz je Abo-Limits gesehen? Läufe mit API-Schlüssel (ohne Limits) zählen nicht zum Abo-Monat. */
629function sawLimits(rec: Rec): boolean {
630  return Object.values(rec.rl).some((d) => LIMIT_KINDS.some((k) => k in d)) || Object.values(rec.lim).some((w) => Object.keys(w).length > 0)
631}
632
633/**
634 * Limits und Abo für `/ledger limits` und die Zeile in der Übersicht. Je Fenster werden `chat + mod` über alle Datensätze
635 * summiert, `pct` ist das Maximum (innerhalb eines Fensters steigt der Wert nur). Schlüssel, die näher als die Toleranz
636 * beieinanderliegen, werden zusammengelegt. `live`: die Fenster der letzten Messung dieses Prozesses (nur für `pct`).
637 */
638export function limitsReport(
639  recs: { rec: Rec }[],
640  now: number,
641  o: { plan?: Plan | null; limSince?: number | null; since?: number | null; live?: readonly Limit[] } = {},
642): LimitsReport {
643  const byKind = new Map<string, Map<number, { usd: number; pct: number }>>(LIMIT_KINDS.map((k) => [k, new Map()]))
644  let first = Infinity
645  for (const { rec } of recs)
646    for (const [kind, wins] of Object.entries(rec.lim)) {
647      const m = byKind.get(kind)
648      if (m)
649        for (const [k, w] of Object.entries(wins)) {
650          const x = m.get(Number(k)) ?? { usd: 0, pct: 0 }
651          x.usd += w.chat + w.mod
652          x.pct = Math.max(x.pct, w.pct)
653          m.set(Number(k), x)
654          first = Math.min(first, w.first)
655        }
656    }
657  for (const l of o.live ?? []) {
658    const m = byKind.get(l.kind)
659    if (!m || l.resetsAt <= now) continue
660    const at = Number(limKey(l.resetsAt))
661    const x = m.get(at) ?? { usd: 0, pct: 0 }
662    x.pct = Math.max(x.pct, l.pct)
663    m.set(at, x)
664  }
665  // Beginn der Fenster-Daten: `meta:limSince`, aber nie später als die früheste vorhandene Fenster-Buchung (setzt ein
666  // Prozess den Wert erst nach Buchungen anderer Chats, z. B. nach einem Reset, würden deren Fenster sonst rückwirkend
667  // unvollständig; Review 0.5.0). Ohne `meta:limSince` gilt die früheste Buchung.
668  const firstAt = Number.isFinite(first) ? first : null
669  const limSince = typeof o.limSince === 'number' && firstAt !== null ? Math.min(o.limSince, firstAt) : (o.limSince ?? firstAt)
670  const windows = (kind: string): LimWindow[] => {
671    const spec = LIM[kind]!
672    const merged: { resetsAt: number; usd: number; pct: number }[] = []
673    for (const [at, x] of [...byKind.get(kind)!.entries()].sort((a, b) => a[0] - b[0])) {
674      const last = merged[merged.length - 1]
675      if (last && at - last.resetsAt < spec.tol) {
676        last.resetsAt = at
677        last.usd += x.usd
678        last.pct = Math.max(last.pct, x.pct)
679      } else merged.push({ resetsAt: at, usd: x.usd, pct: x.pct })
680    }
681    // Hochrechnung nur für vollständige Fenster: Bei einem unvollständigen fehlt der API-Wert vor `limSince`, die Prozent
682    // zählen aber das ganze Fenster; die Zahl wäre systematisch zu niedrig (Smoke-Test 2026-10-08: Woche „100 % ≈ 2,37 $“).
683    // Ohne gebuchten Betrag (Host ohne Kostenbuch) gibt es ebenfalls keine.
684    return merged.map((w) => {
685      const start = w.resetsAt - spec.span
686      const partial = limSince === null || start < limSince
687      return { kind, ...w, start, running: w.resetsAt > now, partial, proj: w.pct >= 5 && w.usd > 0 && !partial ? (w.usd / w.pct) * 100 : null }
688    })
689  }
690  const five = windows('five_hour')
691  const week = windows('seven_day')
692  // Ø nur über die neuesten 60 Fenster: Ein Datensatz behält höchstens 60 (`bookLim`); in älteren Fenstern fehlte sonst
693  // der gekürzte Betrag eines langen Chats, während andere Chats das volle `pct` liefern (Review 0.5.0)
694  // Fenster ohne gebuchten Betrag (Host ohne Kostenbuch) zählen nicht
695  const done = five.slice(-LIM.five_hour!.keep).filter((w) => !w.running && !w.partial && w.pct >= 20 && w.usd > 0)
696  const pctSum = done.reduce((a, w) => a + w.pct, 0)
697
698  let plan: PlanReport | null = null
699  if (o.plan) {
700    const pm = planMonth(o.plan.day, now)
701    const today = dayKey(now)
702    const inMonth = (d: string) => d >= pm.start && d <= today
703    let usd = 0
704    for (const { rec } of recs) {
705      if (!sawLimits(rec)) continue
706      for (const [d, v] of Object.entries(rec.days)) if (inMonth(d)) usd += v
707      for (const m of Object.values(rec.mods)) for (const [d, v] of Object.entries(m.days)) if (inMonth(d)) usd += v.usd
708    }
709    const price = o.plan.price ?? PLANS[o.plan.plan]?.price ?? null
710    const sinceDay = typeof o.since === 'number' ? dayKey(o.since) : null
711    plan = {
712      plan: o.plan.plan,
713      day: o.plan.day,
714      price,
715      listPrice: o.plan.price === undefined && price !== null,
716      ...pm,
717      usd,
718      factor: price ? usd / price : null,
719      from: sinceDay && pm.start < sinceDay ? sinceDay : null,
720    }
721  }
722  return {
723    five: five.find((w) => w.running) ?? null,
724    week: week.find((w) => w.running) ?? null,
725    seenFive: five.length > 0,
726    seenWeek: week.length > 0,
727    history: five.slice(-10).reverse(),
728    avg: pctSum > 0 ? { usd: (done.reduce((a, w) => a + w.usd, 0) / pctSum) * 100, n: done.length } : null,
729    plan,
730    limSince,
731  }
732}
733
734// ---------- Aggregation für /ledger ----------
735
736/** Gruppen-Schlüssel der Skript-Läufe in Projekten (Anzeige übersetzt). */
737export const SCRIPT_PROJECT = ':script'
738
739export type Window = { usd: number; chats: number; mods: number }
740/** `title` leer = kein Name bekannt; die Anzeige nimmt dann „Chat vom …“ mit `firstAt`. */
741export type ChatRow = { id: string; title: string; firstAt: number; project: string; usd: number; lastDay: string; kind: Kind }
742export type ProjectRow = { name: string; usd: number; chats: number }
743export type ModRow = { name: string; usd: number; calls: number }
744export type ModelRow = { key: string; in: number; out: number; cr: number; cw: number; usd: number; n: number }
745/** Ein Balken im Verlauf: Tag oder Woche (Montag), Chat- und Mod-Kosten und ihre Aufteilung nach Modell (`MOD_PART`). */
746export type Bucket = { key: string; usd: number; parts: { key: string; usd: number }[] }
747export type Report = {
748  now: number
749  since: number | null
750  windows: { today: Window; d7: Window; d30: Window; all: Window }
751  projects: ProjectRow[]
752  chats: ChatRow[]
753  mods: ModRow[]
754  models: ModelRow[]
755  /** Verlauf: 14 Tage bzw. 14 Wochen, jeweils neueste zuerst */
756  series: { days: Bucket[]; weeks: Bucket[] }
757  unreadable: number
758  hasCost: boolean
759  total: number
760  /** Geschätzte Größe des Plugin-Speichers in Byte (Grenze 4 MiB, docs/raw/en/reference.md:294) */
761  storeBytes: number
762  writeError: string
763  limits: LimitsReport
764}
765
766export const STORE_LIMIT = 4 * 1024 * 1024
767
768/** `range`: Anzahl Tage einschließlich heute, oder `0` = alles. */
769export function aggregate(
770  recs: { id: string; rec: Rec }[],
771  now: number,
772  opts: {
773    range?: number
774    unreadable?: number
775    hasCost?: boolean
776    since?: number | null
777    storeBytes?: number
778    writeError?: string
779    plan?: Plan | null
780    limSince?: number | null
781    live?: readonly Limit[]
782  },
783): Report {
784  const today = dayKey(now)
785  const inRange = (d: string, n: number) => (n <= 0 ? true : d >= dayBefore(now, n - 1) && d <= today)
786  const range = opts.range ?? 30
787  const win = (n: number): Window => {
788    let sum = 0
789    let chats = 0
790    let mods = 0
791    for (const { rec } of recs) {
792      let mine = 0
793      for (const [d, v] of Object.entries(rec.days)) if (inRange(d, n)) mine += v
794      for (const m of Object.values(rec.mods)) for (const [d, v] of Object.entries(m.days)) if (inRange(d, n)) mods += v.usd
795      sum += mine
796      if (mine > 0) chats++
797    }
798    return { usd: sum, chats, mods }
799  }
800
801  const proj = new Map<string, ProjectRow>()
802  const chats: ChatRow[] = []
803  const mods = new Map<string, ModRow>()
804  const models = new Map<string, ModelRow>()
805  for (const { id, rec } of recs) {
806    for (const [key, m] of [...Object.entries(rec.models), ...Object.entries(rec.modModels)])
807      for (const [d, v] of Object.entries(m.days))
808        if (inRange(d, range)) {
809          const row = models.get(key) ?? { key, in: 0, out: 0, cr: 0, cw: 0, usd: 0, n: 0 }
810          row.in += v.in
811          row.out += v.out
812          row.cr += v.cr
813          row.cw += v.cw
814          row.usd += v.usd
815          row.n += v.n
816          models.set(key, row)
817        }
818    let mine = 0
819    let lastDay = ''
820    for (const [d, v] of Object.entries(rec.days))
821      if (inRange(d, range) && v > 0) {
822        mine += v
823        if (d > lastDay) lastDay = d
824      }
825    if (mine > 0) {
826      const name = rec.kind === 'script' ? SCRIPT_PROJECT : rec.project
827      const p = proj.get(name) ?? { name, usd: 0, chats: 0 }
828      p.usd += mine
829      p.chats++
830      proj.set(name, p)
831      chats.push({ id, title: rec.title.trim(), firstAt: rec.firstAt, project: name, usd: mine, lastDay, kind: rec.kind })
832    }
833    for (const [name, m] of Object.entries(rec.mods))
834      for (const [d, v] of Object.entries(m.days))
835        if (inRange(d, range)) {
836          const row = mods.get(name) ?? { name, usd: 0, calls: 0 }
837          row.usd += v.usd
838          row.calls += v.calls
839          mods.set(name, row)
840        }
841  }
842  return {
843    now,
844    since: opts.since ?? null,
845    windows: { today: win(1), d7: win(7), d30: win(30), all: win(0) },
846    projects: [...proj.values()].sort((a, b) => b.usd - a.usd || a.name.localeCompare(b.name)),
847    chats: chats.sort((a, b) => b.usd - a.usd || b.lastDay.localeCompare(a.lastDay)),
848    mods: [...mods.values()].sort((a, b) => b.usd - a.usd || b.calls - a.calls),
849    models: [...models.values()].sort((a, b) => b.usd - a.usd || b.n - a.n),
850    series: seriesOf(recs, now),
851    unreadable: opts.unreadable ?? 0,
852    hasCost: opts.hasCost ?? true,
853    total: recs.length,
854    storeBytes: opts.storeBytes ?? 0,
855    writeError: opts.writeError ?? '',
856    limits: limitsReport(recs, now, { plan: opts.plan, limSince: opts.limSince, since: opts.since, live: opts.live }),
857  }
858}
859
860/** Montag der Woche zu `YYYY-MM-DD` (lokal). */
861export function weekStart(day: string): string {
862  const [y, m, d] = day.split('-').map(Number) as [number, number, number]
863  const dt = new Date(y, m - 1, d, 12)
864  dt.setDate(dt.getDate() - ((dt.getDay() + 6) % 7))
865  return dayKey(dt.getTime())
866}
867
868/** ISO-Kalenderwoche zu einem Montag `YYYY-MM-DD`. */
869export function isoWeek(monday: string): number {
870  const [y, m, d] = monday.split('-').map(Number) as [number, number, number]
871  const thu = new Date(y, m - 1, d + 3, 12) // Donnerstag derselben Woche bestimmt das Jahr
872  const week1 = new Date(thu.getFullYear(), 0, 4, 12) // 4. Januar liegt immer in KW 1
873  return 1 + Math.round(((thu.getTime() - week1.getTime()) / DAY - 3 + ((week1.getDay() + 6) % 7)) / 7)
874}
875
876/** Teil eines Verlaufsbalkens, der aus Mod-Aufrufen stammt: `mod:sonnet-5-5`, ohne Modell-Daten `mod:unbekannt`. */
877export const MOD_PART = 'mod:'
878
879/** Legendentext eines Verlaufsteils: `opus-5-5` → `Opus 5.5`, `mod:sonnet-5-5` → `Sonnet 5.5 · Mods`. */
880export function partLabel(key: string, lang: Lang): string {
881  const t = T[lang]
882  if (!key.startsWith(MOD_PART)) return key === UNKNOWN_MODEL ? t.noData : modelName(key, lang)
883  const model = key.slice(MOD_PART.length)
884  return model === UNKNOWN_MODEL ? t.modsNoData : t.modsPart(modelName(model, lang))
885}
886
887/**
888 * Verlauf je Tag und je Woche. Die echten Chat-Kosten eines Tages werden je Chat nach den geschätzten Modell-Anteilen
889 * dieses Tages aufgeteilt; ohne Modell-Daten fällt der Betrag unter `UNKNOWN_MODEL`. Dazu kommen die Mod-Aufrufe je
890 * Modell als eigene Teile (`MOD_PART`); was in `mods` steht, aber nicht in `modModels` (Daten vor 0.3.0), wird
891 * `mod:unbekannt`. Der Balken zeigt damit Chat + Mods, wie die Kacheln zusammen.
892 */
893function seriesOf(recs: { rec: Rec }[], now: number): { days: Bucket[]; weeks: Bucket[] } {
894  const days = new Map(Array.from({ length: 14 }, (_, i) => [dayBefore(now, i), new Map<string, number>()] as const))
895  // Gleicher Wochentag i Wochen zurück → Montag jener Woche
896  const weeks = new Map(Array.from({ length: 14 }, (_, i) => [weekStart(dayBefore(now, i * 7)), new Map<string, number>()] as const))
897  const add = (bucket: Map<string, number> | undefined, key: string, v: number) => {
898    if (bucket && v > 0) bucket.set(key, (bucket.get(key) ?? 0) + v)
899  }
900  for (const { rec } of recs)
901    for (const [d, chat] of Object.entries(rec.days)) {
902      if (!(chat > 0)) continue
903      const shares: [string, number][] = []
904      for (const [k, m] of Object.entries(rec.models)) {
905        const v = m.days[d]?.usd ?? 0
906        if (v > 0) shares.push([k, v])
907      }
908      const sum = shares.reduce((a, [, v]) => a + v, 0)
909      const parts: [string, number][] = sum > 0 ? shares.map(([k, v]) => [k, (chat * v) / sum]) : [[UNKNOWN_MODEL, chat]]
910      for (const [k, v] of parts) {
911        add(days.get(d), k, v)
912        add(weeks.get(weekStart(d)), k, v)
913      }
914    }
915  for (const { rec } of recs) {
916    const modDays = new Map<string, number>()
917    for (const m of Object.values(rec.mods)) for (const [d, v] of Object.entries(m.days)) modDays.set(d, (modDays.get(d) ?? 0) + v.usd)
918    for (const [k, m] of Object.entries(rec.modModels))
919      for (const [d, v] of Object.entries(m.days)) {
920        add(days.get(d), MOD_PART + k, v.usd)
921        add(weeks.get(weekStart(d)), MOD_PART + k, v.usd)
922        modDays.set(d, (modDays.get(d) ?? 0) - v.usd)
923      }
924    // Rest ohne Modell; Rundungsreste aus r8 nicht als eigenen Teil zeigen
925    for (const [d, rest] of modDays)
926      if (rest > 1e-6) {
927        add(days.get(d), MOD_PART + UNKNOWN_MODEL, rest)
928        add(weeks.get(weekStart(d)), MOD_PART + UNKNOWN_MODEL, rest)
929      }
930  }
931  const toBuckets = (m: Map<string, Map<string, number>>): Bucket[] =>
932    [...m.entries()].map(([key, parts]) => {
933      const list = [...parts.entries()].map(([k, v]) => ({ key: k, usd: v })).sort((a, b) => b.usd - a.usd)
934      return { key, usd: list.reduce((a, p) => a + p.usd, 0), parts: list }
935    })
936  return { days: toBuckets(days), weeks: toBuckets(weeks) }
937}
938
939/** Argument `7|30|all` → Tage (0 = alles); sonst Standard 30. `alle` bleibt als Alias gültig. */
940export function parseRange(a: string | undefined): number {
941  const s = (a ?? '').trim().toLowerCase()
942  if (RANGE_WORDS.includes(s)) return 0
943  const n = Number(s)
944  return Number.isInteger(n) && n > 0 && n <= 3650 ? n : 30
945}
946
947// ---------- Anzeige-Bausteine, die Text und Zeichnung teilen ----------
948
949/** Projektname zur Anzeige: Skript-Läufe und „ohne Ordner“ in der eingestellten Sprache. */
950export function projectLabel(name: string, lang: Lang): string {
951  if (name === SCRIPT_PROJECT) return T[lang].scriptRuns
952  if (name === NO_FOLDER) return T[lang].noFolder
953  return name
954}
955
956/** Name eines Chats zur Anzeige; ohne Titel „Chat vom …“ bzw. „Chat from …“. */
957export function chatLabel(c: { title: string; firstAt: number }, lang: Lang): string {
958  return c.title || T[lang].chatFrom(dateTime(c.firstAt, lang))
959}
960
961/** Hinweise für Text und Zeichnung: Host ohne Kosten, unlesbare Einträge, Speicher fast voll, Schreibfehler. */
962export function notesOf(r: Report, lang: Lang): string[] {
963  const t = T[lang]
964  const out: string[] = []
965  if (!r.hasCost) out.push(t.noCost)
966  if (r.unreadable) out.push(t.unreadable(r.unreadable))
967  if (r.storeBytes >= STORE_LIMIT * 0.75) out.push(t.storeFull(Math.round((r.storeBytes / STORE_LIMIT) * 100)))
968  if (r.writeError) out.push(t.writeFailed(r.writeError))
969  return out
970}
971
972export type ViewName = 'overview' | 'chats' | 'projects' | 'models' | 'weeks' | 'limits'
973
974/** Seit wann ein unvollständiges Fenster zählt: 5 Stunden mit Uhrzeit, Woche mit Datum. */
975export function partialLabel(w: LimWindow, limSince: number | null, lang: Lang): string {
976  if (!w.partial || limSince === null) return ''
977  return T[lang].partialFrom(w.kind === 'five_hour' ? clock(limSince) : shortDate(dayKey(limSince), lang))
978}
979
980/** Hochrechnung „100 % ≈ …“ (als Schätzung beschriftet), sonst warum es noch keine gibt. */
981export function projectionText(w: LimWindow, lang: Lang): string {
982  const t = T[lang]
983  if (w.proj !== null) return t.projection(usd(w.proj, lang))
984  if (w.partial) return t.projectionPartial
985  return w.pct >= 5 ? t.projectionNone : t.projectionLater
986}
987
988/** Teile der kompakten Limit-Zeile (Übersicht); leer, wenn es weder ein Fenster noch einen Plan gibt. */
989export function limitsLineParts(l: LimitsReport, now: number, lang: Lang): string[] {
990  const t = T[lang]
991  const out: string[] = []
992  if (l.five) out.push(`${t.fiveShort} ${pct(l.five.pct)} · ${usd(l.five.usd, lang)} · ${t.resetAt(resetTime(l.five.resetsAt, now, lang))}`)
993  else if (l.seenFive) out.push(`${t.fiveShort}: ${t.resetPassedShort}`)
994  if (l.week) out.push(`${t.weekShort} ${pct(l.week.pct)} · ${usd(l.week.usd, lang)}`)
995  if (l.plan) out.push(`${t.planShort} ${usd(l.plan.usd, lang)}${l.plan.factor !== null ? ` = ${factor(l.plan.factor, lang)}×` : ''}`)
996  return out
997}
998
999/** Abo-Monat in einer Zeile: Plan, Beginn, Erneuerung, API-Wert gegen Preis (ohne Preis kein Faktor). */
1000export function planLine(p: PlanReport, lang: Lang): { head: string; value: string; note: string } {
1001  const t = T[lang]
1002  const money = (v: number) => usd(v, lang)
1003  const value = p.price !== null && p.factor !== null ? t.planValue(money(p.usd), money(p.price), factor(p.factor, lang)) : t.planValueNoPrice(money(p.usd))
1004  const note = [p.listPrice ? t.listPrice : '', p.from ? t.partialFrom(shortDate(p.from, lang)) : ''].filter(Boolean).join(' · ')
1005  return { head: `${planLabel(p.plan)} · ${t.since(shortDate(p.start, lang))} · ${t.renews(shortDate(p.next, lang), p.daysLeft)}`, value, note }
1006}
1007
1008/** `/ledger limits` als Text: 5 Stunden, Woche, Abo-Monat, Verlauf, Ø (ohne Kopf- und Fußzeile). */
1009function limitsLines(l: LimitsReport, now: number, lang: Lang): string[] {
1010  const t = T[lang]
1011  const money = (v: number) => usd(v, lang)
1012  const out: string[] = []
1013  const hasLim = l.seenFive || l.seenWeek
1014  if (!hasLim) out.push(t.noLimitData)
1015  else
1016    for (const [w, seen, label] of [[l.five, l.seenFive, t.fiveHours], [l.week, l.seenWeek, t.week]] as const) {
1017      if (!w) {
1018        out.push(`${label}: ${seen ? t.resetPassed : t.noWindowYet}`)
1019        continue
1020      }
1021      const bits = [`${label}: ${pct(w.pct)}`, money(w.usd), `${t.resetAt(resetTime(w.resetsAt, now, lang))} (${t.inTime(duration(w.resetsAt - now, lang))})`]
1022      bits.push(projectionText(w, lang))
1023      const from = partialLabel(w, l.limSince, lang)
1024      if (from) bits.push(from)
1025      out.push(bits.join(' · '))
1026    }
1027  if (l.plan) {
1028    const p = planLine(l.plan, lang)
1029    out.push(`${t.subMonth}: ${p.head} · ${p.value}${p.note ? ` (${p.note})` : ''}`)
1030  } else out.push(t.planNotSet)
1031  if (hasLim) {
1032    const rows = l.history.slice(0, 4).map((w) => {
1033      const from = partialLabel(w, l.limSince, lang)
1034      return `${span(w.start, w.resetsAt, lang)} ${money(w.usd)} ${pct(w.pct)}${w.running ? ` ${t.running}` : ''}${from ? ` ${from}` : ''}`
1035    })
1036    if (rows.length) out.push(`${t.lastWindows}: ${rows.join(' · ')}${l.history.length > 4 ? ` · ${t.more(l.history.length - 4)}` : ''}`)
1037    out.push(l.avg ? t.avg(money(l.avg.usd), l.avg.n) : t.avgNone)
1038  }
1039  return out
1040}
1041
1042/**
1043 * Kurze Markdown-Fassung für Claude und für `-p` (höchstens 10 Zeilen, SPEC Verhalten 7). `tag` macht den Text eindeutig,
1044 * damit `ui.render` die passende Zeichnung findet.
1045 */
1046export function summaryText(r: Report, view: ViewName, range: number, tag: string, lang: Lang): string {
1047  const t = T[lang]
1048  const money = (v: number) => usd(v, lang)
1049  const join = <X>(rows: X[], fmt: (x: X) => string, max: number) => {
1050    const shown = rows.slice(0, max).map(fmt).join(' · ')
1051    return rows.length > max ? `${shown} · ${t.more(rows.length - max)}` : shown
1052  }
1053  const notes = notesOf(r, lang)
1054  // Hinweise und Fußzeile immer am Ende, auch wenn Listen gekürzt werden müssen (höchstens 10 Zeilen)
1055  const capped = (lines: string[], foot: string) => {
1056    const tail = [...notes, foot]
1057    return [...lines.slice(0, 10 - tail.length), ...tail].slice(0, 10).join('\n')
1058  }
1059  // Limits gibt es auch ohne Buchung (Plan eingestellt, Messung dieses Prozesses)
1060  if (view === 'limits') return capped([t.sumLimitsTitle(dateTime(r.now, lang), tag), ...limitsLines(r.limits, r.now, lang)], t.sumLimitsFoot)
1061  const lines = [t.sumTitle(dateTime(r.now, lang), tag)]
1062  // Die Limit-Zeile auch im Leerzustand (z. B. direkt nach /ledger reset: Plan und Messung sind noch da)
1063  const lim = view === 'overview' || view === 'weeks' ? limitsLineParts(r.limits, r.now, lang) : []
1064  if (lim.length) lines.push(t.sumLimitsLine(lim.join(' | ')))
1065  if (r.total === 0) return [...lines, ...notes, t.empty].join('\n')
1066  const w = r.windows
1067  if (view === 'overview' || view === 'weeks') {
1068    lines.push(t.sumChats(money(w.today.usd), w.today.chats, money(w.d7.usd), money(w.d30.usd), money(w.all.usd)))
1069    lines.push(t.sumMods(money(w.today.mods), money(w.d30.mods), money(w.all.mods)))
1070    if (r.projects.length) lines.push(t.sumProjects(join(r.projects, (p) => `${projectLabel(p.name, lang)} ${money(p.usd)}`, 5)))
1071    if (r.chats.length) lines.push(t.sumTopChats(join(r.chats, (c) => `${chatLabel(c, lang)} ${money(c.usd)}`, 3)))
1072    if (r.mods.length) lines.push(t.sumModsList(join(r.mods, (m) => `${m.name} ${money(m.usd)} (${m.calls}×)`, 4)))
1073    if (view === 'weeks') lines.push(t.sumWeeks(r.series.weeks.slice(0, 6).map((b) => `${weekLabel(isoWeek(b.key), lang)} ${money(b.usd)}`).join(' · ')))
1074  } else if (view === 'chats') {
1075    const rows = r.chats.slice(0, 20)
1076    lines.push(t.sumChatsHead(rangeLabel(range, lang), rows.length, r.chats.length))
1077    for (let i = 0; i < rows.length; i += 5)
1078      lines.push(rows.slice(i, i + 5).map((c, j) => `${i + j + 1}. ${chatLabel(c, lang)} (${projectLabel(c.project, lang)}) ${money(c.usd)}`).join(' · '))
1079  } else if (view === 'models') {
1080    const sum = r.models.reduce((a, m) => a + m.usd, 0)
1081    lines.push(t.sumModelsHead(rangeLabel(range, lang)))
1082    if (!r.models.length) lines.push(t.noTokens)
1083    for (const m of r.models.slice(0, 6)) {
1084      const tok = t.tokenLine(tokens(m.in, lang), tokens(m.out, lang), tokens(m.cr, lang), tokens(m.cw, lang))
1085      lines.push(t.sumModelLine(modelName(m.key, lang), money(m.usd), sum > 0 ? Math.round((m.usd / sum) * 100) : 0, m.n, tok))
1086    }
1087    if (r.models.length > 6) lines.push(t.sumMoreModels(r.models.length - 6))
1088  } else {
1089    lines.push(t.sumProjectsHead(rangeLabel(range, lang)))
1090    // Höchstens 4 Zeilen à 6; der Rest als „+N weitere“
1091    const shown = r.projects.slice(0, 24)
1092    for (let i = 0; i < shown.length; i += 6)
1093      lines.push(shown.slice(i, i + 6).map((p) => t.sumProjectRow(projectLabel(p.name, lang), money(p.usd), p.chats)).join(' · '))
1094    if (r.projects.length > shown.length) lines[lines.length - 1] += ` · ${t.more(r.projects.length - shown.length)}`
1095  }
1096  return capped(lines, t.sumFoot)
1097}
1098
1099// ---------- /ledger help (docs/HELP-SPEC.md §5 „cost-ledger 0.6.0“) ----------
1100
1101/** Was die Hilfe außer den Einstellungen braucht: Plan, Belegung des Speichers, Beginn der Erfassung. */
1102export type HelpInfo = { plan: Plan | null; storeBytes: number; since: number | null }
1103
1104/** Zahl einer Einstellung in der Sprache: en `2.5`, de `2,5`. */
1105const settingNum = (v: number, lang: Lang) => (lang === 'de' ? String(v).replace('.', ',') : String(v))
1106
1107/**
1108 * Schnappschuss für `/ledger help`: Befehle genau so, wie der Parser sie annimmt (Wörter aus `HELP_WORDS` & Co.),
1109 * Funktionen mit Zustand beim Aufruf, Einstellungen mit ihrem wirksamen Wert (nach `cleanSettings`).
1110 */
1111export function ledgerHelp(s: Settings, info: HelpInfo): HelpData {
1112  const lang = s.lang
1113  const t = T[lang]
1114  const money = (v: number) => usd(v, lang)
1115  const p = info.plan
1116  const price = p ? (p.price ?? (own(PLANS, p.plan) ? PLANS[p.plan]!.price : undefined)) : undefined
1117  const priceText = p ? (price === undefined ? t.noPrice : `${t.perMonth(money(price))}${p.price === undefined ? ` (${t.listPrice})` : ''}`) : ''
1118  const pctUsed = Math.round((Math.max(0, info.storeBytes) / STORE_LIMIT) * 100)
1119  return {
1120    mod: 'cost-ledger',
1121    lang,
1122    intro: t.helpIntro,
1123    commands: [
1124      { cmd: '/ledger', does: t.helpOverview },
1125      { cmd: '/ledger weeks', does: t.helpWeeks },
1126      { cmd: '/ledger chats|projects|models [7|30|all]', does: t.helpLists },
1127      { cmd: '/ledger limits', does: t.helpLimits },
1128      { cmd: '/ledger plan', does: t.helpPlanShow },
1129      { cmd: '/ledger plan <plan> <day|today> [price]', does: t.helpPlanSet },
1130      { cmd: '/ledger plan off', does: t.helpPlanOff },
1131      { cmd: '/ledger reset', does: t.helpReset },
1132      { cmd: '/ledger help', does: t.helpHelp },
1133    ],
1134    notes: [t.helpAliases],
1135    features: [
1136      p
1137        ? { name: t.helpPlanName, state: { kind: 'on', text: `${planLabel(p.plan)} · ${t.helpDay(p.day)} · ${priceText}` }, toggle: '/ledger plan off' }
1138        : { name: t.helpPlanName, state: { kind: 'off', text: t.helpNoPlan }, toggle: '/ledger plan max20 14' },
1139      { name: t.helpStorageName, state: { kind: 'value', text: t.helpStorage(pctUsed) }, toggle: t.helpInfo },
1140      { name: t.helpSinceName, state: { kind: 'value', text: info.since === null ? '–' : fullDate(info.since, lang) }, toggle: t.helpInfo },
1141    ],
1142    settings: [
1143      { title: t.setLanguage, value: lang, isDefault: lang === DEFAULTS.lang },
1144      { title: t.setKeepDays, value: String(s.keepDays), isDefault: s.keepDays === DEFAULTS.keepDays },
1145      { title: t.setDayYellow, value: settingNum(s.dayYellow, lang), isDefault: s.dayYellow === DEFAULTS.dayYellow },
1146      { title: t.setDayRed, value: settingNum(s.dayRed, lang), isDefault: s.dayRed === DEFAULTS.dayRed },
1147    ],
1148    footer: { terminal: t.helpFooterTerminal, desktop: t.helpFooterDesktop },
1149  }
1150}
1151
hooks/view.ts 347 lines
1// cost-ledger: die Übersicht als reiner Datenbaum {type, props, children} (types RenderElement), ohne $.ui.resolve.
2// Nur Box und Text mit Props aus der Allowlist (API-DETAILS.md:1043-1080), sonst zeichnet die Engine ihr Original.
3//
4// Stil wie limit-bars: Claude-Orange nur für Überschriften, Beträge in normaler Schrift, Nebensachen gedimmt; Farbe tragen
5// nur die Balken. Kein festes Außenmaß: Der Desktop meldet mehr Spalten, als er zeigt, und zeichnet Blockzeichen breiter
6// als eine Zelle. Spalten sind deshalb Boxen mit fester Breite; im Desktop sind Balken Boxen mit Hintergrundfarbe und
7// ganzzahliger Prozentbreite (Kommaprozente verwirft er), im Terminal dünne `▄`. Der leere Teil zeigt den Hintergrund.
8import type { RenderElement, RenderNode } from 'claude-code'
9import { T, dateTime, duration, factor, fullDate, pct, rangeLabel, resetTime, shortDate, span, tokens, usd, weekLabel } from './i18n.ts'
10import type { Lang } from './i18n.ts'
11import { MOD_PART, UNKNOWN_MODEL, chatLabel, isoWeek, modelName, notesOf, partLabel, partialLabel, planLabel, planLine, projectLabel, projectionText } from './logic.ts'
12import type { Bucket, LimWindow, LimitsReport, Report, Settings, ViewName } from './logic.ts'
13
14export type View = { report: Report; view: ViewName; range: number }
15type Surface = 'terminal' | 'desktop'
16
17// Theme-Keys statt fester Hex-Werte: Sie folgen dem Theme des Nutzers, hell wie dunkel (types Color/ThemeKey).
18const ORANGE = 'claude'
19const YELLOW = 'warning'
20const RED = 'error'
21// Farben je Modell, nach Anteil vergeben; ohne Modell-Daten gedimmt
22const MODEL_COLORS = ['claude', 'suggestion', 'success', 'permission', 'warning', 'planMode', 'ide', 'remember']
23const UNKNOWN_COLOR = 'inactive'
24
25type Props = Record<string, string | number | boolean>
26
27function el(type: 'Box' | 'Text', props: Props, children: RenderNode[]): RenderElement {
28  return { type, props, children }
29}
30const text = (s: string, props: Props = {}) => el('Text', props, [s])
31const dim = (s: string) => text(s, { dimColor: true })
32const row = (props: Props, kids: RenderNode[]) => el('Box', { flexDirection: 'row', ...props }, kids)
33const col = (props: Props, kids: RenderNode[]) => el('Box', { flexDirection: 'column', ...props }, kids)
34/** Feste Spalte; `right` richtet den Inhalt rechts aus (Beträge). */
35const cell = (width: number, kid: RenderNode, right = false) =>
36  el('Box', { width, flexShrink: 0, ...(right ? { justifyContent: 'flex-end' } : {}) }, [kid])
37const heading = (s: string) => el('Box', { marginTop: 1 }, [text(s, { color: ORANGE, bold: true })])
38
39/** `total` ganze Einheiten nach Anteilen verteilen: erst abrunden, den Rest an die größten Nachkommareste (Summe = total). */
40function largestRemainder(shares: number[], total: number): number[] {
41  const sum = shares.reduce((a, b) => a + b, 0)
42  if (!(sum > 0) || total <= 0) return shares.map(() => 0)
43  const raw = shares.map((x) => (x / sum) * total)
44  const got = raw.map((x) => Math.floor(x))
45  let left = total - got.reduce((a, b) => a + b, 0)
46  const order = raw.map((x, i) => [x - Math.floor(x), i] as const).sort((a, b) => b[0] - a[0])
47  for (const [, i] of order) {
48    if (left <= 0) break
49    got[i]! += 1
50    left -= 1
51  }
52  return got
53}
54
55/**
56 * Balken der Länge `ratio` (0–1), aufgeteilt in `parts` (Anteile) mit je einer Farbe; ein einfarbiger Balken hat einen Teil.
57 * Desktop: Boxen in ganzzahliger Prozentbreite (der Desktop verwirft Kommaprozente), Summe genau 100. Terminal: ganze
58 * Zellen `▄`, verteilt nach größtem Rest; ein Teil unter einer halben Zelle kann dort fehlen.
59 */
60export function stackedBar(surface: Surface, ratio: number, cells: number, parts: { color: string; share: number }[]): RenderElement {
61  const r = Math.max(0, Math.min(1, ratio || 0))
62  const list = parts.filter((p) => p.share > 0)
63  if (surface === 'desktop') {
64    const pct = r > 0 ? Math.max(1, Math.round(r * 100)) : 0
65    const widths = largestRemainder(list.map((p) => p.share), 100)
66    const segs = list.flatMap((p, i) => (widths[i]! > 0 ? [el('Box', { width: `${widths[i]}%`, backgroundColor: p.color }, [' '])] : []))
67    const fill = pct > 0 && segs.length ? [el('Box', { width: `${pct}%`, flexDirection: 'row' }, segs)] : [' ']
68    return el('Box', { flexGrow: 1, minWidth: 6, marginRight: 1 }, fill)
69  }
70  const n = Math.max(1, cells)
71  const on = r > 0 ? Math.max(1, Math.round(r * n)) : 0
72  const got = largestRemainder(list.map((p) => p.share), on)
73  const kids: RenderNode[] = list.flatMap((p, i) => (got[i]! > 0 ? [text('▄'.repeat(got[i]!), { color: p.color })] : []))
74  return el('Box', { width: n + 1, flexShrink: 0 }, [kids.length ? el('Text', {}, kids) : ' '])
75}
76
77const bar = (sf: Surface, ratio: number, cells: number, color: string) => stackedBar(sf, ratio, cells, [{ color, share: 1 }])
78
79/**
80 * Farbe je Verlaufsteil: erst die Chat-Modelle nach Gesamtbetrag, danach die Mod-Teile (`mod:…`), damit sie in der
81 * Legende hinter dem Chat stehen. Beträge ohne Modell-Daten (Chat wie Mods) immer gedimmt.
82 */
83export function modelColors(buckets: Bucket[]): Map<string, string> {
84  const tot = new Map<string, number>()
85  for (const b of buckets) for (const p of b.parts) tot.set(p.key, (tot.get(p.key) ?? 0) + p.usd)
86  const unknown = (k: string) => k === UNKNOWN_MODEL || k === MOD_PART + UNKNOWN_MODEL
87  const isMod = (k: string) => (k.startsWith(MOD_PART) ? 1 : 0)
88  const keys = [...tot.entries()]
89    .filter(([k]) => !unknown(k))
90    .sort((a, b) => isMod(a[0]) - isMod(b[0]) || b[1] - a[1])
91    .map(([k]) => k)
92  const out = new Map(keys.map((k, i) => [k, MODEL_COLORS[i % MODEL_COLORS.length]!]))
93  for (const k of [UNKNOWN_MODEL, MOD_PART + UNKNOWN_MODEL]) if (tot.has(k)) out.set(k, UNKNOWN_COLOR)
94  return out
95}
96
97/** Ampel für Tagesbeträge (`dayYellow`/`dayRed`); darunter die normale Schriftfarbe des Themes (`text`). */
98function ampel(v: number, s: Settings): Props {
99  return { color: v >= s.dayRed ? RED : v >= s.dayYellow ? YELLOW : 'text' }
100}
101
102function cut(s: string, n: number): string {
103  return s.length <= n ? s : `${s.slice(0, Math.max(1, n - 1))}…`
104}
105
106/** Eine Kennzahl: Überschrift gedimmt, Betrag fett, darunter Chats und Mods. */
107function figure(label: string, w: { usd: number; chats: number; mods: number }, width: string, lang: Lang): RenderElement {
108  return col({ width, paddingRight: 2 }, [dim(label), text(usd(w.usd, lang), { bold: true }), dim(`${T[lang].chats(w.chats)} · Mods ${usd(w.mods, lang)}`)])
109}
110
111/**
112 * Verlauf der letzten 14 Tage oder Wochen: je Zeile ein Balken, geteilt nach Modell, darunter die Legende. Der Betrag ist
113 * der echte Chat-Betrag plus die Mod-Aufrufe; die Aufteilung folgt den geschätzten Modell-Anteilen, Mod-Teile stehen in
114 * der Legende als „Modell · Mods“. Die Ampel färbt bei Tagen den Betrag.
115 */
116function historyBlock(r: Report, s: Settings, sf: Surface, inner: number, weeks: boolean): RenderElement[] {
117  const lang = s.lang
118  const t = T[lang]
119  const buckets = weeks ? r.series.weeks : r.series.days
120  const colors = modelColors(buckets)
121  const max = Math.max(0, ...buckets.map((b) => b.usd))
122  const top = buckets.find((b) => b.usd === max && max > 0)?.key
123  const labelW = 7
124  // Terminal: Beschriftung, Betrag 10, Marke 12 → der Rest ist Balken
125  const cells = Math.max(6, Math.min(32, inner - labelW - 10 - 12 - 1))
126  const rows = buckets.map((b) =>
127    row({}, [
128      cell(labelW, dim(weeks ? weekLabel(isoWeek(b.key), lang) : shortDate(b.key, lang))),
129      stackedBar(sf, max > 0 ? b.usd / max : 0, cells, b.parts.map((p) => ({ color: colors.get(p.key) ?? UNKNOWN_COLOR, share: b.usd > 0 ? p.usd / b.usd : 0 }))),
130      cell(10, b.usd > 0 ? text(usd(b.usd, lang), weeks ? {} : ampel(b.usd, s)) : dim('–'), true),
131      cell(12, b.key === top ? dim(`  ${t.highest}`) : ''),
132    ]),
133  )
134  const legend: RenderNode[] = [...colors.entries()].map(([k, c]) =>
135    el('Box', { marginRight: 2 }, [el('Text', {}, [text('▄ ', { color: c }), dim(partLabel(k, lang))])]),
136  )
137  return [heading(weeks ? t.last14Weeks : t.last14Days), ...rows, ...(legend.length ? [row({ flexWrap: 'wrap', marginTop: 1 }, legend)] : [])]
138}
139
140function projectsBlock(r: Report, title: string, sf: Surface, width: number, limit: number, basis: string, lang: Lang): RenderElement {
141  const list = r.projects.slice(0, limit).map((p) => ({ ...p, label: projectLabel(p.name, lang) }))
142  const max = Math.max(0, ...list.map((p) => p.usd))
143  const nameW = Math.min(18, Math.max(8, ...list.map((p) => p.label.length + 1)))
144  const cells = Math.max(4, Math.min(24, width - nameW - 10 - 1 - 2)) // 2 = paddingRight
145  const kids: RenderNode[] = list.length
146    ? list.map((p) => row({}, [cell(nameW, text(cut(p.label, nameW - 1))), bar(sf, max > 0 ? p.usd / max : 0, cells, ORANGE), cell(10, text(usd(p.usd, lang)), true)]))
147    : [dim(T[lang].noCosts)]
148  if (r.projects.length > limit) kids.push(dim(T[lang].more(r.projects.length - limit)))
149  return col({ width: basis, paddingRight: 2 }, [heading(title), ...kids])
150}
151
152function chatsBlock(r: Report, title: string, width: number, limit: number, basis: string, lang: Lang): RenderElement {
153  const list = r.chats.slice(0, limit)
154  const nameW = Math.max(12, Math.min(48, width - 3 - 10))
155  const kids: RenderNode[] = list.length
156    ? list.flatMap((c, i) => [
157        row({}, [
158          cell(3, dim(`${i + 1}`)),
159          el('Box', { flexGrow: 1, minWidth: 8 }, [text(cut(chatLabel(c, lang), nameW), { wrap: 'truncate-end' })]),
160          cell(10, text(usd(c.usd, lang)), true),
161        ]),
162        row({}, [cell(3, ''), dim(`${projectLabel(c.project, lang)} · ${shortDate(c.lastDay, lang)}`)]),
163      ])
164    : [dim(T[lang].noCosts)]
165  return col({ width: basis }, [heading(title), ...kids])
166}
167
168/** `/ledger models`: je Modell Anteil am geschätzten API-Wert als Balken, Betrag, Anzahl und darunter die Tokens. */
169function modelsBlock(r: Report, title: string, sf: Surface, inner: number, lang: Lang): RenderElement {
170  const t = T[lang]
171  const sum = r.models.reduce((a, m) => a + m.usd, 0)
172  const names = r.models.map((m) => modelName(m.key, lang))
173  const nameW = Math.min(16, Math.max(10, ...names.map((n) => n.length + 1)))
174  const cells = Math.max(6, Math.min(32, inner - nameW - 6 - 10 - 8 - 1))
175  const kids: RenderNode[] = r.models.length
176    ? r.models.flatMap((m, i) => [
177        row({ marginTop: 1 }, [
178          cell(nameW, text(names[i]!, { bold: true })),
179          bar(sf, sum > 0 ? m.usd / sum : 0, cells, ORANGE),
180          cell(6, dim(`${sum > 0 ? Math.round((m.usd / sum) * 100) : 0} %`), true),
181          cell(10, text(usd(m.usd, lang)), true),
182          cell(8, dim(`${m.n}×`), true),
183        ]),
184        dim(t.tokenLine(tokens(m.in, lang), tokens(m.out, lang), tokens(m.cr, lang), tokens(m.cw, lang))),
185      ])
186    : [dim(t.noTokens)]
187  kids.push(el('Box', { marginTop: 1 }, [dim(t.modelsNote)]))
188  return col({}, [heading(title), ...kids])
189}
190
191function modsBlock(r: Report, title: string, lang: Lang): RenderElement {
192  const t = T[lang]
193  const line = r.mods.length ? r.mods.map((m) => `${m.name} ${usd(m.usd, lang)} (${m.calls}×)`).join(' · ') : t.noModCalls
194  return col({}, [heading(title), text(line, r.mods.length ? {} : { dimColor: true }), dim(t.modsExtra)])
195}
196
197/**
198 * Ampel für die Auslastung eines Limit-Fensters: grün unter 70 %, gelb unter 90 %, rot ab 90 % (SPEC Nachtrag 0.5.0).
199 * Nach der angezeigten, gerundeten Zahl, damit „70 %“ nie grün ist (`percentUsed` hat eine Nachkommastelle).
200 */
201function limColor(p: number): string {
202  const v = Math.round(p || 0)
203  return v >= 90 ? RED : v >= 70 ? YELLOW : 'success'
204}
205
206/**
207 * Ein Limit-Fenster (5 Stunden oder Woche): Überschrift mit Reset und Countdown (Stand beim Aufruf), rechts „ab …“, falls
208 * das Fenster vor der ersten Fenster-Buchung begann; darunter Balken = % genutzt, %, API-Wert und die Hochrechnung.
209 */
210function windowBlock(w: LimWindow | null, seen: boolean, title: string, l: LimitsReport, now: number, sf: Surface, inner: number, lang: Lang): RenderNode[] {
211  const t = T[lang]
212  if (!w) return [heading(title), dim(seen ? t.resetPassed : t.noWindowYet)]
213  const from = partialLabel(w, l.limSince, lang)
214  const head = row({ justifyContent: 'space-between', flexWrap: 'wrap', marginTop: 1 }, [
215    text(`${title} · ${t.resetAt(resetTime(w.resetsAt, now, lang))} (${t.inTime(duration(w.resetsAt - now, lang))})`, { color: ORANGE, bold: true }),
216    ...(from ? [dim(from)] : []),
217  ])
218  // Terminal: Balken, % 7, Betrag 12; die Hochrechnung bricht bei schmaler Breite in die nächste Zeile um
219  const cells = Math.max(6, Math.min(32, inner - 7 - 12 - 1))
220  const line = row({ flexWrap: 'wrap' }, [
221    bar(sf, w.pct / 100, cells, limColor(w.pct)),
222    cell(7, text(pct(w.pct), { color: limColor(w.pct) }), true),
223    cell(12, text(usd(w.usd, lang), { bold: true }), true),
224    el('Box', { paddingLeft: 3 }, [dim(projectionText(w, lang))]),
225  ])
226  return [head, line]
227}
228
229function planBlock(l: LimitsReport, lang: Lang): RenderNode[] {
230  const t = T[lang]
231  if (!l.plan) return [heading(t.subMonth), dim(t.planNotSet)]
232  const p = planLine(l.plan, lang)
233  return [heading(`${t.subMonth} · ${p.head}`), el('Text', {}, [text(p.value), ...(p.note ? [dim(` · ${p.note}`)] : [])])]
234}
235
236/** Die letzten 10 Fünf-Stunden-Fenster: Balken = API-Wert relativ zum teuersten, Farbe nach % wie oben; darunter das Ø. */
237function windowsHistory(l: LimitsReport, sf: Surface, inner: number, lang: Lang): RenderNode[] {
238  const t = T[lang]
239  const max = Math.max(0, ...l.history.map((w) => w.usd))
240  const labelW = 19
241  // Beschriftung 19, Betrag 10, % 7, Marke 12 → der Rest ist Balken. Reicht die Breite nicht, brechen die Zellen um:
242  // Der kleinste Balken braucht im Terminal 5 Zellen, im Desktop minWidth 6 + marginRight 1 (stackedBar)
243  const cells = Math.max(4, Math.min(24, inner - labelW - 10 - 7 - 12 - 1))
244  const narrow = inner < labelW + (sf === 'desktop' ? 7 : cells + 1) + 10 + 7 + 12
245  const rows = l.history.map((w) =>
246    row(narrow ? { flexWrap: 'wrap' } : {}, [
247      cell(labelW, dim(span(w.start, w.resetsAt, lang))),
248      bar(sf, max > 0 ? w.usd / max : 0, cells, limColor(w.pct)),
249      cell(10, text(usd(w.usd, lang)), true),
250      cell(7, dim(pct(w.pct)), true),
251      cell(12, dim(w.running ? `  ${t.running}` : w.partial ? `  ${partialLabel(w, l.limSince, lang)}` : '')),
252    ]),
253  )
254  return [
255    heading(t.lastWindows),
256    ...(rows.length ? rows : [dim(t.noWindows)]),
257    el('Box', { marginTop: 1 }, [l.avg ? text(t.avg(usd(l.avg.usd, lang), l.avg.n)) : dim(t.avgNone)]),
258  ]
259}
260
261/** `/ledger limits`: 5 Stunden, Woche, Abo-Monat, Verlauf; ohne Limit-Daten ein Hinweis statt der Fenster. */
262function limitsBlocks(r: Report, sf: Surface, inner: number, lang: Lang): RenderNode[] {
263  const t = T[lang]
264  const l = r.limits
265  if (!l.seenFive && !l.seenWeek)
266    return [el('Box', { marginTop: 1 }, [text(t.noLimitData)]), ...(l.plan ? planBlock(l, lang) : [dim(t.planNotSet)])]
267  return [
268    ...windowBlock(l.five, l.seenFive, t.fiveHours, l, r.now, sf, inner, lang),
269    ...windowBlock(l.week, l.seenWeek, t.week, l, r.now, sf, inner, lang),
270    ...planBlock(l, lang),
271    ...windowsHistory(l, sf, inner, lang),
272  ]
273}
274
275/** Kompakte Limit-Zeile oben in der Übersicht; bricht bei schmaler Breite um. Entfällt ohne Fenster und ohne Plan. */
276function limitsLine(r: Report, lang: Lang): RenderElement | null {
277  const t = T[lang]
278  const l = r.limits
279  const money = (v: number) => usd(v, lang)
280  const parts: RenderNode[][] = []
281  if (l.five)
282    parts.push([text(`${t.fiveShort} `), text(pct(l.five.pct), { color: limColor(l.five.pct) }), dim(' · '), text(money(l.five.usd)), dim(` · ${t.resetAt(resetTime(l.five.resetsAt, r.now, lang))}`)])
283  else if (l.seenFive) parts.push([dim(`${t.fiveShort}: ${t.resetPassedShort}`)])
284  if (l.week) parts.push([text(`${t.weekShort} `), text(pct(l.week.pct), { color: limColor(l.week.pct) }), dim(' · '), text(money(l.week.usd))])
285  if (l.plan) parts.push([text(`${t.planShort} ${money(l.plan.usd)}`), ...(l.plan.factor !== null ? [dim(' = '), text(`${factor(l.plan.factor, lang)}×`)] : [])])
286  if (!parts.length) return null
287  const kids: RenderNode[] = parts.map((p, i) => el('Box', { marginRight: 1 }, [el('Text', {}, [...(i ? [dim('│ ')] : []), ...p])]))
288  kids.push(dim('· /ledger limits'))
289  return row({ flexWrap: 'wrap', marginTop: 1 }, kids)
290}
291
292/** Der Befehl, der dieselbe Ansicht mit frischen Zahlen zeichnet (samt Zeitraum). */
293function reloadCommand(v: View): string {
294  if (v.view === 'overview') return '/ledger'
295  if (v.view === 'weeks' || v.view === 'limits') return `/ledger ${v.view}`
296  return `/ledger ${v.view}${v.range === 30 ? '' : v.range <= 0 ? ' all' : ` ${v.range}`}`
297}
298
299/** Der ganze Baum für eine `CommandOutput`-Zeile von `/ledger`; `columns` dient nur als Richtwert (Terminal-Balken, Umbruch). */
300export function ledgerTree(v: View, s: Settings, columns: number, surface: Surface = 'terminal'): RenderElement {
301  const lang = s.lang
302  const t = T[lang]
303  const r = v.report
304  const cols = Math.max(30, Math.min(columns || 100, 140))
305  const inner = cols - 4 // Rahmen und paddingX
306  const limits = v.view === 'limits'
307  // Ohne Knopf (er bräuchte neue Rechte): oben rechts Stand und wie man neu lädt; bei den Limits statt „seit“ der Plan
308  const first = limits ? (r.limits.plan ? `${planLabel(r.limits.plan.plan)} · ` : '') : r.since ? `${t.since(fullDate(r.since, lang))} · ` : ''
309  const stand = `${first}${t.asOf(dateTime(r.now, lang))} · ${t.reload(reloadCommand(v))}`
310  const head = row({ justifyContent: 'space-between', flexWrap: 'wrap' }, [text(limits ? t.limitsHead : 'cost-ledger', { color: ORANGE, bold: true }), dim(stand)])
311  const notes: RenderNode[] = notesOf(r, lang).map((n) => text(n, { color: YELLOW }))
312  const foot = el('Box', { marginTop: 1 }, [dim(limits ? t.limitsFoot : t.foot)])
313  const frame = (kids: RenderNode[]) => col({ borderStyle: 'round', borderDimColor: true, paddingX: 1, width: '100%' }, [head, ...notes, ...kids, foot])
314
315  // Limits gibt es auch ohne Buchung (Plan eingestellt, Messung dieses Prozesses)
316  if (limits) return frame(limitsBlocks(r, surface, inner, lang))
317  // Die Limit-Zeile entfällt nur ohne Fenster und ohne Plan, auch im Leerzustand (z. B. direkt nach /ledger reset)
318  const lim = v.view === 'overview' || v.view === 'weeks' ? limitsLine(r, lang) : null
319  if (r.total === 0) return frame([...(lim ? [lim] : []), el('Box', { marginTop: 1 }, [text(t.empty)])])
320  const range = rangeLabel(v.range, lang)
321  if (v.view === 'chats') return frame([chatsBlock(r, t.chatsIn(range), inner, 20, '100%', lang)])
322  if (v.view === 'projects') return frame([projectsBlock(r, t.projectsIn(range), surface, inner, 40, '100%', lang)])
323  if (v.view === 'models') return frame([modelsBlock(r, t.modelsIn(range), surface, inner, lang)])
324
325  const w = r.windows
326  const d30 = rangeLabel(30, lang)
327  // Spalten als Prozent der Zeile (nie breiter als die Zeile): zwei Blöcke ab etwa 90 Zeichen, vier Kennzahlen ab 80
328  const side = inner >= 90
329  const half = side ? Math.floor(inner / 2) - 2 : inner
330  const fw = inner >= 80 ? '25%' : inner >= 40 ? '50%' : '100%'
331  return frame([
332    ...(lim ? [lim] : []),
333    row({ flexWrap: 'wrap', marginTop: 1 }, [
334      figure(t.today, w.today, fw, lang),
335      figure(t.days7, w.d7, fw, lang),
336      figure(t.days30, w.d30, fw, lang),
337      figure(t.allTime, w.all, fw, lang),
338    ]),
339    ...historyBlock(r, s, surface, inner, v.view === 'weeks'),
340    row({ flexWrap: 'wrap' }, [
341      projectsBlock(r, t.projectsIn(d30), surface, half, 8, side ? '50%' : '100%', lang),
342      chatsBlock(r, t.chatsIn(d30), half, 5, side ? '50%' : '100%', lang),
343    ]),
344    modsBlock(r, t.modsIn(d30), lang),
345  ])
346}
347