Bars for the 5-hour and weekly usage limits above the prompt, a ring showing how long the prompt cache stays warm, and an optional ring for drive usage by file…

Two slim bars on the left of the band above the prompt show live how much of the 5-hour and the weekly limit is used and when each window resets. Next to them, a ring shows how long this chat's prompt cache stays warm. On top of that comes a cache guard: a question before an expensive cold send, a notice shortly before the cache expires, and the commands /cache, /handoff and /keepwarm. Optionally, a second ring shows how full a drive is, split by file type, with /disk for the details (Windows, off by default). Everything fits next to Clawd (clawd-buddy).
Texts are English by default; set language to de for German.
Tested with Claude Code v2.1.295 · Plugin version 0.7.2
| Surface | Appearance |
|---|---|
| Desktop app's Code tab (main surface) | One image, 242 × 56 px (ring only: 58 × 56; with the storage ring 302 × 56), left of Clawd and aligned to the bottom. Stacked: on top 5h 71% · in 2 h 14 min, below it 7d 18% · Mon 09:00, each with a segmented bar (44 segments). The cache ring sits to the right, the storage ring after it |
| Terminal | 2 lines, both windows side by side, at most 44 columns: the label on top, a bar made of ▄ below. When space gets tight it switches to short forms (71% 2h14), then shows only 5h, then nothing. The cache block (◔ 42m, ○ cold) and the storage block (Storage 114G/801G) follow if they fit; the storage block goes first, then the cache block |
5h/7d tag orange (#D77757).highlightAt (default 90 %) the tag and percentage turn bold. From 100 % it reads full and the bar is all red.0% · fresh, dimmed, with an empty bar.5h – and 7d –, and the ring shows –. In the terminal the bars only appear once there are values.$.session.usage() at start and every 10 s, plus every measurement (session.measure, after each turn). usage() returns the values of the latest API response, so it updates even in the middle of a long turn. The 10 s tick polls but only redraws when a displayed number or color changes (otherwise buttons of other mods in the band flicker); it stops when the band is no longer drawn.Runs in: the desktop app's Code tab and the terminal. In claude -p, the Agent SDK, VS Code and mobile it draws nothing and starts no tick.
Every message sends the whole conversation. Served from the prompt cache that is cheap; once the cache has expired, the next message writes everything again. Example with Opus 5.5 at 400k context: warm ≈ $0.08, cold ≈ $3.20 (API value; on a subscription it counts against your quota, there is no bill).
42m), below it the context size (412k): grey below 80k, orange from 80k, red from "big" (/cache big, default 150k). The ring itself only shows the cache state.– before the first request · a small dot in the middle = being kept warm./cache ttl 5|60|auto.turn.step; subagents don't count), and between turns $.session.usage() on the 10 s tick. The state is kept in $.store under cache:<session ID>, so the ring knows how warm the cache is after a restart. Entries older than 7 days are deleted at start.Off by default. Set storagePath in /config → limit-bars to a drive, for example E:\, and a ring appears to the right of the cache ring. Windows only for now; on other systems, or with a path without a drive letter, nothing is drawn.
Storage above the ring (like Cache above the cache ring).114G), below it the drive size (/801G). Sizes are binary, as in Windows Explorer. Before the first scan the ring is grey./disk: the ring larger and for the whole drive: the file types in color, the free space in grey. Next to it a legend: size and share of the used space per file type, plus free space and its share of the drive, the time of the scan and the number of files. In the terminal it is a bar of █ with the legend below; in claude -p and VS Code it is a Markdown table..dll, .exe … programs; .mp4, .wav, .png … media; .pth, .gguf, .safetensors … models; .py, .ts, .json … code; .zip, .apk, .pack … archives). Everything else, files without an extension, and what is used but not readable by the scan (system files, recycle bin) count as "other".powershell.exe, built into Windows) runs two fixed, read-only scripts via $.process.run. The drive path is passed only as the environment variable LB_PATH, never as part of the script. The quick query (size and free space, about 0.2 s) runs at start and every 10 minutes while the band is drawn. The scan by file type reads every folder of the drive (sizes only, no file contents; about 10 s for 560,000 files) and runs once a day after the first session start in the background, and with /disk when the last scan is older than an hour. /disk refresh always scans. If another session is scanning right now, /disk says so and does not start a second scan. The result is kept in $.store under storage.Every part of the display can be shown or hidden on its own. The storage ring is off by default: it only appears once you set storagePath.
| Part | Setting (/config → limit-bars) | Command | Default | |
|---|---|---|---|---|
| 5-hour bar | showFiveHour | `/bars show 5h on\ | off` | on |
| Weekly bar | showWeekly | `/bars show week on\ | off` | on |
| Cache ring | showCache | `/bars show cache on\ | off` | on |
| Storage ring | showStorage (and storagePath) | `/bars show storage on\ | off` | off (no storagePath) |
/bars wins over the setting. /bars reset drops all command values, so the settings apply again. /bars alone shows what is on and where each value comes from./bars applies to all open chats (stored in $.store, picked up within 10 s). Settings are read when the plugin loads. After changing them under /plugin → Configure options, Claude Code runs /reload-plugins when you close the panel (if that would invalidate the prompt cache, it asks first and leaves the change pending; /reload-plugins --force applies it). After claude plugin configure, restart Claude Code. Other open chats keep the old values until they reload./cache, /handoff and /keepwarm keep working; turn them off with /cache warn off and /cache hints off./disk still scans when you call it and storagePath is set. /bars show storage on without storagePath tells you where to set it./bars 5h off, /bars woche aus, /bars speicher an, /bars cache aus.onlyFiveHour still works (on = weekly bar hidden) but is deprecated; use showWeekly.| Command | What it does | ||||
|---|---|---|---|---|---|
/cache | Everything at a glance: state and remaining time, cache duration and its source, model, context, cost of the next message warm and cold, last request (read/written), cold restarts in this session, session cost, limits, keep-warm, settings | ||||
/cache ttl 5, ttl 60, ttl auto · warn on/off · big 150k (big threshold) · hints on/off (notice before expiry) | Settings, stored in $.store; they apply immediately in all open chats | ||||
/handoff | Claude writes a short handoff (≤ 400 words) with the skill uebergabe, in the language set in language. Then the mod asks: New chat with handoff clears this chat (/clear; the old one stays reachable via /resume) and sends the handoff as the first message, so the cache starts small. Keep working here leaves everything as it is | ||||
/handoff continue · /handoff show | Apply the last handoff afterwards (clear and continue with it), or show it (the last 3 are kept in $.store) | ||||
/keepwarm [hours] · /keepwarm off | Keep the cache warm, default 2 h, at most 4 h. Off by default, because every ping costs quota. /keepwarm without a number turns off a running keep-warm | ||||
/disk · /disk refresh | Drive usage by file type, as a ring with a legend (see Storage ring). Scans again if the last scan is older than an hour, or always with refresh | ||||
/bars · `/bars show 5h\ | week\ | cache\ | storage on\ | off · /bars reset` | Show or hide parts of the display in all open chats (see Customize) |
/bars help · /bars ? | All commands, features and settings at a glance, with their current state and how to switch them. In the terminal and the desktop app a drawn table with headings in the theme's warning color (yellow, follows light and dark); in claude -p and VS Code the same as Markdown. It shows the state at the moment you call it; call it again after switching something | ||||
/cache help · /handoff help · /keepwarm help · /disk help | Answer only with "All commands: /bars help". /handoff help does not start a handoff |
The German arguments of earlier versions still work: warnung an|aus, gross/groß, hinweise an|aus (and the older guard, alerts), /handoff weiter|zeigen, /keepwarm aus. An unknown argument names the possible ones and points to /bars help.
Question before a cold send: If the cache is cold and the chat is big (≥ 150k), the next typed message asks Send anyway · Compact first · Cancel, with the cost in the question text. Compacting also reads everything once and saves little with a cold cache; the mod then holds the message back, compacts, and sends it afterwards on its own (this option is not offered with attachments or @file in the text, because they would be missing when resent). If you close the dialog, the message goes out unchanged (fail-open). Slash commands, messages during a turn, and messages from plugins never trigger the question.
Notice shortly before expiry: When a big chat enters the yellow phase, a notice appears once: keep going right away → just write; stopping → run /handoff now while it is cheap; coming back to this chat later → /keepwarm. The handoff pays off before expiry: with a cold cache, writing the handoff itself already rereads everything.
Keep-warm and cost: A ping via $.model.fork reads the cache about 8 min before it expires (with a 5-minute cache, 90 s before), i.e. roughly every 52 min. At 400k context that is ≈ $0.08 per ping, over 3 h ≈ $0.30 instead of $3.20 for a rewrite. Useful, for example, when a background render runs longer than 60 min and you continue in the same chat afterwards. If a ping rewrites instead of reading, or gets no reply, keep-warm turns itself off and tells you. The /cache settings apply to all chats. After /clear or a restart keep-warm is off.
The cache guard is modeled on Cache Keeper by Nate Herk (MIT); the skill uebergabe is based on its session-handoff skill. It has an English and a German template; /handoff passes the language as the skill's argument. See THIRD-PARTY-NOTICES.md in this folder.
/config → limit-bars:
| Key | Title in /config | Meaning | Default |
|---|---|---|---|
language | Language / Sprache | en: English · de: German. Covers the bars, the ring, alt texts, /cache, /handoff, /keepwarm, questions, notices, and the language of the handoff the skill writes | en |
resetStyle | Reset display | mixed: 5h as a countdown, week as a clock time · clock: both as clock times · countdown: both as countdowns | mixed |
highlightAt | Highlight from (%) | from this percentage the tag and value are bold (50 to 100) | 90 |
showFiveHour | Show 5-hour bar | the bar for the 5-hour limit | on |
showWeekly | Show weekly bar | the bar for the weekly limit | on |
showCache | Show cache ring | the cache ring (the cache guard stays) | on |
showStorage | Show storage ring | the storage ring; only shown with storagePath | on |
onlyFiveHour | 5-hour limit only (deprecated) | on: weekly bar hidden; use showWeekly instead | off |
storagePath | Storage drive | drive for the storage ring, for example E:\ (Windows only); empty: no storage ring and no PowerShell | empty |
language is en by default. Set it to de in /config → limit-bars for German texts (71 %, Mo 09:00, ≈ 3,30 $, /cache card in German).71%, Mon 09:00, in 2 d 4 h, ≈ $3.30, 1.2M; de 71 %, Mo 09:00, in 2 T 4 h, ≈ 3,30 $, 1,2M./config are English only.claude plugin validate shows:
hooks: session.start, turn.step, turn.complete, session.compact, prompt.submit, command.run{command=cache}, command.run{command=handoff}, command.run{command=keepwarm}, command.run{command=bars}, command.run{command=disk}, ui.render{component=CommandOutput, props has {command=bars}}, ui.render{component=CommandOutput, props has {command=disk}}, session.measure, ui.render{component=AbovePrompt}
calls: $.clock.every, $.clock.now, $.command.list, $.command.register, $.command.run, $.model.fork, $.process.run, $.prompt.submit, $.session.compact, $.session.id, $.session.usage, $.store.delete, $.store.get, $.store.keys, $.store.set, $.ui.ask, $.ui.invalidate, $.ui.resolve, $.ui.toast
No file system access of its own, no network, no environment variables read, no tokens or credentials. The only process is Windows PowerShell for the storage ring, and only when storagePath is set. In the terminal band, limit-bars passes on the space it uses to the mods further in (smaller bodyColumns).
In plain language:
$.session.usage: limits, context size and session cost at start and on every tick (without breakdown, free)session.measure: takes the percentage and reset time of five_hour and seven_day after every measurementturn.step: reads, for every request of the main loop, how much was read from the cache and how much was written; changes nothingturn.complete: notes the end of a turn and captures the reply of the handoff turnsession.compact: notes a compaction (the first request afterwards does not count as a cold restart)prompt.submit: the question before a cold send (only for a big chat with a cold cache), otherwise passes through unchangedcommand.run for cache, handoff, keepwarm, disk, bars and $.command.register: the five commands$.process.run: only with storagePath set, powershell.exe with the two fixed, read-only scripts of the storage ring (size and free space; sizes by file extension)ui.render for CommandOutput of disk: draws the ring with the legend in place of the Markdown row of /diskui.render for CommandOutput of bars: draws the help table in place of the Markdown row of /bars help (new in 0.7.0); other /bars rows stay Markdown$.command.list, $.command.run: find and start the skill uebergabe (with the language as its argument); /clear for "clear and continue"$.prompt.submit: the handoff as the first message in the cleared chat, or the held-back message after compacting$.session.compact: Compact first in the question$.model.fork: only with /keepwarm on, the keep-warm ping$.session.id, $.store.*: per-chat state, settings, /bars values, last 3 handoffs; cleanup after 7 days$.ui.ask, $.ui.toast: questions and notices$.ui.invalidate, $.ui.resolve: redraw; components Box, Text, Svg$.clock.now, $.clock.every: time of day; 10 s tick while the band is drawn; 30 s tick only while keeping warm; one-shot timers to start commands outside a hookAdd the marketplace once, then install the mod:
claude plugin marketplace add FynnXland/fynn-mods
claude plugin install limit-bars@fynn-mods
Inside a session the same works with /plugin marketplace add FynnXland/fynn-mods and /plugin install limit-bars@fynn-mods. The mod loads in the next session, or after /reload-plugins.
Check: /plugin shows … mod active · limit-bars.
Update: claude plugin update limit-bars@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 limit-bars@fynn-mods.
Try it for one session without installing (from a clone of the repo):
claude --plugin-dir <path-to-clone>/mods/limit-bars
Both mods draw into the same band above the prompt. Claude Code does not define the order in which they run; either way, the bars end up left of Clawd, aligned to the bottom. In the terminal, Clawd gets the width that is left after the bars.
side: left): the bars and Clawd can both end up on the left. There is no separate option for this.resetStyle: countdown): the long forms need up to 46 columns. The terminal then shows the short forms (2h14, 2d23h), the desktop app always the long forms.$.command.run, $.prompt.submit and $.session.compact from inside a running hook. /handoff, "clear and continue" and Compact first therefore run a moment later via a one-shot timer.#9A9A9A and the level colors are chosen for the dark theme; legibility in the light theme has not been checked yet.full applies from ≥ 100; 99.6 % shows 99%.bodyColumns read-only but allow rewriting props, and current Claude Code versions accept it. If a later version rejects it, a single render fails and limit-bars then stacks its 2 lines above the rest (the band gets 2 lines taller).storagePath is a directory picker. If you pick a folder instead of the drive root, the ring still shows the whole drive; only that folder is scanned, and the rest of the used space counts as "other".C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe. If Windows is installed elsewhere, the storage ring stays empty and /disk shows the error.$.process: Claude Code's type definitions mark $.process as "CLI only". It ran in the CLI; if a surface refuses it, the storage ring is simply missing and /disk reports the error./disk refresh still works).new Date). Which time zone that is, is not documented; check that Mon 09:00 matches your own clock.hooks/register.ts 1180 lines1// limit-bars: Hooks-Modul. Im Band über dem Prompt (AbovePrompt, docs/raw/en/interface.md:207-213) zwei schmale Balken für das
2// 5-Stunden- und das Wochenlimit und rechts daneben ein Ring, wie lange der Prompt-Cache dieser Session noch warm ist.
3// Limits: `$.session.usage()` beim Start und bei jedem Tick, dazu `session.measure` (types@2.1.288:10410-10436).
4// Cache: `turn.step` der Hauptschleife (usage mit cache_read/cache_creation, types@2.1.289:12837-12869), gespeichert in `$.store`.
5// Dazu die Cache-Wache nach Nate Herks Cache Keeper (docs/vorlagen/nateherk-cache-keeper, MIT): Rückfrage vor kaltem Senden,
6// Hinweis kurz vor Ablauf, /cache, /handoff mit Skill „uebergabe“, /keepwarm (Standard aus).
7// Texte en/de nach `userConfig.language` (i18n.ts); Argumente englisch, die deutschen gelten weiter als Alias.
8// Fail-open: Ein Fehler heißt nur „keine Anzeige“ bzw. „Nachricht geht unverändert durch“; was andere Mods zeichnen, bleibt erhalten.
9import type { EngineInterface, On, RenderNode, Timer } from 'claude-code'
10import { joinBand, splitBand } from './band.ts'
11import {
12 DEFAULT_SETTINGS,
13 MIN,
14 applySetting,
15 cacheReport,
16 cacheState,
17 cleanMem,
18 cleanSettings,
19 emptyMem,
20 inputCost,
21 observeStep,
22 pingDue,
23 pingMissed,
24 readCost,
25 rewriteCost,
26 ringFor,
27 spanText,
28 terminalBlock,
29 tokensText,
30 ttlOf,
31} from './cache.ts'
32import type { CacheMem, KeepWarm, ReportLimit, Settings, StepUsage } from './cache.ts'
33import { BARS_HINT, CACHE_ARGS, CACHE_HINT, HANDOFF_HINT, T, hhmm, langOf, usd } from './i18n.ts'
34import {
35 DISK_WORDS,
36 HELP_WORDS,
37 HANDOFF_CONTINUE_WORDS,
38 HANDOFF_SHOW_WORDS,
39 KEEP_OFF_WORDS,
40 PARTS,
41 cleanOverrides,
42 displayFromOptions,
43 effectiveDisplay,
44 isHelp,
45 parseBars,
46} from './display.ts'
47import type { Display, Overrides } from './display.ts'
48import { helpMarkdown, helpTree } from './help.ts'
49import type { HelpData } from './help.ts'
50import { HELP_ACCENT, barsHelpData } from './helpdata.ts'
51import type { Lang } from './i18n.ts'
52import { ringFilled } from './ring.ts'
53import {
54 PROBE_SCRIPT,
55 PS_ABS,
56 SCAN_SCRIPT,
57 diskView,
58 driveOf,
59 parseProbe,
60 parseScan,
61 storageMarkdown,
62 storageReport,
63 storageSvg,
64 storageTerminal,
65} from './storage.ts'
66import type { Drive, Scan, StorageReport } from './storage.ts'
67import { desktopSvg } from './svg.ts'
68import { EMPTY, ORANGE, filled, layoutTerminal, pickWindows, placeholderWindows, shownWindows } from './view.ts'
69import type { Opts, ResetStyle, Shown, Win } from './view.ts'
70
71// Optionen aus userConfig, in register() gesetzt
72let lang: Lang = 'en'
73let opts: Opts = { resetStyle: 'mixed', highlightAt: 90, onlyFiveHour: false, lang }
74// Welche Teile zu sehen sind (display.ts): userConfig, darüber die `/bars`-Werte aus `$.store` (`display`), die jeder Takt
75// neu liest, damit ein `/bars` in einem anderen Chat hier binnen 10 s wirkt
76let baseDisplay: Display = displayFromOptions({})
77let overrides: Overrides = {}
78const shownParts = () => effectiveDisplay(baseDisplay, overrides)
79let displayPinned = false // /bars konnte nicht speichern: der lokale Wert gilt, der Takt liest ihn nicht neu
80
81// Limits: flüchtig (Kontingent gehört zum Konto und kommt mit jeder Messung neu)
82let wins: Win[] = []
83let usageAsked = false
84// 10-s-Takt, nur solange das Band gezeichnet wird (Timer mit cancel(), types@2.1.288:11912-11917; eine abgelehnte Periode beendet ihn,
85// types@2.1.288:3251). Jeder Tick holt `$.session.usage()`: Das sind die Werte der letzten API-Antwort (types@2.1.288:11033-11034), also
86// auch mitten in einem langen Turn, während `session.measure` erst am Turn-Ende kommt (Fynn, 2026-10-05: "aktualisiert viel zu selten").
87// Der Takt läuft auch ohne Daten. Neu gezeichnet wird nur, wenn sich etwas Sichtbares ändert (Fynn, 2026-10-05: Jedes
88// Invalidate zeichnet das ganze Band neu, auch die Knöpfe anderer Mods, die dann beim Darüberfahren flackerten).
89const TICK = 10000
90let timer: Timer | null = null
91let drawnKey = '' // was zuletzt gezeichnet wurde (Texte, Farben, Füllstufen)
92let awaitingRender = false // ein Neuzeichnen ist angefordert, aber noch nicht gekommen
93let timerSince = 0 // Uhrzeit beim Start des Takts
94let ticks = 0 // Ticks seit dem Start
95// Lehnt eine Claude-Code-Version das Umschreiben von bodyColumns ab, stapelt limit-bars danach über dem Rest (SPEC.md, Phase 0)
96let noRewrite = false
97
98// Cache dieser Session. `mem` liegt in `$.store` unter `cache:<sessionId>`, damit der Ring nach Neustart oder Reload weiß, wie warm er ist.
99let sessionId = ''
100let mem: CacheMem = emptyMem()
101let settings: Settings = { ...DEFAULT_SETTINGS }
102let justCompacted = false
103let working = false // ein Turn der Hauptschleife läuft (turn.step … turn.complete)
104let currentTurn = ''
105let cold = { count: 0, usd: 0 } // kalte Neustarts dieser Sitzung
106let last: { read: number; written: number; at: number } | null = null
107let sessionUsd: number | null = null
108let alerted = 0 // lastActivity, für die der Hinweis „läuft bald ab“ schon kam
109// Warmhalten: Standard aus, eigener 30-s-Takt nur solange an (es muss auch laufen, wenn das Band nicht gezeichnet wird)
110let keep: KeepWarm | null = null
111let keepTimer: Timer | null = null
112let pinging = false
113let forking = false // ein Warmhalte-Ping ist unterwegs
114// Übergabe: /handoff startet den Skill, turn.complete fängt dessen Antwort ab
115const H = { pending: false, armed: false, notTurn: '', text: '', continuing: false }
116
117// Speicher-Ring (SPEC.md, Ausbau v0.4.0): Laufwerk aus `userConfig.storagePath` (leer = aus, nur Windows). Gesamt/frei über
118// `probe`, Bytes je Dateiart über `scan` (beides `powershell.exe` per `$.process.run`, Pfad nur über LB_PATH). Der Stand liegt
119// in `$.store` unter `storage`, damit der Ring nach einem Neustart sofort da ist und der Scan nur einmal am Tag läuft.
120let storagePath = ''
121let disk: { d?: Drive; scan?: Scan } = {}
122let diskLoaded = false
123let diskStarted = false // startDisk angestoßen (von session.start oder, falls das die Oberfläche nicht nannte, vom ersten Zeichnen)
124let scanRun: Promise<void> | null = null
125const PROBE_EVERY = 60 // Ticks: alle 10 min
126const SCAN_MAX_AGE = 60 * 60000 // /disk scannt neu, wenn der letzte Scan älter ist
127const SCAN_LOCK = 5 * 60000 // ein Scan, der in einer anderen Sitzung gerade läuft, gilt so lange als laufend
128const SCAN_PAUSE = 24 * 60 * 60000 // nach einem gescheiterten Scan keine automatischen Scans (/disk refresh geht immer)
129// Daten der /disk-Ansichten für das Zeichnen der Befehlszeile (Kennung im Text, wie cost-ledger /ledger)
130const reports = new Map<string, StorageReport>()
131let reportNo = 0
132// /bars help: Schnappschuss je Aufruf, höchstens 10 (docs/HELP-SPEC.md §3, templates/help/README.md)
133const helps = new Map<string, HelpData>()
134let helpNo = 0
135// userConfig, wie register() sie bekommt: für die Spalte EINSTELLUNGEN der Hilfe
136let rawOptions: Readonly<Record<string, unknown>> = {}
137
138/** Schnappschuss unter einer neuen Kennung `#…` ablegen; die älteste fällt heraus. */
139function rememberHelp(now: number, data: HelpData): string {
140 const tag = `#${(++helpNo).toString(36)}${now.toString(36).slice(-5)}`
141 helps.set(tag, data)
142 while (helps.size > 10) helps.delete(helps.keys().next().value as string)
143 return tag
144}
145
146const STYLES: readonly ResetStyle[] = ['mixed', 'clock', 'countdown']
147const KEEP_MAX_H = 4
148const SKILL = /(^|:)uebergabe$/
149// Überschrift der Übergabe in beiden Sprachen (skills/uebergabe/SKILL.md); wie bisher auch als `##`/`###`
150const HANDOFF_TITLE = /^#{1,3} (Handoff|Übergabe)\b/m
151
152const msg = (err: unknown) => String((err as Error)?.message ?? err).slice(0, 140)
153
154/**
155 * Einmal `fn` nach `ms`, außerhalb des aufrufenden Hooks. Aus einem command.run-Hook lehnt der Host `$.command.run` und
156 * `$.prompt.submit` ab (SPEC, Bau v0.2.0); ein Timer über das schon genehmigte `$.clock.every` kommt heraus (statt `$.clock.after`).
157 */
158function later($: EngineInterface, ms: number, fn: () => void) {
159 const t = $.clock.every(ms, () => {
160 t.cancel()
161 fn()
162 })
163}
164
165/** Limits einmal abholen (ohne `breakdown` kostenlos, types@2.1.288:11048-11056); abgelehnt: die nächste Messung füllt sie. */
166async function loadWindows($: EngineInterface): Promise<void> {
167 usageAsked = true
168 try {
169 wins = pickWindows((await $.session.usage()).rateLimits)
170 } catch {
171 // kein Anfangsstand
172 }
173}
174
175/** Gehört der Stand noch zu diesem Chat? Nach /clear oder /resume gibt es eine neue Session-ID: dann deren Stand laden. */
176async function bindSession($: EngineInterface): Promise<void> {
177 let id = ''
178 try {
179 id = await $.session.id()
180 } catch {
181 return
182 }
183 if (!id || id === sessionId) return
184 const switched = sessionId !== ''
185 sessionId = id
186 mem = emptyMem()
187 last = null
188 justCompacted = false
189 alerted = 0
190 if (switched) {
191 cold = { count: 0, usd: 0 }
192 stopKeep($, T[lang].whyNewChat)
193 }
194 try {
195 const saved = cleanMem(await $.store.get(`cache:${id}`))
196 if (saved) mem = { lastActivity: saved.lastActivity, ttl: saved.ttl, ttlSource: saved.ttlSource, ctx: saved.ctx, model: saved.model }
197 } catch {
198 // ohne gespeicherten Stand: unbekannt bis zur ersten Anfrage
199 }
200}
201
202function persist($: EngineInterface, now: number) {
203 if (!sessionId) return
204 $.store.set(`cache:${sessionId}`, { ...mem, savedAt: now }).catch(() => {})
205}
206
207/** Einträge älter als 7 Tage aufräumen (SPEC, Persistenz). */
208async function cleanup($: EngineInterface, now: number) {
209 const keys = (await $.store.keys()).filter((k) => k.startsWith('cache:')).slice(0, 200)
210 for (const k of keys) {
211 const m = cleanMem(await $.store.get(k))
212 if (!m || now - m.savedAt > 7 * 24 * 60 * MIN) await $.store.delete(k)
213 }
214}
215
216function stopKeep($: EngineInterface, why: string) {
217 if (!keep) return
218 const k = keep
219 keep = null
220 keepTimer?.cancel()
221 keepTimer = null
222 $.ui.toast(T[lang].keepOff(why, String(k.pings), usd(k.usd, lang)), { timeoutMs: 10000 })
223 $.ui.invalidate('ui.render')
224}
225
226/** Ein Takt des Warmhaltens: Ping per `$.model.fork` kurz vor Ablauf; schreibt er statt zu lesen, schaltet es sich ab. */
227function keepStep($: EngineInterface) {
228 if (!keep || pinging) return
229 const t = T[lang]
230 pinging = true
231 $.clock
232 .now()
233 .then(async (now) => {
234 if (!keep) return
235 if (now >= keep.until) return stopKeep($, t.whyTimeUp)
236 const ttl = ttlOf(mem, settings)
237 if (working || !pingDue(mem.lastActivity, ttl, now)) return
238 if (now - mem.lastActivity >= ttl * MIN) return stopKeep($, t.whyWasCold)
239 forking = true
240 // Nur die Antwort „ok“ zählt nicht, nur das Lesen des Caches; der Prompt bleibt deshalb einsprachig
241 const r = await $.model.fork({ prompt: 'limit-bars keep-warm ping. Reply only with: ok' }).finally(() => {
242 forking = false
243 })
244 if (!keep) return
245 const u = (r as { usage?: StepUsage }).usage
246 if (u) {
247 keep.pings += 1
248 keep.usd += inputCost(u, mem.model, ttl)
249 }
250 // `usage` kommt auch bei einem gescheiterten Fork (api-error, aborted), dann meist mit Nullen (types@2.1.289:5976-5977):
251 // nur eine Antwort, die wirklich aus dem Cache las, zählt als Warmhalten (Review S1)
252 if (!u || (!r.isAnswered && r.reason !== 'empty-reply') || !(u.cache_read_input_tokens > 0)) {
253 return stopKeep($, r.isAnswered || r.reason === 'empty-reply' ? t.whyNoRead : t.whyNoReply(String(r.reason)))
254 }
255 if (pingMissed(u)) return stopKeep($, t.whyRewrote(tokensText(u.cache_creation_input_tokens, lang)))
256 mem = { ...mem, lastActivity: now }
257 persist($, now)
258 $.ui.invalidate('ui.render')
259 })
260 .catch((err) => stopKeep($, t.whyFailed(msg(err))))
261 .finally(() => {
262 pinging = false
263 })
264}
265
266/** Hinweis, sobald ein großer Chat in die gelbe Phase geht; einmal je Phase (SPEC, Vorwarnung). */
267function alertStep($: EngineInterface, now: number) {
268 if (!settings.alerts || keep || mem.ctx < settings.bigTokens || alerted === mem.lastActivity) return
269 const ttl = ttlOf(mem, settings)
270 const st = cacheState(mem.lastActivity, ttl, now, false)
271 if (st.kind !== 'cooling') return
272 alerted = mem.lastActivity
273 $.ui.toast(T[lang].alert(spanText(st.left, lang), tokensText(mem.ctx, lang), usd(rewriteCost(mem.ctx, mem.model, ttl), lang)), { timeoutMs: 20000 })
274}
275
276/** Die Übergabe ohne Vorrede vor der Überschrift (`# Handoff` bzw. `# Übergabe`). */
277function handoffBody(answer: string): string {
278 const t = String(answer || '').trim()
279 const i = t.search(HANDOFF_TITLE)
280 return i > 0 ? t.slice(i) : t
281}
282
283async function loadHandoffs($: EngineInterface): Promise<{ at: number; session: string; text: string }[]> {
284 try {
285 const v = await $.store.get('handoffs')
286 return Array.isArray(v) ? v.filter((h) => h && typeof h.text === 'string' && typeof h.at === 'number') : []
287 } catch {
288 return []
289 }
290}
291
292function captureHandoff($: EngineInterface, answer: string, now: number) {
293 const t = T[lang]
294 H.armed = false
295 const body = handoffBody(answer)
296 if (body.length < 200) {
297 $.ui.toast(t.handoffEmpty, { timeoutMs: 8000 })
298 return
299 }
300 H.text = body
301 // Die letzten 3 Übergaben in `$.store`; scheitert /clear, holt `/handoff show` sie zurück
302 loadHandoffs($)
303 .then((list) => $.store.set('handoffs', [{ at: now, session: sessionId, text: body }, ...list].slice(0, 3)))
304 .catch(() => {})
305 // Nicht im Hook fragen: der Turn endet gerade, ein Dialog hielte ihn offen
306 later($, 300, () => {
307 $.ui
308 // Die Frage endet mit „?“ (types@2.1.289:2340); Optionen tragen keine Beschreibung, darum steht die Erklärung im Fragetext
309 .ask(t.handoffQuestion, { options: [t.clearGo, t.keepChat], header: t.handoffHeader })
310 .then((a) => (a === t.clearGo ? clearAndContinue($) : undefined))
311 .catch(() => $.ui.toast(t.handoffSavedLater, { timeoutMs: 12000 }))
312 })
313}
314
315async function clearAndContinue($: EngineInterface) {
316 const t = T[lang]
317 if (H.continuing) return
318 if (!H.text) {
319 const list = await loadHandoffs($)
320 if (!list[0]) {
321 $.ui.toast(t.handoffNone, { timeoutMs: 8000 })
322 return
323 }
324 H.text = list[0].text
325 }
326 if (working) {
327 $.ui.toast(t.handoffBusy, { timeoutMs: 8000 })
328 return
329 }
330 H.continuing = true
331 const text = `${t.handoffPrefix}\n\n${H.text}`
332 try {
333 try {
334 await $.command.run({ command: 'clear' })
335 } catch (err) {
336 $.ui.toast(t.clearFailed(msg(err)), { timeoutMs: 15000 })
337 return
338 }
339 H.text = ''
340 try {
341 await $.prompt.submit({ text, asUser: true })
342 // /clear startet eine neue Session, die alte bleibt erhalten (docs/raw/related/interactive-mode.md:276)
343 $.ui.toast(t.handoffStarted, { timeoutMs: 8000 })
344 } catch {
345 $.ui.toast(t.handoffSendFailed, { timeoutMs: 15000 })
346 }
347 } finally {
348 H.continuing = false
349 }
350}
351
352/** Was Balken, Ringe und Terminal-Blöcke jetzt zeigen; gemeinsam für das Zeichnen und den Änderungsschlüssel. */
353function snapshot(now: number) {
354 const sh = shownParts()
355 // Woche aus: wie das alte onlyFiveHour; 5h aus: danach herausfiltern (Platzhalter ebenso)
356 const o: Opts = { ...opts, onlyFiveHour: !sh.weekly }
357 const only = (list: Shown[]) => list.filter((x) => (x.tag === '5h' ? sh.fiveHour : sh.weekly))
358 const shown = wins.length > 0 ? only(shownWindows(wins, now, o)) : []
359 const ttl = ttlOf(mem, settings)
360 const st = cacheState(mem.lastActivity, ttl, now, !!keep)
361 const big = mem.ctx >= settings.bigTokens
362 const drive = driveOf(storagePath)
363 const dv = sh.storage && drive && disk.d ? diskView(drive, disk.d, disk.scan, lang) : undefined
364 return {
365 shown,
366 // Desktop: ohne Limits Platzhalter (5h –, 7d –), damit die Balken sofort sichtbar sind; nur die eingeschalteten
367 bars: wins.length > 0 ? shown : only(placeholderWindows(o)),
368 ring: sh.cache ? ringFor(st, ttl, mem.ctx, big, lang) : undefined,
369 block: sh.cache ? terminalBlock(st, big, lang) : null,
370 disk: dv,
371 diskBlock: dv ? `${dv.label} ${dv.main}${dv.sub}` : undefined,
372 }
373}
374
375/** Alles, was im Band sichtbar ist, als Schlüssel: ändert er sich, muss neu gezeichnet werden, sonst nicht. */
376function keyOf(v: ReturnType<typeof snapshot>): string {
377 const r = v.ring
378 return JSON.stringify([
379 v.bars.map((x) => [x.tag, x.pct.long, x.reset?.long ?? '', x.color, x.strong, x.fresh, Math.round(x.ratio * 44)]),
380 r ? [r.main, r.sub ?? '', r.subColor ?? '', r.color, r.mainColor, ringFilled(r.fill), r.kept] : null,
381 v.block?.text ?? '',
382 v.disk ? [v.disk.main, v.disk.sub, v.disk.arcs.map((a) => [a.color, Math.round(a.to * 360)])] : null,
383 ])
384}
385
386// ---------- Speicher-Ring ----------
387
388type StoredDisk = { path: string; total: number; free: number; probedAt: number; scan?: Scan; scanStartedAt?: number; scanFailedAt?: number }
389
390function storedDisk(v: unknown): StoredDisk | undefined {
391 const s = v as StoredDisk | undefined
392 if (!s || typeof s !== 'object' || typeof s.path !== 'string' || !(s.total > 0) || !(s.free >= 0)) return undefined
393 return s
394}
395
396/**
397 * Stand aus dem Store übernehmen, wenn er zum eingestellten Pfad gehört. Mehrere Sitzungen schreiben denselben Eintrag:
398 * ein neuerer Scan (aus einer anderen Sitzung) ersetzt den eigenen, Gesamt/frei nur, solange noch keine eigene Probe da ist.
399 */
400async function readDisk($: EngineInterface): Promise<StoredDisk | undefined> {
401 diskLoaded = true
402 let s: StoredDisk | undefined
403 try {
404 s = storedDisk(await $.store.get('storage'))
405 } catch {
406 return undefined
407 }
408 if (!s || s.path !== storagePath) return undefined
409 if (!disk.d) disk = { ...disk, d: { total: s.total, free: s.free } }
410 if (s.scan && (!disk.scan || s.scan.at > disk.scan.at)) disk = { ...disk, scan: s.scan }
411 return s
412}
413
414type DiskPatch = { scanStartedAt?: number | null; scanFailedAt?: number | null }
415let saving: Promise<void> = Promise.resolve()
416
417/**
418 * Schreiben nach erneutem Lesen: Sperre und Fehlerzeit anderer Sitzungen bleiben, außer `patch` setzt (Zahl) oder löscht
419 * (null) sie. Innerhalb der Sitzung nacheinander, damit eine Probe nicht die gerade gelöste Sperre zurückschreibt.
420 */
421function saveDisk($: EngineInterface, now: number, patch: DiskPatch = {}): Promise<void> {
422 saving = saving.then(() => writeDisk($, now, patch))
423 return saving
424}
425
426async function writeDisk($: EngineInterface, now: number, patch: DiskPatch): Promise<void> {
427 const prev = await readDisk($)
428 if (!disk.d) return
429 const pick = (v: number | null | undefined, old: number | undefined) => (v === null ? undefined : (v ?? old))
430 const v: StoredDisk = {
431 path: storagePath,
432 total: disk.d.total,
433 free: disk.d.free,
434 probedAt: now,
435 scan: disk.scan,
436 scanStartedAt: pick(patch.scanStartedAt, prev?.scanStartedAt),
437 scanFailedAt: pick(patch.scanFailedAt, prev?.scanFailedAt),
438 }
439 try {
440 await $.store.set('storage', v)
441 } catch {
442 // ohne Gedächtnis: der nächste Start fragt neu ab
443 }
444}
445
446/**
447 * Eines der festen Skripte über den absoluten Pfad `PS_ABS` (storage.ts); der Pfad nur über die Umgebung (SPEC: Datenquelle
448 * und Sicherheit). Kein zweiter Versuch mit anderem Programm: eine Ablehnung durch einen Hook bleibt eine Ablehnung
449 * (Review 0.4.0, S5). Fehler kommen übersetzt.
450 */
451async function runPs($: EngineInterface, script: string, timeoutMs: number): Promise<string> {
452 const t = T[lang]
453 const r = await $.process.run([PS_ABS, '-NoProfile', '-NonInteractive', '-Command', script], { env: { LB_PATH: storagePath }, timeoutMs })
454 if (r.exitCode === 2) throw new Error(t.errMissing)
455 if (r.exitCode === 3) throw new Error(t.errUnreadable)
456 if (r.exitCode !== 0) throw new Error(`exit ${r.exitCode}${r.stderr.trim() ? `: ${r.stderr.trim().slice(0, 120)}` : ''}`)
457 if (r.isStdoutTruncated) throw new Error(t.errTruncated)
458 return r.stdout
459}
460
461/** Gesamt und frei holen (0,2 s); scheitert es, bleibt der letzte Stand. */
462async function probeDisk($: EngineInterface): Promise<void> {
463 // Auch wenn session.start die Oberfläche nicht nannte: erst den Store lesen, sonst ginge der gespeicherte Scan verloren
464 if (!diskLoaded) await readDisk($)
465 const d = parseProbe(await runPs($, PROBE_SCRIPT, 30000))
466 if (!d) throw new Error(T[lang].errNoData)
467 disk = { ...disk, d }
468 const now = await $.clock.now()
469 await saveDisk($, now)
470 refresh($, now)
471}
472
473/** Bytes je Dateiart (etwa 10 s Festplattenlast). Läuft nie doppelt: ein zweiter Aufruf wartet auf denselben Scan. */
474function scanDisk($: EngineInterface): Promise<void> {
475 if (scanRun) return scanRun
476 scanRun = (async () => {
477 const started = await $.clock.now()
478 await saveDisk($, started, { scanStartedAt: started })
479 let failed = false
480 try {
481 const s = parseScan(await runPs($, SCAN_SCRIPT, 120000), await $.clock.now())
482 if (!s) throw new Error(T[lang].errNoData)
483 disk = { ...disk, scan: s }
484 } catch (err) {
485 failed = true
486 throw err
487 } finally {
488 // Sperre in jedem Fall lösen; ein gescheiterter Scan lässt den alten Stand stehen und pausiert die automatischen Scans
489 const now = await $.clock.now()
490 await saveDisk($, now, { scanStartedAt: null, scanFailedAt: failed ? now : null })
491 refresh($, now)
492 }
493 })().finally(() => {
494 scanRun = null
495 })
496 return scanRun
497}
498
499const dayOf = (ms: number) => new Date(ms).toDateString()
500
501/** Darf ein automatischer Scan laufen? Nicht, wenn eine andere Sitzung gerade scannt oder ein Scan in den letzten 24 h scheiterte. */
502function scanAllowed(s: StoredDisk | undefined, now: number): boolean {
503 if (s?.scanStartedAt !== undefined && now - s.scanStartedAt < SCAN_LOCK) return false
504 return !(s?.scanFailedAt !== undefined && now - s.scanFailedAt < SCAN_PAUSE)
505}
506
507/** Nach dem Sitzungsstart oder dem ersten Zeichnen: Stand frisch holen, einmal am Tag nach Dateiart scannen. */
508async function startDisk($: EngineInterface): Promise<void> {
509 diskStarted = true
510 // Inzwischen per /bars ausgeblendet: kein PowerShell; ein späteres Einschalten startet neu (Review 0.6.0, S1)
511 if (!shownParts().storage) {
512 diskStarted = false
513 return
514 }
515 await readDisk($)
516 refresh($, await $.clock.now())
517 await probeDisk($)
518 const now = await $.clock.now()
519 const s = await readDisk($)
520 if (shownParts().storage && scanAllowed(s, now) && (!disk.scan || dayOf(disk.scan.at) !== dayOf(now))) await scanDisk($)
521}
522
523/** `/bars`-Werte aus dem Store; abgelehnt: es bleibt beim bisherigen Stand. */
524async function readDisplay($: EngineInterface): Promise<void> {
525 // Ließ sich ein /bars-Wert nicht speichern, gilt er in diesem Chat bis zum nächsten erfolgreichen Schreiben (Review 0.6.0, S2)
526 if (displayPinned) return
527 try {
528 overrides = cleanOverrides(await $.store.get('display'))
529 } catch {
530 // bisheriger Stand
531 }
532}
533
534/** Speicher-Ring starten, wenn er gezeigt werden soll und noch nicht läuft (Sitzungsstart, erstes Zeichnen, Takt). */
535function maybeStartDisk($: EngineInterface): void {
536 if (diskStarted || !driveOf(storagePath) || !shownParts().storage) return
537 diskStarted = true
538 later($, 2000, () => void startDisk($).catch(() => {}))
539}
540
541/** Neu zeichnen nur bei sichtbarer Änderung (Minute, Prozent, Kontext, Zustand). */
542function refresh($: EngineInterface, now: number) {
543 if (keyOf(snapshot(now)) === drawnKey) return
544 awaitingRender = true
545 $.ui.invalidate('ui.render')
546}
547
548export function register(on: On, options: Readonly<Record<string, string | number | boolean | readonly string[]>>) {
549 const hl = typeof options.highlightAt === 'number' && Number.isFinite(options.highlightAt) ? options.highlightAt : 90
550 lang = langOf(options.language)
551 opts = {
552 resetStyle: STYLES.find((s) => s === options.resetStyle) ?? 'mixed',
553 highlightAt: Math.max(50, Math.min(100, hl)),
554 onlyFiveHour: options.onlyFiveHour === true,
555 lang,
556 }
557 storagePath = typeof options.storagePath === 'string' ? options.storagePath.trim() : ''
558 baseDisplay = displayFromOptions(options)
559 rawOptions = options
560
561 on('session.start', async ($, e, next) => {
562 // Nur wo das Band gezeichnet wird (types@2.1.288:9576); `-p`/SDK liefern null, VS Code und mobil zeichnen kein Band
563 if (e.surface === 'terminal' || e.surface === 'desktop') {
564 await readDisplay($)
565 await loadWindows($)
566 if (wins.length > 0) $.ui.invalidate('ui.render')
567 // Speicher-Ring: außerhalb des Hooks, der Sitzungsstart wartet nicht auf PowerShell; ausgeblendet kein PowerShell
568 maybeStartDisk($)
569 }
570 try {
571 settings = cleanSettings(await $.store.get('settings'))
572 } catch {
573 // Standard
574 }
575 await bindSession($)
576 $.clock
577 .now()
578 .then((now) => cleanup($, now))
579 .catch(() => {})
580 // Befehle zuletzt, jeder für sich (CLAUDE.md, Registrieren in session.start)
581 const t = T[lang]
582 for (const c of [
583 { name: 'cache', description: t.cmdCache, argumentHint: CACHE_HINT },
584 { name: 'handoff', description: t.cmdHandoff, argumentHint: HANDOFF_HINT },
585 { name: 'keepwarm', description: t.cmdKeepwarm, argumentHint: t.hintKeepwarm },
586 { name: 'disk', description: t.cmdStorage, argumentHint: '[refresh]' },
587 { name: 'bars', description: t.cmdBars, argumentHint: BARS_HINT },
588 ]) {
589 try {
590 await $.command.register(c)
591 } catch {
592 // ohne diesen Befehl
593 }
594 }
595 return next(e)
596 })
597
598 // Jede Anfrage der Hauptschleife: Cache gelesen oder neu geschrieben? Nur beobachten (Vorlage register.mjs:537-568)
599 on('turn.step', async function* ($, e, next) {
600 // Subagenten nicht; ebenso nicht der eigene Warmhalte-Ping, falls `$.model.fork` als Schritt erscheint (nicht belegt, Review
601 // [UNKLAR]): sonst bliebe `working` ohne turn.complete stehen und das Warmhalten pingte nie wieder
602 if (e.agentId || forking) return yield* next(e)
603 let startedAt = 0
604 try {
605 startedAt = await $.clock.now()
606 } catch {
607 // ohne Uhr keine Auswertung
608 }
609 working = true
610 currentTurn = e.turnId
611 const r = yield* next(e)
612 try {
613 const u = r.usage
614 if (u && startedAt) {
615 await bindSession($)
616 const res = observeStep(mem, u, startedAt, justCompacted)
617 justCompacted = false
618 if (res.coldWritten) {
619 cold.count += 1
620 // Preisstufe nach dem ganzen Prompt der Anfrage (Kontext), nicht nur nach dem neu geschriebenen Teil
621 cold.usd += rewriteCost(res.coldWritten, res.mem.model, ttlOf(res.mem, settings), res.mem.ctx)
622 }
623 mem = res.mem
624 last = { read: u.cache_read_input_tokens, written: u.cache_creation_input_tokens, at: startedAt }
625 persist($, startedAt)
626 refresh($, await $.clock.now())
627 }
628 } catch {
629 // Beiwerk: die Anfrage selbst bleibt unberührt
630 }
631 return r
632 })
633
634 on('turn.complete', async ($, e, next) => {
635 const r = await next(e)
636 if (e.agentId) return r
637 working = false
638 try {
639 if (H.armed && e.turnId !== H.notTurn) captureHandoff($, e.reason === 'answer' ? e.answer : '', await $.clock.now())
640 } catch {
641 H.armed = false
642 }
643 return r
644 })
645
646 // Die erste Anfrage nach einer Kompaktierung schreibt den kürzeren Kontext neu: kein kalter Neustart (Vorlage, justCompacted)
647 on('session.compact', async ($, e, next) => {
648 const r = await next(e)
649 if (!e.agentId && e.trigger !== 'precompute' && r && !('skip' in r && r.skip)) justCompacted = true
650 return r
651 })
652
653 // Rückfrage vor kaltem Senden. Fail-open: lieber eine Nachricht durchlassen als eine verschlucken.
654 on('prompt.submit', async ($, e, next) => {
655 const t = T[lang]
656 let question = ''
657 // Einstellungen frisch lesen: `/cache warn off` in einer anderen Session soll sofort auch hier gelten (0.2.1).
658 // Fail-open: geht das Lesen schief, bleibt der Stand dieser Session.
659 try {
660 settings = cleanSettings(await $.store.get('settings'))
661 } catch {
662 // Stand dieser Session
663 }
664 try {
665 const fromUser = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
666 if (settings.guard && fromUser && !e.turnId && !e.text.trim().startsWith('/')) {
667 await bindSession($)
668 if (mem.ctx >= settings.bigTokens) {
669 const ttl = ttlOf(mem, settings)
670 const st = cacheState(mem.lastActivity, ttl, await $.clock.now(), false)
671 if (st.kind === 'cold') {
672 question = t.guardQuestion(
673 spanText(-st.left, lang),
674 tokensText(mem.ctx, lang),
675 usd(rewriteCost(mem.ctx, mem.model, ttl), lang),
676 usd(readCost(mem.ctx, mem.model), lang),
677 )
678 }
679 }
680 }
681 } catch {
682 return next(e)
683 }
684 if (!question) return next(e)
685 let answer = t.send
686 // Mit Anhängen oder `@datei` kein „Erst komprimieren“: als Plugin-Prompt neu gesendet, fehlten sie (types@2.1.289:8521-8523, Review S3)
687 const resendable = !e.attachments?.length && !/(^|\s)@\S/.test(e.text)
688 try {
689 answer = await $.ui.ask(question, { options: resendable ? [t.send, t.compact, t.abort] : [t.send, t.abort], header: 'Cache' })
690 } catch {
691 // niemand zu fragen (-p) oder Dialog geschlossen: so senden, wie getippt
692 return next(e)
693 }
694 if (answer === t.abort) {
695 try {
696 $.ui.toast(t.abortToast, { timeoutMs: 12000 })
697 } catch {
698 // nur der Hinweis fehlt
699 }
700 return { drop: t.dropAbort }
701 }
702 if (answer === t.compact && resendable) {
703 // Aus prompt.submit lehnt der Host `$.session.compact()` ab („would compact under the turn this hook is holding“, Test-Kit
704 // 2.1.289). Darum: Nachricht zurückhalten, außerhalb des Hooks komprimieren, dann den Text als eigene Nachricht senden.
705 const text = e.text
706 later($, 300, () => {
707 $.session
708 .compact()
709 .catch((err) => $.ui.toast(t.compactFailed(msg(err)), { timeoutMs: 10000 }))
710 .then(() => $.prompt.submit({ text, asUser: true }))
711 .catch((err) => $.ui.toast(t.resendFailed(msg(err), text.slice(0, 160)), { timeoutMs: 30000 }))
712 })
713 return { drop: t.dropCompact }
714 }
715 return next(e)
716 })
717
718 on('command.run', { command: 'cache' }, async ($, e) => {
719 const t = T[lang]
720 // Nebenbefehl: die Hilfe steht unter /bars help (HELP-SPEC §2)
721 if (isHelp(e.args)) return { text: t.seeHelp }
722 const [key, value] = e.args.trim().toLowerCase().split(/\s+/)
723 let note = ''
724 if (key) {
725 // `$.store` teilen sich alle Sessions: vor dem Schreiben neu lesen, sonst überschreibt eine die andere (Review S2)
726 try {
727 settings = cleanSettings(await $.store.get('settings'))
728 } catch {
729 // bleibt beim Stand dieser Session
730 }
731 const s = applySetting(settings, key, value ?? '')
732 if (!s) return { text: t.unknownArg(e.args.trim(), CACHE_ARGS) }
733 settings = s
734 try {
735 await $.store.set('settings', settings)
736 note = `${t.saved}\n`
737 } catch {
738 note = `${t.notSaved}\n`
739 }
740 $.ui.invalidate('ui.render')
741 }
742 const now = await $.clock.now()
743 await bindSession($)
744 let limits: ReportLimit[] = []
745 try {
746 limits = shownWindows(wins, now, opts).map((s) => ({ tag: s.tag, pct: s.pct.long, ratio: s.ratio, reset: s.reset?.long }))
747 } catch {
748 // ohne Limits
749 }
750 const list = await loadHandoffs($)
751 const text = cacheReport({ now, mem, settings, keep, cold, last, limits, sessionUsd, handoffAt: list[0]?.at ?? null, lang })
752 return { text: note + text }
753 })
754
755 on('command.run', { command: 'handoff' }, async ($, e) => {
756 const t = T[lang]
757 // Vor allem anderen: bis 0.6.x startete jedes Argument eine Übergabe, also auch `help` (HELP-SPEC §2)
758 if (isHelp(e.args)) return { text: t.seeHelp }
759 const arg = e.args.trim().toLowerCase()
760 if (HANDOFF_SHOW_WORDS.includes(arg)) {
761 const list = await loadHandoffs($)
762 return { text: list[0] ? t.handoffLast(hhmm(list[0].at), list[0].text) : t.handoffNoneSaved }
763 }
764 if (HANDOFF_CONTINUE_WORDS.includes(arg)) {
765 later($, 300, () => {
766 clearAndContinue($).catch(() => {})
767 })
768 return { text: t.handoffContinuing }
769 }
770 if (H.pending || H.armed) return { text: t.handoffRunning }
771 H.pending = true
772 // Außerhalb des Hooks: den Skill suchen und starten (die Engine stellt ihn hinter einen laufenden Turn). Die Sprache geht als
773 // Argument mit, der Skill schreibt die Übergabe darin (skills/uebergabe/SKILL.md, `$ARGUMENTS`).
774 later($, 300, () => {
775 $.command
776 .list()
777 .then((cmds) => {
778 const cmd = cmds.find((c) => c.name === 'limit-bars:uebergabe') ?? cmds.find((c) => SKILL.test(c.name))
779 if (!cmd) throw new Error(t.skillMissing)
780 H.armed = true
781 H.notTurn = working ? currentTurn : ''
782 return $.command.run({ command: cmd.name, args: lang })
783 })
784 .catch((err) => {
785 H.armed = false
786 $.ui.toast(t.handoffStartFailed(msg(err)), { timeoutMs: 10000 })
787 })
788 .finally(() => {
789 H.pending = false
790 })
791 })
792 return { text: t.handoffWriting }
793 })
794
795 on('command.run', { command: 'keepwarm' }, async ($, e) => {
796 const t = T[lang]
797 if (isHelp(e.args)) return { text: t.seeHelp }
798 const arg = e.args.trim().toLowerCase()
799 if (KEEP_OFF_WORDS.includes(arg) || (arg === '' && keep)) {
800 if (!keep) return { text: t.keepAlreadyOff }
801 stopKeep($, t.whyTurnedOff)
802 return { text: t.keepTurnedOff }
803 }
804 const h = arg ? Number(arg.replace(',', '.')) : 2
805 if (!(h > 0)) return { text: t.keepUsage(String(KEEP_MAX_H)) }
806 const now = await $.clock.now()
807 await bindSession($)
808 const ttl = ttlOf(mem, settings)
809 const st = cacheState(mem.lastActivity, ttl, now, false)
810 if (st.kind === 'unknown') return { text: t.keepNothing }
811 if (st.kind === 'cold') return { text: t.keepCold }
812 const hours = Math.min(KEEP_MAX_H, h)
813 keep = { until: now + hours * 60 * MIN, pings: 0, usd: 0 }
814 keepTimer?.cancel()
815 keepTimer = $.clock.every(30000, () => keepStep($))
816 $.ui.invalidate('ui.render')
817 return {
818 text: t.keepOn(
819 hhmm(keep.until),
820 h > KEEP_MAX_H ? t.keepCapped(String(KEEP_MAX_H)) : '',
821 ttl >= 60 ? '8 min' : '90 s',
822 usd(readCost(mem.ctx, mem.model), lang),
823 tokensText(mem.ctx, lang),
824 String(Math.ceil((hours * 60) / (ttl >= 60 ? 52 : 3.5))),
825 ),
826 }
827 })
828
829 // /bars: Teile der Anzeige ein- und ausblenden (SPEC.md, Ausbau v0.6.0). Gespeichert in `$.store` unter `display`, damit es
830 // in allen offenen Chats gilt (der Takt liest neu); vor dem Schreiben neu lesen, wie bei den /cache-Einstellungen.
831 on('command.run', { command: 'bars' }, async ($, e) => {
832 const t = T[lang]
833 const c = parseBars(e.args)
834 if (c.kind === 'bad') return { text: t.unknownArg(e.args.trim(), BARS_HINT) }
835 await readDisplay($)
836 if (c.kind === 'help') {
837 // Stand beim Aufruf (HELP-SPEC §3 Punkt 3): Einstellungen frisch aus dem Store, wie /cache
838 try {
839 settings = cleanSettings(await $.store.get('settings'))
840 } catch {
841 // Stand dieser Session
842 }
843 await bindSession($)
844 const data = barsHelpData({
845 lang,
846 options: rawOptions,
847 shown: shownParts(),
848 overrides,
849 storagePath,
850 hasDrive: !!driveOf(storagePath),
851 settings,
852 memTtl: mem.ttl,
853 memTtlSource: mem.ttlSource,
854 keep,
855 })
856 return { text: helpMarkdown(data, rememberHelp(await $.clock.now(), data)) }
857 }
858 let note = ''
859 if (c.kind === 'reset') {
860 overrides = {}
861 try {
862 await $.store.delete('display')
863 displayPinned = false
864 } catch (err) {
865 displayPinned = true
866 note = t.barsSaveFailed(msg(err))
867 }
868 } else if (c.kind === 'set') {
869 // Speicher-Ring ohne Laufwerk: nichts speichern, sondern sagen, wo der Pfad hingehört
870 if (c.part === 'storage' && c.on && !driveOf(storagePath)) return { text: storagePath ? t.storageWindowsOnly : t.storageOff }
871 overrides = { ...overrides, [c.part]: c.on }
872 try {
873 await $.store.set('display', overrides)
874 displayPinned = false
875 } catch (err) {
876 displayPinned = true
877 note = t.barsSaveFailed(msg(err))
878 }
879 }
880 try {
881 refresh($, await $.clock.now())
882 maybeStartDisk($)
883 } catch {
884 // Anzeige folgt mit dem nächsten Zeichnen
885 }
886 const sh = shownParts()
887 const rows = PARTS.map((p) => {
888 const state = !sh[p] ? t.barsOff : p !== 'storage' || driveOf(storagePath) ? t.barsOn : storagePath ? t.barsNotWindows : t.barsNoPath
889 return `| ${t.barsPart[p]} | ${state} | ${overrides[p] !== undefined ? t.barsByCommand : t.barsBySetting} |`
890 })
891 const lines: string[] = []
892 if (c.kind === 'set') lines.push(t.barsSet(t.barsPart[c.part], c.on ? t.barsOn : t.barsOff), '')
893 if (c.kind === 'reset') lines.push(t.barsReset, '')
894 lines.push(t.barsTitle, '', t.barsHead, '|---|---|---|', ...rows, '', t.barsUsage)
895 // Cache-Ring aus heißt nur: Anzeige weg (Fynn); Rückfrage und Hinweise bleiben
896 if (c.kind === 'set' && c.part === 'cache' && !c.on) lines.push(t.barsCacheNote)
897 if (note) lines.push('', note)
898 return { text: lines.join('\n') }
899 })
900
901 // /disk: Belegung nach Dateiart. Antwortet mit Markdown; auf Terminal und Desktop zeichnet der CommandOutput-Hook darunter
902 // stattdessen den großen Ring mit Legende (types@2.1.290:9695-9725, Vorbild cost-ledger /ledger).
903 on('command.run', { command: 'disk' }, async ($, e) => {
904 const t = T[lang]
905 if (isHelp(e.args)) return { text: t.seeHelp }
906 const arg = e.args.trim().toLowerCase()
907 if (arg && !DISK_WORDS.includes(arg)) return { text: t.unknownArg(e.args.trim(), DISK_WORDS.join('|')) }
908 if (!storagePath) return { text: t.storageOff }
909 const drive = driveOf(storagePath)
910 if (!drive) return { text: t.storageWindowsOnly }
911 try {
912 await probeDisk($)
913 } catch (err) {
914 if (!disk.d) return { text: t.storageFailed(drive, msg(err)) }
915 }
916 let note = ''
917 const now = await $.clock.now()
918 const s = await readDisk($)
919 const wanted = !disk.scan || now - disk.scan.at > SCAN_MAX_AGE
920 // Ein eigener laufender Scan (etwa vom Sitzungsstart): auf ihn warten statt ihn als fremde Sperre zu melden
921 if (arg === 'refresh' || (wanted && (scanRun || scanAllowed(s, now)))) {
922 try {
923 await scanDisk($)
924 } catch (err) {
925 note = t.storageScanFailed(msg(err))
926 }
927 } else if (wanted && s?.scanStartedAt !== undefined && now - s.scanStartedAt < SCAN_LOCK) {
928 // 0.4.1 (Fynn, 2026-10-06): sonst stand nur „noch nicht gescannt“ da, ohne Grund
929 note = t.storageScanRunning(hhmm(s.scanStartedAt))
930 } else if (s?.scanFailedAt !== undefined && now - s.scanFailedAt < SCAN_PAUSE) {
931 note = t.storageScanPaused(hhmm(s.scanFailedAt))
932 }
933 const d = disk.d
934 if (!d) return { text: t.storageFailed(drive, '–') }
935 const r = storageReport(drive, d, disk.scan, await $.clock.now(), lang)
936 const tag = `#${(++reportNo).toString(36)}${now.toString(36).slice(-5)}`
937 reports.set(tag, r)
938 while (reports.size > 10) reports.delete(reports.keys().next().value as string)
939 return { text: storageMarkdown(r, tag, lang, note) }
940 })
941
942 // Die Zeile von /bars help: gezeichnete Tabelle (help.ts). Nur mit bekannter Kennung; die übrigen /bars-Antworten, Fehlerzeilen
943 // und andere Oberflächen zeichnet die Engine als Text. Kennung ohne Anker suchen: der Text beginnt mit „limit-bars: “
944 // (templates/help/README.md, Befunde).
945 on('ui.render', { component: 'CommandOutput', props: { command: 'bars' } }, async ($, e, next) => {
946 if (e.props.isErrored || (e.surface !== 'terminal' && e.surface !== 'desktop')) return next(e)
947 // Nur die Zeile von `/bars help` bzw. `/bars ?`: sonst zeigte `/bars #<gültige Kennung>` die Tabelle (Review worklist, K2)
948 if (!HELP_WORDS.includes(e.props.args.trim().toLowerCase())) return next(e)
949 const tag = /#[0-9a-z]{5,}/.exec(e.props.text.split('\n')[0] ?? '')?.[0]
950 const data = tag ? helps.get(tag) : undefined
951 return data ? helpTree(data, e.viewport?.columns ?? 100, e.surface, HELP_ACCENT) : next(e)
952 })
953
954 // Die Zeile von /disk: eigener Baum statt Markdown. Unbekannte Kennung (nach Neustart, Hinweise), Fehlerzeile oder eine
955 // andere Oberfläche: die Engine zeichnet den Text.
956 on('ui.render', { component: 'CommandOutput', props: { command: 'disk' } }, async ($, e, next) => {
957 if (e.props.isErrored || (e.surface !== 'terminal' && e.surface !== 'desktop')) return next(e)
958 const tag = /#[0-9a-z]{5,}/.exec(e.props.text.split('\n')[0] ?? '')?.[0]
959 const r = tag ? reports.get(tag) : undefined
960 if (!r) return next(e)
961 try {
962 const { Box, Text } = $.ui.resolve(e)
963 if (e.surface === 'desktop') {
964 const { Svg } = $.ui.resolve(e)
965 const pic = storageSvg(r, lang)
966 return Box({ key: 'disk', flexDirection: 'column', children: [Svg({ source: pic.source, alt: pic.alt, width: pic.width, height: pic.height })] })
967 }
968 const v = storageTerminal(r, e.viewport?.columns ?? 80, lang)
969 return Box({
970 key: 'disk',
971 flexDirection: 'column',
972 children: [
973 Text({ bold: true, children: [r.view.alt] }),
974 Box({ flexDirection: 'row', children: v.bar.map((b) => Text({ color: b.color, children: ['█'.repeat(b.n)] })) }),
975 ...v.lines.map((l) => Box({ flexDirection: 'row', children: [Text({ color: l.color, children: ['■ '] }), Text({ children: [l.text] })] })),
976 Text({ dimColor: true, children: [v.footer] }),
977 ],
978 })
979 } catch {
980 return next(e)
981 }
982 })
983
984 on('session.measure', async ($, e, next) => {
985 // Bei jeder Messung übernehmen, nicht nur wenn `changed` `rateLimits` nennt; ein fehlendes Fenster verschwindet
986 try {
987 wins = pickWindows(e.rateLimits)
988 refresh($, await $.clock.now())
989 } catch {
990 // Beiwerk: die Messung selbst bleibt unberührt
991 }
992 return next(e)
993 })
994
995 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
996 let called = false
997 let nextFailed = false
998 let theirs: RenderNode | null | undefined = null
999 // Jeder Aufruf von `next` läuft hierüber: so ist klar, ob ein Fehler aus der Kette stammt oder aus diesem Hook
1000 const pass = async (arg: typeof e) => {
1001 called = true
1002 try {
1003 return await next(arg)
1004 } catch (err) {
1005 nextFailed = true
1006 throw err
1007 }
1008 }
1009 try {
1010 if (e.surface !== 'terminal' && e.surface !== 'desktop') return await pass(e)
1011 // Nennt `session.start` die Oberfläche nicht (z. B. eine App, die als SDK startet), holt das erste Zeichnen den Anfangsstand
1012 // Ersatzweg, wenn session.start die Oberfläche nicht nannte: auch die /bars-Werte holen (Review 0.6.0, S1)
1013 if (!usageAsked) {
1014 await readDisplay($)
1015 await loadWindows($)
1016 }
1017 maybeStartDisk($)
1018 const now = await $.clock.now()
1019 awaitingRender = false
1020 // Eine abgelehnte Periode beendet das Intervall still: bleiben zwei Ticks aus, wird der Takt neu gestartet
1021 if (timer && now - timerSince >= (ticks + 2) * TICK) {
1022 timer.cancel()
1023 timer = null
1024 }
1025 if (!timer) {
1026 timerSince = now
1027 ticks = 0
1028 timer = $.clock.every(TICK, () => {
1029 ticks += 1
1030 // Neuzeichnen angefordert, aber keins gekommen (Band eingeklappt, Sitzung verdeckt): Takt beenden; das nächste Zeichnen
1031 // startet ihn neu. Ohne Änderung wird nichts angefordert, dann läuft er still weiter (usage() ist kostenlos).
1032 if (awaitingRender) {
1033 timer?.cancel()
1034 timer = null
1035 return
1036 }
1037 // Kostenlos ohne `breakdown`; leer heißt "noch keine Antwort", dann bleibt der bisherige Stand
1038 bindSession($)
1039 .then(() => readDisplay($))
1040 .then(() => maybeStartDisk($))
1041 .then(() => $.session.usage())
1042 .then((u) => {
1043 const fresh = pickWindows(u.rateLimits)
1044 if (fresh.length > 0) wins = fresh
1045 // Kontext zwischen den Turns (types@2.1.289:10406-10411); fehlt nach /clear und /compact bis zur nächsten Antwort
1046 if (mem.lastActivity && typeof u.context.tokens === 'number') mem = { ...mem, ctx: u.context.tokens }
1047 if (u.cost) sessionUsd = u.cost.usd
1048 return $.clock.now()
1049 })
1050 .then((t) => {
1051 alertStep($, t)
1052 refresh($, t)
1053 })
1054 .catch(() => {
1055 // abgelehnt: es bleibt beim letzten Stand
1056 })
1057 // Speicher-Ring: Gesamt/frei alle 10 min (nur solange gezeichnet wird, wie der ganze Takt)
1058 if (ticks % PROBE_EVERY === 0 && driveOf(storagePath) && shownParts().storage) probeDisk($).catch(() => {})
1059 })
1060 }
1061 // Eine Umfrage hält das Band; ein Hook weicht ihr (types@2.1.288:9580)
1062 if (e.props.hasSurvey) return await pass(e)
1063 const view = snapshot(now)
1064 drawnKey = keyOf(view)
1065 // Alles ausgeblendet: nichts Eigenes und kein freigehaltener Platz (bodyColumns unverändert); der Takt läuft weiter,
1066 // damit ein `/bars` aus einem anderen Chat und die Hinweise vor Ablauf hier ankommen
1067 if (view.bars.length === 0 && !view.ring && !view.disk) return await pass(e)
1068 const { Box, Text } = $.ui.resolve(e)
1069 let mine: RenderNode
1070 let stack = false
1071 if (e.surface === 'desktop') {
1072 // Desktop: was eingeschaltet ist (Cache-Ring vor der ersten Anfrage grau mit „–“), Balken mit Werten, sobald es Limits gibt; kein Umschreiben von
1073 // bodyColumns, weil Clawds Svg eine feste Größe hat
1074 theirs = await pass(e)
1075 const { Svg } = $.ui.resolve(e)
1076 // Ohne Limits stehen Platzhalter da (5h –, 7d –), damit die Balken wie der Ring sofort sichtbar sind
1077 const pic = desktopSvg(view.bars, view.ring, view.disk)
1078 // Unten bündig mit Clawds Füßen, auch wenn die Desktop-App alignItems der äußeren Zeile nicht umsetzt (Spalte + flex-end)
1079 mine = Box({ key: 'limit-bars', flexShrink: 0, flexDirection: 'column', justifyContent: 'flex-end', children: [Svg({ source: pic.source, alt: pic.alt, width: pic.width, height: pic.height })] })
1080 } else {
1081 // Hinter den Balken: Cache-Block, dahinter der Speicher-Block; wird es eng, fällt der Speicher-Block zuerst weg
1082 const cacheP = view.block ? [{ text: view.block.text, color: view.block.color, dim: false }] : []
1083 const diskP = view.diskBlock ? [{ text: view.diskBlock, color: undefined, dim: true }] : []
1084 let parts = [...cacheP, ...diskP]
1085 const joined = (p: typeof parts) => (p.length > 0 ? p.map((x) => x.text).join(' ') : undefined)
1086 let lay = layoutTerminal(view.shown, e.props.bodyColumns, joined(parts))
1087 if (diskP.length > 0 && !lay?.extra) {
1088 parts = cacheP
1089 lay = layoutTerminal(view.shown, e.props.bodyColumns, joined(parts))
1090 }
1091 if (!lay) return await pass(e)
1092 if (noRewrite) {
1093 theirs = await pass(e)
1094 stack = true
1095 } else {
1096 // Platz abgeben: Mods weiter innen (z. B. clawd-buddy) sehen die Breite ohne die Balken. Props-Rewrites sind erlaubt
1097 // (types@2.1.288:9103-9104), das Feld heißt aber "Read-only" (types@2.1.288:9598-9604). Phase 0 hat belegt, dass die
1098 // Engine es annimmt; lehnt eine spätere Version ab, scheitert dieses Zeichnen, und ab dann wird gestapelt.
1099 try {
1100 theirs = await pass({ ...e, props: { ...e.props, bodyColumns: e.props.bodyColumns - lay.width } })
1101 } catch (err) {
1102 noRewrite = true
1103 throw err
1104 }
1105 }
1106 const children: RenderNode[] = lay.blocks.map((b) => {
1107 const s = b.shown
1108 const f = filled(s.ratio, b.width)
1109 return Box({
1110 flexDirection: 'column',
1111 width: b.width,
1112 flexShrink: 0,
1113 children: [
1114 Box({
1115 flexDirection: 'row',
1116 children: [
1117 Text({ color: ORANGE, bold: s.strong, children: [s.tag] }),
1118 Text({ children: [' '] }),
1119 s.fresh ? Text({ dimColor: true, children: [b.pct] }) : Text({ color: s.color, bold: s.strong, children: [b.pct] }),
1120 ...(b.rest ? [Text({ dimColor: true, wrap: 'truncate-end', children: [b.rest] })] : []),
1121 ],
1122 }),
1123 Box({
1124 flexDirection: 'row',
1125 children: [
1126 ...(f > 0 ? [Text({ color: s.color, children: ['▄'.repeat(f)] })] : []),
1127 ...(f < b.width ? [Text({ color: EMPTY, children: ['▄'.repeat(b.width - f)] })] : []),
1128 ],
1129 }),
1130 ],
1131 })
1132 })
1133 // Cache- und Speicher-Block hinter den Balken, auf der Zeile der Beschriftungen
1134 if (lay.extra && parts.length > 0) {
1135 const line = parts.flatMap((p, i) => [
1136 ...(i > 0 ? [Text({ children: [' '] })] : []),
1137 p.dim ? Text({ dimColor: true, children: [p.text] }) : Text({ color: p.color, children: [p.text] }),
1138 ])
1139 children.push(Box({ key: 'cache', flexDirection: 'column', flexShrink: 0, children: [Box({ flexDirection: 'row', children: line }), Text({ children: [' '] })] }))
1140 }
1141 mine = Box({
1142 key: 'limit-bars',
1143 width: lay.width,
1144 // Nie schrumpfen: läuft das Band über, gibt die dehnbare Seite nach, nicht die Beschriftung
1145 flexShrink: 0,
1146 flexDirection: 'row',
1147 columnGap: 2,
1148 children,
1149 })
1150 }
1151 // Ebenen anderer Mods (Quick-Replies, sidekick) bleiben über dem Grund, nie neben den Balken, egal wer in der Kette außen
1152 // liegt (band.ts, docs/BAND.md). Die Balken kommen nur neben den Grund.
1153 const { layers, base } = splitBand(theirs)
1154 if (!base) return joinBand(layers, mine)
1155 // Ohne weitere Mods antwortet der Kern mit seinem eigenen Knoten `{type:'engine'}` (types@2.1.288:9033-9041), der im Band
1156 // nichts zeichnet (types@2.1.288:9570). Dann ist limit-bars innen bzw. allein, der Knoten bleibt erhalten. Er steht NEBEN den
1157 // Balken, nicht darunter: Die Desktop-App gibt ihm Höhe, übereinander rutschten die Balken über Clawds Füße (Fynn, 2026-10-03).
1158 if (typeof base === 'object' && base.type === 'engine' && !stack) {
1159 return joinBand(layers, Box({ flexDirection: 'row', alignItems: 'flex-end', children: [mine, Box({ flexGrow: 1, children: [base] })] }))
1160 }
1161 if (stack) return joinBand(layers, Box({ flexDirection: 'column', children: [mine, base] }))
1162 // limit-bars weiter außen: Balken links, der Rest rechts, unten bündig (wie clawd-buddy, gespiegelt gebaut)
1163 return joinBand(
1164 layers,
1165 Box({
1166 flexDirection: 'row',
1167 alignItems: 'flex-end',
1168 // Rechte Seite: Inhalt nach rechts (alignItems) und nach unten (justifyContent) als Spalte, unabhängig von alignItems der Zeile
1169 children: [mine, Box({ flexGrow: 1, flexDirection: 'column', justifyContent: 'flex-end', alignItems: 'flex-end', children: [base] })],
1170 }),
1171 )
1172 } catch (err) {
1173 // Fehler aus der Kette: weiterwerfen, die Engine behandelt den Hook dann als gescheitert (docs/raw/en/events.md:313-316)
1174 if (nextFailed) throw err
1175 // Eigener Fehler (fail-open): ohne Anzeige; der Inhalt der anderen bleibt, `next` läuft höchstens einmal
1176 return called ? theirs : next(e)
1177 }
1178 })
1179}
1180hooks/band.ts 108 lines1// band.ts: gemeinsames Protokoll für das Band über dem Prompt (AbovePrompt). Gleiche Kopie in limit-bars, clawd-buddy,
2// quick-replies und sidekick (Mods importieren nur relativ); Beschreibung in docs/BAND.md, Änderungen immer in allen Kopien.
3//
4// Die Reihenfolge der Mods in der Kette hängt von der Installation ab (docs/raw/en/events.md:291-305). Damit das Band trotzdem
5// immer gleich aussieht, gibt es zwei Arten von Inhalt:
6// - Grund: was nebeneinander in einer Zeile steht (Limit-Balken links, Clawd rechts). Jeder Mod setzt seinen Teil neben den Grund.
7// - Ebenen: was als eigene Zeile über dem Grund steht (Quick-Replies, sidekick). Ein Box-Knoten mit key `layer:<Höhe>:<Name>`.
8// Jeder Mod holt die Ebenen aus dem, was `next` liefert, heraus, baut nur den Grund um und setzt die Ebenen wieder obenauf,
9// die höchste zuoberst. So wandert keine Ebene in die Zeile eines anderen Mods, egal wer außen liegt.
10import type { RenderElement, RenderNode } from 'claude-code'
11
12const LAYER = 'layer:'
13const ROOT = 'band'
14const BASE = 'band-base'
15
16export type Split = { layers: RenderElement[]; base: RenderNode | null }
17
18type Data = { type?: unknown; props?: Record<string, unknown>; children?: unknown[] }
19
20const keyOf = (n: unknown): string => {
21 const k = (n as Data | null)?.props?.key
22 return typeof k === 'string' ? k : ''
23}
24
25/** Höhe einer Ebene aus ihrem key (`layer:20:quick-replies` → 20); kein Ebenen-key → -1. */
26export function levelOf(n: unknown): number {
27 const k = keyOf(n)
28 if (!k.startsWith(LAYER)) return -1
29 const v = Number(k.slice(LAYER.length).split(':')[0])
30 return Number.isFinite(v) ? v : 0
31}
32
33/** Name einer Ebene (`layer:20:quick-replies` → quick-replies). */
34export function nameOf(n: unknown): string {
35 const k = keyOf(n)
36 return k.startsWith(LAYER) ? k.slice(LAYER.length).split(':').slice(1).join(':') : ''
37}
38
39/** Den Grund aus einer Wurzel holen, die joinBand gebaut hat. */
40function baseOf(root: Data): unknown {
41 const wrap = (root.children ?? []).find((c) => keyOf(c) === BASE) as Data | undefined
42 const inner = wrap?.children?.[0] as Data | undefined
43 return inner?.children?.[0] ?? null
44}
45
46function lift(node: unknown, out: RenderElement[]): unknown {
47 if (!node || typeof node !== 'object') return node
48 const n = node as Data
49 if (levelOf(n) >= 0) {
50 out.push(n as RenderElement)
51 return null
52 }
53 // Wurzel eines anderen Mods: ihre Ebenen einsammeln, an ihrer Stelle steht nur noch ihr Grund
54 if (keyOf(n) === ROOT) {
55 for (const c of n.children ?? []) if (levelOf(c) >= 0) out.push(c as RenderElement)
56 return lift(baseOf(n), out)
57 }
58 if (!Array.isArray(n.children)) return node
59 let changed = false
60 const kids: unknown[] = []
61 for (const c of n.children) {
62 const l = lift(c, out)
63 if (l !== c) changed = true
64 if (l !== null) kids.push(l)
65 }
66 // Nichts gefunden: derselbe Knoten, damit ein Band ohne Ebenen genau so bleibt, wie es war
67 return changed ? { ...n, children: kids } : node
68}
69
70/** Ebenen aus dem Ergebnis von `next` herausholen, auch aus fremden Hüllen; der Rest ist der Grund. */
71export function splitBand(theirs: RenderNode | null | undefined): Split {
72 const layers: RenderElement[] = []
73 const base = lift(theirs ?? null, layers) as RenderNode | null
74 return { layers, base }
75}
76
77/** Ebenen (höchste oben) über den Grund setzen; ohne Ebenen bleibt der Grund unverändert. */
78export function joinBand(layers: readonly RenderElement[], base: RenderNode | null | undefined): RenderNode | null {
79 if (layers.length === 0) return base ?? null
80 const sorted = layers
81 .map((l, i) => ({ l, i }))
82 .sort((a, b) => levelOf(b.l) - levelOf(a.l) || a.i - b.i)
83 .map((x) => x.l)
84 const kids: RenderNode[] = [...sorted]
85 if (base !== null && base !== undefined) {
86 // Der Grund steht in einer eigenen Zeile unten bündig, nie direkt in der Spalte (quick-replies, Lehren 4 und 5)
87 kids.push(
88 box({ key: BASE, flexDirection: 'row', alignItems: 'flex-end' }, [
89 box({ flexGrow: 1, flexDirection: 'column', justifyContent: 'flex-end' }, [base]),
90 ]),
91 )
92 }
93 return box({ key: ROOT, flexDirection: 'column', justifyContent: 'flex-end' }, kids)
94}
95
96/** Eine Ebene: eigener Box-Knoten mit `layer:<Höhe>:<Name>`, der Inhalt unverändert darin. */
97export function layer(level: number, name: string, content: RenderNode): RenderElement {
98 return box({ key: `${LAYER}${level}:${name}`, flexDirection: 'column', flexShrink: 0 }, [content])
99}
100
101// Elemente als reine Daten (types@2.1.289:11659-11684), ohne $.ui.resolve
102function box(props: Record<string, string | number | boolean>, children: RenderNode[]): RenderElement {
103 return { type: 'Box', props, children } as RenderElement
104}
105
106/** Höhen der Ebenen: weiter oben = größere Zahl. */
107export const LEVEL = { quickReplies: 20, sidekick: 30 } as const
108hooks/cache.ts 383 lines1// limit-bars: Cache-Logik ohne `$`. Wie warm ist der Prompt-Cache dieser Session, welche Cache-Dauer (TTL) gilt, was kostet ein
2// Neuschreiben, und was zeigen Ring, Terminal-Block und `/cache`. Nachgebaut nach Nate Herks Cache Keeper
3// (docs/vorlagen/nateherk-cache-keeper, MIT, Copyright (c) 2026 Nate Herk): Zustände und TTL-Messung nach register.mjs:232-244 und
4// :537-568, Preistabelle und Schreibfaktoren nach hooks/pricing.mjs.
5import { CACHE_ARGS, T, dec, hhmm, usd } from './i18n.ts'
6import type { Lang } from './i18n.ts'
7import type { RingView } from './ring.ts'
8import { EMPTY, GREEN, GREY, ORANGE, RED, YELLOW } from './view.ts'
9
10export const MIN = 60000
11
12/** Was über den Cache dieser Session bekannt ist; persistiert in `$.store` unter `cache:<sessionId>`. */
13export type CacheMem = {
14 lastActivity: number // Start der letzten Anfrage, die den Cache gelesen oder geschrieben hat (die TTL läuft ab dem Start)
15 ttl: 5 | 60 // gemessen oder Standard
16 ttlSource: 'Standard' | 'gemessen' // gespeicherte Werte, nicht übersetzen (Anzeige: cacheReport)
17 ctx: number // Eingabe gesamt der letzten Anfrage bzw. `$.session.usage().context.tokens`
18 model: string
19}
20
21export type Settings = {
22 ttl: 0 | 5 | 60 // 0 = auto
23 guard: boolean // Rückfrage vor kaltem Senden
24 bigTokens: number
25 alerts: boolean // Vorwarnung in der gelben Phase
26}
27
28export const DEFAULT_SETTINGS: Settings = { ttl: 0, guard: true, bigTokens: 150000, alerts: true }
29
30export function emptyMem(): CacheMem {
31 return { lastActivity: 0, ttl: 60, ttlSource: 'Standard', ctx: 0, model: '' }
32}
33
34/** Gilt die gesetzte oder die gemessene/Standard-TTL, in Minuten. */
35export function ttlOf(mem: CacheMem, s: Settings): 5 | 60 {
36 return s.ttl || mem.ttl
37}
38
39export type StateKind = 'unknown' | 'warm' | 'cooling' | 'cold' | 'kept'
40export type CacheState = { kind: StateKind; left: number } // left: Restzeit in ms, negativ = seit so langem kalt
41
42/** Zustand aus letzter Aktivität, TTL und Uhrzeit. Gelb: die letzten 5 min (bei 5-min-TTL die letzte Minute). */
43export function cacheState(lastActivity: number, ttlMin: number, now: number, kept: boolean): CacheState {
44 if (!lastActivity) return { kind: 'unknown', left: 0 }
45 const left = ttlMin * MIN - (now - lastActivity)
46 if (left <= 0) return { kind: 'cold', left }
47 if (kept) return { kind: 'kept', left }
48 if (left <= (ttlMin >= 60 ? 5 : 1) * MIN) return { kind: 'cooling', left }
49 return { kind: 'warm', left }
50}
51
52export type StepUsage = { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number; model?: string }
53
54function totalInput(u: StepUsage): number {
55 return (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
56}
57
58/**
59 * Wertet eine Anfrage der Hauptschleife aus (Start `startedAt`). Ein Neuschreiben nach 5,5 bis 60 min Pause zeigt 5-min-TTL,
60 * ein Treffer nach mehr als 5,5 min beweist 60. Die erste Anfrage nach `/compact` schreibt den kürzeren Kontext neu: erwartet,
61 * kein kalter Neustart. Rückgabe: neuer Stand und ob es ein kalter Neustart war (geschriebene Tokens).
62 */
63export function observeStep(mem: CacheMem, u: StepUsage, startedAt: number, afterCompact: boolean): { mem: CacheMem; coldWritten: number } {
64 const total = totalInput(u)
65 const written = u.cache_creation_input_tokens || 0
66 const read = u.cache_read_input_tokens || 0
67 const gap = mem.lastActivity ? startedAt - mem.lastActivity : 0
68 const next: CacheMem = { ...mem, lastActivity: startedAt, ctx: total, model: u.model || mem.model }
69 let coldWritten = 0
70 if (!afterCompact && mem.lastActivity && total > 30000) {
71 if (written / total > 0.5) {
72 coldWritten = written
73 if (gap > 5.5 * MIN && gap < 60 * MIN) {
74 next.ttl = 5
75 next.ttlSource = 'gemessen'
76 }
77 } else if (gap > 5.5 * MIN && read / total > 0.8) {
78 next.ttl = 60
79 next.ttlSource = 'gemessen'
80 }
81 }
82 return { mem: next, coldWritten }
83}
84
85// Dollar je Million Tokens (Claude-API-Preistabelle, Stand 2026-10-07, docs „Pricing“). Schreiben kostet 1,25 × Input
86// (5-min-TTL) bzw. 2 × (1 h). Längere IDs zuerst: 'opus-5' darf 'opus-5-5' nicht schlucken.
87// `long`: Haiku 5.5 kostet bei einem Prompt über 100 000 Tokens das Fünffache (0,50/0,05 statt 0,10/0,01; 1-h-Schreiben
88// 1,00 statt 0,20, docs „Pricing“). Annahme (Schluss, die Preisseite zählt den Prompt nicht wörtlich aus): Prompt sind alle
89// Eingabe-Tokens einer Anfrage, gecachte eingeschlossen, denn input_tokens, cache_read_input_tokens und
90// cache_creation_input_tokens zählen alle zum Kontext und Caching ändert nur den Preis (docs „Context windows“). Bei
91// limit-bars ist das die Kontextgröße der letzten Anfrage; die nächste ist etwas größer, knapp unter 100k ist die Stufe also
92// eine Schätzung (SPEC, Bau 0.6.1).
93type Price = { input: number; read: number; long?: { above: number; factor: number } }
94const TABLE: readonly [string, Price][] = [
95 ['fable-5-1', { input: 10, read: 0.25 }],
96 ['mythos-5-1', { input: 10, read: 0.25 }],
97 ['fable-5', { input: 10, read: 1 }],
98 ['opus-5-5', { input: 4, read: 0.2 }],
99 ['opus-5', { input: 5, read: 0.5 }],
100 ['opus-4-8', { input: 5, read: 0.5 }],
101 ['opus-4-7', { input: 5, read: 0.5 }],
102 ['opus-4-6', { input: 5, read: 0.5 }],
103 ['sonnet-5-5', { input: 2, read: 0.1 }],
104 ['sonnet-5', { input: 2, read: 0.2 }],
105 ['sonnet-4-6', { input: 3, read: 0.3 }],
106 ['haiku-5-5', { input: 0.1, read: 0.01, long: { above: 100_000, factor: 5 } }],
107 ['haiku-4-5', { input: 1, read: 0.1 }],
108]
109// Der Alias `haiku` ist seit Claude Code 2.1.293 Haiku 5.5 (Probe unter 2.1.295: modelUsage nur claude-haiku-5-5, Kosten zu
110// den Listenpreisen von Haiku 5.5; Befund cost-ledger a57f8d4, SPEC Nachtrag 0.7.2). Volle IDs wie claude-haiku-4-5-… treffen
111// weiter die Tabelle oben.
112const FAMILY: readonly [string, string][] = [
113 ['fable', 'fable-5-1'],
114 ['mythos', 'mythos-5-1'],
115 ['opus', 'opus-5-5'],
116 ['sonnet', 'sonnet-5-5'],
117 ['haiku', 'haiku-5-5'],
118]
119
120/**
121 * Preis je Million Tokens für eine Modell-ID (`claude-opus-5-5`, `claude-haiku-4-5-20251001`, `opus[1m]` …). Mit
122 * `promptTokens` gilt bei Modellen mit Stufe (Haiku 5.5) über der Grenze der höhere Preis; genau an der Grenze der normale.
123 */
124export function priceFor(model: string, promptTokens?: number): { id: string; input: number; read: number } {
125 const id = String(model || '')
126 .toLowerCase()
127 .replace(/^claude-/, '')
128 .replace(/\[.*?\]/g, '')
129 .replace(/-\d{8}$/, '')
130 .trim()
131 const out = (key: string, p: Price) => {
132 const f = p.long && (promptTokens ?? 0) > p.long.above ? p.long.factor : 1
133 return { id: key, input: p.input * f, read: p.read * f }
134 }
135 for (const [key, p] of TABLE) if (id === key || id.startsWith(key)) return out(key, p)
136 for (const [fam, key] of FAMILY) {
137 const hit = TABLE.find(([k]) => k === key)
138 if (id.includes(fam) && hit) return out(key, hit[1])
139 }
140 return { id: 'opus-5-5', input: 4, read: 0.2 }
141}
142
143/** Neuschreiben von `tokens` (API-Wert in $); `promptTokens` für die Preisstufe, ohne Angabe `tokens` (die Kontextgröße). */
144export function rewriteCost(tokens: number, model: string, ttlMin: number, promptTokens = tokens): number {
145 return ((tokens || 0) * priceFor(model, promptTokens).input * (ttlMin >= 60 ? 2 : 1.25)) / 1e6
146}
147
148/** Lesen von `tokens` aus dem Cache (API-Wert in $); `promptTokens` wie bei `rewriteCost`. */
149export function readCost(tokens: number, model: string, promptTokens = tokens): number {
150 return ((tokens || 0) * priceFor(model, promptTokens).read) / 1e6
151}
152
153/** Kosten einer Anfrage ohne Ausgabe (Warmhalte-Ping), API-Wert in $; die Preisstufe nach dem ganzen Prompt dieser Anfrage. */
154export function inputCost(u: StepUsage, model: string, ttlMin: number): number {
155 const prompt = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
156 const p = priceFor(u.model || model, prompt)
157 return ((u.input_tokens || 0) * p.input + (u.cache_read_input_tokens || 0) * p.read + (u.cache_creation_input_tokens || 0) * p.input * (ttlMin >= 60 ? 2 : 1.25)) / 1e6
158}
159
160/** `412k`, en `1.2M` / de `1,2M`, `950` */
161export function tokensText(n: number, lang: Lang): string {
162 const v = Math.max(0, Math.round(n || 0))
163 if (v >= 1e6) return `${dec(v / 1e6, 1, lang)}M`
164 if (v >= 1e4) return `${Math.round(v / 1e3)}k`
165 if (v >= 1e3) return `${dec(v / 1e3, 1, lang)}k`
166 return String(v)
167}
168
169/** Restzeit für den Ring: `42m`, `<1m` (abgerundet). */
170function leftText(ms: number): string {
171 const m = Math.floor(Math.max(0, ms) / MIN)
172 return m < 1 ? '<1m' : `${m}m`
173}
174
175/** Dauer in Worten: `12 min`, `2 h 5 min`, `under 1 min` (de `unter 1 min`); `long` für alt-Texte (`less than a minute`). */
176export function spanText(ms: number, lang: Lang, long = false): string {
177 const m = Math.floor(Math.max(0, ms) / MIN)
178 if (m < 1) return long ? T[lang].underMinuteLong : T[lang].underMinute
179 if (m <= 60) return `${m} min`
180 return `${Math.floor(m / 60)} h ${m % 60} min`
181}
182
183/** Ab hier steht die Kontextzahl unter dem Ring orange (Fynn, 0.2.1); feste Grenze, keine Einstellung. */
184const CTX_WARN = 80000
185
186/** Farbe der Kontextzahl: grau unter 80k, orange ab 80k, rot ab `bigTokens` (`big`). Der Ring selbst zeigt nur den Cache. */
187function contextColor(ctx: number, big: boolean): string {
188 if (big) return RED
189 return ctx >= CTX_WARN ? ORANGE : GREY
190}
191
192/**
193 * Was der Ring zeigt; gefüllt ist der verbleibende Anteil der TTL. Kalt: ganzer Ring rot ab `bigTokens` (`big`), sonst grau
194 * und leer. Vorher grau mit `–`.
195 */
196export function ringFor(st: CacheState, ttlMin: number, ctx: number, big: boolean, lang: Lang): RingView {
197 const t = T[lang]
198 const sub = ctx > 0 ? tokensText(ctx, lang) : undefined
199 const subColor = contextColor(ctx, big)
200 const context = ctx > 0 ? t.ringCtxAlt(tokensText(ctx, lang)) : ''
201 switch (st.kind) {
202 case 'unknown':
203 return { fill: 0, color: EMPTY, main: '–', mainColor: GREY, kept: false, alt: t.ringUnknownAlt }
204 case 'cold':
205 return big
206 ? { fill: 1, color: RED, main: t.cold, mainColor: RED, sub, subColor, kept: false, alt: `${t.ringColdAlt}${context}` }
207 : { fill: 0, color: EMPTY, main: t.cold, mainColor: GREY, sub, subColor, kept: false, alt: `${t.ringColdAlt}${context}` }
208 default: {
209 const color = st.kind === 'cooling' ? YELLOW : GREEN
210 return {
211 fill: Math.max(0, Math.min(1, st.left / (ttlMin * MIN))),
212 color,
213 main: leftText(st.left),
214 mainColor: color,
215 sub,
216 subColor,
217 kept: st.kind === 'kept',
218 alt: `${t.ringLeftAlt(t.state[st.kind], spanText(st.left, lang, true))}${context}`,
219 }
220 }
221 }
222}
223
224/** Terminal: Block hinter den Balken (`◔ 42m`, `○ cold`); vor der ersten Anfrage nichts. */
225export function terminalBlock(st: CacheState, big: boolean, lang: Lang): { text: string; color: string } | null {
226 switch (st.kind) {
227 case 'unknown':
228 return null
229 case 'cold':
230 return { text: `○ ${T[lang].cold}`, color: big ? RED : GREY }
231 case 'cooling':
232 return { text: `◔ ${leftText(st.left)}`, color: YELLOW }
233 default:
234 return { text: `${st.kind === 'kept' ? '◆' : '◔'} ${leftText(st.left)}`, color: GREEN }
235 }
236}
237
238/** `150k`, `1.5m`, `200000` → Tokens; sonst null. */
239export function parseTokens(text: string): number | null {
240 const m = String(text || '').trim().toLowerCase().replace(',', '.').match(/^(\d+(?:\.\d+)?)\s*([km]?)$/)
241 if (!m) return null
242 const n = Math.round(Number(m[1]) * (m[2] === 'm' ? 1e6 : m[2] === 'k' ? 1e3 : 1))
243 return n > 0 ? n : null
244}
245
246/**
247 * Einstellungen aus `/cache <schlüssel> <wert>`; null, wenn nichts davon passt. Englisch (`warn`, `hints`, `big`, `on`/`off`),
248 * dazu die älteren Formen `guard`, `alerts` und die deutschen Aliase `warnung`, `hinweise`, `gross`/`groß`, `an`/`aus`.
249 */
250/** Schlüssel und feste Werte von `/cache` (exportiert für den Vollständigkeitstest der Hilfe). */
251export const CACHE_WORDS = {
252 ttl: ['ttl'],
253 ttlValues: ['5', '60', 'auto'],
254 warn: ['warn', 'guard', 'warnung'],
255 hints: ['hints', 'alerts', 'hinweise'],
256 big: ['big', 'gross', 'groß'],
257} as const
258
259export function applySetting(s: Settings, key: string, value: string): Settings | null {
260 const onOff = value === 'on' || value === 'an' ? true : value === 'off' || value === 'aus' ? false : null
261 const is = (list: readonly string[]) => list.includes(key)
262 if (is(CACHE_WORDS.ttl)) {
263 if (value === '5') return { ...s, ttl: 5 }
264 if (value === '60') return { ...s, ttl: 60 }
265 if (value === 'auto') return { ...s, ttl: 0 }
266 return null
267 }
268 if (is(CACHE_WORDS.warn) && onOff !== null) return { ...s, guard: onOff }
269 if (is(CACHE_WORDS.hints) && onOff !== null) return { ...s, alerts: onOff }
270 if (is(CACHE_WORDS.big)) {
271 const n = parseTokens(value)
272 return n ? { ...s, bigTokens: n } : null
273 }
274 return null
275}
276
277/** Settings aus `$.store` absichern (fremde oder alte Werte fallen auf den Standard). */
278export function cleanSettings(v: unknown): Settings {
279 const o = (v && typeof v === 'object' ? v : {}) as Record<string, unknown>
280 return {
281 ttl: o.ttl === 5 || o.ttl === 60 ? o.ttl : 0,
282 guard: typeof o.guard === 'boolean' ? o.guard : DEFAULT_SETTINGS.guard,
283 bigTokens: typeof o.bigTokens === 'number' && o.bigTokens > 0 ? o.bigTokens : DEFAULT_SETTINGS.bigTokens,
284 alerts: typeof o.alerts === 'boolean' ? o.alerts : DEFAULT_SETTINGS.alerts,
285 }
286}
287
288/** Gespeicherten Stand absichern; null bei Unbrauchbarem. */
289export function cleanMem(v: unknown): (CacheMem & { savedAt: number }) | null {
290 const o = (v && typeof v === 'object' ? v : null) as Record<string, unknown> | null
291 if (!o || typeof o.lastActivity !== 'number') return null
292 return {
293 lastActivity: o.lastActivity,
294 ttl: o.ttl === 5 ? 5 : 60,
295 ttlSource: o.ttlSource === 'gemessen' ? 'gemessen' : 'Standard',
296 ctx: typeof o.ctx === 'number' ? o.ctx : 0,
297 model: typeof o.model === 'string' ? o.model : '',
298 savedAt: typeof o.savedAt === 'number' ? o.savedAt : o.lastActivity,
299 }
300}
301
302export type KeepWarm = { until: number; pings: number; usd: number }
303
304/** Wann der nächste Warmhalte-Ping fällig ist: bei 60-min-TTL 8 min vor Ablauf, bei 5-min-TTL 90 s vorher. */
305export function pingDue(lastActivity: number, ttlMin: number, now: number): boolean {
306 if (!lastActivity) return false
307 const margin = ttlMin >= 60 ? 8 * MIN : 90000
308 return now - lastActivity >= ttlMin * MIN - margin
309}
310
311/** Ein Ping, der schreibt statt zu lesen, hat den Cache verfehlt (Vorlage register.mjs:378-381). */
312export function pingMissed(u: StepUsage): boolean {
313 return (u.cache_creation_input_tokens || 0) > 0.1 * Math.max(1, u.cache_read_input_tokens || 0)
314}
315
316export type ReportLimit = { tag: string; pct: string; ratio: number; reset?: string }
317
318export type Report = {
319 now: number
320 mem: CacheMem
321 settings: Settings
322 keep: KeepWarm | null
323 cold: { count: number; usd: number }
324 last: { read: number; written: number; at: number } | null
325 limits: ReportLimit[]
326 sessionUsd: number | null
327 handoffAt: number | null
328 lang: Lang
329}
330
331/** Balken aus Linienzeichen gleicher Höhe (━ gefüllt, ─ leer), als Inline-Code in fester Breite. Blockzeichen █/░ standen in der Desktop-Schrift verschieden hoch (Fynn, Screenshot 2026-10-05). */
332export function textBar(ratio: number, width = 20): string {
333 const n = Math.max(0, Math.min(width, Math.round((Number.isFinite(ratio) ? ratio : 0) * width)))
334 return `\`${'━'.repeat(n)}${'─'.repeat(width - n)}\``
335}
336
337/**
338 * Der Text von `/cache` als Markdown-Karte: Die Engine zeichnet Befehlsausgaben als Markdown (types@2.1.289:9512-9514).
339 * Oben Zustand und Restzeit mit Balken, dann eine Tabelle mit Kosten und Messwerten, die Limits als Balken,
340 * zuletzt Einstellungen und Befehle (Argumente englisch, i18n.ts `CACHE_ARGS`). Beträge sind API-Wert.
341 */
342export function cacheReport(r: Report): string {
343 const L = r.lang
344 const t = T[L]
345 const ttl = ttlOf(r.mem, r.settings)
346 const st = cacheState(r.mem.lastActivity, ttl, r.now, !!r.keep)
347 const source = r.settings.ttl ? t.srcSet : r.mem.ttlSource === 'gemessen' ? t.srcMeasured : t.srcDefault
348 const onOff = (v: boolean) => (v ? t.on : t.off)
349 const perPing = usd(readCost(r.mem.ctx, r.mem.model), L)
350 const out: string[] = [t.repTitle(t.state[st.kind]), '']
351 if (st.kind === 'unknown') out.push(t.repUnknown)
352 else if (st.kind === 'cold') out.push(`${textBar(0)} ${t.repColdSince(spanText(-st.left, L))}`)
353 else out.push(`${textBar(st.left / (ttl * MIN))} ${t.repLeft(spanText(st.left, L), hhmm(r.mem.lastActivity + ttl * MIN))}`)
354 out.push('', '| | |', '|---|---|')
355 out.push(`| **${t.repTtl}** | ${ttl} min (${source}) |`)
356 if (r.mem.model) out.push(`| **${t.repModel}** | ${priceFor(r.mem.model).id} |`)
357 if (r.mem.ctx > 0) {
358 out.push(`| **${t.repContext}** | ${t.repTokens(tokensText(r.mem.ctx, L))}${r.mem.ctx >= r.settings.bigTokens ? t.repBig : ''} |`)
359 out.push(`| **${t.repNext}** | ${t.repNextValue(perPing, usd(rewriteCost(r.mem.ctx, r.mem.model, ttl), L))} |`)
360 }
361 if (r.last) out.push(`| **${t.repLast}** | ${t.repLastValue(hhmm(r.last.at), tokensText(r.last.read, L), tokensText(r.last.written, L))} |`)
362 out.push(`| **${t.repCold}** | ${t.repColdValue(String(r.cold.count), r.cold.count ? ` · ${usd(r.cold.usd, L)}` : '')} |`)
363 if (r.sessionUsd !== null) out.push(`| **${t.repSession}** | ${t.repSessionValue(usd(r.sessionUsd, L))} |`)
364 out.push(
365 r.keep
366 ? `| **${t.repKeep}** | ${t.repKeepOn(hhmm(r.keep.until), String(r.keep.pings), usd(r.keep.usd, L), perPing)} |`
367 : `| **${t.repKeep}** | ${t.off} |`,
368 )
369 if (r.limits.length) {
370 out.push('', '**Limits**', '')
371 for (const l of r.limits) out.push(`${textBar(l.ratio, 14)} **${l.tag}** ${l.pct}${l.reset ? ` · ${l.reset}` : ''} `)
372 }
373 out.push('', t.repSettings, '')
374 out.push(t.repGuard(onOff(r.settings.guard), tokensText(r.settings.bigTokens, L)))
375 out.push(t.repAlerts(onOff(r.settings.alerts)))
376 out.push(`- ${t.repChange}: ${CACHE_ARGS.split(' · ').map((a) => `\`/cache ${a}\``).join(' · ')}`)
377 out.push('', t.repCommands, '')
378 out.push(r.keep ? `- \`/keepwarm off\` ${t.repKeepStop}` : `- \`/keepwarm ${t.hoursArg}\` ${t.repKeepStart(ttl >= 60 ? '52 min' : `${dec(3.5, 1, L)} min`)}`)
379 out.push(`- \`/handoff\` ${t.repHandoff}${r.handoffAt ? ` · ${t.repHandoffLast(hhmm(r.handoffAt))}, \`/handoff show\`` : ''}`)
380 out.push('', t.repFooter)
381 return out.join('\n')
382}
383hooks/i18n.ts 498 lines1// limit-bars: Texte en/de und Formatierer, ohne `$`. Die Sprache kommt aus `userConfig.language` (Standard en, release/I18N.md).
2// Beide Tabellen haben dieselben Schlüssel; Funktionen bekommen nur fertig formatierte Teile (Zahlen, Zeiten, Beträge).
3// Befehle und Argumente sind in beiden Sprachen englisch; die deutschen Argumente gelten weiter als Alias (cache.ts, register.ts).
4
5export type Lang = 'en' | 'de'
6
7export function langOf(v: unknown): Lang {
8 return v === 'de' ? 'de' : 'en'
9}
10
11const two = (n: number) => String(n).padStart(2, '0')
12
13/** Uhrzeit `14:30` in lokaler Zeit; in beiden Sprachen gleich. */
14export function hhmm(ms: number): string {
15 const d = new Date(ms)
16 return `${two(d.getHours())}:${two(d.getMinutes())}`
17}
18
19/** Dezimalzahl: en `1.5`, de `1,5`. */
20export function dec(n: number, digits: number, lang: Lang): string {
21 const s = n.toFixed(digits)
22 return lang === 'de' ? s.replace('.', ',') : s
23}
24
25/** Ganze Zahl mit Tausendertrennung: en `562,932`, de `562.932`. */
26export function int(n: number, lang: Lang): string {
27 return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, lang === 'de' ? '.' : ',')
28}
29
30/** API-Wert in Dollar: en `≈ $3.30`, `< $0.01`; de `≈ 3,30 $`, `< 0,01 $`. */
31export function usd(v: number, lang: Lang): string {
32 const amount = (s: string) => (lang === 'de' ? `${s} $` : `$${s}`)
33 if (!(v > 0)) return `≈ ${amount('0')}`
34 if (v < 0.01) return `< ${amount(dec(0.01, 2, lang))}`
35 return `≈ ${amount(dec(v, 2, lang))}`
36}
37
38const en = {
39 // Balken
40 fiveName: '5-hour limit',
41 weekName: 'Weekly limit',
42 weekdays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
43 dayUnit: 'd',
44 pct: (n: string) => `${n}%`,
45 full: 'full',
46 fresh: 'fresh',
47 freshAlt: 'just reset',
48 unknown: 'not known yet',
49 resetAlt: (when: string) => `resets ${when}`,
50
51 // Dauer und Cache-Zustand
52 underMinute: 'under 1 min',
53 underMinuteLong: 'less than a minute',
54 cold: 'cold',
55 state: { unknown: 'not known yet', warm: 'warm', cooling: 'cooling down', cold: 'cold', kept: 'being kept warm' },
56 ringUnknownAlt: 'Cache not known yet (no request yet)',
57 ringCtxAlt: (tokens: string) => `, context ${tokens} tokens`,
58 ringColdAlt: 'Cache cold',
59 ringLeftAlt: (word: string, span: string) => `Cache ${word}, ${span} left`,
60
61 // /cache
62 repTitle: (word: string) => `### Prompt cache · ${word}`,
63 repUnknown: 'No request in this chat yet. The ring starts with the first reply.',
64 repColdSince: (span: string) => `**cold for ${span}**`,
65 repLeft: (span: string, at: string) => `**${span} left** · until ${at}`,
66 repTtl: 'Cache duration',
67 srcSet: 'set by you',
68 srcMeasured: 'measured',
69 srcDefault: 'default',
70 repModel: 'Model',
71 repContext: 'Context',
72 repTokens: (n: string) => `${n} tokens`,
73 repBig: ' · big',
74 repNext: 'Next message',
75 repNextValue: (warm: string, cold: string) => `warm ${warm} · cold (rewrite) ${cold}`,
76 repLast: 'Last request',
77 repLastValue: (at: string, read: string, written: string) => `${at} · ${read} read from the cache, ${written} written`,
78 repCold: 'Cold restarts',
79 repColdValue: (n: string, cost: string) => `${n}${cost} this session`,
80 repSession: 'Session so far',
81 repSessionValue: (cost: string) => `${cost} (as in /cost)`,
82 repKeep: 'Keep-warm',
83 repKeepOn: (until: string, pings: string, cost: string, perPing: string) => `on until ${until} · ${pings} ping(s) ${cost} · ${perPing} per ping`,
84 on: 'on',
85 off: 'off',
86 repSettings: '**Settings**',
87 repGuard: (state: string, from: string) => `- Question before a cold send: **${state}** from ${from}`,
88 repAlerts: (state: string) => `- Notice shortly before expiry: **${state}**`,
89 repChange: 'Change',
90 repCommands: '**Commands**',
91 repKeepStop: 'stops keep-warm',
92 repKeepStart: (every: string) => `keeps the cache warm (at most 4 h, one ping every ~${every})`,
93 repHandoff: 'writes a handoff for a fresh chat',
94 repHandoffLast: (at: string) => `last ${at}`,
95 repFooter: '*Amounts are API value; on a subscription it counts against your quota.*',
96 saved: 'Saved.',
97 notSaved: 'Applies until restart (saving was refused).',
98 unknownArg: (arg: string, possible: string) => `Unknown: "${arg}". Possible: ${possible} → \`/bars help\``,
99
100 // Befehle registrieren
101 cmdCache: 'Prompt cache: state, cost, settings',
102 cmdHandoff: 'Write a handoff, then clear the chat and continue with it',
103 cmdKeepwarm: 'Keep the prompt cache warm (costs quota), at most 4 h',
104 hintKeepwarm: '[hours|off]',
105 hoursArg: '[hours]',
106
107 // Rückfrage vor kaltem Senden
108 guardQuestion: (since: string, tokens: string, cold: string, warm: string) =>
109 `The cache has been cold for ${since}. Sending rewrites ${tokens} tokens (${cold} API value; warm it would be ${warm}). ` +
110 'Compacting also reads everything once and saves little now. How to continue?',
111 send: 'Send anyway',
112 compact: 'Compact first',
113 abort: 'Cancel',
114 abortToast: 'Not sent. New topic: new chat. Same work: just send, or /handoff for a small fresh chat.',
115 dropAbort: 'limit-bars: cancelled before rewriting the cold cache',
116 dropCompact: 'limit-bars: will be sent after compacting',
117 compactFailed: (err: string) => `Compacting failed (${err}); sending without.`,
118 resendFailed: (err: string, text: string) => `Message could not be sent (${err}): "${text}"`,
119
120 // Hinweis kurz vor Ablauf
121 alert: (span: string, ctx: string, cost: string) =>
122 `Cache expires in ${span} (${ctx} context, rewrite ${cost}). ` +
123 'Continuing right away: just write. Stopping: /handoff now, while it is cheap. Coming back to this chat later: /keepwarm.',
124
125 // Übergabe
126 handoffHeader: 'Handoff',
127 handoffQuestion:
128 'Handoff ready. "New chat with handoff" clears this chat (/clear, it stays reachable via /resume) and sends the handoff as the first message; the cache then starts small. Start a new chat?',
129 clearGo: 'New chat with handoff',
130 keepChat: 'Keep working here',
131 handoffSavedLater: 'Handoff saved. Later: /handoff continue (clears the chat) or /handoff show.',
132 handoffEmpty: 'The handoff turn ended without a handoff; nothing saved.',
133 handoffNone: 'No handoff yet. Run /handoff first.',
134 handoffBusy: 'Claude is still working. /handoff continue once the turn is done.',
135 handoffPrefix: 'Handoff from my previous chat:',
136 clearFailed: (err: string) => `/clear failed (${err}). The handoff is saved: /handoff show prints it.`,
137 handoffStarted: 'New chat started with the handoff. The old chat stays reachable via /resume.',
138 handoffSendFailed: 'Chat cleared, but the handoff could not be sent. /handoff show prints it.',
139 handoffLast: (at: string, text: string) => `Last handoff (${at}):\n\n${text}`,
140 handoffNoneSaved: 'No handoff saved yet.',
141 handoffContinuing: 'Clearing the chat and continuing with the handoff.',
142 handoffRunning: 'A handoff is already running.',
143 skillMissing: 'skill "uebergabe" not loaded (/reload-plugins)',
144 handoffStartFailed: (err: string) => `Could not start the handoff: ${err}`,
145 handoffWriting: 'Writing the handoff (skill "uebergabe"). Afterwards you can clear the chat and continue with it.',
146
147 // Warmhalten
148 keepOff: (why: string, pings: string, cost: string) => `Keep-warm off (${why}). ${pings} ping(s), ${cost} API value.`,
149 whyNewChat: 'new chat',
150 whyTimeUp: 'time is up',
151 whyWasCold: 'the cache was already cold',
152 whyNoRead: 'the ping read nothing from the cache',
153 whyNoReply: (reason: string) => `the ping got no reply (${reason})`,
154 whyRewrote: (tokens: string) => `the ping wrote ${tokens} tokens instead of reading the cache`,
155 whyFailed: (err: string) => `ping failed: ${err}`,
156 whyTurnedOff: 'turned off',
157 keepAlreadyOff: 'Keep-warm is already off.',
158 keepTurnedOff: 'Keep-warm off.',
159 keepUsage: (max: string) => `Usage: /keepwarm [hours|off], at most ${max} h. → \`/bars help\``,
160 keepNothing: 'No request in this chat yet, so nothing to keep warm.',
161 keepCold: 'The cache is already cold. Keeping it warm would first rewrite it; the next message does that anyway.',
162 keepOn: (until: string, capped: string, lead: string, perPing: string, ctx: string, pings: string) =>
163 `Keep-warm on until ${until}${capped}. A ping reads the cache about ${lead} before it expires: ${perPing} API value per ping at ${ctx} context, ` +
164 `so about ${pings} pings. If a ping rewrites instead of reading, keep-warm turns itself off. Off: /keepwarm off`,
165 keepCapped: (max: string) => ` (at most ${max} h)`,
166
167 // Speicher-Ring und /disk
168 diskAlt: (drive: string, used: string, total: string) => `Drive ${drive} ${used} of ${total} used`,
169 groups: {
170 programs: 'Programs & libraries',
171 media: 'Media',
172 models: 'AI models & data',
173 code: 'Code & text',
174 archives: 'Archives & packages',
175 other: 'Other',
176 },
177 diskFree: 'Free',
178 cmdStorage: 'Drive usage by file type: ring with legend',
179 storageOff: 'The storage ring is off. Set `storagePath` in /config, for example `E:\\`.',
180 storageWindowsOnly: 'The storage ring works on Windows only for now: `storagePath` needs a drive letter, for example `E:\\`.',
181 storageFailed: (drive: string, err: string) => `Could not read drive ${drive}: ${err}`,
182 storageScanFailed: (err: string) => `Scan failed (${err}), showing the last result.`,
183 storageTitle: (drive: string, used: string, total: string, pct: string) => `### Drive ${drive} · ${used} of ${total} used (${pct})`,
184 storageScanned: (at: string, files: string) => `Scanned ${at} · ${files} files`,
185 storageNoScan: 'Not scanned by file type yet.',
186 storageHead: '| File type | Size | Share |',
187 storageOfDrive: (pct: string) => `${pct} of the drive`,
188 storageScanRunning: (at: string) => `Another session has been scanning the drive since ${at}. Run /disk again in a moment.`,
189 storageScanPaused: (at: string) => `The last scan failed at ${at}; automatic scans pause for 24 h. Retry: /disk refresh`,
190 errMissing: 'path not found',
191 errUnreadable: 'folder not readable',
192 errTruncated: 'output truncated',
193 errNoData: 'no usable output',
194
195 // /bars: Anzeige anpassen
196 cmdBars: 'Show or hide parts of the display: 5h, week, cache, storage',
197 barsTitle: '### limit-bars · display',
198 barsHead: '| Part | Shown | Set by |',
199 barsPart: { fiveHour: '5-hour bar', weekly: 'Weekly bar', cache: 'Cache ring', storage: 'Storage ring' },
200 barsOn: 'on',
201 barsOff: 'off',
202 barsByCommand: '/bars',
203 barsBySetting: 'settings',
204 barsNoPath: 'on, but no `storagePath`',
205 barsNotWindows: 'on, but `storagePath` has no drive letter',
206 barsUsage: 'Change: `/bars show 5h|week|cache|storage on|off` · back to the settings: `/bars reset`. Applies to all open chats within 10 s.',
207 barsSet: (part: string, state: string) => `${part}: ${state}. Applies to all open chats within 10 s; \`/bars reset\` goes back to the settings.`,
208 barsReset: 'Back to the settings (`/config` → limit-bars).',
209 barsCacheNote: 'The cold-send question and the notices stay on; turn them off with `/cache warn off` and `/cache hints off`.',
210 barsSaveFailed: (err: string) => `Could not save (${err}); applies to this chat only until it restarts.`,
211
212 // /bars help (docs/HELP-SPEC.md §5 „limit-bars 0.7.0“); Abschnittsüberschriften und „an/aus“ stehen in help.ts
213 seeHelp: 'All commands: `/bars help`',
214 help: {
215 intro: 'Usage limits as bars above the prompt, a ring for the prompt cache and an optional storage ring. The cache guard asks before an expensive cold send.',
216 bars: 'which parts of the display are shown',
217 barsShow: 'show or hide a part, in all open chats within 10 s (also without show)',
218 barsReset: 'back to the settings',
219 barsHelp: 'this help',
220 cache: 'cache state, cost of the next message, settings',
221 cacheTtl: 'cache duration: 5 or 60 min, auto = measured',
222 cacheWarn: 'question before a cold send in a big chat',
223 cacheHints: 'notice shortly before the cache expires',
224 cacheBig: 'from what context a chat counts as big, e.g. 150k',
225 handoff: 'write a handoff, then clear the chat and continue with it',
226 handoffShow: 'print the last handoff',
227 handoffContinue: 'clear the chat and continue with the last handoff',
228 keepwarm: 'keep the cache warm, default 2 h, at most 4 h (costs quota)',
229 keepwarmOff: 'stop keep-warm',
230 disk: 'drive usage by file type; Windows only, needs storagePath; refresh scans again',
231 skill: 'starts on "handoff", "wrap up the chat" …; /handoff uses it',
232 skillCmd: 'skill uebergabe',
233 aliases: 'Also accepted: /bars ? = help · /bars status · show = zeigen · reset = zurücksetzen, zuruecksetzen · an|aus · 5h = five, fivehour, 5hour, 5-hour · week = weekly, 7d, woche, wochenlimit · storage = disk, speicher · /cache warn = warnung, guard · hints = hinweise, alerts · big = groß, gross · /handoff zeigen|weiter · /keepwarm aus',
234 fiveHour: '5-hour bar',
235 weekly: 'Weekly bar',
236 cacheRing: 'Cache ring',
237 storageRing: 'Storage ring',
238 byCommand: 'by /bars',
239 bySetting: 'by setting',
240 noPath: 'no storagePath',
241 noDrive: 'no drive letter',
242 setPath: 'setting storagePath',
243 guard: 'Question before cold send',
244 alerts: 'Notice before expiry',
245 big: 'Threshold "big"',
246 ttl: 'Cache duration',
247 ttlAuto: (min: string, src: string) => `auto · ${min} min${src ? ` ${src}` : ''}`,
248 ttlSet: (min: string) => `${min} min`,
249 keep: 'Keep-warm',
250 keepUntil: (at: string) => `until ${at}`,
251 keepToggle: '/keepwarm [hours]',
252 empty: 'empty = off',
253 settings: {
254 language: 'Language',
255 resetStyle: 'Reset display',
256 highlightAt: 'Highlight from (%)',
257 showFiveHour: 'Show 5-hour bar',
258 showWeekly: 'Show weekly bar',
259 showCache: 'Show cache ring',
260 showStorage: 'Show storage ring',
261 onlyFiveHour: '5-hour limit only (deprecated)',
262 storagePath: 'Storage drive',
263 },
264 footerTerminal: 'Change settings: /plugin configure limit-bars · Turn the mod off: /plugin disable limit-bars',
265 footerDesktop: 'Turn the mod off: + → Plugins → Manage plugins · Change settings: /plugin configure limit-bars in a terminal',
266 },
267}
268
269export type Texts = typeof en
270
271const de: Texts = {
272 fiveName: '5-Stunden-Limit',
273 weekName: 'Wochenlimit',
274 weekdays: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'],
275 dayUnit: 'T',
276 pct: (n) => `${n} %`,
277 full: 'voll',
278 fresh: 'frisch',
279 freshAlt: 'gerade zurückgesetzt',
280 unknown: 'noch unbekannt',
281 resetAlt: (when) => `Reset ${when}`,
282
283 underMinute: 'unter 1 min',
284 underMinuteLong: 'unter einer Minute',
285 cold: 'kalt',
286 state: { unknown: 'noch unbekannt', warm: 'warm', cooling: 'kühlt ab', cold: 'kalt', kept: 'wird warmgehalten' },
287 ringUnknownAlt: 'Cache noch unbekannt (noch keine Anfrage)',
288 ringCtxAlt: (tokens) => `, Kontext ${tokens} Tokens`,
289 ringColdAlt: 'Cache kalt',
290 ringLeftAlt: (word, span) => `Cache ${word}, noch ${span}`,
291
292 repTitle: (word) => `### Prompt-Cache · ${word}`,
293 repUnknown: 'Noch keine Anfrage in diesem Chat. Der Ring startet mit der ersten Antwort.',
294 repColdSince: (span) => `**kalt seit ${span}**`,
295 repLeft: (span, at) => `**noch ${span}** · bis ${at}`,
296 repTtl: 'Cache-Dauer',
297 srcSet: 'von dir gesetzt',
298 srcMeasured: 'gemessen',
299 srcDefault: 'Standard',
300 repModel: 'Modell',
301 repContext: 'Kontext',
302 repTokens: (n) => `${n} Tokens`,
303 repBig: ' · groß',
304 repNext: 'Nächste Nachricht',
305 repNextValue: (warm, cold) => `warm ${warm} · kalt (Neuschreiben) ${cold}`,
306 repLast: 'Letzte Anfrage',
307 repLastValue: (at, read, written) => `${at} · ${read} aus dem Cache gelesen, ${written} neu geschrieben`,
308 repCold: 'Kalte Neustarts',
309 repColdValue: (n, cost) => `${n}${cost} in dieser Sitzung`,
310 repSession: 'Sitzung bisher',
311 repSessionValue: (cost) => `${cost} (wie /cost)`,
312 repKeep: 'Warmhalten',
313 repKeepOn: (until, pings, cost, perPing) => `an bis ${until} · ${pings} Ping(s) ${cost} · je Ping ${perPing}`,
314 on: 'an',
315 off: 'aus',
316 repSettings: '**Einstellungen**',
317 repGuard: (state, from) => `- Rückfrage vor kaltem Senden: **${state}** ab ${from}`,
318 repAlerts: (state) => `- Hinweis kurz vor Ablauf: **${state}**`,
319 repChange: 'Ändern',
320 repCommands: '**Befehle**',
321 repKeepStop: 'beendet das Warmhalten',
322 repKeepStart: (every) => `hält den Cache warm (höchstens 4 h, ein Ping je ~${every})`,
323 repHandoff: 'schreibt eine Übergabe für einen frischen Chat',
324 repHandoffLast: (at) => `letzte ${at}`,
325 repFooter: '*Beträge sind API-Wert; auf dem Abo zählt es aufs Kontingent.*',
326 saved: 'Gespeichert.',
327 notSaved: 'Gilt bis zum Neustart (Speichern abgelehnt).',
328 unknownArg: (arg, possible) => `Unbekannt: „${arg}“. Möglich: ${possible} → \`/bars help\``,
329
330 cmdCache: 'Prompt-Cache: Zustand, Kosten, Einstellungen',
331 cmdHandoff: 'Übergabe schreiben, danach Chat leeren und damit weitermachen',
332 cmdKeepwarm: 'Prompt-Cache warmhalten (kostet Kontingent), höchstens 4 h',
333 hintKeepwarm: '[stunden|off]',
334 hoursArg: '[stunden]',
335
336 guardQuestion: (since, tokens, cold, warm) =>
337 `Der Cache ist seit ${since} kalt. Senden schreibt ${tokens} Tokens neu (${cold} API-Wert; warm wären es ${warm}). ` +
338 'Komprimieren liest dafür auch einmal alles und spart jetzt kaum. Wie weiter?',
339 send: 'Trotzdem senden',
340 compact: 'Erst komprimieren',
341 abort: 'Abbrechen',
342 abortToast: 'Nicht gesendet. Neues Thema: neuer Chat. Gleiche Arbeit: einfach senden, oder /handoff für einen kleinen frischen Chat.',
343 dropAbort: 'limit-bars: vor dem Neuschreiben des kalten Caches abgebrochen',
344 dropCompact: 'limit-bars: wird nach dem Komprimieren gesendet',
345 compactFailed: (err) => `Komprimieren ging nicht (${err}); sende ohne.`,
346 resendFailed: (err, text) => `Nachricht ließ sich nicht senden (${err}): „${text}“`,
347
348 alert: (span, ctx, cost) =>
349 `Cache läuft in ${span} ab (${ctx} Kontext, Neuschreiben ${cost}). ` +
350 'Machst du gleich weiter: einfach schreiben. Hörst du auf: jetzt /handoff, solange es günstig ist. Kommst du später in diesen Chat zurück: /keepwarm.',
351
352 handoffHeader: 'Übergabe',
353 handoffQuestion:
354 'Übergabe fertig. „Neuer Chat mit Übergabe“ leert diesen Chat (/clear, er bleibt über /resume erreichbar) und schickt die Übergabe als erste Nachricht; der Cache startet dann klein. Neuen Chat starten?',
355 clearGo: 'Neuer Chat mit Übergabe',
356 keepChat: 'Hier weiterarbeiten',
357 handoffSavedLater: 'Übergabe gespeichert. Später: /handoff continue (leert den Chat) oder /handoff show.',
358 handoffEmpty: 'Der Übergabe-Turn endete ohne Übergabe; nichts gespeichert.',
359 handoffNone: 'Noch keine Übergabe. Erst /handoff.',
360 handoffBusy: 'Claude arbeitet noch. /handoff continue, sobald der Turn fertig ist.',
361 handoffPrefix: 'Übergabe aus meinem vorigen Chat:',
362 clearFailed: (err) => `/clear ging nicht (${err}). Die Übergabe ist gespeichert: /handoff show gibt sie aus.`,
363 handoffStarted: 'Neuer Chat mit der Übergabe gestartet. Der alte Chat bleibt über /resume erreichbar.',
364 handoffSendFailed: 'Chat geleert, aber die Übergabe ließ sich nicht senden. /handoff show gibt sie aus.',
365 handoffLast: (at, text) => `Letzte Übergabe (${at}):\n\n${text}`,
366 handoffNoneSaved: 'Noch keine Übergabe gespeichert.',
367 handoffContinuing: 'Leere den Chat und mache mit der Übergabe weiter.',
368 handoffRunning: 'Eine Übergabe läuft schon.',
369 skillMissing: 'Skill „uebergabe“ nicht geladen (/reload-plugins)',
370 handoffStartFailed: (err) => `Übergabe ließ sich nicht starten: ${err}`,
371 handoffWriting: 'Erstelle die Übergabe (Skill „uebergabe“). Danach kannst du den Chat leeren und mit ihr weitermachen.',
372
373 keepOff: (why, pings, cost) => `Warmhalten aus (${why}). ${pings} Ping(s), ${cost} API-Wert.`,
374 whyNewChat: 'neuer Chat',
375 whyTimeUp: 'Zeit abgelaufen',
376 whyWasCold: 'der Cache war schon kalt',
377 whyNoRead: 'der Ping las nichts aus dem Cache',
378 whyNoReply: (reason) => `der Ping bekam keine Antwort (${reason})`,
379 whyRewrote: (tokens) => `der Ping schrieb ${tokens} Tokens neu, statt den Cache zu lesen`,
380 whyFailed: (err) => `Ping fehlgeschlagen: ${err}`,
381 whyTurnedOff: 'ausgeschaltet',
382 keepAlreadyOff: 'Warmhalten ist schon aus.',
383 keepTurnedOff: 'Warmhalten aus.',
384 keepUsage: (max) => `Aufruf: /keepwarm [stunden|off], höchstens ${max} h. → \`/bars help\``,
385 keepNothing: 'Noch keine Anfrage in diesem Chat, also nichts warmzuhalten.',
386 keepCold: 'Der Cache ist schon kalt. Warmhalten würde ihn erst neu schreiben; die nächste Nachricht macht das ohnehin.',
387 keepOn: (until, capped, lead, perPing, ctx, pings) =>
388 `Warmhalten an bis ${until}${capped}. Ein Ping liest den Cache etwa ${lead} vor Ablauf: ${perPing} API-Wert je Ping bei ${ctx} Kontext, ` +
389 `also etwa ${pings} Pings. Schreibt ein Ping neu statt zu lesen, schaltet es sich ab. Aus: /keepwarm off`,
390 keepCapped: (max) => ` (höchstens ${max} h)`,
391
392 diskAlt: (drive, used, total) => `Laufwerk ${drive} ${used} von ${total} belegt`,
393 groups: {
394 programs: 'Programme & Bibliotheken',
395 media: 'Medien',
396 models: 'KI-Modelle & Daten',
397 code: 'Code & Text',
398 archives: 'Archive & Pakete',
399 other: 'Sonstiges',
400 },
401 diskFree: 'Frei',
402 cmdStorage: 'Belegung des Laufwerks nach Dateiart: Ring mit Legende',
403 storageOff: 'Der Speicher-Ring ist aus. In /config `storagePath` setzen, zum Beispiel `E:\\`.',
404 storageWindowsOnly: 'Der Speicher-Ring geht vorerst nur unter Windows: `storagePath` braucht einen Laufwerksbuchstaben, zum Beispiel `E:\\`.',
405 storageFailed: (drive, err) => `Laufwerk ${drive} nicht lesbar: ${err}`,
406 storageScanFailed: (err) => `Scan fehlgeschlagen (${err}), angezeigt wird der letzte Stand.`,
407 storageTitle: (drive, used, total, pct) => `### Laufwerk ${drive} · ${used} von ${total} belegt (${pct})`,
408 storageScanned: (at, files) => `Gescannt ${at} · ${files} Dateien`,
409 storageNoScan: 'Noch nicht nach Dateiart gescannt.',
410 storageHead: '| Dateiart | Größe | Anteil |',
411 storageOfDrive: (pct) => `${pct} des Laufwerks`,
412 storageScanRunning: (at) => `Eine andere Sitzung scannt das Laufwerk seit ${at}. Gleich noch einmal /disk aufrufen.`,
413 storageScanPaused: (at) => `Der letzte Scan ist um ${at} gescheitert; automatische Scans pausieren 24 h. Neu versuchen: /disk refresh`,
414 errMissing: 'Pfad nicht gefunden',
415 errUnreadable: 'Ordner nicht lesbar',
416 errTruncated: 'Ausgabe abgeschnitten',
417 errNoData: 'keine verwertbare Ausgabe',
418
419 cmdBars: 'Teile der Anzeige ein- oder ausblenden: 5h, Woche, Cache, Speicher',
420 barsTitle: '### limit-bars · Anzeige',
421 barsHead: '| Teil | Sichtbar | Gesetzt durch |',
422 barsPart: { fiveHour: '5-Stunden-Balken', weekly: 'Wochen-Balken', cache: 'Cache-Ring', storage: 'Speicher-Ring' },
423 barsOn: 'an',
424 barsOff: 'aus',
425 barsByCommand: '/bars',
426 barsBySetting: 'Einstellung',
427 barsNoPath: 'an, aber ohne `storagePath`',
428 barsNotWindows: 'an, aber `storagePath` ohne Laufwerksbuchstaben',
429 barsUsage: 'Ändern: `/bars show 5h|week|cache|storage on|off` · zurück zu den Einstellungen: `/bars reset`. Wirkt in allen offenen Chats innerhalb von 10 s.',
430 barsSet: (part, state) => `${part}: ${state}. Wirkt in allen offenen Chats innerhalb von 10 s; \`/bars reset\` geht zurück zu den Einstellungen.`,
431 barsReset: 'Zurück zu den Einstellungen (`/config` → limit-bars).',
432 barsCacheNote: 'Die Rückfrage vor kaltem Senden und die Hinweise bleiben; aus mit `/cache warn off` und `/cache hints off`.',
433 barsSaveFailed: (err) => `Nicht gespeichert (${err}); gilt nur in diesem Chat bis zum Neustart.`,
434
435 seeHelp: 'Alle Befehle: `/bars help`',
436 help: {
437 intro: 'Limits als Balken über dem Prompt, ein Ring für den Prompt-Cache und ein optionaler Speicher-Ring. Die Cache-Wache fragt vor einem teuren kalten Senden.',
438 bars: 'welche Teile der Anzeige zu sehen sind',
439 barsShow: 'Teil ein- oder ausblenden, in allen offenen Chats binnen 10 s (auch ohne show)',
440 barsReset: 'zurück zu den Einstellungen',
441 barsHelp: 'diese Hilfe',
442 cache: 'Zustand des Caches, Kosten der nächsten Nachricht, Einstellungen',
443 cacheTtl: 'Cache-Dauer: 5 oder 60 min, auto = gemessen',
444 cacheWarn: 'Rückfrage vor kaltem Senden in einem großen Chat',
445 cacheHints: 'Hinweis kurz bevor der Cache abläuft',
446 cacheBig: 'ab welchem Kontext ein Chat als groß gilt, z. B. 150k',
447 handoff: 'Übergabe schreiben, danach Chat leeren und damit weitermachen',
448 handoffShow: 'letzte Übergabe ausgeben',
449 handoffContinue: 'Chat leeren und mit der letzten Übergabe weitermachen',
450 keepwarm: 'Cache warmhalten, Standard 2 h, höchstens 4 h (kostet Kontingent)',
451 keepwarmOff: 'Warmhalten beenden',
452 disk: 'Belegung nach Dateiart; nur Windows, braucht storagePath; refresh scannt neu',
453 skill: 'startet bei „Übergabe“, „Chat abschließen“ …; /handoff nutzt ihn',
454 skillCmd: 'Skill uebergabe',
455 aliases: 'Auch erkannt: /bars ? = help · /bars status · show = zeigen · reset = zurücksetzen, zuruecksetzen · an|aus · 5h = five, fivehour, 5hour, 5-hour · week = weekly, 7d, woche, wochenlimit · storage = disk, speicher · /cache warn = warnung, guard · hints = hinweise, alerts · big = groß, gross · /handoff zeigen|weiter · /keepwarm aus',
456 fiveHour: '5-Stunden-Balken',
457 weekly: 'Wochen-Balken',
458 cacheRing: 'Cache-Ring',
459 storageRing: 'Speicher-Ring',
460 byCommand: 'per Befehl',
461 bySetting: 'per Einstellung',
462 noPath: 'ohne storagePath',
463 noDrive: 'ohne Laufwerksbuchstaben',
464 setPath: 'Einstellung storagePath',
465 guard: 'Rückfrage vor kaltem Senden',
466 alerts: 'Hinweis vor Ablauf',
467 big: 'Schwelle „groß“',
468 ttl: 'Cache-Dauer',
469 ttlAuto: (min, src) => `auto · ${min} min${src ? ` ${src}` : ''}`,
470 ttlSet: (min) => `${min} min`,
471 keep: 'Warmhalten',
472 keepUntil: (at) => `bis ${at}`,
473 keepToggle: '/keepwarm [stunden]',
474 empty: 'leer = aus',
475 settings: {
476 language: 'Sprache',
477 resetStyle: 'Reset-Anzeige',
478 highlightAt: 'Hervorheben ab (%)',
479 showFiveHour: '5-Stunden-Balken zeigen',
480 showWeekly: 'Wochen-Balken zeigen',
481 showCache: 'Cache-Ring zeigen',
482 showStorage: 'Speicher-Ring zeigen',
483 onlyFiveHour: 'Nur 5-Stunden-Limit (veraltet)',
484 storagePath: 'Speicher-Laufwerk',
485 },
486 footerTerminal: 'Einstellungen ändern: /plugin configure limit-bars · Mod abschalten: /plugin disable limit-bars',
487 footerDesktop: 'Mod abschalten: + → Plugins → Manage plugins · Einstellungen ändern: im Terminal /plugin configure limit-bars',
488 },
489}
490
491export const T: Readonly<Record<Lang, Texts>> = { en, de }
492
493/** Einstellungen von `/cache`, in Hilfe und Karte immer in der englischen Form (deutsche Aliase: cache.ts `applySetting`). */
494export const CACHE_ARGS = 'ttl 5|60|auto · warn on|off · big 150k · hints on|off'
495export const CACHE_HINT = '[ttl 5|60|auto] [warn on|off] [big 150k] [hints on|off]'
496export const HANDOFF_HINT = '[continue|show]'
497export const BARS_HINT = '[show 5h|week|cache|storage on|off] [reset] [help]'
498hooks/display.ts 101 lines1// limit-bars: welche Teile der Anzeige zu sehen sind (SPEC.md, Ausbau v0.6.0), reine Funktionen ohne `$`.
2// Zwei Quellen: userConfig (`showFiveHour`, `showWeekly`, `showCache`, `showStorage`, dazu das alte `onlyFiveHour`) und
3// `/bars` (in `$.store` unter `display`, wirkt in allen offenen Chats). Vorrang: ein per Befehl gesetzter Wert, sonst die
4// Einstellung. Abgeschaltet wird nur die Anzeige; Rückfrage, Hinweise und Befehle des Caches bleiben (Fynn, 2026-10-06).
5
6export const PARTS = ['fiveHour', 'weekly', 'cache', 'storage'] as const
7export type Part = (typeof PARTS)[number]
8export type Display = Record<Part, boolean>
9export type Overrides = Partial<Display>
10
11/** Englischer Name je Teil, wie er in `/bars` steht. */
12export const PART_ARG: Readonly<Record<Part, string>> = { fiveHour: '5h', weekly: 'week', cache: 'cache', storage: 'storage' }
13
14const bool = (v: unknown, fallback: boolean) => (typeof v === 'boolean' ? v : fallback)
15
16/**
17 * Stand aus userConfig. Standard wie bis 0.5.x: 5h, Woche und Cache an; Speicher an, zeigt aber nur mit `storagePath` etwas.
18 * `onlyFiveHour: true` schaltet die Woche weiter aus (veraltet, bleibt gültig).
19 */
20export function displayFromOptions(options: Readonly<Record<string, unknown>>): Display {
21 return {
22 fiveHour: bool(options.showFiveHour, true),
23 weekly: bool(options.showWeekly, true) && options.onlyFiveHour !== true,
24 cache: bool(options.showCache, true),
25 storage: bool(options.showStorage, true),
26 }
27}
28
29/** Gespeicherte `/bars`-Werte; nur echte Booleans der vier Teile zählen. */
30export function cleanOverrides(v: unknown): Overrides {
31 const out: Overrides = {}
32 if (!v || typeof v !== 'object') return out
33 for (const p of PARTS) {
34 const x = (v as Record<string, unknown>)[p]
35 if (typeof x === 'boolean') out[p] = x
36 }
37 return out
38}
39
40/** Was gilt: der Befehl schlägt die Einstellung. */
41export function effectiveDisplay(base: Display, ov: Overrides): Display {
42 return { fiveHour: ov.fiveHour ?? base.fiveHour, weekly: ov.weekly ?? base.weekly, cache: ov.cache ?? base.cache, storage: ov.storage ?? base.storage }
43}
44
45const PART_ALIASES: ReadonlyArray<[Part, readonly string[]]> = [
46 ['fiveHour', ['5h', 'five', 'fivehour', '5-hour', '5hour']],
47 ['weekly', ['week', 'weekly', '7d', 'woche', 'wochenlimit']],
48 ['cache', ['cache']],
49 ['storage', ['storage', 'disk', 'speicher']],
50]
51
52/** Teil aus einem Argument, in beiden Sprachen (release/I18N.md §3). */
53export function parsePart(s: string): Part | undefined {
54 const x = s.trim().toLowerCase()
55 return PART_ALIASES.find(([, names]) => names.includes(x))?.[0]
56}
57
58/** `on`/`off` und die deutschen Aliase `an`/`aus`. */
59export function parseOnOff(s: string): boolean | undefined {
60 const x = s.trim().toLowerCase()
61 if (x === 'on' || x === 'an') return true
62 if (x === 'off' || x === 'aus') return false
63 return undefined
64}
65
66export type BarsCommand = { kind: 'status' } | { kind: 'reset' } | { kind: 'help' } | { kind: 'set'; part: Part; on: boolean } | { kind: 'bad' }
67
68// Wortlisten des Parsers, exportiert für den Vollständigkeitstest der Hilfe (docs/HELP-SPEC.md §6 Punkt 5)
69/** `help` und `?`, nur als einziges Wort (HELP-SPEC §2); gilt auch für die Nebenbefehle `/cache`, `/handoff`, `/keepwarm`, `/disk`. */
70export const HELP_WORDS: readonly string[] = ['help', '?']
71export const STATUS_WORDS: readonly string[] = ['status', 'show']
72export const RESET_WORDS: readonly string[] = ['reset', 'zurücksetzen', 'zuruecksetzen']
73export const SHOW_WORDS: readonly string[] = ['show', 'zeigen']
74export const ON_OFF_WORDS: readonly string[] = ['on', 'off', 'an', 'aus']
75export const PART_WORDS: readonly string[] = PART_ALIASES.flatMap(([, names]) => names)
76
77// Wortlisten der Nebenbefehle (register.ts nutzt sie; Vollständigkeitstest der Hilfe)
78export const HANDOFF_SHOW_WORDS: readonly string[] = ['show', 'zeigen']
79export const HANDOFF_CONTINUE_WORDS: readonly string[] = ['continue', 'weiter']
80export const KEEP_OFF_WORDS: readonly string[] = ['off', 'aus']
81export const DISK_WORDS: readonly string[] = ['refresh']
82
83/** Ist das ganze Argument `help` oder `?` (ein Wort, Groß-/Kleinschreibung egal)? */
84export function isHelp(args: string): boolean {
85 const w = args.trim().toLowerCase().split(/\s+/).filter(Boolean)
86 return w.length === 1 && HELP_WORDS.includes(w[0]!)
87}
88
89/** `/bars`, `/bars help`, `/bars reset`, `/bars show <teil> on|off` und kurz `/bars <teil> on|off`. */
90export function parseBars(args: string): BarsCommand {
91 const w = args.trim().toLowerCase().split(/\s+/).filter(Boolean)
92 if (w.length === 0 || (w.length === 1 && STATUS_WORDS.includes(w[0]!))) return { kind: 'status' }
93 if (isHelp(args)) return { kind: 'help' }
94 if (w.length === 1 && RESET_WORDS.includes(w[0]!)) return { kind: 'reset' }
95 const rest = SHOW_WORDS.includes(w[0]!) ? w.slice(1) : w
96 if (rest.length !== 2) return { kind: 'bad' }
97 const part = parsePart(rest[0]!)
98 const on = parseOnOff(rest[1]!)
99 return part && on !== undefined ? { kind: 'set', part, on } : { kind: 'bad' }
100}
101hooks/help.ts 205 lines1// 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}
205hooks/helpdata.ts 119 lines1// limit-bars: Inhalt von `/bars help` (docs/HELP-SPEC.md §5 „limit-bars 0.7.0“), reine Funktion ohne `$`. Gezeichnet wird er
2// von help.ts (Kopie von templates/help/help.ts). Stand beim Aufruf: Was register.ts übergibt, wird nicht live umgeschrieben.
3import type { KeepWarm, Settings } from './cache.ts'
4import { DEFAULT_SETTINGS, tokensText } from './cache.ts'
5import { PART_ARG } from './display.ts'
6import type { Display, Overrides, Part } from './display.ts'
7import type { HelpData, HelpFeature, HelpSetting } from './help.ts'
8import { T, hhmm } from './i18n.ts'
9import type { Lang } from './i18n.ts'
10
11/**
12 * Akzent der Hilfe: Theme-Key `warning` (Gelb), folgt dem Theme hell und dunkel (HELP-SPEC §4, geändert 2026-10-09;
13 * vorher `#F2C94C`, im hellen Theme nur etwa 1,5:1). ThemeKey: types@2.1.295:12590.
14 */
15export const HELP_ACCENT = 'warning'
16
17export type HelpInput = {
18 lang: Lang
19 /** userConfig, wie register() sie bekommt */
20 options: Readonly<Record<string, unknown>>
21 /** Was gerade gilt (Einstellung, darüber `/bars`) und welche Teile per `/bars` gesetzt sind */
22 shown: Display
23 overrides: Overrides
24 /** `storagePath` gesetzt bzw. mit Laufwerksbuchstaben */
25 storagePath: string
26 hasDrive: boolean
27 settings: Settings
28 /** Cache-Dauer dieser Sitzung ohne gesetzten Wert: gemessen oder Standard (cache.ts `CacheMem`) */
29 memTtl: 5 | 60
30 memTtlSource: 'Standard' | 'gemessen'
31 keep: KeepWarm | null
32}
33
34// Standards aus plugin.json (userConfig `default`)
35const DEFAULTS: Readonly<Record<string, string | number | boolean>> = {
36 language: 'en',
37 resetStyle: 'mixed',
38 highlightAt: 90,
39 showFiveHour: true,
40 showWeekly: true,
41 showCache: true,
42 showStorage: true,
43 onlyFiveHour: false,
44}
45const SETTING_KEYS = ['language', 'resetStyle', 'highlightAt', 'showFiveHour', 'showWeekly', 'showCache', 'showStorage', 'onlyFiveHour', 'storagePath'] as const
46
47
48export function barsHelpData(i: HelpInput): HelpData {
49 const t = T[i.lang]
50 const h = t.help
51 const onOff = (on: boolean) => (on ? t.on : t.off)
52
53 // Schalter: der Befehl, der den Zustand ändert (HELP-SPEC §4)
54 const part = (p: Part, name: string): HelpFeature => {
55 const by = i.overrides[p] !== undefined ? h.byCommand : h.bySetting
56 const on = i.shown[p]
57 // Ohne Laufwerk hilft kein /bars: dann ist storagePath der Schalter, auch wenn der Ring per /bars aus ist
58 if (p === 'storage' && !i.hasDrive) {
59 if (!on) return { name, state: { kind: 'off', text: `${onOff(false)} · ${by}` }, toggle: h.setPath }
60 return { name, state: { kind: 'off', text: i.storagePath ? h.noDrive : h.noPath }, toggle: h.setPath }
61 }
62 return { name, state: { kind: on ? 'on' : 'off', text: `${onOff(on)} · ${by}` }, toggle: `/bars ${PART_ARG[p]} ${on ? 'off' : 'on'}` }
63 }
64
65 const s = i.settings
66 // auto: die gemessene Dauer mit Quelle; ohne Messung reicht „(Standard)“ hinter dem Wert
67 const ttlText = s.ttl ? h.ttlSet(String(s.ttl)) : h.ttlAuto(String(i.memTtl), i.memTtlSource === 'gemessen' ? t.srcMeasured : '')
68
69 const settingValue = (k: (typeof SETTING_KEYS)[number]): HelpSetting => {
70 const title = h.settings[k]
71 if (k === 'storagePath') return i.storagePath ? { title, value: i.storagePath } : { title, value: h.empty, isDefault: true }
72 if (k === 'language') return { title, value: i.lang, isDefault: i.lang === DEFAULTS.language }
73 const raw = i.options[k]
74 const v = raw === undefined ? DEFAULTS[k]! : raw
75 const value = typeof v === 'boolean' ? onOff(v) : String(v)
76 return { title, value, isDefault: v === DEFAULTS[k] }
77 }
78
79 return {
80 mod: 'limit-bars',
81 lang: i.lang,
82 intro: h.intro,
83 commands: [
84 { cmd: '/bars', does: h.bars },
85 { cmd: '/bars show 5h|week|cache|storage on|off', does: h.barsShow },
86 { cmd: '/bars reset', does: h.barsReset },
87 { cmd: '/bars help', does: h.barsHelp },
88 { cmd: '/cache', does: h.cache },
89 { cmd: '/cache ttl 5|60|auto', does: h.cacheTtl },
90 { cmd: '/cache warn on|off', does: h.cacheWarn },
91 { cmd: '/cache hints on|off', does: h.cacheHints },
92 { cmd: '/cache big <n>', does: h.cacheBig },
93 { cmd: '/handoff', does: h.handoff },
94 { cmd: '/handoff show', does: h.handoffShow },
95 { cmd: '/handoff continue', does: h.handoffContinue },
96 { cmd: i.lang === 'de' ? '/keepwarm [stunden]' : '/keepwarm [hours]', does: h.keepwarm },
97 { cmd: '/keepwarm off', does: h.keepwarmOff },
98 { cmd: '/disk [refresh]', does: h.disk },
99 { cmd: h.skillCmd, does: h.skill },
100 ],
101 notes: [h.aliases],
102 features: [
103 part('fiveHour', h.fiveHour),
104 part('weekly', h.weekly),
105 part('cache', h.cacheRing),
106 part('storage', h.storageRing),
107 { name: h.guard, state: { kind: s.guard ? 'on' : 'off' }, toggle: `/cache warn ${s.guard ? 'off' : 'on'}` },
108 { name: h.alerts, state: { kind: s.alerts ? 'on' : 'off' }, toggle: `/cache hints ${s.alerts ? 'off' : 'on'}` },
109 { name: h.big, state: { kind: 'value', text: tokensText(s.bigTokens, i.lang), isDefault: s.bigTokens === DEFAULT_SETTINGS.bigTokens }, toggle: '/cache big <n>' },
110 { name: h.ttl, state: { kind: 'value', text: ttlText, isDefault: s.ttl === DEFAULT_SETTINGS.ttl && i.memTtlSource !== 'gemessen' }, toggle: '/cache ttl 5|60|auto' },
111 i.keep
112 ? { name: h.keep, state: { kind: 'on', text: h.keepUntil(hhmm(i.keep.until)) }, toggle: '/keepwarm off' }
113 : { name: h.keep, state: { kind: 'off' }, toggle: h.keepToggle },
114 ],
115 settings: SETTING_KEYS.map(settingValue),
116 footer: { terminal: h.footerTerminal, desktop: h.footerDesktop },
117 }
118}
119hooks/ring.ts 64 lines1// limit-bars: Cache-Ring als Svg-Baustein, reine Funktionen ohne `$`. Stil wie die Balken: Segmente mit Fuge, hier 28 Bögen
2// auf einem glatten Kreis (Mittellinie r = 18, 4 px stark, je Segment etwa 3 px Bogen und 1 px Fuge) in einem Feld von 40 × 40 px.
3// Gefüllt ist der verbleibende Anteil; der Ring leert sich im Uhrzeigersinn ab 12 Uhr (SPEC.md, Ausbau v0.2.0, Anzeige).
4// Fynn (2026-10-05) zum ersten Prototyp aus Pixel-Kacheln: „zu verpixelt, nicht annähernd rund“ – darum Bögen statt Raster.
5import { EMPTY, GREY, ORANGE } from './view.ts'
6
7/** Was der Ring zeigt: Füllung 0..1, Farbe der gefüllten Segmente, Text in der Mitte, Markierung fürs Warmhalten, `alt`. */
8export type RingView = {
9 fill: number
10 color: string
11 main: string // Restzeit (`42m`, `<1m`), `cold`/`kalt` oder `–`
12 mainColor: string
13 sub?: string // Kontext (`412k`)
14 subColor?: string // Farbe der Kontextzahl nach Kontextgröße (cache.ts `contextColor`); fehlt = grau
15 kept: boolean // Warmhalten aktiv: kleiner Punkt über der Restzeit
16 alt: string
17}
18
19export const RING_SEGMENTS = 28
20export const RING_SIZE = 40
21const RING_LABEL_H = 16 // Kürzel "Cache" (0–12) und 4 px Fuge, wie Text und Balken
22export const RING_H = RING_LABEL_H + RING_SIZE // 56, so hoch wie Clawds Bild
23const R = 18 // Mittellinie; außen 20, innen 16
24const STROKE = 4
25const GAP_PX = 1 // Fuge auf der Mittellinie, wie zwischen den Balken-Segmenten
26export const FONT = "ui-monospace, 'Cascadia Mono', Consolas, Menlo, monospace"
27
28/** Anzahl gefüllter Segmente für eine Füllung 0..1; ab einem Rest > 0 mindestens eins. */
29export function ringFilled(fill: number): number {
30 if (!(fill > 0)) return 0
31 return Math.max(1, Math.min(RING_SEGMENTS, Math.round(fill * RING_SEGMENTS)))
32}
33
34const f2 = (n: number) => String(Math.round(n * 100) / 100)
35/** Text für `<text>` im Svg: Sonderzeichen als Entitäten, `·` als `·`. */
36export const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/·/g, '·')
37
38/** Bogen des Segments i (0 = ab 12 Uhr, im Uhrzeigersinn) als Pfad um den Mittelpunkt (cx, cy). */
39function segmentPath(i: number, cx: number, cy: number): string {
40 const step = (2 * Math.PI) / RING_SEGMENTS
41 const half = GAP_PX / 2 / R
42 const a0 = i * step + half
43 const a1 = (i + 1) * step - half
44 // Winkel ab 12 Uhr im Uhrzeigersinn: x = sin, y = −cos
45 const p = (a: number) => `${f2(cx + R * Math.sin(a))} ${f2(cy - R * Math.cos(a))}`
46 return `M${p(a0)}A${R} ${R} 0 0 1 ${p(a1)}`
47}
48
49/** Der Ring samt Kürzel als Svg-Fragment, links oben bei (x, y); belegt RING_SIZE × RING_H. */
50export function ringSvg(r: RingView, x: number, y: number): string {
51 const on = ringFilled(r.fill)
52 const cx = x + RING_SIZE / 2
53 const cy = y + RING_LABEL_H + RING_SIZE / 2
54 let out = `<text x="${cx}" y="${y + 11}" text-anchor="middle" font-family="${FONT}" font-size="12" fill="${ORANGE}">Cache</text>`
55 // Geleert wird ab 12 Uhr: die ersten (n − on) Segmente im Uhrzeigersinn sind leer
56 for (let i = 0; i < RING_SEGMENTS; i++) {
57 out += `<path d="${segmentPath(i, cx, cy)}" fill="none" stroke-width="${STROKE}" stroke="${i >= RING_SEGMENTS - on ? r.color : EMPTY}"/>`
58 }
59 if (r.kept) out += `<rect x="${cx - 1}" y="${cy - 11}" width="2" height="2" fill="${r.color}"/>`
60 out += `<text x="${cx}" y="${cy + 1}" text-anchor="middle" font-family="${FONT}" font-size="11" fill="${r.mainColor}">${esc(r.main)}</text>`
61 if (r.sub) out += `<text x="${cx}" y="${cy + 10}" text-anchor="middle" font-family="${FONT}" font-size="8" fill="${r.subColor ?? GREY}">${esc(r.sub)}</text>`
62 return out
63}
64hooks/storage.ts 349 lines1// limit-bars: Speicher-Ring, reine Funktionen ohne `$` (SPEC.md, Ausbau v0.4.0). Ausgabe der PowerShell-Skripte lesen,
2// Endungen auf Gruppen verteilen, Größen formatieren und den Ring als Svg-Fragment zeichnen. Die Skripte selbst stehen
3// hier, damit Tests prüfen können, dass der Pfad nie im Skripttext steht (nur über LB_PATH).
4import { FONT, RING_H, RING_SEGMENTS, RING_SIZE, esc } from './ring.ts'
5import { EMPTY, GREY, ORANGE } from './view.ts'
6import { T, dec, int } from './i18n.ts'
7import type { Lang } from './i18n.ts'
8
9export const GROUPS = ['programs', 'media', 'models', 'code', 'archives', 'other'] as const
10export type Group = (typeof GROUPS)[number]
11
12// Kategorische Palette im Stil der übrigen limit-bars-Farben (Fynn 2026-10-06: „vom Style her angleichen“): weich, OKLCH-
13// Helligkeit 0,58–0,66 und Sättigung 0,11–0,13 wie ORANGE/RED/GREEN (0,60–0,73 / 0,13–0,15), Farbtöne weg von Grün, Gelb,
14// Rot und Orange. dataviz-Validator --mode dark in dieser Reihenfolge: alle Prüfungen bestanden, ohne Warnung.
15// `other` ist neutral, damit es nicht wie eine eigene Art wirkt
16export const GROUP_COLOR: Readonly<Record<Group, string>> = {
17 programs: '#5497D9',
18 media: '#C1628A',
19 models: '#BF853A',
20 code: '#12A7A7',
21 archives: '#8668B6',
22 other: '#808080',
23}
24
25const EXTS: Readonly<Record<Exclude<Group, 'other'>, string>> = {
26 programs: 'dll exe lib so dylib pyd pyc jar aot dill a o obj pdb node wasm class dex msi sys ocx',
27 media: 'wav mp3 flac ogg m4a aac opus mp4 mov mkv webm avi png jpg jpeg gif webp psd tif tiff bmp ico heic',
28 models: 'pth pt gguf safetensors ckpt onnx bin npy npz h5 tflite parquet index',
29 code: 'py js ts tsx jsx mjs cjs json md txt html css scss dart java kt c cc cpp h hpp rs go cs yaml yml toml xml sql map sh ps1 lock',
30 archives: 'zip 7z rar tar gz tgz xz bz2 apk aab whl nupkg mrpack pack iso',
31}
32
33const GROUP_OF: ReadonlyMap<string, Group> = new Map(
34 (Object.keys(EXTS) as Exclude<Group, 'other'>[]).flatMap((g) => EXTS[g].split(' ').map((x) => ['.' + x, g] as [string, Group])),
35)
36
37/** Gruppe einer Endung (mit Punkt, z. B. `.dll`); unbekannt oder ohne Endung: `other`. */
38export function groupOf(ext: string): Group {
39 return GROUP_OF.get(ext.toLowerCase()) ?? 'other'
40}
41
42// Absoluter Pfad, damit keine powershell.exe aus dem Arbeitsordner der Sitzung zum Zug kommt (Review 0.4.0, Hinweis 1)
43export const PS_ABS = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
44
45// Feste Skripte für Windows PowerShell 5.1 (`powershell.exe`). Ohne doppelte Anführungszeichen, damit das Quoting der
46// Argumente unter Windows nichts verändert. Der Pfad kommt nur aus $env:LB_PATH. Lesen nur Verzeichnisse und Größen.
47export const PROBE_SCRIPT = [
48 "$ErrorActionPreference='Stop'",
49 '$d=New-Object IO.DriveInfo ([IO.Path]::GetPathRoot($env:LB_PATH))',
50 '$t=[char]9',
51 "'total'+$t+$d.TotalSize",
52 "'free'+$t+$d.TotalFreeSpace",
53].join('; ')
54
55// Exit 2: Pfad gibt es nicht; exit 3: der Startordner ist nicht lesbar (sonst sähe ein Tippfehler aus wie „0 Dateien“)
56export const SCAN_SCRIPT = [
57 'if(-not [IO.Directory]::Exists($env:LB_PATH)){ exit 2 }',
58 '$h=@{}; $n=0; $s=0; $t=[char]9; $rp=[IO.FileAttributes]::ReparsePoint; $first=$true',
59 '$st=New-Object System.Collections.Generic.Stack[string]; $st.Push($env:LB_PATH)',
60 'while($st.Count -gt 0){ $d=$st.Pop(); try { $di=New-Object IO.DirectoryInfo $d; ' +
61 'foreach($c in $di.EnumerateDirectories()){ if(-not ($c.Attributes -band $rp)){ $st.Push($c.FullName) } }; ' +
62 'foreach($f in $di.EnumerateFiles()){ $n++; $s+=$f.Length; $e=$f.Extension.ToLowerInvariant(); $h[$e]=$h[$e]+$f.Length } } ' +
63 'catch { if($first){ exit 3 } }; $first=$false }',
64 "'files'+$t+$n",
65 "'scanned'+$t+$s",
66 "foreach($k in $h.Keys){ 'ext'+$t+$k+$t+$h[$k] }",
67].join('; ')
68
69/** Laufwerksbuchstabe eines Windows-Pfads (`E:\…` → `E:`); sonst undefined (dann gibt es keinen Ring). */
70export function driveOf(path: string): string | undefined {
71 const m = /^([A-Za-z]):/.exec(path.trim())
72 return m ? `${m[1]!.toUpperCase()}:` : undefined
73}
74
75export type Drive = { total: number; free: number }
76export type Scan = { at: number; files: number; scanned: number; groups: Record<Group, number> }
77
78const num = (s: string | undefined) => (s !== undefined && /^\d+$/.test(s.trim()) ? Number(s.trim()) : NaN)
79
80/** `total<TAB>n` und `free<TAB>n` aus dem Probe-Skript; unvollständig oder unplausibel → undefined. */
81export function parseProbe(stdout: string): Drive | undefined {
82 const v: Record<string, number> = {}
83 for (const line of stdout.split(/\r?\n/)) {
84 const [k, n] = line.split('\t')
85 if (k === 'total' || k === 'free') v[k] = num(n)
86 }
87 const total = v.total ?? NaN
88 const free = v.free ?? NaN
89 return total > 0 && free >= 0 && free <= total ? { total, free } : undefined
90}
91
92/** Ausgabe des Scan-Skripts → Bytes je Gruppe. Ohne `files`/`scanned` → undefined. `other` bekommt später den Rest. */
93export function parseScan(stdout: string, at: number): Scan | undefined {
94 let files = NaN
95 let scanned = NaN
96 const groups = Object.fromEntries(GROUPS.map((g) => [g, 0])) as Record<Group, number>
97 for (const line of stdout.split(/\r?\n/)) {
98 const p = line.split('\t')
99 if (p[0] === 'files') files = num(p[1])
100 else if (p[0] === 'scanned') scanned = num(p[1])
101 else if (p[0] === 'ext' && p.length === 3) {
102 const b = num(p[2])
103 if (b > 0) groups[groupOf(p[1]!)] += b
104 }
105 }
106 return Number.isFinite(files) && Number.isFinite(scanned) ? { at, files, scanned, groups } : undefined
107}
108
109/** Gruppen für die Anzeige: `other` bekommt zusätzlich, was belegt, aber nicht gescannt ist (System, Papierkorb …). */
110export function shownGroups(drive: Drive, scan: Scan): Record<Group, number> {
111 const used = drive.total - drive.free
112 return { ...scan.groups, other: scan.groups.other + Math.max(0, used - scan.scanned) }
113}
114
115const GIB = 1024 ** 3
116const MIB = 1024 ** 2
117
118/** Kurz wie im Explorer (binär): `512M`, `114G`, ab 1000G `1.2T` (de `1,2T`). */
119export function sizeShort(bytes: number, lang: Lang): string {
120 if (bytes < GIB) return `${Math.round(bytes / MIB)}M`
121 const g = bytes / GIB
122 if (Math.round(g) < 1000) return `${Math.round(g)}G`
123 return `${dec(g / 1024, 1, lang)}T`
124}
125
126/** Lang für Legende und `alt`: `114 GB`, `1.2 TB`, `512 MB`. */
127export function sizeLong(bytes: number, lang: Lang): string {
128 const s = sizeShort(bytes, lang)
129 return `${s.slice(0, -1)} ${s.slice(-1)}B`
130}
131
132/** Ein Bogen auf dem Ring als Anteil 0..1 ab 12 Uhr im Uhrzeigersinn. */
133export type Arc = { from: number; to: number; color: string }
134
135/** Was der Speicher-Ring zeigt. */
136export type DiskView = { label: string; main: string; sub: string; arcs: Arc[]; alt: string }
137
138/**
139 * Bögen in fester Reihenfolge der Gruppen.
140 * - Band (`withFree` aus): Der ganze Ring ist das Belegte (Variante B, Fynn 2026-10-06: anteilig am ganzen Laufwerk war
141 * der farbige Teil bei 14 % Belegung im Band kaum zu erkennen). Ohne Scan ein grauer Ring, ohne Belegung ein leerer.
142 * - `/disk` (`withFree` an, Fynn 2026-10-06): Der Ring ist das ganze Laufwerk, der freie Teil grau (`EMPTY`).
143 */
144export function diskArcs(drive: Drive, scan: Scan | undefined, withFree = false): Arc[] {
145 const used = drive.total - drive.free
146 if (!(used > 0)) return [{ from: 0, to: 1, color: EMPTY }]
147 const g = scan ? shownGroups(drive, scan) : undefined
148 const parts = g ? GROUPS.map((k) => ({ bytes: Math.max(0, g[k]), color: GROUP_COLOR[k] })) : [{ bytes: used, color: GREY }]
149 const sum = parts.reduce((n, p) => n + p.bytes, 0)
150 if (!(sum > 0)) return [{ from: 0, to: 1, color: GREY }]
151 // Mit freiem Teil: Bezug ist das Laufwerk (gescannt kann größer sein als belegt, dann an der Summe)
152 const base = withFree ? Math.max(drive.total, sum + drive.free) : sum
153 const arcs: Arc[] = []
154 let at = 0
155 for (const p of parts) {
156 const w = p.bytes / base
157 if (w <= 0) continue
158 arcs.push({ from: at, to: Math.min(1, at + w), color: p.color })
159 at += w
160 }
161 if (withFree && at < 1) arcs.push({ from: at, to: 1, color: EMPTY })
162 return arcs
163}
164
165/** Kürzel über dem Ring, in beiden Sprachen (Fynn, 2026-10-06: „nicht E:, sondern Storage“). */
166export const DISK_LABEL = 'Storage'
167/** Breite des Felds: das Kürzel ist breiter als der Ring (7 Zeichen bei 12 px Monospace ≈ 51 px). */
168export const DISK_W = 52
169
170/** Ansicht für Band und `/disk`: Kürzel `Storage`, Mitte belegt, darunter `/gesamt` (Fynn, 2026-10-06). */
171export function diskView(drive: string, d: Drive, scan: Scan | undefined, lang: Lang, withFree = false): DiskView {
172 const used = d.total - d.free
173 return {
174 label: DISK_LABEL,
175 main: sizeShort(used, lang),
176 sub: `/${sizeShort(d.total, lang)}`,
177 arcs: diskArcs(d, scan, withFree),
178 alt: T[lang].diskAlt(drive, sizeLong(used, lang), sizeLong(d.total, lang)),
179 }
180}
181
182const R = 18 // wie der Cache-Ring: Mittellinie, außen 20, innen 16
183const STROKE = 4
184const f2 = (n: number) => String(Math.round(n * 100) / 100)
185
186// Segmente mit Fugen wie der Cache-Ring (Fynn 2026-10-06: „geriffelt wie Cache“). Im Band 28 Segmente mit 1 px Fuge, genau
187// wie RING_SEGMENTS; in /disk (doppelt so groß gezeichnet) 56 mit 0,5 px Fuge, das ergibt nach dem Vergrößern dasselbe
188// Verhältnis von Segment zu Fuge (3 : 1) wie im Band.
189export const DISK_SEGMENTS = RING_SEGMENTS
190export const BIG_SEGMENTS = 56
191
192/**
193 * Farbe je Segment: jeder Bogen bekommt Segmente nach seinem Anteil (größter Rest), jeder sichtbare Bogen mindestens eins,
194 * damit auch kleine Gruppen im Ring erscheinen. Fehlt dafür Platz, gibt der größte Bogen ab.
195 */
196export function segmentColors(arcs: readonly Arc[], n: number): string[] {
197 const parts = arcs.map((a) => ({ color: a.color, exact: Math.max(0, a.to - a.from) * n, count: 0 }))
198 for (const p of parts) p.count = p.exact > 0 ? Math.max(1, Math.floor(p.exact)) : 0
199 let total = parts.reduce((s, p) => s + p.count, 0)
200 while (total > n) {
201 const big = parts.reduce((a, b) => (b.count > a.count ? b : a))
202 if (big.count <= 1) break
203 big.count -= 1
204 total -= 1
205 }
206 while (total < n) {
207 const next = parts.reduce((a, b) => (b.exact - b.count > a.exact - a.count ? b : a))
208 next.count += 1
209 total += 1
210 }
211 return parts.flatMap((p) => Array<string>(p.count).fill(p.color)).slice(0, n)
212}
213
214/** Segment i von n als Bogen um (cx, cy), ab 12 Uhr im Uhrzeigersinn, mit `gap` px Fuge auf der Mittellinie. */
215function segmentPath(i: number, n: number, gap: number, cx: number, cy: number): string {
216 const step = (2 * Math.PI) / n
217 const half = gap / 2 / R
218 const p = (t: number) => `${f2(cx + R * Math.sin(t))} ${f2(cy - R * Math.cos(t))}`
219 return `M${p(i * step + half)}A${R} ${R} 0 0 1 ${p((i + 1) * step - half)}`
220}
221
222/** Der Speicher-Ring samt Kürzel als Svg-Fragment, links oben bei (x, y); belegt DISK_W × RING_H, der Ring ist so groß wie der Cache-Ring. */
223export function diskRingSvg(v: DiskView, x: number, y: number, n = DISK_SEGMENTS, gap = 1): string {
224 const cx = x + DISK_W / 2
225 const cy = y + (RING_H - RING_SIZE) + RING_SIZE / 2
226 let out = `<text x="${cx}" y="${y + 11}" text-anchor="middle" font-family="${FONT}" font-size="12" fill="${ORANGE}">${esc(v.label)}</text>`
227 segmentColors(v.arcs, n).forEach((color, i) => {
228 out += `<path d="${segmentPath(i, n, gap, cx, cy)}" fill="none" stroke-width="${STROKE}" stroke="${color}"/>`
229 })
230 out += `<text x="${cx}" y="${cy + 1}" text-anchor="middle" font-family="${FONT}" font-size="11" fill="${GREY}">${esc(v.main)}</text>`
231 out += `<text x="${cx}" y="${cy + 10}" text-anchor="middle" font-family="${FONT}" font-size="8" fill="${GREY}">${esc(v.sub)}</text>`
232 return out
233}
234
235// ---------- /disk ----------
236
237/** Eine Zeile der Legende: Gruppe (oder frei), Bytes, Anteil am Belegten bzw. am Laufwerk (0..1). */
238export type StorageRow = { key: Group | 'free'; name: string; color: string; bytes: number; share: number }
239
240/** Alles, was `/disk` zeigt; aus einem Stand gebaut, damit Text und Zeichnung dieselben Zahlen haben. */
241export type StorageReport = { drive: string; d: Drive; scan?: Scan; view: DiskView; rows: StorageRow[]; free: StorageRow; at: number }
242
243export function storageReport(drive: string, d: Drive, scan: Scan | undefined, now: number, lang: Lang): StorageReport {
244 const t = T[lang]
245 const rows: StorageRow[] = []
246 if (scan) {
247 const g = shownGroups(d, scan)
248 // Gescannt kann größer sein als belegt (Kompression, Hardlinks, Cloud-Platzhalter): Anteile dann an der Summe
249 const base = Math.max(d.total - d.free, GROUPS.reduce((n, k) => n + g[k], 0))
250 for (const k of GROUPS) {
251 if (g[k] > 0) rows.push({ key: k, name: t.groups[k], color: GROUP_COLOR[k], bytes: g[k], share: base > 0 ? g[k] / base : 0 })
252 }
253 rows.sort((a, b) => b.bytes - a.bytes)
254 }
255 const free: StorageRow = { key: 'free', name: t.diskFree, color: EMPTY, bytes: d.free, share: d.free / d.total }
256 return { drive, d, scan, view: diskView(drive, d, scan, lang, true), rows, free, at: now }
257}
258
259/** Prozent mit einer Nachkommastelle: en `58.7%`, de `58,7 %`. */
260export function pct1(share: number, lang: Lang): string {
261 return T[lang].pct(dec(share * 100, 1, lang))
262}
263
264const two = (n: number) => String(n).padStart(2, '0')
265const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] // nur en, de zeigt `06.10.`
266
267/** Zeitpunkt des Scans: heute `10:42`, sonst en `Oct 6 10:42`, de `06.10. 10:42`. */
268export function scanTime(at: number, now: number, lang: Lang): string {
269 const a = new Date(at)
270 const n = new Date(now)
271 const hm = `${two(a.getHours())}:${two(a.getMinutes())}`
272 if (a.toDateString() === n.toDateString()) return hm
273 return lang === 'de' ? `${two(a.getDate())}.${two(a.getMonth() + 1)}. ${hm}` : `${MONTHS[a.getMonth()]} ${a.getDate()} ${hm}`
274}
275
276/** Markdown-Text von `/disk` (für `-p`, VS Code, das Modell); `tag` steht am Ende der ersten Zeile (wie `/ledger`). */
277export function storageMarkdown(r: StorageReport, tag: string, lang: Lang, note = ''): string {
278 const t = T[lang]
279 const used = r.d.total - r.d.free
280 const lines = [`${t.storageTitle(r.drive, sizeLong(used, lang), sizeLong(r.d.total, lang), pct1(used / r.d.total, lang))} ${tag}`, '']
281 if (r.scan) {
282 lines.push(t.storageHead, '|---|---:|---:|')
283 for (const x of r.rows) lines.push(`| ${x.name} | ${sizeLong(x.bytes, lang)} | ${pct1(x.share, lang)} |`)
284 lines.push('', `${t.diskFree}: ${sizeLong(r.d.free, lang)} · ${t.storageOfDrive(pct1(r.free.share, lang))}`)
285 lines.push(t.storageScanned(scanTime(r.scan.at, r.at, lang), int(r.scan.files, lang)))
286 } else {
287 lines.push(`${t.diskFree}: ${sizeLong(r.d.free, lang)} · ${t.storageOfDrive(pct1(r.free.share, lang))}`, t.storageNoScan)
288 }
289 if (note) lines.push('', note)
290 return lines.join('\n')
291}
292
293const BIG = 2 // Vergrößerung des Rings in der Befehlsansicht: 80 × 112 px
294const ROW_H = 20
295const TEXT = '#D0D0D0' // Namen in der Legende (Textfarbe, nicht die Reihenfarbe)
296
297/** Desktop-Bild von `/disk`: großer Ring links, Legende rechts (Farbfeld, Name, Größe, Anteil). */
298export function storageSvg(r: StorageReport, lang: Lang): { source: string; width: number; height: number; alt: string } {
299 const t = T[lang]
300 const ringW = DISK_W * BIG
301 const x0 = ringW + 24
302 const nameW = 190 // längster Name etwa 24 Zeichen bei 12 px Monospace (≈ 7,3 px je Zeichen)
303 const xSize = x0 + 18 + nameW + 64
304 const xPct = xSize + 72
305 // Die Zeile „frei“ trägt rechts den längeren Text „85,7 % des Laufwerks“: das Bild muss ihn ganz fassen
306 const freeText = t.storageOfDrive(pct1(r.free.share, lang))
307 const width = Math.ceil(Math.max(xPct, xSize + 12 + freeText.length * 7.3) + 8)
308 const legend = [...r.rows, r.free]
309 const footer = r.scan ? t.storageScanned(scanTime(r.scan.at, r.at, lang), int(r.scan.files, lang)) : t.storageNoScan
310 const legendH = (legend.length + 1) * ROW_H + 8
311 const height = Math.max(RING_H * BIG, legendH)
312 let body = `<g transform="scale(${BIG})">${diskRingSvg(r.view, 0, 0, BIG_SEGMENTS, 0.5)}</g>`
313 const y0 = Math.max(0, (height - legendH) / 2)
314 legend.forEach((x, i) => {
315 const y = y0 + i * ROW_H
316 const share = x.key === 'free' ? t.storageOfDrive(pct1(x.share, lang)) : pct1(x.share, lang)
317 body +=
318 `<rect x="${x0}" y="${y + 3}" width="10" height="10" rx="2" fill="${x.color}"/>` +
319 `<text x="${x0 + 18}" y="${y + 12}" font-family="${FONT}" font-size="12" fill="${x.key === 'free' ? GREY : TEXT}">${esc(x.name)}</text>` +
320 `<text x="${xSize}" y="${y + 12}" text-anchor="end" font-family="${FONT}" font-size="12" fill="${TEXT}">${esc(sizeLong(x.bytes, lang))}</text>` +
321 `<text x="${x.key === 'free' ? xSize + 12 : xPct}" y="${y + 12}"${x.key === 'free' ? '' : ' text-anchor="end"'} font-family="${FONT}" font-size="12" fill="${GREY}">${esc(share)}</text>`
322 })
323 body += `<text x="${x0}" y="${y0 + legend.length * ROW_H + 14}" font-family="${FONT}" font-size="10" fill="${GREY}">${esc(footer)}</text>`
324 const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${body}</svg>`
325 const alt = [r.view.alt, ...r.rows.map((x) => `${x.name} ${sizeLong(x.bytes, lang)}`), `${t.diskFree} ${sizeLong(r.d.free, lang)}`].join('; ')
326 return { source, width, height, alt }
327}
328
329/** Terminal-Ansicht von `/disk`: Balken aus Zellen je Gruppe (Breite `cols`) und Legendenzeilen. */
330export function storageTerminal(r: StorageReport, cols: number, lang: Lang): { bar: { color: string; n: number }[]; lines: { color: string; text: string }[]; footer: string } {
331 const t = T[lang]
332 const w = Math.max(10, Math.min(60, cols - 2))
333 const bar: { color: string; n: number }[] = []
334 // Wie der große Ring: das ganze Laufwerk, Gruppen nach Größe, der freie Teil zuletzt
335 let drawn = 0
336 for (const a of diskArcs(r.d, r.scan, true)) {
337 const n = Math.round(a.to * w) - drawn
338 if (n > 0) bar.push({ color: a.color, n })
339 drawn += Math.max(0, n)
340 }
341 const nameW = Math.max(...[...r.rows, r.free].map((x) => x.name.length))
342 const lines = [...r.rows, r.free].map((x) => ({
343 color: x.color,
344 text: `${x.name.padEnd(nameW)} ${sizeLong(x.bytes, lang).padStart(7)} ${x.key === 'free' ? t.storageOfDrive(pct1(x.share, lang)) : pct1(x.share, lang).padStart(7)}`,
345 }))
346 const footer = r.scan ? t.storageScanned(scanTime(r.scan.at, r.at, lang), int(r.scan.files, lang)) : t.storageNoScan
347 return { bar, lines, footer }
348}
349hooks/svg.ts 70 lines1// limit-bars: Desktop-Grafik. Ein Svg-Bild (ohne isInteractive, types@2.1.288:11578-11605), 4-px-Raster, fest gestapelt:
2// je Fenster eine Textzeile (12 px) und darunter ein Segmentbalken (8 px); 5h oben, Woche unten, unten bündig.
3// Rechts daneben im selben Bild der Cache-Ring (ring.ts) und der Speicher-Ring (storage.ts), damit sie sicher zwischen
4// Balken und Clawd sitzen.
5import { FONT, RING_H, RING_SIZE, esc, ringSvg } from './ring.ts'
6import type { RingView } from './ring.ts'
7import { DISK_W, diskRingSvg } from './storage.ts'
8import type { DiskView } from './storage.ts'
9import { EMPTY, GREY, ORANGE } from './view.ts'
10import type { Shown } from './view.ts'
11
12const SEGMENTS = 44 // je 4 px: 3 px Segment, 1 px Fuge
13const PITCH = 4
14const SEG = 3
15const BAR_H = 8
16const GROUP = 24 // Text 0–12, Fuge 4, Balken 16–24
17const GROUP_GAP = 8
18const SPACE_RIGHT = 8 // Abstand zu Clawd
19const BAR_W = SEGMENTS * PITCH // 176
20
21function group(s: Shown, top: number): string {
22 const dim = s.fresh ? ' opacity="0.55"' : ''
23 const w = s.strong ? ' font-weight="700"' : ''
24 const rest = s.reset ? ` · ${s.reset.long}` : ''
25 const text =
26 `<text x="0" y="${top + 11}" font-family="${FONT}" font-size="12"${dim}>` +
27 `<tspan fill="${ORANGE}"${w}>${s.tag}</tspan>` +
28 `<tspan fill="${s.fresh ? GREY : s.color}"${w}> ${esc(s.pct.long)}</tspan>` +
29 (rest ? `<tspan fill="${GREY}">${esc(rest)}</tspan>` : '') +
30 `</text>`
31 const on = Math.max(0, Math.min(SEGMENTS, Math.round(s.ratio * SEGMENTS)))
32 let segs = ''
33 for (let i = 0; i < SEGMENTS; i++) {
34 segs += `<rect x="${i * PITCH}" y="${top + 16}" width="${SEG}" height="${BAR_H}" rx="0.5" fill="${i < on ? s.color : EMPTY}"/>`
35 }
36 return text + segs
37}
38
39const PAD_LEFT = 10
40const RING_GAP = 8 // Abstand Balken – Ring
41
42/**
43 * Das Bild für bis zu zwei Fenster, optional den Cache-Ring und den Speicher-Ring rechts daneben (zwischen Balken und
44 * Clawd). Höhe ohne Ring 24 (ein Fenster) bzw. 56 (zwei); mit einem Ring immer 56, die Balken dann unten bündig.
45 */
46export function desktopSvg(
47 shown: readonly Shown[],
48 ring?: RingView,
49 disk?: DiskView,
50): { source: string; width: number; height: number; alt: string } {
51 const list = shown.slice(0, 2)
52 const barsH = list.length > 0 ? list.length * GROUP + (list.length - 1) * GROUP_GAP : 0
53 const height = ring || disk ? Math.max(RING_H, barsH) : barsH
54 // Etwas Luft zum linken Rand des Bands (Fynn): der Inhalt rückt im Bild um PAD_LEFT nach rechts
55 let x = list.length > 0 ? BAR_W : 0
56 const place = (w: number) => {
57 const at = x > 0 ? x + RING_GAP : 0
58 x = at + w
59 return at
60 }
61 const dy = height - barsH
62 let body = list.map((s, i) => group(s, dy + i * (GROUP + GROUP_GAP))).join('')
63 if (ring) body += ringSvg(ring, place(RING_SIZE), height - RING_H)
64 if (disk) body += diskRingSvg(disk, place(DISK_W), height - RING_H)
65 const width = PAD_LEFT + x + SPACE_RIGHT
66 const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><g transform="translate(${PAD_LEFT} 0)">${body}</g></svg>`
67 const alt = [...list.map((s) => `${s.name} ${s.pct.alt}${s.reset ? `, ${s.reset.alt}` : ''}`), ...(ring ? [ring.alt] : []), ...(disk ? [disk.alt] : [])].join('; ')
68 return { source, width, height, alt }
69}
70hooks/view.ts 177 lines1// limit-bars: reine Anzeige-Logik ohne `$`. Aus den Fenstern der letzten Messung und der Uhrzeit wird,
2// was je Fenster zu sehen ist (Texte lang und kurz, Farbe, Füllung), und im Terminal die Aufteilung der Breite.
3import { T, hhmm } from './i18n.ts'
4import type { Lang } from './i18n.ts'
5
6export type Kind = 'five_hour' | 'seven_day'
7export type Win = { kind: Kind; percentUsed: number; resetsAt?: number }
8export type ResetStyle = 'mixed' | 'clock' | 'countdown'
9export type Opts = { resetStyle: ResetStyle; highlightAt: number; onlyFiveHour: boolean; lang: Lang }
10
11// Farben aus der clawd-buddy-Palette (stage.ts PAL E/Y/M/D)
12export const ORANGE = '#D77757'
13export const GREEN = '#6CC070'
14export const YELLOW = '#F2C94C'
15export const RED = '#C9594B'
16export const EMPTY = '#4A4A4A'
17export const GREY = '#9A9A9A'
18
19export type Shown = {
20 tag: '5h' | '7d'
21 name: string // für `alt`
22 color: string // Füllung und Prozentwert
23 strong: boolean // ab highlightAt bzw. voll: fett
24 fresh: boolean // Reset vorbei, noch keine neue Messung: gedimmt
25 ratio: number // 0..1, Anteil der Füllung
26 /** Ganze Prozent: `long` (en `71%`, de `71 %`), `short` (`71%`), `alt` für den alt-Text. */
27 pct: { long: string; short: string; alt: string }
28 reset?: { long: string; short: string; alt: string }
29}
30
31const KINDS: readonly Kind[] = ['five_hour', 'seven_day']
32
33/** Übernimmt aus `rateLimits` (types@2.1.288:10597-10614) nur `five_hour` und `seven_day`; ein unlesbares `resetsAt` gilt als fehlend. */
34export function pickWindows(rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]): Win[] {
35 const out: Win[] = []
36 for (const kind of KINDS) {
37 const r = rateLimits.find((x) => x.kind === kind)
38 if (!r || typeof r.percentUsed !== 'number' || !Number.isFinite(r.percentUsed)) continue
39 const t = typeof r.resetsAt === 'string' ? Date.parse(r.resetsAt) : NaN
40 out.push({ kind, percentUsed: r.percentUsed, resetsAt: Number.isFinite(t) ? t : undefined })
41 }
42 return out
43}
44
45/** Uhrzeit des Resets in lokaler Zeit: am selben Tag `14:30`, sonst en `Mon 09:00` / de `Mo 09:00` (kurz `Mon9:00`). */
46export function clockText(at: number, now: number, lang: Lang): { long: string; short: string } {
47 const d = new Date(at)
48 const n = new Date(now)
49 const hm = hhmm(at)
50 const sameDay = d.getFullYear() === n.getFullYear() && d.getMonth() === n.getMonth() && d.getDate() === n.getDate()
51 if (sameDay) return { long: hm, short: hm }
52 const wd = T[lang].weekdays[d.getDay()]
53 return { long: `${wd} ${hm}`, short: `${wd}${d.getHours()}:${hm.slice(3)}` }
54}
55
56/** Restzeit bis zum Reset, abgerundet: `in 3 d 4 h` (de `in 3 T 4 h`), `in 2 h 14 min`, `in 14 min`, `in < 1 min`. */
57export function countdownText(ms: number, lang: Lang): { long: string; short: string } {
58 const min = Math.floor(Math.max(0, ms) / 60000)
59 const d = Math.floor(min / 1440)
60 const h = Math.floor((min % 1440) / 60)
61 const m = min % 60
62 const day = T[lang].dayUnit
63 if (d >= 1) return { long: `in ${d} ${day} ${h} h`, short: `${d}${day}${h}h` }
64 if (h >= 1) return { long: `in ${h} h ${m} min`, short: `${h}h${String(m).padStart(2, '0')}` }
65 if (m >= 1) return { long: `in ${m} min`, short: `${m}m` }
66 return { long: 'in < 1 min', short: '<1m' }
67}
68
69/**
70 * Platzhalter, solange Claude Code noch keine Limits kennt (vor der ersten API-Antwort): `5h –` und `7d –`, leerer Balken,
71 * grau. Nur auf dem Desktop, damit die Balken wie der Ring sofort dastehen (Fynn, 2026-10-05).
72 */
73export function placeholderWindows(opts: Opts): Shown[] {
74 const t = T[opts.lang]
75 const one = (tag: '5h' | '7d', name: string): Shown => ({
76 tag,
77 name,
78 color: GREY,
79 strong: false,
80 fresh: true, // grau wie nach dem Reset
81 ratio: 0,
82 pct: { long: '–', short: '–', alt: t.unknown },
83 })
84 return opts.onlyFiveHour ? [one('5h', t.fiveName)] : [one('5h', t.fiveName), one('7d', t.weekName)]
85}
86
87/** Was je Fenster zu sehen ist, 5h zuerst. */
88export function shownWindows(wins: readonly Win[], now: number, opts: Opts): Shown[] {
89 const t = T[opts.lang]
90 const out: Shown[] = []
91 for (const w of wins) {
92 const five = w.kind === 'five_hour'
93 if (!five && opts.onlyFiveHour) continue
94 const tag = five ? '5h' : '7d'
95 const name = five ? t.fiveName : t.weekName
96 if (w.resetsAt !== undefined && now >= w.resetsAt) {
97 const zero = t.pct('0')
98 out.push({
99 tag,
100 name,
101 color: GREEN,
102 strong: false,
103 fresh: true,
104 ratio: 0,
105 pct: { long: zero, short: '0%', alt: zero },
106 reset: { long: t.fresh, short: t.fresh, alt: t.freshAlt },
107 })
108 continue
109 }
110 const p = w.percentUsed
111 const full = p >= 100
112 const color = full || p >= 85 ? RED : p >= 60 ? YELLOW : GREEN
113 // Ganze Prozent, kaufmännisch gerundet wie die Usage-Anzeige der App (Fynn: Balken zeigte 58, /usage 59);
114 // "100" erst, wenn das Fenster wirklich voll ist, darunter höchstens 99.
115 const n = String(Math.min(99, Math.max(0, Math.round(p))))
116 const useClock = opts.resetStyle === 'clock' || (opts.resetStyle === 'mixed' && !five)
117 let reset: Shown['reset']
118 if (w.resetsAt !== undefined) {
119 const r = useClock ? clockText(w.resetsAt, now, opts.lang) : countdownText(w.resetsAt - now, opts.lang)
120 reset = { ...r, alt: t.resetAlt(r.long) }
121 }
122 out.push({
123 tag,
124 name,
125 color,
126 strong: full || p >= opts.highlightAt,
127 fresh: false,
128 ratio: full ? 1 : Math.max(0, Math.min(1, p / 100)),
129 pct: full ? { long: t.full, short: t.full, alt: t.full } : { long: t.pct(n), short: `${n}%`, alt: t.pct(n) },
130 reset,
131 })
132 }
133 return out
134}
135
136export type Block = { shown: Shown; width: number; pct: string; rest: string }
137export type TermLayout = { blocks: Block[]; width: number; extra?: string }
138
139const MIN_BLOCK = 12
140const GAP = 2
141
142function block(s: Shown, short: boolean): Block {
143 const pct = short ? s.pct.short : s.pct.long
144 const rest = s.reset ? (short ? ` ${s.reset.short}` : ` · ${s.reset.long}`) : ''
145 return { shown: s, pct, rest, width: Math.max(MIN_BLOCK, s.tag.length + 1 + pct.length + rest.length) }
146}
147
148/**
149 * Terminal: verfügbar sind `min(44, bodyColumns − 40)` Spalten, dieselbe Regel wie clawd-buddy v0.1.4 (register.ts:322).
150 * Blöcke nebeneinander, 2 Spalten Fuge, 1 Spalte Abstand zu Clawd; `width` ist die ganze Breite einschließlich dieses Abstands.
151 * Stufen: lang mit Cache-Block (`extra`, z. B. `◔ 42m`) → lang → kurz → nur das erste Fenster (lang, dann kurz) → nichts.
152 * Ohne Fenster steht der Cache-Block allein, sofern er passt.
153 */
154export function layoutTerminal(shown: readonly Shown[], bodyColumns: number, extra?: string): TermLayout | null {
155 const avail = Math.min(44, bodyColumns - 40)
156 if (avail < MIN_BLOCK + 1) return null
157 if (shown.length === 0) return extra && extra.length + 1 <= avail ? { blocks: [], width: extra.length + 1, extra } : null
158 if (extra) {
159 const blocks = shown.map((s) => block(s, false))
160 const used = blocks.reduce((a, b) => a + b.width, 0) + GAP * blocks.length + extra.length
161 if (used + 1 <= avail) return { blocks, width: used + 1, extra }
162 }
163 // Langform zuerst, wird es eng die Kurzform
164 const tries: Block[][] = [shown.map((s) => block(s, false)), shown.map((s) => block(s, true))]
165 if (shown.length > 1) tries.push([block(shown[0], false)], [block(shown[0], true)])
166 for (const blocks of tries) {
167 const used = blocks.reduce((a, b) => a + b.width, 0) + GAP * (blocks.length - 1)
168 if (used + 1 <= avail) return { blocks, width: used + 1 }
169 }
170 return null
171}
172
173/** Gefüllte Zellen eines Balkens der Breite `width`. */
174export function filled(ratio: number, width: number): number {
175 return Math.max(0, Math.min(width, Math.round(ratio * width)))
176}
177