SLOPSHOPPER

limit-bars

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…

newbandrowscommandtoastprompt
★ 3v0.7.2MITupdated 2026-10-09FynnXland/fynn-mods/mods/limit-bars
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · limit-bars
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /cache ⎿ limit-bars: ### Prompt cache · not known yet ⎿ limit-bars: ⎿ limit-bars: No request in this chat yet. The ring starts with the first reply. ⎿ limit-bars: ⎿ limit-bars: | | | ⎿ limit-bars: |---|---| 5h 31% ⟨Claude Code's own drawing⟩ ▄▄▄▄▄▄▄▄▄▄▄▄ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h 31% ⟨Claude Code's own drawing⟩ ▄▄▄▄▄▄▄▄▄▄▄▄
README

limit-bars

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

Display

SurfaceAppearance
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
Terminal2 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
  • Color of the fill and the percentage: green below 60 %, yellow from 60 %, red from 85 %. The empty part is dark grey, the 5h/7d tag orange (#D77757).
  • From highlightAt (default 90 %) the tag and percentage turn bold. From 100 % it reads full and the bar is all red.
  • After a reset, until a new measurement arrives: 0% · fresh, dimmed, with an empty bar.
  • Before Claude Code knows the limits (new chat, before the first reply), the desktop app shows empty, dimmed bars with 5h – and 7d –, and the ring shows –. In the terminal the bars only appear once there are values.
  • Data source: $.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.

Cache ring

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).

  • Ring: 28 segments; the filled part is the remaining cache time, emptying clockwise from 12 o'clock. In the middle the remaining time (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.
  • Colors: green = warm · yellow = last 5 min (with a 5-minute cache, the last minute) · cold is grey, from "big" (default 150k) all red · – before the first request · a small dot in the middle = being kept warm.
  • Cache duration: default 60 min (measured on a subscription: after a 6.8 min pause the cache was read, not rewritten). The mod re-checks: a cache hit after a pause of more than 5.5 min confirms 60, a rewrite after 5.5 to 60 min indicates 5. Override with /cache ttl 5|60|auto.
  • Data source: every request of the main loop (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.

Storage ring

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.

  • Label: Storage above the ring (like Cache above the cache ring).
  • Ring in the band: the whole ring is the used space, split by file type in a fixed order: programs & libraries (blue), media (rose), AI models & data (ochre), code & text (teal), archives & packages (lavender), other (grey). Like the cache ring it is made of 28 segments with small gaps; the colors are soft tones in the style of the bars. In the middle the used space (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.
  • Grouping: by file extension (.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".
  • Data source: Windows PowerShell (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.

Customize

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.

PartSetting (/config → limit-bars)CommandDefault
5-hour barshowFiveHour`/bars show 5h on\off`on
Weekly barshowWeekly`/bars show week on\off`on
Cache ringshowCache`/bars show cache on\off`on
Storage ringshowStorage (and storagePath)`/bars show storage on\off`off (no storagePath)
  • Two ways, one rule: a value set with /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.
  • Hiding the cache ring only hides the ring. The question before a cold send, the notice before expiry, /cache, /handoff and /keepwarm keep working; turn them off with /cache warn off and /cache hints off.
  • Hiding the storage ring stops the background queries and scans (no PowerShell). /disk still scans when you call it and storagePath is set. /bars show storage on without storagePath tells you where to set it.
  • Everything hidden: limit-bars draws nothing and keeps no space free in the band; other mods (clawd-buddy, quick-replies) use the full width.
  • Short forms and German aliases: /bars 5h off, /bars woche aus, /bars speicher an, /bars cache aus.
  • onlyFiveHour still works (on = weekly bar hidden) but is deprecated; use showWeekly.

Commands

CommandWhat it does
/cacheEverything 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
/handoffClaude 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 showApply the last handoff afterwards (clear and continue with it), or show it (the last 3 are kept in $.store)
/keepwarm [hours] · /keepwarm offKeep 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 refreshDrive 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 helpAnswer 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.

Configuration

/config → limit-bars:

KeyTitle in /configMeaningDefault
languageLanguage / Spracheen: English · de: German. Covers the bars, the ring, alt texts, /cache, /handoff, /keepwarm, questions, notices, and the language of the handoff the skill writesen
resetStyleReset displaymixed: 5h as a countdown, week as a clock time · clock: both as clock times · countdown: both as countdownsmixed
highlightAtHighlight from (%)from this percentage the tag and value are bold (50 to 100)90
showFiveHourShow 5-hour barthe bar for the 5-hour limiton
showWeeklyShow weekly barthe bar for the weekly limiton
showCacheShow cache ringthe cache ring (the cache guard stays)on
showStorageShow storage ringthe storage ring; only shown with storagePathon
onlyFiveHour5-hour limit only (deprecated)on: weekly bar hidden; use showWeekly insteadoff
storagePathStorage drivedrive for the storage ring, for example E:\ (Windows only); empty: no storage ring and no PowerShellempty

Language

  • 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).
  • Formats: en 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.
  • Commands and arguments are English in both languages; the German arguments stay valid as aliases.
  • The settings titles in /config are English only.

Rights

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)
  • Hook session.measure: takes the percentage and reset time of five_hour and seven_day after every measurement
  • Hook turn.step: reads, for every request of the main loop, how much was read from the cache and how much was written; changes nothing
  • Hook turn.complete: notes the end of a turn and captures the reply of the handoff turn
  • Hook session.compact: notes a compaction (the first request afterwards does not count as a cold restart)
  • Hook prompt.submit: the question before a cold send (only for a big chat with a cold cache), otherwise passes through unchanged
  • Hooks command.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)
  • Hook ui.render for CommandOutput of disk: draws the ring with the legend in place of the Markdown row of /disk
  • Hook ui.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 hook

Installation

Add 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

Working with clawd-buddy

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.

Known limitations

  • Before the first API response Claude Code knows no limits; the bars appear with the first response (at most 10 s later). The CLI queries at startup by itself, the desktop app apparently only with the first message.
  • Clawd on the left (clawd-buddy side: left): the bars and Clawd can both end up on the left. There is no separate option for this.
  • Countdown in both windows (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.
  • Commands from hooks: Claude Code rejects $.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.
  • Notice shortly before expiry only appears while this chat's band is being drawn (10 s tick). Keep-warm has its own tick and also runs without the band.
  • Cache estimate: the ring counts from the start of the last main-loop request. If something else reads the same cache, it does not see that.
  • Fixed colors in the desktop image: the Svg is an image and does not follow the theme. The grey #9A9A9A and the level colors are chosen for the dark theme; legibility in the light theme has not been checked yet.
  • Exactly 100 %? It is not documented whether an exhausted 5-hour or weekly window reports exactly 100. full applies from ≥ 100; 99.6 % shows 99%.
  • Terminal narrower than 53 columns: no bars.
  • Giving up space in the terminal relies on observed behavior: Claude Code's type definitions call 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).
  • Storage ring on a folder: 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".
  • Windows folder: PowerShell is started by its full path C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe. If Windows is installed elsewhere, the storage ring stays empty and /disk shows the error.
  • Storage ring and $.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.
  • Scan duration: about 10 to 20 s for 560,000 files on an SSD. A network drive mapped to a letter is scanned too and can hit the 2-minute limit; after a failed scan, automatic scans pause for 24 h (/disk refresh still works).
  • Time zone: the clock-time display uses the local time of the hooks runtime (new Date). Which time zone that is, is not documented; check that Mon 09:00 matches your own clock.
Source 11 files
hooks/register.ts 1180 lines
1// 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}
1180
hooks/band.ts 108 lines
1// 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
108
hooks/cache.ts 383 lines
1// 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}
383
hooks/i18n.ts 498 lines
1// 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]'
498
hooks/display.ts 101 lines
1// 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}
101
hooks/help.ts 205 lines
1// help.ts: die gezeichnete Hilfe-Tabelle für `/<befehl> help` (docs/HELP-SPEC.md §3-§4). Allgemein gehalten: Eine Mod
2// liefert nur `HelpData` und ihre Akzentfarbe; Aufbau, Spalten, Farben der Schalter und die Markdown-Fassung stehen hier.
3// Vorlage für alle Mods: templates/help/ (README dort). Ohne `$`, nur Daten → Baum bzw. Text, deshalb ohne Engine testbar.
4//
5// Baum aus reinen Daten {type, props, children} (types RenderElement), nur Box und Text mit erlaubten Props. Desktop:
6// Spaltenbreiten nur als ganzzahlige Prozent, sonst verwirft er den ganzen Baum (cost-ledger 0.3.1). Unter 60 Spalten
7// stehen die Spalten untereinander.
8import type { RenderElement, RenderNode } from 'claude-code'
9
10export type HelpLang = 'en' | 'de'
11export type HelpSurface = 'terminal' | 'desktop'
12
13/** Eine Zeile unter BEFEHLE bzw. BEDIENUNG: Befehl oder Bedienelement (Akzentfarbe) und seine Wirkung. */
14export type HelpCommand = { cmd: string; does: string }
15/**
16 * Zustand einer Funktion: Schalter (`on` → „● an“ in `success`, `off` → „○ aus“ in `inactive`) oder ein Wert als Text, mit
17 * „(Standard)“, wenn `isDefault`. `text` ersetzt „an“/„aus“; bei `on` steht er dann in der normalen Schriftfarbe (lange
18 * Texte bleiben lesbar), nur der Punkt ist grün.
19 */
20export type HelpState = { kind: 'on' | 'off'; text?: string } | { kind: 'value'; text: string; isDefault?: boolean }
21/** Eine Zeile unter FUNKTIONEN; `toggle` ist der Befehl, der den Zustand ändert, sonst z. B. „Einstellung“ oder „nur Info“. */
22export type HelpFeature = { name: string; state: HelpState; toggle: string }
23/** Eine Zeile unter EINSTELLUNGEN: Titel des userConfig-Felds (übersetzt) und sein aktueller Wert. */
24export type HelpSetting = { title: string; value: string; isDefault?: boolean }
25/** Schnappschuss beim Aufruf von `help`; die Zeichnung schreibt sich danach nicht um (HELP-SPEC §3 Punkt 3). */
26export type HelpData = {
27  /** Name der Mod im Titel */
28  mod: string
29  lang: HelpLang
30  /** Ein Satz, was die Mod macht */
31  intro: string
32  commands: HelpCommand[]
33  /** Gedimmte Zeilen unter den Befehlen (z. B. Aliase) */
34  notes?: string[]
35  /** BEDIENUNG: nur, wenn es Klicks oder Tasten gibt */
36  controls?: HelpCommand[]
37  features: HelpFeature[]
38  settings: HelpSetting[]
39  /** Weg zum Ändern der Einstellungen und zum Abschalten der Mod; Terminal und Desktop brauchen verschiedene Wege */
40  footer: { terminal: string; desktop: string }
41}
42
43const LABELS = {
44  en: {
45    help: 'Help',
46    commands: 'COMMANDS',
47    controls: 'CONTROLS',
48    features: 'FEATURES',
49    status: 'STATUS',
50    toggle: 'TOGGLE',
51    settings: 'SETTINGS (/plugin)',
52    value: 'VALUE',
53    on: 'on',
54    off: 'off',
55    isDefault: '(default)',
56  },
57  de: {
58    help: 'Hilfe',
59    commands: 'BEFEHLE',
60    controls: 'BEDIENUNG',
61    features: 'FUNKTIONEN',
62    status: 'STATUS',
63    toggle: 'UMSCHALTEN',
64    settings: 'EINSTELLUNGEN (/plugin)',
65    value: 'WERT',
66    on: 'an',
67    off: 'aus',
68    isDefault: '(Standard)',
69  },
70} as const
71
72export function helpLabels(lang: HelpLang) {
73  return LABELS[lang]
74}
75
76type Props = Record<string, string | number | boolean>
77const el = (type: 'Box' | 'Text', props: Props, children: RenderNode[]): RenderElement => ({ type, props, children })
78const text = (s: string, props: Props = {}) => el('Text', props, [s])
79const dim = (s: string) => text(s, { dimColor: true })
80const row = (props: Props, kids: RenderNode[]) => el('Box', { flexDirection: 'row', ...props }, kids)
81const col = (props: Props, kids: RenderNode[]) => el('Box', { flexDirection: 'column', ...props }, kids)
82const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
83
84/** Zustand als Text (für die Markdown-Fassung und zum Messen der Spaltenbreite). */
85export function stateText(s: HelpState, lang: HelpLang): string {
86  const L = LABELS[lang]
87  if (s.kind === 'value') return s.isDefault ? `${s.text} ${L.isDefault}` : s.text
88  return `${s.kind === 'on' ? '●' : '○'} ${s.text ?? (s.kind === 'on' ? L.on : L.off)}`
89}
90
91/**
92 * Zustand gezeichnet: „● an“ in `success`, „○ aus“ in `inactive`; mit eigenem Text bei `on` nur der Punkt grün. Werte in
93 * der normalen Schriftfarbe, „(Standard)“ gedimmt.
94 */
95function stateNode(s: HelpState, lang: HelpLang): RenderElement {
96  const L = LABELS[lang]
97  if (s.kind === 'value') return el('Text', {}, [text(s.text), ...(s.isDefault ? [dim(` ${L.isDefault}`)] : [])])
98  const on = s.kind === 'on'
99  const label = s.text ?? (on ? L.on : L.off)
100  const labelProps: Props = on ? (s.text === undefined ? { color: 'success' } : {}) : { color: 'inactive' }
101  return el('Text', {}, [text(on ? '● ' : '○ ', { color: on ? 'success' : 'inactive' }), text(label, labelProps)])
102}
103
104/**
105 * Spalten einer Zeile. Terminal: feste Zellen, die letzte füllt den Rest. Desktop: ganzzahlige Prozent mit Summe 100
106 * (`share` = gewünschte Breite in Zeichen, daraus die Anteile).
107 */
108function columns(sf: HelpSurface, inner: number, widths: number[], kids: RenderNode[], props: Props = {}): RenderElement {
109  if (sf === 'desktop') {
110    const pct = widths.slice(0, -1).map((w) => clamp(Math.round((w / inner) * 100), 10, 60))
111    const rest = Math.max(10, 100 - pct.reduce((a, b) => a + b, 0))
112    const all = [...pct, rest]
113    // Summe genau 100: Überhang vom größten Anteil abziehen
114    const over = all.reduce((a, b) => a + b, 0) - 100
115    if (over > 0) all[all.indexOf(Math.max(...all))]! -= over
116    return row(props, kids.map((k, i) => el('Box', { width: `${all[i]}%`, paddingRight: 1 }, [k])))
117  }
118  return row(
119    props,
120    kids.map((k, i) => (i < kids.length - 1 ? el('Box', { width: widths[i]!, flexShrink: 0 }, [k]) : el('Box', { flexGrow: 1, flexShrink: 1 }, [k]))),
121  )
122}
123
124const heading = (s: string, accent: string) => text(s, { color: accent, bold: true })
125
126/**
127 * Der ganze Baum für eine `CommandOutput`-Zeile. `columns` ist `e.viewport?.columns` (begrenzt auf 30-140), `accent` die
128 * Akzentfarbe der Mod (Hex oder Theme-Key), nur für Titel, Überschriften und Befehle.
129 */
130export function helpTree(d: HelpData, columnsHint: number, surface: HelpSurface, accent: string): RenderElement {
131  const L = LABELS[d.lang]
132  const cols = clamp(columnsHint || 100, 30, 140)
133  const inner = cols - 4 // Rahmen und paddingX
134  const narrow = cols < 60
135  const kids: RenderNode[] = [heading(`${d.mod} · ${L.help}`, accent), text(d.intro)]
136
137  const commandRows = (title: string, list: HelpCommand[]) => {
138    if (!list.length) return
139    kids.push(el('Box', { marginTop: 1 }, [heading(title, accent)]))
140    const cmdW = clamp(Math.max(...list.map((c) => c.cmd.length)) + 2, 12, Math.floor(inner * 0.5))
141    for (const c of list)
142      kids.push(
143        narrow
144          ? col({}, [text(c.cmd, { color: accent }), el('Box', { paddingLeft: 2 }, [text(c.does)])])
145          : columns(surface, inner, [cmdW, inner - cmdW], [text(c.cmd, { color: accent }), text(c.does)]),
146      )
147  }
148  commandRows(L.commands, d.commands)
149  for (const n of d.notes ?? []) kids.push(dim(n))
150  commandRows(L.controls, d.controls ?? [])
151
152  if (d.features.length) {
153    const nameW = clamp(Math.max(L.features.length, ...d.features.map((f) => f.name.length)) + 2, 12, Math.floor(inner * 0.34))
154    const stateW = clamp(Math.max(L.status.length, ...d.features.map((f) => stateText(f.state, d.lang).length)) + 2, 10, Math.floor(inner * 0.4))
155    if (narrow) {
156      kids.push(el('Box', { marginTop: 1 }, [heading(L.features, accent)]))
157      // Zustand und Umschalten in einem Text, damit sie als ein Absatz umbrechen statt als zwei schmale Spalten
158      for (const f of d.features)
159        kids.push(col({}, [text(f.name), el('Box', { paddingLeft: 2 }, [el('Text', {}, [stateNode(f.state, d.lang), dim(` · ${f.toggle}`)])])]))
160    } else {
161      const widths = [nameW, stateW, inner - nameW - stateW]
162      kids.push(columns(surface, inner, widths, [heading(L.features, accent), heading(L.status, accent), heading(L.toggle, accent)], { marginTop: 1 }))
163      for (const f of d.features) kids.push(columns(surface, inner, widths, [text(f.name), stateNode(f.state, d.lang), dim(f.toggle)]))
164    }
165  }
166
167  if (d.settings.length) {
168    const titleW = clamp(Math.max(L.settings.length, ...d.settings.map((s) => s.title.length)) + 2, 12, Math.floor(inner * 0.5))
169    const value = (s: HelpSetting) => stateNode({ kind: 'value', text: s.value, isDefault: s.isDefault }, d.lang)
170    if (narrow) {
171      kids.push(el('Box', { marginTop: 1 }, [heading(L.settings, accent)]))
172      for (const s of d.settings) kids.push(col({}, [text(s.title), el('Box', { paddingLeft: 2 }, [value(s)])]))
173    } else {
174      const widths = [titleW, inner - titleW]
175      kids.push(columns(surface, inner, widths, [heading(L.settings, accent), heading(L.value, accent)], { marginTop: 1 }))
176      for (const s of d.settings) kids.push(columns(surface, inner, widths, [text(s.title), value(s)]))
177    }
178  }
179
180  kids.push(el('Box', { marginTop: 1 }, [dim(surface === 'desktop' ? d.footer.desktop : d.footer.terminal)]))
181  return col({ borderStyle: 'round', borderDimColor: true, paddingX: 1, width: '100%', key: `${d.mod}-help` }, kids)
182}
183
184/**
185 * Kompakte Markdown-Fassung: was Claude mitliest und was `-p`, das SDK und VS Code zeigen. `tag` (Kennung `#…`) steht in
186 * der ersten Zeile, darüber findet der Render-Hook den Schnappschuss. Fußzeile mit dem Terminal-Weg. Leerzeilen zwischen
187 * den Blöcken: Sonst hängt Markdown (CommonMark) alles nach einer Liste an deren letzten Punkt.
188 */
189export function helpMarkdown(d: HelpData, tag: string): string {
190  const L = LABELS[d.lang]
191  const lines = [`**${d.mod} · ${L.help}**${tag ? ` · ${tag}` : ''}`, '', d.intro]
192  const list = (title: string, items: HelpCommand[]) => {
193    if (!items.length) return
194    lines.push('', `**${title}**`, ...items.map((c) => `- \`${c.cmd}\`: ${c.does}`))
195  }
196  list(L.commands, d.commands)
197  if (d.notes?.length) lines.push('', ...d.notes)
198  list(L.controls, d.controls ?? [])
199  if (d.features.length) lines.push('', `**${L.features}:** ${d.features.map((f) => `${f.name} ${stateText(f.state, d.lang)} (${f.toggle})`).join(' · ')}`)
200  if (d.settings.length)
201    lines.push('', `**${L.settings}:** ${d.settings.map((s) => `${s.title} ${s.value}${s.isDefault ? ` ${L.isDefault}` : ''}`).join(' · ')}`)
202  lines.push('', d.footer.terminal)
203  return lines.join('\n')
204}
205
hooks/helpdata.ts 119 lines
1// 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}
119
hooks/ring.ts 64 lines
1// 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 `&#183;`. */
36export const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/·/g, '&#183;')
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}
64
hooks/storage.ts 349 lines
1// 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}
349
hooks/svg.ts 70 lines
1// 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}
70
hooks/view.ts 177 lines
1// 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