Personal mods above the prompt: plan progress bars and context, 5-hour and weekly usage meters

Personal Claude Code mods, in one plugin. Each feature can be switched on or off on its own.

| Feature | Option | What it draws |
|---|---|---|
| Plan progress | planProgress | Progress bars for multi-step tasks (stages, steps, subagent strips, sounds), above the prompt, on top |
| End-of-turn notice | turnNotify | When a turn ran longer than turnNotifySeconds (60 by default): the done sound and a toast, Claude a terminé en 4 min 12 s · 0,82 $ over modèle 2 min 30 s, outils 1 min 42 s · 68 tok/s; the decision sound when a call waits on your approval |
| Git status | gitStatus | The status line under the prompt: branch, changed and new files, ahead/behind its upstream (⎇ main · 3 modifiés · 1 nouveau · ↑1) |
| Usage meters | usageMeters | Context, prompt cache, session (5-hour) and weekly limits, always at the bottom: the share used and the detail on one line, the bar under it |
Other options:
planEnforcement (default soft): how hard Claude is held to its bars.strict: a 4th change without a bar is refused once, a stale bar gets a reminder, and a turn that ends with an open bar is sent back.soft: reminders only, nothing is refused or sent back.off: no reminders; Claude uses the bars when it finds them useful.sounds (soft, classic, off; default soft): the sounds when a bar waits for a decision, fails or finishes. soft is still-mods' own (made by tools/make-sounds.py), classic plan-progress'.timeZone (default Europe/Paris), the zone of the 5-hour reset time.palette (default default): the colours of the bars and meters, see Palettes.language (auto, en, fr; default auto): the labels' language. auto follows Claude Code's language setting, then the system locale.Change them with the /still-mods command, which works in the desktop Code tab too (it has no /config menu):
/still-mods show the options
/still-mods usageMeters off switch a feature off
/still-mods timeZone Asia/Tokyo set a text option
/still-mods palette dracula pick a palette
They are also rows in the terminal's /config menu. A plugin loaded from a folder has no such rows in the desktop app, so /still-mods then writes the option in ~/.claude/settings.json itself and touches .reload-stamp (ignored by git) to reload the mod. The options are stored there:
"pluginConfigs": { "still-mods": { "options": { "usageMeters": false } } }
The palette option colours what the mod draws, from popular editor themes:
| Palette | Variant | Source |
|---|---|---|
default | still-mods' own colours | |
catppuccin-mocha | dark | catppuccin/palette |
catppuccin-latte | light | catppuccin/palette |
dracula | dark | Dracula spec |
alucard | light (Dracula's) | Dracula spec |
night-owl | dark | sdras/night-owl-vscode-theme |
synthwave-84 | dark | robb0wen/synthwave-vscode |
tokyo-night | dark (Storm) | folke/tokyonight.nvim |
Each palette sets the plan bars' state colours (running, waiting, error, done) and the meters' fills (green, orange, red), track and time marker, with every value taken from the theme's own repository.
Claude creates a bar for a multi-step task through the plan_progress tool (2 to 10 stages) and moves it as it works. Up to 5 bars show at once; past that, finished ones go first.
/still-mods-progress-demo carry made-up costs. The amounts sit in a column before the percentage, on one line, or as the figure over its currency when the band is too narrow.sounds).One column each for the context, the session (5-hour) limit and the weekly limit: the title and the share used, the reset time dimmed at the right (↻ 32 min · 15:00), and the bar under them. The context shows the session's cost and its tokens instead (3,42 $ · 210k / 1M).
The cost is the engine's ledger for the session, at API prices: on a subscription it is what the work would cost through the API, not what is billed. Where the host keeps no ledger it is left out.
The meters sit on as few rows as keep every title, figure and detail whole, the rows balanced: one row of four on a wide band, two by two on a medium one, one per row on a phone.
Thin cuts split the session bar into its 5 hours and the weekly bar into its 7 days, counted from the window's start: the fill against them tells whether usage keeps to the average. The context bar has a cut at every 10 % from 50 %.
A Cache column (option cacheMeter) follows the context. Its figure is the share of the last main-thread response's input the prompt cache served: green from 80 %, orange from 50 %, red under. A drop means something broke the cache (tools, model or instructions changed). Its bar is the time the cache has left since that response, draining, orange in its last 15 %: chaud · 41 min, then froid once it expired, when the next message writes the whole context to the cache again. The lifetime is the cacheTtl option (1h by default, or 5m): the engine does not say it at any time.
Hovering a meter shows a card that explains it: the context's tokens and the session's cost; a limit's window, its share used against the time gone and what that pace means, its reset and how to read the bar; the cache's hit rate, when the last response came and when the cache expires. The surface shows and hides the card itself (no hook runs); there is none without a pointer (a terminal without a mouse, the mobile app).
The blue line is the time gone in the window. A fill short of it means the limit lasts until the reset; a fill past it means usage runs ahead of time. The colour says the same: green at least 10 points behind the line (or under 10 % used), orange close to it or a little past, red more than 15 points past it, or at 90 % used whatever the time.
The engine reports only the 5-hour and weekly windows (and a gateway's spend limit): the per-model weekly limit and the usage credits the desktop panel shows are not available to mods.
hooks/register.tsx: reads the options and registers the features that are on.features/<feature>.tsx: one feature, registering its own hooks.features/settings.ts: the /still-mods command, always on.features/band.ts: stacks the parts of the band above the prompt.features/git-status.ts: the git status line, from git status --porcelain=v1 -b in the session's folder, at start, after each call that may change the tree, and every 15 seconds.features/turn-notify.ts: the end-of-turn notice. A mod cannot tell whether the app is in front, so the threshold is what keeps short answers silent.features/i18n.ts: the labels in English and French.features/palettes.ts: the colour palettes, each value taken from the theme's own repository.sounds/<theme>/: the sound themes; tools/make-sounds.py synthesizes sounds/soft/.The engine follows $ only into functions of the same file, so a feature keeps its hooks and the functions they hand $ to in its own file. An event hooked without a matcher can be hooked only once in the whole plugin, so each feature hooks session.start with the matcher { isInteractive: [true, false] }, which takes every session.
Adding a feature: a file in features/, a userConfig field in .claude-plugin/plugin.json, a line in hooks/register.tsx.
The repository is a marketplace holding this one plugin (.claude-plugin/marketplace.json):
/plugin marketplace add stillgbx/still-mods
/plugin install still-mods@still-mods
The bars and meters draw on every surface: terminal, desktop, VS Code and the Claude mobile app (when it follows a session, the mod runs where the session runs). Mobile draws no input fields, which the mod does not use.
Do not install it from the marketplace on the machine that loads the folder below, or it loads twice.
CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json points to this folder. CLAUDE_CODE_PLUGIN_DIR_WATCH=1 there too makes the desktop app watch it: saving a file reloads the mod (without it, only a terminal session watches, and the desktop loads the mod at session start).
Check it with the engine's own CLI (the claude on PATH may be older):
claude plugin validate .claude-plugin/plugin.json # the plugin and its hooks
claude plugin validate . # the marketplace
/still-mods: show or change the options./still-mods-progress: show or hide the bars./still-mods-progress-demo: add a sample bar, one of 5 at random./still-mods-progress-clear: remove every bar./still-mods-progress-sounds: play the decision, error and done sounds of the current theme.still-mods started from the ideas and the work of two mods, combined here and extended:
plan_progress tool, the agent strips, the sounds and the rules that keep the bars up to date.The palettes take their values from the themes' own repositories: Catppuccin, Dracula and Alucard, Night Owl, SynthWave '84 and Tokyo Night.
Their notices are in NOTICE. still-mods itself is under the MIT License (LICENSE).
hooks/register.tsx 22 lines1// still-mods: each feature lives in ../features, registers its own hooks and is switched on or off
2// by its userConfig option. Adding one: a file in ../features, a field in plugin.json, a line here.
3import type { Register } from 'claude-code'
4
5import { registerGitStatus } from '../features/git-status'
6import { registerTurnNotify } from '../features/turn-notify'
7import type { PaletteName } from '../features/palettes'
8import { registerPlanProgress, type Enforcement, type SoundTheme } from '../features/plan-progress'
9import { registerSettings } from '../features/settings'
10import { registerUsageMeters } from '../features/usage-meters'
11
12export const register: Register = (on, options) => {
13 const opts = options as { planProgress?: boolean; planEnforcement?: Enforcement; sounds?: SoundTheme; usageMeters?: boolean; gitStatus?: boolean; cacheMeter?: boolean; cacheTtl?: '5m' | '1h'; turnNotify?: boolean; turnNotifySeconds?: number; timeZone?: string; language?: string; palette?: PaletteName }
14 // always there: the /still-mods command that changes the options below
15 registerSettings(on, options as Record<string, unknown>)
16 // a missing value means on, the manifest's default
17 if (opts.planProgress !== false) registerPlanProgress(on, { enforcement: opts.planEnforcement, language: opts.language, sounds: opts.sounds, palette: opts.palette })
18 if (opts.turnNotify !== false) registerTurnNotify(on, { seconds: opts.turnNotifySeconds, sounds: opts.sounds, language: opts.language })
19 if (opts.gitStatus !== false) registerGitStatus(on, { language: opts.language })
20 if (opts.usageMeters !== false) registerUsageMeters(on, { timeZone: opts.timeZone, language: opts.language, palette: opts.palette, cacheMeter: opts.cacheMeter, cacheTtl: opts.cacheTtl })
21}
22features/git-status.ts 96 lines1// The git status line under the prompt: the branch, the changed and new files, and how far the
2// branch is ahead of or behind its upstream. Read from `git status --porcelain=v1 -b` in the session's
3// folder, at start, after each call that may change the tree, and every few seconds.
4import type { EngineInterface, Register } from 'claude-code'
5
6import { type Locale, resolveLocale, systemLocale } from './i18n'
7
8const POLL_MS = 15_000
9// a burst of edits refreshes once, this long after the last
10const SETTLE_MS = 800
11const TREE_TOOLS = ['Bash', 'PowerShell', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit']
12
13let locale: Locale = 'en'
14let language = 'auto'
15let shown: string | undefined
16let pending: { cancel(): void } | null = null
17
18const TEXT = {
19 en: {
20 changed: (n: number) => `${n} changed`,
21 added: (n: number) => `${n} new`,
22 clean: 'clean',
23 local: 'no upstream',
24 detached: 'detached',
25 },
26 fr: {
27 changed: (n: number) => `${n} modifié${n === 1 ? '' : 's'}`,
28 added: (n: number) => `${n} nouveau${n === 1 ? '' : 'x'}`,
29 clean: 'propre',
30 local: 'sans suivi',
31 detached: 'détachée',
32 },
33}
34
35export function registerGitStatus(on: Parameters<Register>[0], options: { language?: string }) {
36 language = options.language ?? 'auto'
37
38 // a matcher, so other features may hook session.start too
39 on('session.start', { isInteractive: [true, false] }, async ($, e, next) => {
40 const settings = await $.settings.read()
41 locale = resolveLocale(language, settings.language, systemLocale())
42 await refresh($)
43 $.clock.every(POLL_MS, () => refresh($))
44 return next(e)
45 })
46
47 on('tool.call', { tool: TREE_TOOLS }, async ($, e, next) => {
48 const ran = await next(e)
49 pending?.cancel()
50 pending = $.clock.after(SETTLE_MS, () => {
51 pending = null
52 void refresh($)
53 })
54 return ran
55 })
56}
57
58async function refresh($: EngineInterface) {
59 let text: string | undefined
60 try {
61 const run = await $.process.run(['git', 'status', '--porcelain=v1', '-b'], { timeoutMs: 5000 })
62 text = run.exitCode === 0 ? describe(run.stdout) : undefined
63 } catch {
64 text = undefined
65 }
66 // outside a repository, or git missing: no line at all
67 if (text === shown) return
68 shown = text
69 $.ui.status(text)
70}
71
72// "## main...origin/main [ahead 1, behind 2]" then one line per file ("?? " for a new one)
73function describe(out: string) {
74 const T = TEXT[locale]
75 const lines = out.split(/\r?\n/).filter(Boolean)
76 const head = lines[0]?.startsWith('## ') ? lines[0].slice(3) : ''
77 const files = lines.slice(head ? 1 : 0)
78 const added = files.filter(l => l.startsWith('??')).length
79 const changed = files.length - added
80
81 let branch = head.split('...')[0]?.split(' ')[0] ?? ''
82 if (branch === 'HEAD') branch = T.detached
83 if (branch.startsWith('No')) branch = head.replace(/^No commits yet on /, '')
84 const ahead = Number(/ahead (\d+)/.exec(head)?.[1] ?? 0)
85 const behind = Number(/behind (\d+)/.exec(head)?.[1] ?? 0)
86 const hasUpstream = head.includes('...')
87
88 const parts = [`⎇ ${branch}`]
89 if (changed > 0) parts.push(T.changed(changed))
90 if (added > 0) parts.push(T.added(added))
91 if (changed === 0 && added === 0) parts.push(T.clean)
92 if (!hasUpstream) parts.push(T.local)
93 else if (ahead > 0 || behind > 0) parts.push([ahead > 0 ? `↑${ahead}` : '', behind > 0 ? `↓${behind}` : ''].filter(Boolean).join(' '))
94 return parts.join(' · ')
95}
96features/turn-notify.ts 135 lines1// The end-of-turn notice: when a turn of the main thread ran longer than a threshold, a sound and a
2// toast with its time and cost; when a tool call waits on the person's approval, the decision sound.
3// A mod cannot tell whether the app is in front, so the threshold keeps short answers silent.
4import type { EngineInterface, Register } from 'claude-code'
5
6import { formatUsd, type Locale, resolveLocale, systemLocale } from './i18n'
7
8type Theme = 'soft' | 'classic' | 'off'
9
10let thresholdMs = 60_000
11let theme: Theme = 'soft'
12let language = 'auto'
13let locale: Locale = 'en'
14// when each turn of the main thread began, the session's cost then, and the time its model requests took
15const turns = new Map<string, { startedAt: number; usd: number | null; modelMs: number }>()
16// tool calls that were asked about, so a quick settle by the mode stays silent
17const asked = new Set<string>()
18
19const TEXT = {
20 en: {
21 done: (time: string, cost: string) => `Claude finished in ${time}${cost ? ' · ' + cost : ''}`,
22 split: (model: string, tools: string) => `model ${model}, tools ${tools}`,
23 speed: (n: number) => `${n} tok/s`,
24 failed: (time: string) => `Claude stopped on an error after ${time}`,
25 refused: (time: string) => `Claude declined to go on after ${time}`,
26 },
27 fr: {
28 done: (time: string, cost: string) => `Claude a terminé en ${time}${cost ? ' · ' + cost : ''}`,
29 split: (model: string, tools: string) => `modèle ${model}, outils ${tools}`,
30 speed: (n: number) => `${n} tok/s`,
31 failed: (time: string) => `Claude s'est arrêté sur une erreur après ${time}`,
32 refused: (time: string) => `Claude a refusé de continuer après ${time}`,
33 },
34}
35
36export function registerTurnNotify(on: Parameters<Register>[0], options: { seconds?: number; sounds?: Theme; language?: string }) {
37 thresholdMs = Math.max(0, options.seconds ?? 60) * 1000
38 theme = options.sounds ?? 'soft'
39 language = options.language ?? 'auto'
40
41 // a matcher, so other features may hook session.start too
42 on('session.start', { isInteractive: [true, false] }, async ($, e, next) => {
43 const settings = await $.settings.read()
44 locale = resolveLocale(language, settings.language, systemLocale())
45 return next(e)
46 })
47
48 // matchers that take every value, so the plan bars may hook these events too
49 on('turn.start', { turnId: /^/ }, async ($, e, next) => {
50 turns.set(e.turnId, { startedAt: await $.clock.now(), usd: await sessionUsd($), modelMs: 0 })
51 return next(e)
52 })
53
54 // each model request of a main-thread turn, timed from its ask to its last chunk
55 on('turn.step', { index: /^/ }, async function* ($, e, next) {
56 const turn = e.agentId ? undefined : turns.get(e.turnId)
57 const started = await $.clock.now()
58 const result = yield* next(e)
59 if (turn) turn.modelMs += (await $.clock.now()) - started
60 return result
61 })
62
63 on('turn.complete', { reason: /^/ }, async ($, e, next) => {
64 const result = await next(e)
65 const start = turns.get(e.turnId)
66 turns.delete(e.turnId)
67 // a subagent's turn, a turn stopped by the person, or one too short to call for attention
68 if (e.agentId || !start || e.reason === 'aborted') return result
69 const took = (await $.clock.now()) - start.startedAt
70 if (took < thresholdMs) return result
71 const T = TEXT[locale]
72 const time = formatDuration(took)
73 if (e.reason === 'answer') {
74 const usd = await sessionUsd($)
75 const cost = usd != null && start.usd != null && usd - start.usd >= 0.005 ? formatUsd(locale, usd - start.usd) : ''
76 play($, 'done')
77 // the rest of the turn is the tools and whatever waited between requests
78 const toolsMs = Math.max(0, took - start.modelMs)
79 const output = e.usage?.output_tokens ?? 0
80 const speed = start.modelMs > 0 && output > 0 ? Math.round(output / (start.modelMs / 1000)) : 0
81 const extra = [start.modelMs > 0 ? T.split(formatDuration(start.modelMs), formatDuration(toolsMs)) : '', speed > 0 ? T.speed(speed) : '']
82 const detail = extra.filter(Boolean).join(' · ')
83 $.ui.toast(T.done(time, cost) + (detail ? `\n${detail}` : ''), { timeoutMs: 8000 })
84 } else {
85 play($, 'error')
86 $.ui.toast(e.reason === 'refusal' ? T.refused(time) : T.failed(time))
87 }
88 return result
89 })
90
91 // a call still held after a moment waits on the person: the mode often settles an ask in a blink
92 on('tool.check', { tool: /^/ }, async ($, e, next) => {
93 const verdict = await next(e)
94 const useId = e.tool_use_id
95 if (useId && verdict.decision === 'ask') {
96 asked.add(useId)
97 $.clock.after(700, () => {
98 if (asked.delete(useId)) play($, 'decision')
99 })
100 }
101 return verdict
102 })
103
104 // the call went on (approved or denied): no sound left to play for it
105 on('tool.call', { tool: /^/ }, async ($, e, next) => {
106 const ran = await next(e)
107 if (e.tool_use_id) asked.delete(e.tool_use_id)
108 return ran
109 })
110}
111
112async function sessionUsd($: EngineInterface) {
113 const usage = await $.session.usage()
114 return typeof usage.cost?.usd === 'number' ? usage.cost.usd : null
115}
116
117// the theme's sound: the engine's player, else PowerShell where it cannot play
118function play($: EngineInterface, name: 'decision' | 'error' | 'done') {
119 if (theme === 'off') return
120 const file = `${$.plugin.root}/sounds/${theme}/${name}.wav`.replace(/\//g, '\\')
121 void $.audio.play({ asset: `sounds/${theme}/${name}.wav` }).catch(() =>
122 $.process
123 .run(['powershell', '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', `(New-Object Media.SoundPlayer '${file}').PlaySync()`], { timeoutMs: 5000 })
124 .catch(() => undefined),
125 )
126}
127
128function formatDuration(ms: number) {
129 const s = Math.round(ms / 1000)
130 const m = Math.floor(s / 60)
131 if (m === 0) return `${s} s`
132 if (m < 60) return `${m} min ${String(s % 60).padStart(2, '0')} s`
133 return `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')}`
134}
135features/palettes.ts 75 lines1// Colour palettes for what still-mods draws (the plan bars, the meters), from popular editor themes.
2// Only the mod's own drawings change: the app's text and background stay as the app draws them, and
3// a mod cannot tell whether the app is light or dark, so each palette names its variant.
4//
5// Every value is taken from the theme's own repository:
6// - Catppuccin: https://github.com/catppuccin/palette (palette.json, Mocha and Latte)
7// - Dracula and Alucard: https://draculatheme.com/spec
8// - Night Owl: https://github.com/sdras/night-owl-vscode-theme (Night Owl-color-theme.json)
9// - SynthWave '84: https://github.com/robb0wen/synthwave-vscode (synthwave-color-theme.json)
10// - Tokyo Night Storm: https://github.com/folke/tokyonight.nvim (lua/tokyonight/colors/storm.lua)
11
12export type PaletteName = 'default' | 'catppuccin-mocha' | 'catppuccin-latte' | 'dracula' | 'alucard' | 'night-owl' | 'synthwave-84' | 'tokyo-night'
13
14export type Palette = {
15 // plan bar states
16 running: string
17 waiting: string
18 error: string
19 done: string
20 // meter fills, by pace
21 success: string
22 warning: string
23 danger: string
24 // the meter's empty track and its time marker
25 track: string
26 marker: string
27}
28
29export const PALETTE_NAMES: PaletteName[] = ['default', 'catppuccin-mocha', 'catppuccin-latte', 'dracula', 'alucard', 'night-owl', 'synthwave-84', 'tokyo-night']
30
31const PALETTES: Record<PaletteName, Palette> = {
32 // still-mods' own colours
33 default: {
34 running: '#8B7CF6', waiting: '#E09A1E', error: '#E5484D', done: '#30A46C',
35 success: '#4caf50', warning: '#e0a526', danger: '#e5534b', track: 'rgba(128,128,128,0.3)', marker: '#5b9bff',
36 },
37 // mauve, peach, red, green; yellow; surface1; blue
38 'catppuccin-mocha': {
39 running: '#cba6f7', waiting: '#fab387', error: '#f38ba8', done: '#a6e3a1',
40 success: '#a6e3a1', warning: '#f9e2af', danger: '#f38ba8', track: '#45475a', marker: '#89b4fa',
41 },
42 'catppuccin-latte': {
43 running: '#8839ef', waiting: '#fe640b', error: '#d20f39', done: '#40a02b',
44 success: '#40a02b', warning: '#df8e1d', danger: '#d20f39', track: '#bcc0cc', marker: '#1e66f5',
45 },
46 // purple, orange, red, green; yellow; selection; cyan
47 dracula: {
48 running: '#bd93f9', waiting: '#ffb86c', error: '#ff5555', done: '#50fa7b',
49 success: '#50fa7b', warning: '#f1fa8c', danger: '#ff5555', track: '#44475a', marker: '#8be9fd',
50 },
51 alucard: {
52 running: '#644ac9', waiting: '#a34d14', error: '#cb3a2a', done: '#14710a',
53 success: '#14710a', warning: '#846e15', danger: '#cb3a2a', track: '#cfcfde', marker: '#036a96',
54 },
55 // ansi magenta, the escape orange, ansi red, ansi green; bright yellow; selection; ansi blue
56 'night-owl': {
57 running: '#c792ea', waiting: '#f78c6c', error: '#ef5350', done: '#22da6e',
58 success: '#22da6e', warning: '#ffeb95', danger: '#ef5350', track: '#1d3b53', marker: '#82aaff',
59 },
60 // pink, orange, red, green; yellow; a light veil over the dark background; cyan
61 'synthwave-84': {
62 running: '#ff7edb', waiting: '#ff8b39', error: '#fe4450', done: '#72f1b8',
63 success: '#72f1b8', warning: '#f3e70f', danger: '#fe4450', track: 'rgba(255,255,255,0.14)', marker: '#36f9f6',
64 },
65 // magenta, orange, red, green; yellow; fg_gutter; cyan
66 'tokyo-night': {
67 running: '#bb9af7', waiting: '#ff9e64', error: '#f7768e', done: '#9ece6a',
68 success: '#9ece6a', warning: '#e0af68', danger: '#f7768e', track: '#3b4261', marker: '#7dcfff',
69 },
70}
71
72export function palette(name: unknown): Palette {
73 return PALETTES[(PALETTE_NAMES as string[]).includes(name as string) ? (name as PaletteName) : 'default']
74}
75features/plan-progress.tsx 1109 lines1// Plan progress bars, from plan-progress by Kirill Serditov (https://github.com/zycck/claude-mods, MIT):
2// see NOTICE. Combined into still-mods with options, translations and a few additions.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5
6import type { AgentRun, Plan, PlanStage, PlanState, PlanStep, StepStatus } from '../types'
7import { stack } from './band'
8import { palette, type PaletteName } from './palettes'
9import { formatUsd, type Locale, resolveLocale, strings, systemLocale } from './i18n'
10
11const TOOL = 'mcp__still-mods__plan_progress'
12const plans = atom({ plugin: 'still-mods', key: 'plans' } as const, [])
13const MAX_BARS = 5
14// a space as wide as a digit, so ' 0%' and '100%' take the same room
15const FIGURE_SPACE = String.fromCharCode(0x2007)
16const isOpen = atom({ plugin: 'still-mods', key: 'isOpen' } as const, true)
17const tick = atom({ plugin: 'still-mods', key: 'tick' } as const, 0)
18const STRIP_H = 18
19const STRIP_GAP = 3
20const MAX_STRIPS = 4 // past this, the finished ones fold into one "+N more" strip
21const FOLD_MS = 5000 // finished strips stay this long, failed ones stay until the bar closes
22
23// set from the palette option when the module registers
24const STATE_COLOR: Record<PlanState, string> = { running: '#8B7CF6', needs_input: '#E09A1E', error: '#E5484D', done: '#30A46C' }
25const STATE_GLYPH: Record<PlanState, string> = { running: '●', needs_input: '?', error: '!', done: '✓' }
26const STATUSES: StepStatus[] = ['pending', 'active', 'done', 'error', 'skipped']
27const TRACK_H = 22
28const NARROW = 360
29
30// how hard the module holds the model to its bars, the planEnforcement option:
31// strict refuses a call and sends a turn back, soft only reminds, off leaves the bars to the model
32export type Enforcement = 'strict' | 'soft' | 'off'
33let enforcement: Enforcement = 'soft'
34// the labels' language: the language option, else read at session.start
35let language = 'auto'
36// the sound theme, the sounds option: a folder of sounds/, or off
37export type SoundTheme = 'soft' | 'classic' | 'off'
38let soundTheme: SoundTheme = 'soft'
39let locale: Locale = 'en'
40const L = () => strings(locale)
41
42const RULES_BODY = `Tasks needing more than ~3 edits or commands get a bar via ${TOOL}: create it once with the full breakdown (2-10 stages with short steps, or kind "todo" for one flat list; titles of at most 4 words, in the user's language), then update it with short calls only: {id, next:true} when the active step is finished, or {id, done:[...], active:"..."}, {id, failed:"...", note}. Send state "needs_input" with a note before asking the user to decide. Never describe the bars to the user.`
43const rules = () =>
44 enforcement === 'off'
45 ? `# Progress bars
46Optional: for long multi-step tasks you may show a bar via ${TOOL}. ${RULES_BODY.slice(RULES_BODY.indexOf('create it once'))}`
47 : `# Progress bars
48${RULES_BODY}`
49
50type Raw = Record<string, unknown>
51const str = (v: unknown, max = 120) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim().slice(0, max) : '')
52const status = (v: unknown): StepStatus => (STATUSES.includes(v as StepStatus) ? (v as StepStatus) : 'pending')
53const list = (v: unknown): Raw[] => (Array.isArray(v) ? v.filter(x => x && typeof x === 'object') : []) as Raw[]
54const isFinished = (s: StepStatus) => s === 'done' || s === 'skipped'
55
56const same = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
57
58// short updates: {next:true}, {done:[titles]}, {active:title}, {failed:title} against the stored plan
59function applyOps(stages: PlanStage[], input: Raw): PlanStage[] {
60 const next = stages.map(s => ({ ...s, steps: s.steps.map(st => ({ ...st })) }))
61 const steps = next.flatMap(s => s.steps)
62 const find = (title: string) => steps.find(st => same(st.title, title))
63 if (input.next === true) {
64 const at = steps.findIndex(st => st.status === 'active') >= 0 ? steps.findIndex(st => st.status === 'active') : steps.findIndex(st => !isFinished(st.status))
65 const cur = steps[at]
66 if (cur) cur.status = 'done'
67 const following = steps.slice(at + 1).find(st => st.status === 'pending')
68 if (following) following.status = 'active'
69 }
70 for (const t of Array.isArray(input.done) ? input.done : []) {
71 const st = typeof t === 'string' ? find(t) : undefined
72 if (st) st.status = 'done'
73 }
74 const active = typeof input.active === 'string' ? find(input.active) : undefined
75 if (active) {
76 const at = steps.indexOf(active)
77 steps.forEach((st, i) => {
78 if (st.status === 'active' && i !== at) st.status = i < at ? 'done' : 'pending'
79 })
80 active.status = 'active'
81 }
82 const failed = typeof input.failed === 'string' ? find(input.failed) : undefined
83 if (failed) failed.status = 'error'
84
85 return next
86}
87
88function normalize(input: Raw, prev: Plan | null, now: number, id: string): Plan {
89 const isPartial = list(input.stages).length === 0 && prev !== null
90 const stages: PlanStage[] = isPartial ? applyOps(prev.stages, input) : list(input.stages)
91 .map(s => ({
92 name: str(s.name, 80) || 'Stage',
93 steps: list(s.steps).map(st => ({
94 title: str(st.title) || 'Step',
95 status: status(st.status),
96 substeps: list(st.substeps).map(sub => ({ title: str(sub.title) || '…', status: status(sub.status) })),
97 })),
98 }))
99 .filter(s => s.steps.length > 0) as PlanStage[]
100 const title = str(input.title, 80) || prev?.title || 'Plan'
101 const steps = stages.flatMap(s => s.steps)
102 const isAllDone = steps.length > 0 && steps.every(s => isFinished(s.status))
103 const asked = input.state as PlanState
104 const failedNow = typeof input.failed === 'string'
105 const state: PlanState = ['running', 'needs_input', 'error', 'done'].includes(asked) ? asked : isAllDone ? 'done' : failedNow ? 'error' : 'running'
106
107 return {
108 id,
109 title,
110 kind: input.kind === 'todo' || (isPartial && prev?.kind === 'todo') ? 'todo' : 'plan',
111 stages,
112 state,
113 note: str(input.note, 160) || null,
114 startedAt: prev && prev.title === title ? prev.startedAt : now,
115 }
116}
117
118const clean = (s: string) =>
119 s
120 .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
121 .replace(/[*_`]/g, '')
122 .replace(/^\s*(\d+[.)]|[-*+]|\[[ xX]\])\s+/, '')
123 .replace(/^(\d+[.)]|\[[ xX]\])\s+/, '')
124 .trim()
125
126function parsePlan(markdown: string, now: number): Plan | null {
127 let title = ''
128 const headed: PlanStage[] = []
129 const items: { depth: number; text: string }[] = []
130 for (const line of markdown.split(/\r?\n/)) {
131 const h = line.match(/^(#{1,4})\s+(.*)$/)
132 if (h) {
133 const text = clean(h[2] ?? '')
134 if (h[1] === '#' && !title) title = text
135 else headed.push({ name: text, steps: [] })
136 continue
137 }
138 const li = line.match(/^(\s*)(\d+[.)]|[-*+])\s+(.*)$/)
139 if (!li) continue
140 const depth = Math.floor((li[1] ?? '').replace(/\t/g, ' ').length / 2)
141 const text = clean(li[3] ?? '').slice(0, 120)
142 if (!text) continue
143 items.push({ depth, text })
144 const stage = headed[headed.length - 1]
145 if (!stage) continue
146 const step = stage.steps[stage.steps.length - 1]
147 if (depth === 0 || !step) stage.steps.push({ title: text, status: 'pending', substeps: [] })
148 else step.substeps.push({ title: text, status: 'pending' })
149 }
150 let stages = headed.filter(s => s.steps.length > 0)
151 if (stages.length === 0) {
152 if (items.some(i => i.depth > 0)) {
153 for (const item of items) {
154 const stage = stages[stages.length - 1]
155 if (item.depth === 0 || !stage) stages.push({ name: item.text, steps: [] })
156 else stage.steps.push({ title: item.text, status: 'pending', substeps: [] })
157 }
158 stages = stages.map(s => (s.steps.length ? s : { ...s, steps: [{ title: s.name, status: 'pending', substeps: [] }] }))
159 } else if (items.length > 0) {
160 stages = [{ name: 'Tasks', steps: items.map(i => ({ title: i.text, status: 'pending' as StepStatus, substeps: [] })) }]
161 }
162 }
163 if (stages.length === 0) return null
164 const first = stages[0]?.steps[0]
165 if (first) first.status = 'active'
166
167 return { id: 'plan', title: title || 'Plan', kind: stages.length === 1 ? 'todo' : 'plan', stages, state: 'running', note: null, startedAt: now }
168}
169
170function st(title: string, s: StepStatus): PlanStep {
171 return { title, status: s, substeps: [] }
172}
173
174// sample bars for /still-mods-progress-demo, one picked at random each time and added to the others
175const tr = (en: string, fr: string) => (locale === 'fr' ? fr : en)
176
177const DEMOS: ((now: number) => Omit<Plan, 'id'>)[] = [
178 // several stages, mid-way
179 now => ({
180 title: tr('Orders module', 'Module commandes'),
181 kind: 'plan',
182 state: 'running',
183 note: null,
184 startedAt: now - 260_000,
185 stages: [
186 { name: tr('Analysis', 'Analyse'), steps: [st(tr('Read modules', 'Lire les modules'), 'done'), st(tr('Find dependencies', 'Dépendances'), 'done'), st(tr('List changes', 'Lister les changements'), 'done')] },
187 {
188 name: tr('DB migration', 'Migration BDD'),
189 steps: [st(tr('Table schema', 'Schéma des tables'), 'done'), st(tr('Create migration', 'Créer la migration'), 'done'), st(tr('Move data', 'Migrer les données'), 'active'), st(tr('Indexes', 'Index'), 'pending')],
190 },
191 { name: 'API', steps: [st('Endpoints', 'pending'), st('Validation', 'pending'), st(tr('Access rules', "Règles d'accès"), 'pending')] },
192 { name: tr('Interface', 'Interface'), steps: [st(tr('List page', 'Page liste'), 'pending'), st(tr('Order card', 'Fiche commande'), 'pending'), st(tr('Filters', 'Filtres'), 'pending')] },
193 { name: tr('Verify', 'Vérification'), steps: [st('Tests', 'pending'), st('Build', 'pending')] },
194 ],
195 }),
196 // a flat todo list, just started
197 now => ({
198 title: tr('Fix login bugs', 'Bugs de connexion'),
199 kind: 'todo',
200 state: 'running',
201 note: null,
202 startedAt: now - 45_000,
203 stages: [
204 {
205 name: tr('Tasks', 'Tâches'),
206 steps: [
207 st(tr('Reproduce the timeout', 'Reproduire le timeout'), 'done'),
208 st(tr('Refresh the token', 'Rafraîchir le jeton'), 'active'),
209 st(tr('Remember me', 'Se souvenir de moi'), 'pending'),
210 st(tr('Error messages', "Messages d'erreur"), 'pending'),
211 st(tr('Regression tests', 'Tests de non-régression'), 'pending'),
212 ],
213 },
214 ],
215 }),
216 // waiting for a decision
217 now => ({
218 title: tr('Payment provider', 'Prestataire de paiement'),
219 kind: 'plan',
220 state: 'needs_input',
221 note: tr('Stripe or Adyen?', 'Stripe ou Adyen ?'),
222 startedAt: now - 520_000,
223 stages: [
224 { name: tr('Compare', 'Comparer'), steps: [st(tr('Fees', 'Frais'), 'done'), st(tr('Countries', 'Pays'), 'done'), st(tr('Pick one', 'Choisir'), 'active')] },
225 { name: tr('Integrate', 'Intégrer'), steps: [st('SDK', 'pending'), st('Webhooks', 'pending')] },
226 { name: tr('Ship', 'Livrer'), steps: [st(tr('Sandbox tests', 'Tests sandbox'), 'pending'), st(tr('Go live', 'Mise en prod'), 'pending')] },
227 ],
228 }),
229 // stopped on a failure
230 now => ({
231 title: tr('Release 2.4', 'Version 2.4'),
232 kind: 'plan',
233 state: 'error',
234 note: tr('Build failed on Windows', 'Build en échec sous Windows'),
235 startedAt: now - 900_000,
236 stages: [
237 { name: tr('Prepare', 'Préparer'), steps: [st('Changelog', 'done'), st(tr('Bump version', 'Version'), 'done')] },
238 { name: 'Build', steps: [st('Linux', 'done'), st('macOS', 'done'), st('Windows', 'error')] },
239 { name: tr('Publish', 'Publier'), steps: [st(tr('Upload', 'Envoi'), 'pending'), st(tr('Announce', 'Annonce'), 'pending')] },
240 ],
241 }),
242 // finished
243 now => ({
244 title: tr('Docs refresh', 'Mise à jour de la doc'),
245 kind: 'todo',
246 state: 'done',
247 note: null,
248 startedAt: now - 1_800_000,
249 stages: [{ name: tr('Tasks', 'Tâches'), steps: [st('README', 'done'), st(tr('API reference', 'Référence API'), 'done'), st(tr('Examples', 'Exemples'), 'done')] }],
250 }),
251]
252
253// a template not on screen yet when one is left, under an id of its own so it adds a bar
254// what each sample has cost so far, in US dollars, in the order of DEMOS
255const DEMO_SPENT = [1.27, 0.18, 0.46, 2.35, 0.62]
256
257// a template not on screen yet when one is left, under an id of its own so it adds a bar; its cost
258// is made up: a start that far back in the session's ledger, or the whole for a finished one
259function demoPlan(now: number, shown: Plan[], usd: number | null): Plan {
260 const titles = new Set(shown.map(p => p.title))
261 const made = DEMOS.map((make, i) => ({ plan: make(now), spent: DEMO_SPENT[i] ?? 0.5 }))
262 const fresh = made.filter(d => !titles.has(d.plan.title))
263 const pool = fresh.length > 0 ? fresh : made
264 const pick = pool[Math.floor(Math.random() * pool.length)] ?? made[0]!
265 const isDone = pick.plan.state === 'done'
266 return {
267 ...pick.plan,
268 id: `demo-${now.toString(36)}`,
269 costStart: usd == null ? null : usd - pick.spent,
270 cost: isDone ? pick.spent : null,
271 }
272}
273
274// ---------- drawing ----------
275
276type Where = { pos: number; total: number; stage: number; step: number; stageSize: number }
277
278function where(p: Plan): Where {
279 const steps = p.stages.flatMap((s, i) => s.steps.map((step, j) => ({ i, j, step })))
280 const at = steps.findIndex(x => !isFinished(x.step.status))
281 const pos = p.state === 'done' || at < 0 ? steps.length : at
282 const cur = steps[Math.min(pos, steps.length - 1)]
283 const stage = cur?.i ?? 0
284
285 return { pos, total: steps.length, stage, step: pos >= steps.length ? (p.stages[stage]?.steps.length ?? 0) : (cur?.j ?? 0) + 1, stageSize: p.stages[stage]?.steps.length ?? 0 }
286}
287
288// the step at work: the active one, else the first not finished
289function currentStep(p: Plan): PlanStep | undefined {
290 const steps = p.stages.flatMap(s => s.steps)
291 return steps.find(st => st.status === 'active') ?? steps.find(st => !isFinished(st.status))
292}
293
294// the knob's text colour on a fill: white, or near black on a fill too light for it
295function inkOn(fill: string) {
296 const [r, g, b] = hex(fill).map(v => {
297 const c = v / 255
298 return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
299 })
300 const lum = 0.2126 * (r ?? 0) + 0.7152 * (g ?? 0) + 0.0722 * (b ?? 0)
301 // white while it keeps 3:1 (WCAG's floor for bold text), as on the default colours; dark past that
302 return 1.05 / (lum + 0.05) >= 3 ? '#fff' : '#1c1b22'
303}
304
305const hex = (h: string) => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16))
306const mix = (a: number[], b: number[], m: number) => a.map((v, i) => Math.round(v + ((b[i] ?? 0) - v) * m))
307const rgb = (c: number[]) => `rgb(${c.join(',')})`
308const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
309const hash = (a: number, b: number, k: number) => {
310 const x = Math.sin(a * 127.1 + b * 311.7 + k * 74.7) * 43758.5453
311 return x - Math.floor(x)
312}
313const textWidth = (s: string, px = 6.7) => [...s].reduce((w, ch) => w + (/[ -鿿]/.test(ch) ? 12 : /[ilI.,:;'|!]/.test(ch) ? 3.4 : /[mwMWШЩЖМ]/.test(ch) ? 9.5 : px), 0)
314
315const ICON_PATH: Partial<Record<PlanState, string>> = {
316 needs_input: 'M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3M12 17h.01',
317 error: 'M18 6 6 18M6 6l12 12',
318 done: 'M20 6 9 17l-5-5',
319}
320
321// last drawn head position per plan, so a redraw glides from where the bar was
322const lastHead = new Map<string, number>()
323
324function trackSvg(p: Plan, W: number): string {
325 const H = TRACK_H
326 const w = where(p)
327 const done = p.state === 'done'
328 // the fill is exactly the finished share: a fresh plan starts empty
329 const frac = done ? 1 : Math.min(1, w.pos / Math.max(1, w.total))
330 const fx = frac * W
331 const key = p.id
332 const from = lastHead.get(key) ?? fx
333 lastHead.set(key, fx)
334
335 const acc = hex(STATE_COLOR[p.state])
336 const light = mix(acc, [255, 255, 255], 0.32)
337 const grey = [132, 130, 138]
338 const ease = 'calcMode="spline" keyTimes="0;1" keySplines=".2 .8 .2 1"'
339 const glide = Math.abs(from - fx) > 0.5
340
341 const bounds: number[] = []
342 let acc2 = 0
343 p.stages.forEach((s, i) => {
344 acc2 += s.steps.length
345 if (i < p.stages.length - 1) bounds.push((acc2 / w.total) * W)
346 })
347
348 // pixels: 3px grid, 7 rows, denser and closer to the state colour towards the head
349 const buckets = [0, 1, 2, 3, 4].map(b => {
350 const m = b / 4
351 const dense = done ? 0.8 : 0.22 + 0.78 * Math.pow(m, 1.5)
352 return { color: rgb(done ? light : mix(grey, light, m)), opacity: (0.35 + 0.65 * dense).toFixed(2) }
353 })
354 let px = ''
355 for (let col = 0; col * 3 < fx; col++) {
356 const x = col * 3
357 const u = Math.min(1, (x + 1.5) / fx)
358 const dense = done ? 0.8 : 0.22 + 0.78 * Math.pow(u, 1.5)
359 const bucket = done ? 4 : Math.min(4, Math.floor(Math.min(1, Math.pow(u, 0.9) * 1.1) * 4.99))
360 for (let r = 0; r < 7; r++) {
361 if (hash(col, r, 1) > dense + 0.1) continue
362 px += `<rect x="${x}" y="${1 + r * 3}" class="b${bucket} t${Math.floor(hash(col, r, 2) * 4)}"/>`
363 }
364 }
365
366 let marks = ''
367 let k = 0
368 p.stages.forEach((s, i) => {
369 s.steps.forEach((_, j) => {
370 if (k > 0) {
371 const x = (k / w.total) * W
372 const isStage = j === 0
373 // stage boundaries are full-height lines, steps are short ticks; bright once passed
374 const passed = x < fx - 1
375 const h = isStage ? H : 8
376 const fill = passed ? rgb(mix(light, [255, 255, 255], 0.45)) : '#8A8984'
377 const opacity = passed ? (isStage ? 0.95 : 0.6) : isStage ? 0.7 : 0.45
378 marks += `<rect x="${(x - (isStage ? 1 : 0.75)).toFixed(1)}" y="${(H - h) / 2}" width="${isStage ? 2 : 1.5}" height="${h}" rx=".75" fill="${fill}" opacity="${opacity}"/>`
379 }
380 k++
381 })
382 void i
383 })
384
385 // knob: a pill with stage and count, or a round dot with the stage number when narrow
386 const isNarrow = W < NARROW
387 const color = STATE_COLOR[p.state]
388 // the knob's text and icon: white on a dark fill, near black on a light one (pastel palettes)
389 const ink = inkOn(color)
390 const icon = ICON_PATH[p.state]
391 const single = p.stages.length === 1
392 const number = single ? Math.min(w.total, w.pos + 1) : w.stage + 1
393 let knob = ''
394 let kw = H
395 if (isNarrow) {
396 const label = done ? '' : String(number)
397 knob = `<circle cx="0" cy="${H / 2}" r="${H / 2}" fill="${color}"/>${
398 done ? `<path d="${ICON_PATH.done}" transform="translate(-6 5) scale(.5)" fill="none" stroke="${ink}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>` : `<text x="0" y="${H / 2 + 4.2}" text-anchor="middle" class="kt">${label}</text>`
399 }`
400 } else {
401 // one stage (a todo list): the step at work names the knob; several: the stage at work
402 const name = done ? L().done : single ? (currentStep(p)?.title ?? p.stages[0]?.name ?? L().tasks) : (p.stages[w.stage]?.name ?? '')
403 const agents = p.agents ?? []
404 const base = p.id === AGENTS ? `${w.pos}/${w.total}` : done ? `${w.total}/${w.total}` : single ? `${number}/${w.total}` : `${w.step}/${w.stageSize}`
405 const agentCount = agents.length > 0 && p.id !== AGENTS ? ` · ${agents.filter(a => a.state === 'done').length}/${agents.length} agents` : ''
406 const count = base + agentCount
407 const iconW = icon ? 16 : 0
408 const countW = textWidth(count, 6.5)
409 const maxW = Math.max(80, W * 0.55)
410 let shown = name
411 while (shown.length > 3 && 20 + iconW + textWidth(shown) + 6 + countW > maxW) shown = shown.slice(0, -1)
412 if (shown !== name) shown = shown.trimEnd() + '…'
413 kw = Math.round(20 + iconW + textWidth(shown) + 6 + countW)
414 const left = -kw / 2 + 10
415 knob = `<rect x="${-kw / 2}" y="0" width="${kw}" height="${H}" rx="${H / 2}" fill="${color}"/>`
416 if (icon) knob += `<path d="${icon}" transform="translate(${left} 5) scale(.5)" fill="none" stroke="${ink}" stroke-width="3.6" stroke-linecap="round" stroke-linejoin="round"/>`
417 knob += `<text x="${left + iconW}" y="${H / 2 + 4.2}" class="kt">${esc(shown)}<tspan class="kc" dx="6">${count}</tspan></text>`
418 }
419 const clampX = (x: number) => Math.max(kw / 2, Math.min(W - kw / 2, x))
420 const kx = clampX(fx)
421 const kFrom = clampX(from)
422
423 const style = `<style>
424.b0{fill:${buckets[0]?.color};fill-opacity:${buckets[0]?.opacity}}.b1{fill:${buckets[1]?.color};fill-opacity:${buckets[1]?.opacity}}
425.b2{fill:${buckets[2]?.color};fill-opacity:${buckets[2]?.opacity}}.b3{fill:${buckets[3]?.color};fill-opacity:${buckets[3]?.opacity}}
426.b4{fill:${buckets[4]?.color};fill-opacity:${buckets[4]?.opacity}}
427rect[class]{width:2px;height:2px}
428.t0,.t1,.t2,.t3{animation:tw ${done ? 3.2 : 2.2}s ease-in-out infinite}
429.t1{animation-duration:${done ? 3.8 : 2.8}s;animation-delay:-.7s}.t2{animation-duration:${done ? 4.4 : 1.9}s;animation-delay:-1.3s}.t3{animation-duration:${done ? 3.5 : 3.3}s;animation-delay:-.4s}
430@keyframes tw{0%,100%{opacity:1}50%{opacity:${done ? 0.8 : 0.45}}}
431.kt{font:500 12px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;fill:${ink}}
432.kc{font-weight:400;fill-opacity:.75}
433@media (prefers-reduced-motion:reduce){.t0,.t1,.t2,.t3{animation:none}}
434</style>`
435 const glideFill = glide ? `<animate attributeName="width" from="${from.toFixed(1)}" to="${fx.toFixed(1)}" dur=".45s" ${ease} fill="freeze"/>` : ''
436 const glideKnob = glide ? `<animateTransform attributeName="transform" type="translate" from="${kFrom.toFixed(1)} 0" to="${kx.toFixed(1)} 0" dur=".45s" ${ease} fill="freeze"/>` : ''
437
438 return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${style}
439<defs><clipPath id="pill"><rect width="${W}" height="${H}" rx="${H / 2}"/></clipPath><clipPath id="fill"><rect width="${fx.toFixed(1)}" height="${H}">${glideFill}</rect></clipPath>
440<linearGradient id="base" x1="0" x2="${fx.toFixed(1)}" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="${rgb(acc)}" stop-opacity="${done ? 0.3 : 0.05}"/><stop offset="1" stop-color="${rgb(acc)}" stop-opacity=".33"/></linearGradient></defs>
441<g clip-path="url(#pill)"><rect width="${W}" height="${H}" fill="#808080" fill-opacity=".16"/>
442<g clip-path="url(#fill)"><rect width="${fx.toFixed(1)}" height="${H}" fill="url(#base)"/>${px}</g>${marks}</g>
443<g transform="translate(${kx.toFixed(1)} 0)">${glideKnob}${knob}</g></svg>`
444}
445
446const AGENT_COLOR: Record<AgentRun['state'], string> = {
447 running: STATE_COLOR.running,
448 waiting: STATE_COLOR.needs_input,
449 done: STATE_COLOR.done,
450 error: STATE_COLOR.error,
451}
452
453function applyPalette(name: PaletteName | undefined) {
454 const p = palette(name)
455 Object.assign(STATE_COLOR, { running: p.running, needs_input: p.waiting, error: p.error, done: p.done })
456 Object.assign(AGENT_COLOR, { running: p.running, waiting: p.waiting, error: p.error, done: p.done })
457}
458
459const elapsed = (ms: number) => {
460 const sec = Math.max(0, Math.round(ms / 1000))
461 return sec < 60 ? `${sec}s` : `${Math.floor(sec / 60)}m ${sec % 60}s`
462}
463
464// which strips show: all of a small batch; in a big one the unfinished first, the rest folded into one line
465function visibleAgents(p: Plan, now: number): { shown: AgentRun[]; hidden: AgentRun[] } | null {
466 const list = p.agents ?? []
467 if (list.length === 0) return null
468 const hasError = list.some(a => a.state === 'error')
469 if (p.agentsDoneAt && now - p.agentsDoneAt > FOLD_MS && !hasError) return null
470 if (list.length <= MAX_STRIPS) return { shown: list, hidden: [] }
471 const keep = new Set(list.filter(a => a.state !== 'done').slice(0, MAX_STRIPS - 1).map(a => a.id))
472 for (const a of [...list].reverse()) {
473 if (keep.size >= MAX_STRIPS - 1) break
474 keep.add(a.id)
475 }
476 return { shown: list.filter(a => keep.has(a.id)), hidden: list.filter(a => !keep.has(a.id)) }
477}
478
479// what each strip showed last time it was drawn, so a change morphs from the old status instead of jumping
480const lastStrip = new Map<string, { tool: string; color: string }>()
481const MORPH = '.2s'
482
483const stripsHeight = (n: number) => n * STRIP_H + (n - 1) * STRIP_GAP
484
485// one tinted strip per agent: state colour, name, what it does now and for how long; not a progress bar
486function stripsSvg(v: { shown: AgentRun[]; hidden: AgentRun[] }, W: number, now: number): string {
487 const isNarrow = W < NARROW
488 const rows: string[] = []
489 v.shown.forEach((a, i) => {
490 const c = AGENT_COLOR[a.state]
491 const y = i * (STRIP_H + STRIP_GAP)
492 const indent = a.depth > 0 ? 12 : 0
493 let px = ''
494 if (a.state === 'running') {
495 for (let col = 0; col * 3 < W; col++) {
496 for (let r = 0; r < 4; r++) {
497 if (hash(col + i * 41, r, 5) > 0.2) continue
498 px += `<rect x="${col * 3}" y="${y + 3 + r * 3.6}" class="t${Math.floor(hash(col, r, 6) * 4)}" fill="${c}" fill-opacity=".32"/>`
499 }
500 }
501 }
502 const nameRoom = isNarrow ? W - 30 - indent : W * 0.5
503 let name = (a.depth > 0 ? '↳ ' : '') + a.title
504 while (name.length > 4 && textWidth(name, 6.2) > nameRoom) name = name.slice(0, -1)
505 if (name !== (a.depth > 0 ? '↳ ' : '') + a.title) name = name.trimEnd() + '…'
506 const nameX = 19 + indent
507 const toolX = nameX + textWidth(name, 6.2) + 8
508 const time = elapsed((a.endedAt ?? now) - a.startedAt)
509 // a status change: the old word blurs out while the new one blurs in, and the tint flows to the new colour
510 const was = lastStrip.get(a.id)
511 lastStrip.set(a.id, { tool: a.tool, color: c })
512 const isToolChanged = was !== undefined && was.tool !== a.tool
513 const flow = (attr: string) => (was && was.color !== c ? `<animate attributeName="${attr}" from="${was.color}" to="${c}" dur="${MORPH}" fill="freeze"/>` : '')
514 const tool = isNarrow
515 ? ''
516 : (isToolChanged ? `<text x="${toolX}" y="${y + 12.5}" class="sn mo" style="fill:${was.color}">${esc(was.tool)}</text>` : '') +
517 `<text x="${toolX}" y="${y + 12.5}" class="sn${isToolChanged ? ' mi' : ''}" style="fill:${c}">${esc(a.tool)}</text>` +
518 `<text x="${W - 9}" y="${y + 12.5}" text-anchor="end" class="sn st">${time}</text>`
519 rows.push(
520 `<rect x="0" y="${y}" width="${W}" height="${STRIP_H}" rx="${STRIP_H / 2}" fill="${c}" fill-opacity=".15">${flow('fill')}</rect>${px}` +
521 `<circle cx="${10 + indent}" cy="${y + STRIP_H / 2}" r="3" fill="${c}"${a.state === 'running' ? ' class="sd"' : ''}>${flow('fill')}</circle>` +
522 `<text x="${nameX}" y="${y + 12.5}" class="sn">${esc(name)}</text>` +
523 tool,
524 )
525 })
526 if (v.hidden.length > 0) {
527 const y = v.shown.length * (STRIP_H + STRIP_GAP)
528 const doneCount = v.hidden.filter(a => a.state === 'done').length
529 rows.push(
530 `<rect x="0" y="${y}" width="${W}" height="${STRIP_H}" rx="${STRIP_H / 2}" fill="#808080" fill-opacity=".14"/>` +
531 `<text x="10" y="${y + 12.5}" class="sn st">${esc(L().moreAgents(v.hidden.length))} · ${esc(L().nDone(doneCount))}</text>`,
532 )
533 }
534 return `<style>.sn{font:400 11.5px 'Anthropic Sans',ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif;fill:#F0EEFC}.st{fill-opacity:.65}
535.sd{animation:sp 1.1s ease-in-out infinite}@keyframes sp{50%{opacity:.3}}
536.mi{animation:mi ${MORPH} ease-out both}@keyframes mi{from{opacity:0;filter:blur(3px)}}
537.mo{animation:mo ${MORPH} ease-in both}@keyframes mo{to{opacity:0;filter:blur(3px)}}
538@media (prefers-reduced-motion:reduce){.sd,.mi,.mo{animation:none}.mo{opacity:0}}</style>${rows.join('')}`
539}
540
541function plural(n: number, word: string) {
542 return `${n} ${word}${n === 1 ? '' : 's'}`
543}
544
545// ---------- engine glue ----------
546
547// the engine's player first (afplay on macOS); PowerShell where it cannot play
548function play($: EngineInterface, name: 'decision' | 'error' | 'done') {
549 if (soundTheme === 'off') return
550 const file = `${$.plugin.root}/sounds/${soundTheme}/${name}.wav`.replace(/\//g, '\\')
551 void $.audio.play({ asset: `sounds/${soundTheme}/${name}.wav` }).catch(() =>
552 $.process
553 .run(['powershell', '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', `(New-Object Media.SoundPlayer '${file}').PlaySync()`], { timeoutMs: 5000 })
554 .catch(() => undefined),
555 )
556}
557
558// the agents bar is the mod's own; the model never owes it an update
559const AGENTS = 'agents:auto' // slug() never yields ':', so no model id can take it
560const isOpenPlan = (p: Plan) => p.id !== AGENTS && p.state === 'running' && !p.stages.flatMap(s => s.steps).every(s => isFinished(s.status))
561
562const slug = (s: string) =>
563 s
564 .toLowerCase()
565 .replace(/[^\p{L}\p{N}]+/gu, '-')
566 .replace(/^-|-$/g, '')
567 .slice(0, 40) || 'plan'
568
569// adds or replaces one bar by id; keeps at most MAX_BARS, dropping finished ones first
570// computed inside update() from the latest list, so concurrent writers (parallel agents) do not drop each other
571function placeBar(list: readonly Plan[], next: Plan): Plan[] {
572 const prev = list.find(p => p.id === next.id)
573 // an update keeps its row; a new bar goes to the bottom
574 const rest = prev ? list.map(p => (p.id === next.id ? next : p)) : [...list, next]
575 while (rest.length > MAX_BARS) {
576 const doneAt = rest.findIndex(p => p.state === 'done')
577 rest.splice(doneAt >= 0 ? doneAt : 0, 1)
578 }
579 return rest
580}
581
582function chime($: EngineInterface, prev: PlanState | undefined, next: PlanState) {
583 if (next === prev) return
584 if (next === 'needs_input') play($, 'decision')
585 if (next === 'error') play($, 'error')
586 if (next === 'done') play($, 'done')
587}
588
589async function putPlan($: EngineInterface, next: Plan) {
590 let prev: Plan | undefined
591 const usd = await sessionUsd($)
592 await update($, plans, list => {
593 prev = list.find(p => p.id === next.id)
594 // the cost runs from the bar's opening; it is fixed when the bar first turns done
595 const costStart = prev ? (prev.costStart ?? null) : (next.costStart ?? usd)
596 const isDoneNow = next.state === 'done' && prev?.state !== 'done'
597 const cost = next.cost ?? (isDoneNow && costStart != null && usd != null ? usd - costStart : (prev?.cost ?? null))
598 return placeBar(list, { ...next, costStart, cost: next.state === 'done' ? cost : null })
599 })
600 chime($, prev?.state, next.state)
601 if (!prev) await update($, isOpen, () => true)
602}
603
604// an amount as two lines, the figure and then its currency: "2,53 $" or "$2.53" -> ["2,53", "$"]
605function splitCurrency(amount: string) {
606 if (!amount) return []
607 const figure = amount.replace(/\s*\$\s*/, '')
608 return [figure, '$']
609}
610
611// a bar's cost: fixed once it is done, else the session's cost since it opened; null under half a cent
612function spentOn(p: Plan, usdNow: number | null) {
613 const spent = p.state === 'done' ? p.cost : p.costStart != null && usdNow != null ? usdNow - p.costStart : null
614 return spent != null && spent >= 0.005 ? spent : null
615}
616
617// what the session has cost so far in US dollars, or null where the host keeps no ledger
618async function sessionUsd($: EngineInterface) {
619 const usage = await $.session.usage()
620 return typeof usage.cost?.usd === 'number' ? usage.cost.usd : null
621}
622
623// ---------- agents: drawn from engine events alone, no model calls ----------
624// each subagent lives on a bar as one state strip: the open task bar it was started under,
625// the bar of its parent agent, or the mod's own "Agents" bar when no task is open.
626// Module maps: a reload forgets running agents, whose strips then stay until the bar is closed.
627const agentHome = new Map<string, string>() // agentId -> bar id
628const toolUses = new Map<string, string>() // tool_use_id -> agentId, to find who waits on a permission
629const waiting = new Set<string>()
630let foldUntil = 0 // keep ticking until finished strips have folded
631
632// the mod's own bar mirrors its agents as steps, finished first, so percent and count read done/total
633function syncAuto(p: Plan, now: number): Plan {
634 const agents = p.agents ?? []
635 const isOver = agents.length > 0 && agents.every(a => a.state === 'done' || a.state === 'error')
636 const agentsDoneAt = isOver ? (p.agentsDoneAt ?? now) : null
637 if (p.id !== AGENTS) return { ...p, agentsDoneAt }
638 const rank = (a: AgentRun) => (a.state === 'done' ? 0 : a.state === 'error' ? 1 : 2)
639 const steps: PlanStep[] = [...agents]
640 .sort((a, b) => rank(a) - rank(b))
641 .map(a => ({ title: a.title, status: a.state === 'done' ? 'done' : a.state === 'error' ? 'error' : 'active', substeps: [] }))
642 const state: PlanState = isOver
643 ? agents.some(a => a.state === 'error') ? 'error' : 'done'
644 : agents.some(a => a.state === 'waiting') ? 'needs_input' : 'running'
645 return { ...p, agentsDoneAt, stages: [{ name: 'Agents', steps }], state }
646}
647
648function addRun(p: Plan, run: AgentRun, parentId: string | undefined, now: number): Plan {
649 // a batch that has finished makes room for the next one
650 const list = p.agentsDoneAt ? [] : [...(p.agents ?? [])]
651 let at = list.length
652 const parentAt = parentId ? list.findIndex(a => a.id === parentId) : -1
653 if (parentAt >= 0) {
654 at = parentAt + 1
655 while (at < list.length && (list[at]?.depth ?? 0) > 0) at++
656 }
657 list.splice(at, 0, run)
658 return syncAuto({ ...p, agents: list, agentsDoneAt: null }, now)
659}
660
661// changes one agent's strip inside the latest list; sounds follow the bar's state
662async function editAgent($: EngineInterface, agentId: string, change: (a: AgentRun) => AgentRun) {
663 const home = agentHome.get(agentId)
664 if (!home) return
665 const now = await $.clock.now()
666 let before: PlanState | undefined
667 let after: PlanState | undefined
668 let isFolding = false
669 await update($, plans, list =>
670 list.map(p => {
671 if (p.id !== home || !p.agents?.some(a => a.id === agentId)) return p
672 before = p.state
673 const next = syncAuto({ ...p, agents: p.agents.map(a => (a.id === agentId ? change(a) : a)) }, now)
674 after = next.state
675 isFolding = !p.agentsDoneAt && next.agentsDoneAt !== null
676 return next
677 }),
678 )
679 if (isFolding) foldUntil = now + FOLD_MS + 1500
680 if (before !== undefined && after !== undefined) chime($, before, after)
681}
682
683async function dropPlan($: EngineInterface, id: string) {
684 lastHead.delete(id)
685 for (const p of await read($, plans)) if (p.id === id) for (const a of p.agents ?? []) lastStrip.delete(a.id)
686 await update($, plans, list => list.filter(p => p.id !== id))
687}
688
689const STEP_SCHEMA = {
690 type: 'object',
691 required: ['title', 'status'],
692 properties: {
693 title: { type: 'string' },
694 status: { enum: STATUSES },
695 substeps: {
696 type: 'array',
697 items: { type: 'object', required: ['title', 'status'], properties: { title: { type: 'string' }, status: { enum: STATUSES } } },
698 },
699 },
700}
701
702// only calls that change something count as work for the enforcement below; reading and searching are free
703const WORK_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', 'PowerShell'])
704const WORK_BEFORE_PLAN = 3 // the 4th changing call without a plan is refused once
705const CALLS_BEFORE_NUDGE = 6 // working calls without a plan update before a reminder
706
707
708export function registerPlanProgress(on: Parameters<Register>[0], options: { enforcement?: Enforcement; language?: string; sounds?: SoundTheme; palette?: PaletteName }) {
709 applyPalette(options.palette)
710 enforcement = options.enforcement ?? 'soft'
711 soundTheme = options.sounds ?? 'soft'
712 language = options.language ?? 'auto'
713 // a matcher, so other features may hook session.start too
714 on('session.start', { isInteractive: [true, false] }, async ($, e, next) => {
715 await startPlanProgress($)
716 return next(e)
717 })
718
719 // the bars sit above whatever the hooks beneath draw
720 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
721 const bars = await planBars($, e)
722 const rest = await next(e)
723 return stack($.ui.resolve(e).Box, [['plan-bars', bars], ['below-plans', rest]]) ?? rest
724 })
725
726 // per-turn bookkeeping; module variables are fine here, a reload just starts a fresh count
727 let workCalls = 0
728 let sinceUpdate = 0
729 let isPlanTouched = false
730 let hasRefused = false
731 let isWaitingOnBackground = false
732
733 on('turn.start', async ($, e, next) => {
734 workCalls = 0
735 sinceUpdate = 0
736 isPlanTouched = false
737 hasRefused = false
738 isWaitingOnBackground = false
739
740 return next(e)
741 })
742
743 // the rule lives in the cached system prompt; a message only carries one short line when bars are open,
744 // and the person answering clears any "needs input" without a model call
745 on('prompt.submit', async ($, e, next) => {
746 if (e.origin.kind !== 'composer') return next(e)
747 const list = await read($, plans)
748 if (list.some(p => p.state === 'needs_input')) {
749 await update($, plans, all => all.map(p => (p.state === 'needs_input' ? { ...p, state: 'running' as const, note: null } : p)))
750 }
751 const open = list.filter(p => p.state !== 'done' && p.id !== AGENTS)
752 if (open.length === 0) return next(e)
753 const line = `still-mods open bars: ${open
754 .map(p => {
755 const w = where(p)
756 return `${p.id} (${p.stages[w.stage]?.name ?? ''} ${w.step}/${w.stageSize})`
757 })
758 .join(', ')}`
759
760 return next({ ...e, context: [...(e.context ?? []), line] })
761 })
762
763 // watches the main loop's changing calls: refuses once when multi-step work starts without a bar,
764 // and reminds to update the bar when it goes stale mid-turn
765 on('tool.call', async ($, e, next) => {
766 // a subagent's call only names its current tool on its strip; no gate, no reminders
767 if (e.agentId) {
768 const agentId = e.agentId
769 if (!agentHome.has(agentId)) return next(e)
770 await editAgent($, agentId, a => ({ ...a, state: 'running', tool: e.tool }))
771 if (e.tool_use_id) toolUses.set(e.tool_use_id, agentId)
772 const ran = await next(e)
773 if (e.tool_use_id) toolUses.delete(e.tool_use_id)
774 if (waiting.delete(agentId)) await editAgent($, agentId, a => (a.state === 'waiting' ? { ...a, state: 'running' } : a))
775 return ran
776 }
777 if (!WORK_TOOLS.has(e.tool)) return next(e)
778 isWaitingOnBackground = (e as unknown as Raw).run_in_background === true
779 const hasLivePlan = isPlanTouched || (await read($, plans)).some(isOpenPlan)
780 const asksForBar = enforcement !== 'off' && !hasLivePlan && !hasRefused && workCalls >= WORK_BEFORE_PLAN
781 if (asksForBar) {
782 hasRefused = true
783 if (enforcement === 'strict') return { deny: `still-mods: several changes ahead. Create a bar with ${TOOL} first, then retry.` }
784 }
785 const ran = await next(e)
786 // a shell call that only read (ls, git status, grep) is not work
787 if (ran.deny !== undefined || ran.isReadOnly) return ran
788 workCalls += 1
789 sinceUpdate += 1
790 // soft: the call ran, and the model is told once that a bar would fit
791 if (asksForBar) return { ...ran, context: [...(ran.context ?? []), `still-mods: several changes ahead, consider a bar with ${TOOL}.`] }
792 if (enforcement !== 'off' && hasLivePlan && sinceUpdate >= CALLS_BEFORE_NUDGE) {
793 sinceUpdate = 0
794
795 return { ...ran, context: [...(ran.context ?? []), `still-mods: bar is stale, send {id, next:true} or {id, done, active}.`] }
796 }
797
798 return ran
799 })
800
801 // an open bar at the end of a turn: a question to the user marks it waiting on its own;
802 // only a turn that did work and left the bar unexplained is sent back once
803 on('classic.Stop', async ($, e, next) => {
804 const result = await next(e)
805 if (e.stop_hook_active || result.block || isWaitingOnBackground || (e.background_tasks?.length ?? 0) > 0) return result
806 const open = (await read($, plans)).filter(isOpenPlan)
807 if (open.length === 0) return result
808 const asks = /\?\s*$/.test(e.last_assistant_message ?? '')
809 if (asks) {
810 const last = open[open.length - 1]
811 if (last) await putPlan($, { ...last, state: 'needs_input' })
812
813 return result
814 }
815 // only strict sends the turn back; soft and off leave the open bar as it is
816 if (enforcement !== 'strict' || (workCalls === 0 && !isPlanTouched)) return result
817
818 return {
819 ...result,
820 block: `still-mods: ${open.map(p => p.id).join(', ')} still open. Update each with ${TOOL}: {id, next:true}, or state "done", "needs_input" or "error" with a note.`,
821 }
822 })
823
824
825 on('prompt.compose', async ($, e, next) => {
826 const result = await next(e)
827
828 return { sections: [...result.sections, { id: 'still-mods:rules', text: rules(), scope: 'session' as const }] }
829 })
830
831 on('tool.call', { tool: TOOL }, async ($, e) => {
832 const raw = e as unknown as Raw
833 const now = await $.clock.now()
834 const list = await read($, plans)
835 const id = slug(str(raw.id, 60) || str(raw.title, 80))
836 const next = normalize(raw, list.find(p => p.id === id) ?? null, now, id)
837 if (next.stages.length === 0) return { deny: `plan_progress: no bar "${id}" yet; create it with title and stages.` }
838 isPlanTouched = true
839 sinceUpdate = 0
840 await putPlan($, next)
841 const w = where(next)
842
843 const active = next.stages.flatMap(st => st.steps).find(st => st.status === 'active')
844
845 return { result: `${id}: ${Math.min(w.pos, w.total)}/${w.total}, ${next.state}${active ? `, active "${active.title}"` : ''}` }
846 })
847
848 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
849 const live = (await read($, plans)).filter(p => p.state === 'running').pop()
850 if (live) await update($, plans, list => list.map(p => (p.id === live.id ? { ...p, state: 'needs_input' as const } : p)))
851 play($, 'decision')
852 const ran = await next(e)
853 if (live) await update($, plans, list => list.map(p => (p.id === live.id && p.state === 'needs_input' ? { ...p, state: 'running' as const } : p)))
854
855 return ran
856 })
857
858 on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
859 play($, 'decision')
860 const ran = await next(e)
861 const text = ran.deny === undefined && ran.isError !== true ? (ran.result as { plan?: unknown } | undefined)?.plan : undefined
862 if (typeof text === 'string') {
863 const parsed = parsePlan(text, await $.clock.now())
864 if (parsed) await putPlan($, { ...parsed, id: slug(parsed.title) })
865 }
866
867 return ran
868 })
869
870 on('command.run', { command: 'still-mods-progress' }, async $ => {
871 if ((await read($, plans)).length === 0) return { text: 'No plan yet. /still-mods-progress-demo shows a sample.' }
872 const open = await read($, isOpen)
873 await update($, isOpen, () => !open)
874
875 return { text: open ? 'Progress bars hidden.' : 'Progress bars shown.' }
876 })
877
878 on('command.run', { command: 'still-mods-progress-demo' }, async $ => {
879 await putPlan($, demoPlan(await $.clock.now(), await read($, plans), await sessionUsd($)))
880 await update($, isOpen, () => true)
881
882 return { text: locale === 'fr' ? 'Barre d’exemple ajoutée au-dessus du prompt.' : 'Sample bar added above the prompt.' }
883 })
884
885 on('command.run', { command: 'still-mods-progress-clear' }, async $ => {
886 await update($, plans, () => [])
887
888 return { text: 'Progress bars removed.' }
889 })
890
891 on('command.run', { command: 'still-mods-progress-sounds' }, async $ => {
892 play($, 'decision')
893 $.clock.after(900, () => play($, 'error'))
894 $.clock.after(1800, () => play($, 'done'))
895
896 if (soundTheme === 'off') return { text: locale === 'fr' ? 'Sons coupés (/still-mods sounds soft pour les remettre).' : 'Sounds are off (/still-mods sounds soft turns them on).' }
897 return { text: locale === 'fr' ? `Sons (${soundTheme}) : décision, erreur, terminé.` : `Sounds (${soundTheme}): decision, error, done.` }
898 })
899
900 // always drawn, so the person sees the mod is loaded; dim while there is nothing to show
901 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
902 const count = (await read($, plans)).length
903 const open = await read($, isOpen)
904 const { Box, Button } = $.ui.resolve(e)
905 // other mods add their labels to modes beneath us; keep them
906 const below = await next(e)
907 const press = () =>
908 count === 0
909 ? $.ui.toast(L().pluginOn)
910 : update($, isOpen, () => !open)
911
912 return (
913 <Box flexDirection="row" alignItems="center" gap={1}>
914 <Button key="progress-toggle" dimColor={count === 0 || !open} label={count > 1 ? `${L().progress} ${count}` : L().progress} onPress={press} />
915 {below}
916 </Box>
917 )
918 })
919
920
921
922 on('agent.spawn', async ($, e, next) => {
923 const started = await next(e)
924 if (!('agentId' in started) || !started.agentId) return started
925 const id = started.agentId
926 const now = await $.clock.now()
927 const parentHome = e.parentAgentId ? agentHome.get(e.parentAgentId) : undefined
928 const home = parentHome ?? [...(await read($, plans))].reverse().find(isOpenPlan)?.id ?? AGENTS
929 agentHome.set(id, home)
930 const run: AgentRun = {
931 id,
932 title: (e.description || e.subagentType).slice(0, 60),
933 state: 'running',
934 tool: 'Starting',
935 startedAt: now,
936 endedAt: null,
937 depth: parentHome ? 1 : 0,
938 }
939 let isNew = false
940 await update($, plans, list => {
941 if (list.some(p => p.id === home)) return list.map(p => (p.id === home ? addRun(p, run, e.parentAgentId, now) : p))
942 isNew = true
943 const auto: Plan = { id: AGENTS, title: 'Agents', kind: 'todo', stages: [], state: 'running', note: null, startedAt: now }
944 return placeBar(list, addRun(auto, run, undefined, now))
945 })
946 if (isNew) await update($, isOpen, () => true)
947
948 return started
949 })
950
951 // an agent waiting on a permission prompt turns its strip amber until the call goes on
952 on('tool.check', async ($, e, next) => {
953 const verdict = await next(e)
954 const agentId = e.tool_use_id ? toolUses.get(e.tool_use_id) : undefined
955 const useId = e.tool_use_id
956 // the mode often settles an ask by itself in a blink; only a call still held after a moment waits on the person
957 if (agentId && useId && verdict.decision === 'ask') {
958 $.clock.after(600, async () => {
959 if (toolUses.get(useId) !== agentId) return
960 waiting.add(agentId)
961 await editAgent($, agentId, a => ({ ...a, state: 'waiting', tool: L().needsApproval }))
962 })
963 }
964
965 return verdict
966 })
967
968 on('turn.complete', async ($, e, next) => {
969 const agentId = e.agentId
970 if (agentId && agentHome.has(agentId)) {
971 const now = await $.clock.now()
972 const isFailed = e.reason !== 'answer'
973 const tool = e.reason === 'aborted' ? L().stopped : isFailed ? L().failed : L().done
974 await editAgent($, agentId, a => ({ ...a, state: isFailed ? 'error' : 'done', tool, endedAt: now }))
975 // the mod's own bar sounds through its state; a strip on a task bar sounds here
976 if (isFailed && agentHome.get(agentId) !== AGENTS) play($, 'error')
977 agentHome.delete(agentId)
978 waiting.delete(agentId)
979 }
980 // a plan whose steps are all finished closes itself
981 for (const p of await read($, plans)) {
982 if (p.id === AGENTS) continue
983 if (p.state === 'done') continue
984 const steps = p.stages.flatMap(s => s.steps)
985 if (steps.length > 0 && steps.every(s => isFinished(s.status))) await putPlan($, { ...p, state: 'done' })
986 }
987
988 return next(e)
989 })
990}
991
992// what the plan bars need at each session.start
993export async function startPlanProgress($: EngineInterface) {
994 const settings = await $.settings.read()
995 locale = resolveLocale(language, settings.language, systemLocale())
996 await $.tool.register({
997 name: 'plan_progress',
998 description: 'Live progress bar above the prompt, one per id. Create with title + stages; update with short ops (next, done, active, failed) or state.',
999 inputSchema: {
1000 type: 'object',
1001 required: ['id'],
1002 properties: {
1003 id: { type: 'string', description: 'Bar id; reuse it for updates' },
1004 title: { type: 'string' },
1005 kind: { enum: ['plan', 'todo'] },
1006 stages: {
1007 type: 'array',
1008 description: 'Full breakdown, only when creating or restructuring',
1009 items: { type: 'object', required: ['name', 'steps'], properties: { name: { type: 'string' }, steps: { type: 'array', items: STEP_SCHEMA } } },
1010 },
1011 next: { type: 'boolean', description: 'Active step finished, start the next one' },
1012 done: { type: 'array', items: { type: 'string' }, description: 'Step titles now finished' },
1013 active: { type: 'string', description: 'Step title now in progress' },
1014 failed: { type: 'string', description: 'Step title that failed' },
1015 state: { enum: ['running', 'needs_input', 'error', 'done'] },
1016 note: { type: 'string', description: 'One line for needs_input or error' },
1017 },
1018 },
1019 })
1020 $.clock.every(1000, async () => {
1021 if (agentHome.size > 0 || (await $.clock.now()) < foldUntil) await update($, tick, n => n + 1)
1022 })
1023 await $.command.register({ name: 'still-mods-progress', description: 'Show or hide the progress bars' })
1024 await $.command.register({ name: 'still-mods-progress-demo', description: 'Show a sample plan in the progress bars' })
1025 await $.command.register({ name: 'still-mods-progress-sounds', description: 'Play the decision, error and done sounds' })
1026 await $.command.register({ name: 'still-mods-progress-clear', description: 'Remove all progress bars' })
1027}
1028
1029// the plan bars, or null when there are none to show
1030export async function planBars($: EngineInterface, e: any): Promise<any> {
1031 const list = await read($, plans)
1032 if (list.length === 0 || e.props.hasSurvey || !(await read($, isOpen))) return null
1033 const t = $.ui.resolve(e)
1034 const { Box, Button, Text } = t
1035 const Svg = 'Svg' in t ? t.Svg : null
1036 const total = Math.max(320, (e.props.bodyColumns || 100) * 8)
1037 // every bar has the same width and is pinned to the right edge (fixed-width percent, close button),
1038 // so rows line up whatever their titles; the slack goes into the gap after the title.
1039 // Desktop reports ~8 CSS px per column; glyph, gaps, percent and the close button take ~126 px.
1040 const titleWidth = Math.min(Math.round(total * 0.3), Math.max(...list.map(p => Math.round(textWidth(p.title, 6.4)))))
1041 await read($, tick)
1042 const usdNow = await sessionUsd($)
1043 // the cost column: as wide as the widest amount, kept on one line and right-aligned, its room taken
1044 // from the track so every row still lines up
1045 const costs = list.map(p => spentOn(p, usdNow)).map(v => (v == null ? '' : formatUsd(locale, v)))
1046 const oneLine = Math.max(0, ...costs.map(c => [...c].length))
1047 // too narrow for the amounts on one line: the figure above, the currency under it
1048 const isTight = oneLine > 0 && total - titleWidth - 140 - (oneLine * 8 + 8) < 120
1049 const costLines = costs.map(c => (isTight ? splitCurrency(c) : [c]))
1050 const costCells = Math.max(0, ...costLines.flat().map(c => [...c].length))
1051 const costPx = costCells > 0 ? costCells * 8 + 8 : 0
1052 const trackW = Math.max(120, Math.min(1400, total - titleWidth - 140 - costPx))
1053 const now = await $.clock.now()
1054 // a hairline between task bars, so each bar and its agent strips read as one group
1055 const divider = `<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="1"><rect width="${total}" height="1" fill="#808080" fill-opacity=".22"/></svg>`
1056
1057 return (
1058 <Box flexDirection="column" gap={1}>
1059 {list.flatMap((p, i) => {
1060 const v = visibleAgents(p, now)
1061 const stripsH = v ? 5 + stripsHeight(v.shown.length + (v.hidden.length > 0 ? 1 : 0)) : 0
1062 const source = v
1063 ? `<svg xmlns="http://www.w3.org/2000/svg" width="${trackW}" height="${TRACK_H + stripsH}">${trackSvg(p, trackW)}<g transform="translate(0 ${TRACK_H + 5})">${stripsSvg(v, trackW, now)}</g></svg>`
1064 : trackSvg(p, trackW)
1065 const agentsAlt = v ? `; agents: ${(p.agents ?? []).map(a => `${a.title} ${a.state}`).join(', ')}` : ''
1066 const line = i > 0 && Svg ? [<Svg key={`div-${p.id}`} source={divider} alt="" width={total} height={1} />] : []
1067 const w = where(p)
1068 const pct = p.state === 'done' ? 100 : Math.round((Math.min(w.pos, w.total) / Math.max(1, w.total)) * 100)
1069 const color = STATE_COLOR[p.state]
1070 const stageName = (p.stages.length === 1 ? currentStep(p)?.title : undefined) ?? p.stages[w.stage]?.name ?? ''
1071 const alt =
1072 p.state === 'done'
1073 ? `${p.title}: done, ${plural(w.total, 'step')}`
1074 : `${p.title}: ${stageName}, step ${w.step} of ${w.stageSize}, ${pct}%${p.note ? ` — ${p.note}` : ''}${agentsAlt}`
1075 const bar = `${'━'.repeat(Math.round(pct / 4))}${'─'.repeat(25 - Math.round(pct / 4))}`
1076
1077 return [
1078 ...line,
1079 <Box key={`bar-${p.id}`} flexDirection="row" alignItems={v ? 'flex-start' : 'center'} gap={1}>
1080 <Text color={color}>{STATE_GLYPH[p.state]}</Text>
1081 <Text wrap="truncate">{p.title}</Text>
1082 <Box flexGrow={1} />
1083 {Svg ? (
1084 <Svg source={source} alt={alt} width={trackW} height={TRACK_H + stripsH} />
1085 ) : (
1086 <Text>
1087 <Text color={color}>{bar.replace(/─/g, '')}</Text>
1088 <Text dimColor>{bar.replace(/━/g, '')}</Text>
1089 <Text color={color}>{` ${stageName} ${w.step}/${w.stageSize}`}</Text>
1090 </Text>
1091 )}
1092 {costCells > 0 ? (
1093 <Box key={`cost-${p.id}`} width={costCells} flexShrink={0} flexDirection="column" alignItems="flex-end">
1094 {(costLines[i] ?? []).map((line, n) => (
1095 <Text key={`cost-${p.id}-${n}`} dimColor wrap="truncate">
1096 {line}
1097 </Text>
1098 ))}
1099 </Box>
1100 ) : null}
1101 <Text dimColor>{`${String(pct).padStart(3, FIGURE_SPACE)}%`}</Text>
1102 <Button key={`close-${p.id}`} plain dimColor label="✕" onPress={() => dropPlan($, p.id)} />
1103 </Box>,
1104 ]
1105 })}
1106 </Box>
1107 )
1108}
1109features/settings.ts 192 lines1// The /still-mods command: lists every option of the plugin with its value, and changes one, for
2// surfaces with no /config menu (the desktop Code tab). A change goes through $.config.set when the
3// option is a row of the config menu; a plugin loaded from a folder has no row there in the desktop
4// app, so the command then writes pluginConfigs in ~/.claude/settings.json itself and touches a file
5// of the plugin, which makes the watched folder reload it with the new options.
6import type { EngineInterface, Register } from 'claude-code'
7
8import { type Locale, resolveLocale, systemLocale } from './i18n'
9import { PALETTE_NAMES } from './palettes'
10
11const COMMAND = 'still-mods'
12const PLUGIN = 'still-mods'
13// written after a change so the watched folder reloads the plugin; ignored by git
14const RELOAD_STAMP = '.reload-stamp'
15
16type Field = { name: string; kind: 'boolean' | 'choice' | 'text' | 'number'; choices?: string[]; fallback: string | boolean | number; label: Record<Locale, string> }
17
18// the manifest's userConfig, in the order the list shows it
19const FIELDS: Field[] = [
20 { name: 'planProgress', kind: 'boolean', fallback: true, label: { en: 'Plan progress bars', fr: 'Barres de progression des plans' } },
21 {
22 name: 'planEnforcement',
23 kind: 'choice',
24 choices: ['strict', 'soft', 'off'],
25 fallback: 'soft',
26 label: { en: 'How hard Claude is held to its bars', fr: 'Exigence envers Claude sur les barres' },
27 },
28 {
29 name: 'sounds',
30 kind: 'choice',
31 choices: ['soft', 'classic', 'off'],
32 fallback: 'soft',
33 label: { en: 'Sounds for decision, error, done', fr: 'Sons de décision, erreur, fin' },
34 },
35 { name: 'turnNotify', kind: 'boolean', fallback: true, label: { en: 'Sound and toast when a long turn ends', fr: 'Son et notification à la fin d’un long tour' } },
36 { name: 'turnNotifySeconds', kind: 'number', fallback: 60, label: { en: 'Seconds a turn must last to notify', fr: 'Durée minimale du tour pour notifier (s)' } },
37 { name: 'gitStatus', kind: 'boolean', fallback: true, label: { en: 'Git status line (branch, changes, ahead/behind)', fr: 'Ligne d’état git (branche, modifs, avance/retard)' } },
38 { name: 'cacheMeter', kind: 'boolean', fallback: true, label: { en: 'Prompt cache column (hit rate, time left)', fr: 'Colonne cache (taux, temps restant)' } },
39 { name: 'cacheTtl', kind: 'choice', choices: ['1h', '5m'], fallback: '1h', label: { en: 'Prompt cache lifetime', fr: 'Durée de vie du cache' } },
40 { name: 'usageMeters', kind: 'boolean', fallback: true, label: { en: 'Context and limit meters', fr: 'Compteurs de contexte et de limites' } },
41 {
42 name: 'palette',
43 kind: 'choice',
44 choices: PALETTE_NAMES,
45 fallback: 'default',
46 label: { en: 'Colours of the bars and meters', fr: 'Couleurs des barres et compteurs' },
47 },
48 { name: 'language', kind: 'choice', choices: ['auto', 'en', 'fr'], fallback: 'auto', label: { en: 'Labels language', fr: 'Langue des libellés' } },
49 { name: 'timeZone', kind: 'text', fallback: 'Europe/Paris', label: { en: 'Reset time zone (IANA)', fr: 'Fuseau des heures de reset (IANA)' } },
50]
51
52const TEXT = {
53 en: {
54 title: 'still-mods options',
55 change: `Change one: /${COMMAND} <option> <value>, e.g. /${COMMAND} usageMeters off`,
56 commands: `Commands: /${COMMAND}-progress (show or hide the bars), /${COMMAND}-progress-demo, /${COMMAND}-progress-clear, /${COMMAND}-progress-sounds`,
57 unknown: (f: string) => `Unknown option "${f}".`,
58 needs: (f: string, v: string) => `"${f}" takes ${v}.`,
59 notChanged: (f: string, why: string) => `${f} not changed: ${why}`,
60 changed: (f: string, v: string) => `${f} = ${v}. The plugin reloads with it.`,
61 written: (f: string, v: string, path: string) => `${f} = ${v}, saved in ${path}. The plugin reloads with it.`,
62 noWrite: (f: string, v: string, why: string) =>
63 `${f} could not be saved (${why}). Set it in ~/.claude/settings.json:\n"pluginConfigs": { "${PLUGIN}": { "options": { "${f}": ${v} } } }`,
64 anyText: 'any text',
65 },
66 fr: {
67 title: 'Options de still-mods',
68 change: `Pour changer : /${COMMAND} <option> <valeur>, ex. /${COMMAND} usageMeters off`,
69 commands: `Commandes : /${COMMAND}-progress (afficher ou masquer les barres), /${COMMAND}-progress-demo, /${COMMAND}-progress-clear, /${COMMAND}-progress-sounds`,
70 unknown: (f: string) => `Option inconnue « ${f} ».`,
71 needs: (f: string, v: string) => `« ${f} » accepte ${v}.`,
72 notChanged: (f: string, why: string) => `${f} inchangé : ${why}`,
73 changed: (f: string, v: string) => `${f} = ${v}. Le mod se recharge avec cette valeur.`,
74 written: (f: string, v: string, path: string) => `${f} = ${v}, enregistré dans ${path}. Le mod se recharge avec cette valeur.`,
75 noWrite: (f: string, v: string, why: string) =>
76 `${f} n'a pas pu être enregistré (${why}). Règle-le dans ~/.claude/settings.json :\n"pluginConfigs": { "${PLUGIN}": { "options": { "${f}": ${v} } } }`,
77 anyText: 'un texte',
78 },
79}
80
81// the values this load runs with, from register(on, options)
82let current: Record<string, unknown> = {}
83let locale: Locale = 'en'
84
85export function registerSettings(on: Parameters<Register>[0], options: Record<string, unknown>) {
86 current = options
87
88 // a matcher, so other features may hook session.start too
89 on('session.start', { isInteractive: [true, false] }, async ($, e, next) => {
90 const settings = await $.settings.read()
91 locale = resolveLocale(current.language, settings.language, systemLocale())
92 await $.command.register({
93 name: COMMAND,
94 description: locale === 'fr' ? 'Afficher ou changer les options de still-mods' : 'Show or change the still-mods options',
95 argumentHint: '[' + FIELDS.map(f => f.name).join('|') + '] [value]',
96 })
97 return next(e)
98 })
99
100 on('command.run', { command: COMMAND }, async ($, e) => {
101 const [name, ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
102 if (!name) return { text: await describe($) }
103 return { text: await change($, name, rest.join(' ')) }
104 })
105}
106
107// this plugin's rows of the config menu, keyed `<plugin>.<field>` (`<plugin>@inline.<field>` loaded from a folder)
108async function ownRows($: EngineInterface) {
109 const rows = await $.config.list()
110 return rows
111 .filter(row => row.key.startsWith(PLUGIN + '.') || row.key.startsWith(PLUGIN + '@'))
112 .map(row => ({ ...row, field: row.key.slice(row.key.lastIndexOf('.') + 1) }))
113}
114
115function accepted(field: Field) {
116 if (field.kind === 'boolean') return 'on | off'
117 if (field.kind === 'choice') return (field.choices ?? []).join(' | ')
118 if (field.kind === 'number') return locale === 'fr' ? 'un nombre' : 'a number'
119 return TEXT[locale].anyText
120}
121
122async function describe($: EngineInterface) {
123 const rows = await ownRows($)
124 const width = Math.max(...FIELDS.map(f => f.name.length))
125 const lines = FIELDS.map(field => {
126 const row = rows.find(r => r.field === field.name)
127 const value = row ? row.value : (current[field.name] ?? field.fallback)
128 return ` ${field.name.padEnd(width)} ${formatValue(value).padEnd(12)} ${field.label[locale]} (${accepted(field)})`
129 })
130 const T = TEXT[locale]
131 return [T.title, ...lines, '', T.change, T.commands].join('\n')
132}
133
134async function change($: EngineInterface, name: string, raw: string) {
135 const T = TEXT[locale]
136 const field = FIELDS.find(f => f.name.toLowerCase() === name.toLowerCase())
137 if (!field) return `${T.unknown(name)}\n\n${await describe($)}`
138 const value = parseValue(field, raw)
139 if (value === undefined) return T.needs(field.name, accepted(field))
140 const row = (await ownRows($)).find(r => r.field === field.name)
141 if (!row) {
142 try {
143 const path = await saveOption($, field.name, value)
144 current = { ...current, [field.name]: value }
145 return T.written(field.name, formatValue(value), path)
146 } catch (error) {
147 return T.noWrite(field.name, JSON.stringify(value), error instanceof Error ? error.message : String(error))
148 }
149 }
150 const result = await $.config.set({ key: row.key, value })
151 if (result.deny) return T.notChanged(field.name, result.deny)
152 return T.changed(field.name, formatValue(result.value))
153}
154
155// writes pluginConfigs.<plugin>.options.<name> in the user settings, keeping every other key, then
156// touches the reload stamp so the watched plugin folder loads again with the new options
157async function saveOption($: EngineInterface, name: string, value: string | boolean | number) {
158 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
159 if (!home) throw new Error('no home directory')
160 const path = home.replace(/\\/g, '/') + '/.claude/settings.json'
161 const text = (await $.fs.exists(path)) ? await $.fs.read(path) : '{}'
162 const settings = JSON.parse(text) as Record<string, any>
163 const configs = (settings.pluginConfigs ??= {})
164 const own = (configs[PLUGIN] ??= {})
165 own.options = { ...(own.options ?? {}), [name]: value }
166 await $.fs.write(path, JSON.stringify(settings, null, 2) + '\n')
167 await $.fs.write(`${$.plugin.root}/${RELOAD_STAMP}`, new Date().toISOString() + '\n')
168 return path
169}
170
171function parseValue(field: Field, raw: string): string | boolean | number | undefined {
172 const word = raw.trim()
173 if (field.kind === 'number') {
174 const n = Number(word.replace(',', '.'))
175 return word && Number.isFinite(n) && n >= 0 ? n : undefined
176 }
177 if (field.kind === 'boolean') {
178 const w = word.toLowerCase()
179 if (['on', 'true', 'yes', 'oui', '1'].includes(w)) return true
180 if (['off', 'false', 'no', 'non', '0'].includes(w)) return false
181 return undefined
182 }
183 if (field.kind === 'choice') return field.choices?.find(c => c === word.toLowerCase())
184 return word || undefined
185}
186
187function formatValue(value: unknown) {
188 if (value === true) return 'on'
189 if (value === false) return 'off'
190 return String(value)
191}
192features/usage-meters.tsx 557 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { stack } from './band'
4import { palette, type PaletteName } from './palettes'
5import { formatUsd, type Locale, resolveLocale, strings, systemLocale } from './i18n'
6
7// Usage meters: context, 5-hour and weekly limit usage, adapted from usage-meter by HolyGrail
8// (https://github.com/HolyGrail/claude-mods): see NOTICE.
9
10let context: any = null
11// what the session has cost so far, in US dollars; null where the host keeps no ledger
12let costUsd: number | null = null
13let rateLimits: any[] = []
14// when rateLimits was last measured, in $.clock.now() milliseconds
15let measuredAt = 0
16let ticker: { cancel(): void } | null = null
17// this session's own key in the store
18let ownKey: string | null = null
19
20// rate limits are per account, so sessions share readings through $.store and each shows the newest;
21// each session writes only its own key, so no write can overwrite another session's reading
22const KEY_PREFIX = 'reading:'
23const HOUR_MS = 3_600_000
24// readings older than the longest window say nothing current
25const STALE_MS = 8 * 24 * HOUR_MS
26// the session.end reasons after which the meters stop; /clear, /resume and logout keep them running
27const FINAL_REASONS = ['prompt_input_exit', 'other']
28const TICK_MS = 60_000
29// reset times are shown in this zone, the timeZone option
30let timeZone = 'Europe/Paris'
31// the labels' language: the language option, else read at session.start
32let language = 'auto'
33let locale: Locale = 'en'
34
35// title: a key of the labels, or the label itself; showsDay adds the weekday to the reset time
36// divisions: the bar is cut in that many equal parts, one per hour or per day of the window
37const WINDOWS: Record<string, { title: string; ms?: number; showsDay?: boolean; divisions?: number }> = {
38 five_hour: { title: 'fiveHour', ms: 5 * HOUR_MS, divisions: 5 },
39 seven_day: { title: 'sevenDay', ms: 7 * 24 * HOUR_MS, showsDay: true, divisions: 7 },
40 spend_limit: { title: '$' },
41}
42// the context bar's cuts: every tenth from half full, where the window starts to matter
43const CONTEXT_TICKS = [0.5, 0.6, 0.7, 0.8, 0.9]
44// shown even before any reading, so the band keeps its shape from the first frame
45const ALWAYS_SHOWN = ['five_hour', 'seven_day']
46
47// pace thresholds: margin is the elapsed share of the window minus the used share
48const GREEN_MIN_MARGIN = 10
49const RED_BELOW_MARGIN = -15
50const GREEN_MAX_USED = 10
51const RED_MIN_USED = 90
52
53// the narrowest a meter's column gets, in terminal cells
54const MIN_BAR_CELLS = 12
55// the desktop draws about this many CSS pixels per column
56const PX_PER_COLUMN = 8
57const METER_GAP = 3
58const BAND_RESERVED_COLUMNS = 2
59const SVG_BAR_HEIGHT = 12
60// set from the palette option when the module registers
61let usesPalette = false
62const SVG_COLORS: Record<string, string> = { success: '#4caf50', warning: '#e0a526', error: '#e5534b', track: 'rgba(128,128,128,0.3)', marker: '#5b9bff', tick: 'rgba(0,0,0,0.38)' }
63const MARKER_COLOR = 'cyan'
64// the hover card's background: a raw colour close to the band's in the app's dark theme (a theme key
65// the surface does not know makes it refuse the whole band)
66const CARD_BACKGROUND = '#2b2b2b'
67const CARD_MIN_CELLS = 40
68
69type Meter = {
70 key: string
71 label: string
72 used: number | undefined
73 elapsed: number | null
74 detail: string
75 ticks?: number[]
76 // a figure and a colour of their own, where the bar measures something else (the cache)
77 percent?: string
78 status?: string | null
79 barStatus?: string
80 // the lines of the card shown over the meter while the pointer is on it
81 card?: string[]
82}
83
84// the main thread's prompt cache: the share of the last response's input it served, and when that
85// response came, from which the cache's time to live runs; kept per session in the store
86let cacheTtlMs = HOUR_MS
87// the cacheMeter option
88let cacheShown = true
89let cacheRate: number | null = null
90let lastResponseAt: number | null = null
91let cacheKey: string | null = null
92const CACHE_PREFIX = 'cache:'
93// the cache counts as nearly gone in the last part of its life
94const CACHE_LOW_SHARE = 0.15
95
96// session.start fires again on a reload: the store stays, these variables start over
97async function startUsageMeters($: EngineInterface) {
98 const settings = await $.settings.read()
99 locale = resolveLocale(language, settings.language, systemLocale())
100 ticker?.cancel()
101 rateLimits = []
102 measuredAt = 0
103 ownKey = KEY_PREFIX + (await $.session.id())
104 await loadCache($)
105 const usage: any = await $.session.usage()
106 context = usage.context
107 costUsd = typeof usage.cost?.usd === 'number' ? usage.cost.usd : null
108 if (usage.rateLimits.length > 0) await publishSnapshot($, usage.rateLimits)
109 await refresh($)
110 ticker = $.clock.every(TICK_MS, async () => {
111 await refresh($)
112 $.ui.invalidate('ui.render')
113 })
114 $.ui.invalidate('ui.render')
115}
116
117export function registerUsageMeters(on: Parameters<Register>[0], options: { timeZone?: string; language?: string; palette?: PaletteName; cacheTtl?: '5m' | '1h'; cacheMeter?: boolean }) {
118 cacheTtlMs = options.cacheTtl === '5m' ? 5 * 60_000 : HOUR_MS
119 cacheShown = options.cacheMeter !== false
120 const p = palette(options.palette)
121 usesPalette = (options.palette ?? 'default') !== 'default'
122 Object.assign(SVG_COLORS, { success: p.success, warning: p.warning, error: p.danger, track: p.track, marker: p.marker })
123 if (options.timeZone) timeZone = options.timeZone
124 language = options.language ?? 'auto'
125
126 // the meters sit below whatever the hooks beneath draw, next to the prompt
127 // a matcher, so other features may hook session.start too
128 on('session.start', { isInteractive: [true, false] }, async ($, e, next) => {
129 await startUsageMeters($)
130 return next(e)
131 })
132
133 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
134 const rest = await next(e)
135 const meters = await usageMeters($, e)
136 return stack($.ui.resolve(e).Box, [['above-meters', rest], ['usage-meters', meters]]) ?? rest
137 })
138 on('session.end', async ($, e, next) => {
139 if (!FINAL_REASONS.includes(e.reason) || !ownKey) return next(e)
140 ticker?.cancel()
141 await releaseKey($)
142 return next(e)
143 })
144
145 // /clear, /resume, /branch and compaction change the context; all but compaction switch session id
146 on('classic.SessionStart', { source: ['clear', 'resume', 'fork', 'compact'] }, async ($, e, next) => {
147 const key = KEY_PREFIX + (await $.session.id())
148 if (ownKey && key !== ownKey) {
149 await releaseKey($)
150 ownKey = key
151 }
152 context = ((await $.session.usage()) as any).context
153 const since = (e as unknown as { seconds_since_last_response?: number }).seconds_since_last_response
154 if (typeof since === 'number') await saveCache($, cacheRate, (await $.clock.now()) - since * 1000)
155 $.ui.invalidate('ui.render')
156 return next(e)
157 })
158
159 // each response of the main thread: how much of its input the cache served, and the time it came
160 on('turn.step', { turnId: /^/ }, async function* ($, e, next) {
161 const result = yield* next(e)
162 const usage = result.usage
163 if (!e.agentId && usage) {
164 const input = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
165 await saveCache($, input > 0 ? (usage.cache_read_input_tokens / input) * 100 : cacheRate, await $.clock.now())
166 $.ui.invalidate('ui.render')
167 }
168 return result
169 })
170
171 on('session.measure', async ($, e, next) => {
172 context = e.context
173 if (typeof e.cost?.usd === 'number') costUsd = e.cost.usd
174 if (e.changed.includes('rateLimits')) await remember($, e.rateLimits as any[])
175 $.ui.invalidate('ui.render')
176 return next(e)
177 })
178}
179
180async function usageMeters($: EngineInterface, e: any): Promise<any> {
181 const elements: any = $.ui.resolve(e)
182 const now = await $.clock.now()
183 const L = strings(locale)
184 const meters: Meter[] = [{ key: 'ctx', label: L.ctx, used: context?.percent, elapsed: null, detail: contextDetail(), ticks: CONTEXT_TICKS, card: contextCard() }]
185 if (cacheShown) meters.push(cacheMeter(now))
186 for (const limit of rateLimits) meters.push(readLimit(limit, now))
187 // the windows arrive with the first API response; until then they show as unknown
188 for (const kind of ALWAYS_SHOWN) {
189 if (!rateLimits.some(l => l.kind === kind)) meters.push({ key: kind, label: titleOf(kind), used: undefined, elapsed: null, detail: L.waiting, card: [windowTitle(kind), L.cardWaiting] })
190 }
191 const columns = e.props.bodyColumns ?? 0
192 const room = columns - BAND_RESERVED_COLUMNS
193 // as many meters on a row as fit whole, their title, figure and detail untruncated; the rows
194 // balanced (4 meters as 2 + 2, not 3 + 1), down to one per row on a phone
195 const need = Math.max(MIN_BAR_CELLS, ...meters.map(widthOf))
196 const fit = Math.max(1, Math.min(meters.length, Math.floor((room + METER_GAP) / (need + METER_GAP))))
197 const rowCount = Math.ceil(meters.length / fit)
198 const perRow = Math.ceil(meters.length / rowCount)
199 const cells = Math.max(MIN_BAR_CELLS, Math.floor((room - METER_GAP * (perRow - 1)) / perRow))
200 const gauge = 'Svg' in elements ? 'svg' : 'text'
201 const rows = Array.from({ length: rowCount }, (_, r) => meters.slice(r * perRow, (r + 1) * perRow))
202 // equal columns from edge to edge, each two lines: the figures, then the bar
203 return elements.Box({
204 key: 'usage-meters',
205 flexDirection: 'column',
206 rowGap: 1,
207 children: rows.map((row, r) =>
208 elements.Box({ key: `meters-row-${r}`, flexDirection: 'row', columnGap: METER_GAP, children: row.map(m => meter(elements, gauge, cells, m)) }),
209 ),
210 })
211}
212
213async function remember($: EngineInterface, limits: any[]) {
214 // take the time first, so a refresh meanwhile cannot pair old limits with it
215 const now = await $.clock.now()
216 rateLimits = limits
217 measuredAt = now
218 if (ownKey) await $.store.set(ownKey, { at: now, limits })
219}
220
221// takes the newest reading any session saved, unless this session's own is newer still
222async function refresh($: EngineInterface) {
223 const { entries, newest } = await scan($)
224 if (newest && newest.reading.at >= measuredAt) {
225 rateLimits = newest.reading.limits
226 measuredAt = newest.reading.at
227 } else if (!newest && measuredAt < (await $.clock.now()) - STALE_MS) {
228 rateLimits = []
229 measuredAt = 0
230 }
231 await prune($, entries, newest?.key)
232}
233
234// keeps this session's reading marked ended if it is the newest, removes it otherwise
235async function releaseKey($: EngineInterface) {
236 if (!ownKey) return
237 const { entries, newest } = await scan($)
238 if (newest?.key === ownKey) await $.store.set(ownKey, { ...newest.reading, ended: true })
239 else await $.store.delete(ownKey)
240 await prune($, entries, newest?.key)
241}
242
243async function prune($: EngineInterface, entries: { key: string; reading: any }[], newestKey: string | undefined) {
244 const cutoff = (await $.clock.now()) - STALE_MS
245 for (const { key, reading } of entries) {
246 if (key === ownKey || key === newestKey) continue
247 if (!isReading(reading) || reading.ended === true || reading.at < cutoff) await $.store.delete(key)
248 }
249}
250
251// a shared reading always wins over the startup snapshot of usage(), which may be older
252async function publishSnapshot($: EngineInterface, snapshot: any[]) {
253 const { newest } = await scan($)
254 if (newest) {
255 rateLimits = newest.reading.limits
256 measuredAt = newest.reading.at
257 return
258 }
259 await remember($, snapshot)
260}
261
262async function scan($: EngineInterface) {
263 const entries: { key: string; reading: any }[] = []
264 let newest: { key: string; reading: any } | null = null
265 const cutoff = (await $.clock.now()) - STALE_MS
266 for (const key of await $.store.keys()) {
267 if (!key.startsWith(KEY_PREFIX)) continue
268 const reading: any = await $.store.get(key)
269 entries.push({ key, reading })
270 if (!isReading(reading) || reading.at < cutoff) continue
271 if (!newest || reading.at > newest.reading.at || (reading.at === newest.reading.at && key > newest.key)) newest = { key, reading }
272 }
273 return { entries, newest }
274}
275
276function isReading(value: any) {
277 return value != null && typeof value.at === 'number' && Array.isArray(value.limits)
278}
279
280function readLimit(limit: any, now: number): Meter {
281 const window = WINDOWS[limit.kind]
282 const label = titleOf(limit.kind)
283 const resetsAtMs = limit.resetsAt == null ? null : Date.parse(limit.resetsAt)
284 // a window that has reset since the last reading starts again from zero
285 if (resetsAtMs != null && resetsAtMs <= now) return { key: limit.kind, label, used: 0, elapsed: window?.ms ? 0 : null, detail: '', ticks: divisionPoints(window?.divisions) }
286 const elapsed = window?.ms && resetsAtMs != null ? clamp(100 - ((resetsAtMs - now) / window.ms) * 100) : null
287 const detail = resetsAtMs == null ? '' : strings(locale).resetsIn(untilReset(resetsAtMs - now)) + ' · ' + resetClock(resetsAtMs, window?.showsDay === true)
288 const card = limitCard(limit.kind, limit.percentUsed, elapsed, resetsAtMs, now)
289 return { key: limit.kind, label, used: limit.percentUsed, elapsed, detail, ticks: divisionPoints(window?.divisions), card }
290}
291
292// what a limit's card says: the window, the share used against the time gone and what that pace
293// means, the reset, and how to read the bar
294function limitCard(kind: string, used: number, elapsed: number | null, resetsAtMs: number | null, now: number) {
295 const L = strings(locale)
296 const lines = [windowTitle(kind)]
297 if (elapsed != null) {
298 const margin = elapsed - used
299 const pace = margin >= GREEN_MIN_MARGIN ? 'under' : margin < RED_BELOW_MARGIN ? 'over' : 'near'
300 lines.push(`${L.cardUsed(Math.round(used) + '%', Math.round(elapsed) + '%')} · ${L.cardPace[pace]}`)
301 } else {
302 lines.push(Math.round(used) + '%')
303 }
304 if (resetsAtMs != null) lines.push(L.cardReset(resetClock(resetsAtMs, WINDOWS[kind]?.showsDay === true), untilReset(resetsAtMs - now)))
305 if (kind === 'five_hour') lines.push(L.cardTicksHours)
306 if (kind === 'seven_day') lines.push(L.cardTicksDays)
307 return lines
308}
309
310function windowTitle(kind: string) {
311 const L = strings(locale)
312 return kind === 'five_hour' ? L.cardFiveHour : kind === 'seven_day' ? L.cardSevenDay : titleOf(kind)
313}
314
315// what the context's card says: the tokens in the window, the session's cost, the cuts
316function contextCard() {
317 const L = strings(locale)
318 if (!context?.window) return [L.cardCtxNone]
319 const pct = typeof context.percent === 'number' ? Math.round(context.percent) + '%' : '—'
320 const tokens = typeof context.tokens === 'number' ? formatTokens(context.tokens) : '—'
321 const lines = [L.cardCtx(tokens, formatTokens(context.window), pct)]
322 if (costUsd != null) lines.push(L.cardCost(formatUsd(locale, costUsd)))
323 lines.push(L.cardCtxTicks)
324 return lines
325}
326
327// the cache column: its hit rate as the figure, the time it has left as the bar, which drains
328function cacheMeter(now: number): Meter {
329 const L = strings(locale)
330 const rate = cacheRate == null ? '—' : Math.round(cacheRate) + '%'
331 const rateStatus = cacheRate == null ? null : cacheRate >= 80 ? 'success' : cacheRate >= 50 ? 'warning' : 'error'
332 const tokens = typeof context?.tokens === 'number' ? formatTokens(context.tokens) : null
333 if (lastResponseAt == null) return { key: 'cache', label: L.cache, used: undefined, elapsed: null, detail: L.waiting, percent: rate, status: rateStatus, card: [L.cardCacheNone] }
334 const at = resetClock(lastResponseAt, false)
335 const left = lastResponseAt + cacheTtlMs - now
336 if (left <= 0) {
337 const card = [L.cardCacheRate(rate), L.cardCacheCold(at), ...(tokens ? [L.cardCacheNext(tokens)] : [])]
338 return { key: 'cache', label: L.cache, used: 0, elapsed: null, detail: L.cold, percent: rate, status: rateStatus, card }
339 }
340 const share = (left / cacheTtlMs) * 100
341 const card = [L.cardCacheRate(rate), L.cardCacheWarm(at, resetClock(lastResponseAt + cacheTtlMs, false), untilReset(left)), ...(tokens ? [L.cardCacheNext(tokens)] : [])]
342 return {
343 card,
344 key: 'cache',
345 label: L.cache,
346 used: share,
347 elapsed: null,
348 detail: L.warm(untilReset(left)),
349 percent: rate,
350 status: rateStatus,
351 // the bar's own colour: green while it has time, orange near the end
352 barStatus: share / 100 <= CACHE_LOW_SHARE ? 'warning' : 'success',
353 }
354}
355
356async function loadCache($: EngineInterface) {
357 cacheKey = CACHE_PREFIX + (await $.session.id())
358 const saved: any = await $.store.get(cacheKey)
359 cacheRate = typeof saved?.rate === 'number' ? saved.rate : null
360 lastResponseAt = typeof saved?.at === 'number' ? saved.at : null
361 // other sessions' readings past any cache's life say nothing; their keys go
362 const cutoff = (await $.clock.now()) - 2 * HOUR_MS
363 for (const key of await $.store.keys()) {
364 if (!key.startsWith(CACHE_PREFIX) || key === cacheKey) continue
365 const other: any = await $.store.get(key)
366 if (typeof other?.at !== 'number' || other.at < cutoff) await $.store.delete(key)
367 }
368}
369
370async function saveCache($: EngineInterface, rate: number | null, at: number) {
371 cacheRate = rate
372 lastResponseAt = at
373 if (cacheKey) await $.store.set(cacheKey, { rate, at })
374}
375
376function titleOf(kind: string) {
377 const title = WINDOWS[kind]?.title ?? kind
378 const L = strings(locale) as Record<string, unknown>
379 return typeof L[title] === 'string' ? (L[title] as string) : title
380}
381
382// the session's cost, then the tokens in the window and its size: 3,42 $ · 210k / 1M
383function contextDetail() {
384 const cost = costUsd == null ? '' : formatUsd(locale, costUsd)
385 const tokens = contextTokens()
386 return cost && tokens ? `${cost} · ${tokens}` : cost || tokens
387}
388
389function contextTokens() {
390 if (!context?.window) return ''
391 const used = typeof context.tokens === 'number' ? context.tokens : typeof context.percent === 'number' ? (context.percent / 100) * context.window : null
392 return used == null ? formatTokens(context.window) : formatTokens(used) + ' / ' + formatTokens(context.window)
393}
394
395function formatTokens(n: number) {
396 if (n >= 1_000_000) return String(Math.round(n / 100_000) / 10) + 'M'
397 if (n >= 1000) return Math.round(n / 1000) + 'k'
398 return String(Math.round(n))
399}
400
401function statusOf(used: number, elapsed: number | null) {
402 if (used >= RED_MIN_USED) return 'error'
403 if (elapsed == null) return used >= 80 ? 'error' : used >= 50 ? 'warning' : 'success'
404 const margin = elapsed - used
405 if (margin < RED_BELOW_MARGIN) return 'error'
406 if (margin < GREEN_MIN_MARGIN && used >= GREEN_MAX_USED) return 'warning'
407 return 'success'
408}
409
410// the cells a meter's first line takes whole: title, figure, two spaces, detail
411function widthOf(m: Meter) {
412 const percent = m.percent ?? (typeof m.used === 'number' ? Math.round(m.used) + '%' : '—')
413 return [...m.label].length + 1 + [...percent].length + 2 + [...m.detail].length
414}
415
416// two lines in one column: the title and the share used, the reset dimmed at the right; then the bar
417function meter({ Box, Text, Svg }: any, gauge: string, cells: number, m: Meter) {
418 const { key, label, used, elapsed, detail, ticks } = m
419 const known = typeof used === 'number'
420 const barStatus = m.barStatus ?? (known ? statusOf(used, elapsed) : null)
421 const status = m.status !== undefined ? m.status : barStatus
422 const percent = m.percent ?? (known ? Math.round(used) + '%' : '—')
423 const hasFigure = m.percent !== undefined ? m.percent !== '—' : known
424 const head = Box({
425 flexDirection: 'row',
426 justifyContent: 'space-between',
427 columnGap: 1,
428 children: [
429 Box({
430 flexDirection: 'row',
431 columnGap: 1,
432 flexShrink: 0,
433 children: [Text({ bold: true, children: [label] }), Text({ bold: true, ...(hasFigure ? { color: textColor(status) } : { dimColor: true }), children: [percent] })],
434 }),
435 Text({ dimColor: true, wrap: 'truncate', children: [detail] }),
436 ],
437 })
438 const bar =
439 gauge === 'svg'
440 ? Svg({
441 source: svgBar(cells * PX_PER_COLUMN, known ? used : 0, elapsed, barStatus, ticks),
442 alt: `${label} ${percent}${detail ? ', ' + detail : ''}${elapsed == null ? '' : ', ' + Math.round(elapsed) + '%'}`,
443 width: cells * PX_PER_COLUMN,
444 height: SVG_BAR_HEIGHT,
445 })
446 : textBar(Text, cells, known ? used : 0, elapsed, barStatus, ticks)
447 const children = [head, bar]
448 if (m.card && m.card.length > 0) children.push(hoverCard(Box, Text, m.card, cells))
449 return Box({ key: 'meter-' + key, flexDirection: 'column', width: cells, flexShrink: 0, children })
450}
451
452// a card for the meter, drawn hidden and shown by the surface while the pointer is on the meter's
453// keyed box: no hook runs. The desktop draws it as a floating card over the band, with its own border
454function hoverCard(Box: any, Text: any, lines: string[], cells: number) {
455 // no key of its own: a keyed Box is its own hover scope, and a hidden one can never be hovered
456 return Box({
457 position: 'absolute',
458 top: 0,
459 left: 0,
460 width: Math.max(cells, CARD_MIN_CELLS),
461 display: 'none',
462 hover: { display: 'flex' },
463 flexDirection: 'column',
464 paddingX: 1,
465 backgroundColor: CARD_BACKGROUND,
466 children: lines.map((line, i) => Text({ key: `card-${i}`, wrap: 'wrap', ...(i === 0 ? { bold: true } : {}), children: [line] })),
467 })
468}
469
470// used cells in the status color, the rest dim, and the time marker
471function textBar(Text: any, cells: number, used: number, elapsed: number | null, status: string | null, ticks: number[] = []) {
472 const filled = Math.round((clamp(used) / 100) * cells)
473 // the cells of the cuts, shown on the unused part only
474 const tickCells = new Set(ticks.map(f => Math.round(f * cells)))
475 const marker = elapsed == null ? -1 : Math.min(cells - 1, Math.floor((elapsed / 100) * cells))
476 const markerStyle = { color: MARKER_COLOR, bold: true }
477 const usedStyle = status ? { color: textColor(status) } : { dimColor: true }
478 const restStyle = { dimColor: true }
479 const runs: { text: string; style: object }[] = []
480 for (let i = 0; i < cells; i++) {
481 const cell =
482 i === marker
483 ? { char: '┃', style: markerStyle }
484 : i < filled
485 ? { char: '█', style: usedStyle }
486 : { char: tickCells.has(i) ? '┊' : '░', style: restStyle }
487 const last = runs.at(-1)
488 if (last && last.style === cell.style) last.text += cell.char
489 else runs.push({ text: cell.char, style: cell.style })
490 }
491 return Text({ children: runs.map(run => Text({ ...run.style, children: [run.text] })) })
492}
493
494// a rounded track with the used share, thin cuts (each hour or day, or the context's upper tenths),
495// and for a timed window a marker at the time gone
496function svgBar(width: number, used: number, elapsed: number | null, status: string | null, ticks: number[] = []) {
497 const height = SVG_BAR_HEIGHT
498 const barH = 6
499 const y = (height - barH) / 2
500 const r = barH / 2
501 const fill = Math.round((clamp(used) / 100) * width)
502 const parts = [
503 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
504 `<clipPath id="c"><rect y="${y}" width="${width}" height="${barH}" rx="${r}"/></clipPath>`,
505 `<g clip-path="url(#c)">`,
506 `<rect y="${y}" width="${width}" height="${barH}" fill="${SVG_COLORS.track}"/>`,
507 ]
508 if (fill > 0) parts.push(`<rect y="${y}" width="${fill}" height="${barH}" fill="${SVG_COLORS[status ?? 'success']}"/>`)
509 for (const f of ticks) parts.push(`<rect x="${(f * width - 0.5).toFixed(1)}" y="${y}" width="1" height="${barH}" fill="${SVG_COLORS.tick}"/>`)
510 parts.push('</g>')
511 if (elapsed != null) {
512 const x = Math.min(width - 2, Math.max(0, Math.round((elapsed / 100) * width) - 1))
513 parts.push(`<rect x="${x}" width="2" height="${height}" rx="1" fill="${SVG_COLORS.marker}"/>`)
514 }
515 parts.push('</svg>')
516 return parts.join('')
517}
518
519// where the window's hours or days begin, as shares of the bar, the ends left out
520function divisionPoints(divisions?: number) {
521 if (!divisions || divisions < 2) return []
522 return Array.from({ length: divisions - 1 }, (_, i) => (i + 1) / divisions)
523}
524
525// a palette's own hex, else the surface's theme colour of that name (success, warning, error)
526function textColor(status: string | null) {
527 if (!status) return undefined
528 return usesPalette ? SVG_COLORS[status] : status
529}
530
531function clamp(percent: number) {
532 return Math.min(Math.max(percent, 0), 100)
533}
534
535// the reset as HH:MM in timeZone, with the short weekday for the long windows ("mar. 09:00")
536function resetClock(ms: number, showsDay: boolean) {
537 const tag = locale === 'fr' ? 'fr-FR' : 'en-GB'
538 try {
539 const time = new Intl.DateTimeFormat(tag, { timeZone, hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(ms))
540 if (!showsDay) return time
541 return new Intl.DateTimeFormat(tag, { timeZone, weekday: 'short' }).format(new Date(ms)) + ' ' + time
542 } catch {
543 const d = new Date(ms)
544 return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
545 }
546}
547
548function untilReset(ms: number) {
549 const minutes = Math.max(0, Math.ceil(ms / 60_000))
550 const days = Math.floor(minutes / 1440)
551 const hours = Math.floor((minutes % 1440) / 60)
552 const dayUnit = locale === 'fr' ? 'j' : 'd'
553 if (days > 0) return days + dayUnit + ' ' + hours + 'h'
554 if (hours > 0) return hours + 'h' + String(minutes % 60).padStart(2, '0')
555 return (minutes % 60) + ' min'
556}
557features/i18n.ts 115 lines1// Labels in the person's language. The `language` option picks it; `auto` follows Claude Code's own
2// `language` setting, then the system locale. Pure functions: each feature reads the settings itself
3// and hands the result to resolveLocale, since `$` never crosses a file.
4
5export type Locale = 'en' | 'fr'
6
7const STRINGS = {
8 en: {
9 done: 'Done',
10 tasks: 'Tasks',
11 agents: 'Agents',
12 progress: 'Progress',
13 needsApproval: 'Needs approval',
14 stopped: 'Stopped',
15 failed: 'Failed',
16 moreAgents: (n: number) => `+${n} more agent${n === 1 ? '' : 's'}`,
17 nDone: (n: number) => `${n} done`,
18 pluginOn: 'still-mods is on. A bar appears when Claude starts a task with several steps.',
19 resetsIn: (d: string) => `↻ ${d}`,
20 waiting: 'waiting for a response',
21 ctx: 'Context',
22 fiveHour: 'Session',
23 sevenDay: 'Week',
24 cache: 'Cache',
25 warm: (d: string) => `warm · ${d}`,
26 cold: 'cold, the next message re-caches',
27 cardCacheRate: (rate: string) => `Prompt cache · ${rate} of the last response's input came from the cache`,
28 cardCacheWarm: (at: string, until: string, left: string) => `Last response at ${at} · expires at ${until} (in ${left}) if nothing is sent`,
29 cardCacheCold: (at: string) => `Last response at ${at} · the cache has expired`,
30 cardCacheNext: (tokens: string) => `Then the next message writes ${tokens} of context to the cache again`,
31 cardCacheNone: 'Prompt cache · no response yet in this session',
32 cardCtx: (tokens: string, window: string, pct: string) => `Context · ${tokens} of ${window} tokens (${pct})`,
33 cardCtxNone: 'Context · no response yet in this session',
34 cardCost: (cost: string) => `Session cost so far: ${cost}, at API prices`,
35 cardCtxTicks: 'A cut every 10 % from 50 %, where the window starts to fill',
36 cardFiveHour: 'Session limit · a 5-hour window',
37 cardSevenDay: 'Weekly limit · a 7-day window',
38 cardUsed: (used: string, gone: string) => `${used} used · ${gone} of the window gone`,
39 cardPace: { under: 'below the average pace: it lasts until the reset', near: 'close to the average pace', over: 'above the average pace: it may run out before the reset' } as Record<string, string>,
40 cardReset: (at: string, left: string) => `Resets ${at} (in ${left})`,
41 cardTicksHours: 'A cut per hour · blue line: the time gone',
42 cardTicksDays: 'A cut per day · blue line: the time gone',
43 cardWaiting: 'No reading yet: the windows come with the first response',
44 },
45 fr: {
46 done: 'Terminé',
47 tasks: 'Tâches',
48 agents: 'Agents',
49 progress: 'Progression',
50 needsApproval: 'Approbation requise',
51 stopped: 'Arrêté',
52 failed: 'Échec',
53 moreAgents: (n: number) => `+${n} autre${n === 1 ? '' : 's'} agent${n === 1 ? '' : 's'}`,
54 nDone: (n: number) => `${n} terminé${n === 1 ? '' : 's'}`,
55 pluginOn: 'still-mods est actif. Une barre apparaît quand Claude lance une tâche en plusieurs étapes.',
56 resetsIn: (d: string) => `↻ ${d}`,
57 waiting: 'en attente d’une réponse',
58 ctx: 'Contexte',
59 fiveHour: 'Session',
60 sevenDay: 'Semaine',
61 cache: 'Cache',
62 warm: (d: string) => `chaud · ${d}`,
63 cold: 'froid, le prochain message recache',
64 cardCacheRate: (rate: string) => `Cache du prompt · ${rate} de l’entrée de la dernière réponse venaient du cache`,
65 cardCacheWarm: (at: string, until: string, left: string) => `Dernière réponse à ${at} · expire à ${until} (dans ${left}) si rien n’est envoyé`,
66 cardCacheCold: (at: string) => `Dernière réponse à ${at} · le cache a expiré`,
67 cardCacheNext: (tokens: string) => `Le prochain message réécrira alors ${tokens} de contexte en cache`,
68 cardCacheNone: 'Cache du prompt · pas encore de réponse dans cette session',
69 cardCtx: (tokens: string, window: string, pct: string) => `Contexte · ${tokens} tokens sur ${window} (${pct})`,
70 cardCtxNone: 'Contexte · pas encore de réponse dans cette session',
71 cardCost: (cost: string) => `Coût de la session : ${cost}, au tarif de l’API`,
72 cardCtxTicks: 'Un repère tous les 10 % à partir de 50 %, là où la fenêtre se remplit',
73 cardFiveHour: 'Limite de session · une fenêtre de 5 h',
74 cardSevenDay: 'Limite hebdomadaire · une fenêtre de 7 jours',
75 cardUsed: (used: string, gone: string) => `${used} utilisés · ${gone} de la fenêtre écoulée`,
76 cardPace: { under: 'sous le rythme moyen : elle tiendra jusqu’au reset', near: 'proche du rythme moyen', over: 'au-dessus du rythme moyen : elle risque d’être atteinte avant le reset' } as Record<string, string>,
77 cardReset: (at: string, left: string) => `Réinitialisation ${at} (dans ${left})`,
78 cardTicksHours: 'Un repère par heure · trait bleu : le temps écoulé',
79 cardTicksDays: 'Un repère par jour · trait bleu : le temps écoulé',
80 cardWaiting: 'Pas encore de relevé : les fenêtres arrivent avec la première réponse',
81 },
82} as const
83
84export type Strings = (typeof STRINGS)['en']
85
86export function strings(locale: Locale): Strings {
87 return STRINGS[locale] as Strings
88}
89
90// the first candidate that names a known language: an option, a setting ("french", "fr-FR"), a locale
91export function resolveLocale(...candidates: unknown[]): Locale {
92 for (const c of candidates) {
93 if (typeof c !== 'string' || !c || c === 'auto') continue
94 const word = c.trim().toLowerCase()
95 if (word.startsWith('fr') || word === 'français' || word === 'francais') return 'fr'
96 if (word.startsWith('en') || word === 'english') return 'en'
97 }
98 return 'en'
99}
100
101// a cost in US dollars, as the locale writes it: 3,42 $ or $3.42
102export function formatUsd(locale: Locale, usd: number) {
103 const digits = usd < 10 ? 2 : usd < 100 ? 1 : 0
104 return locale === 'fr' ? `${usd.toFixed(digits).replace('.', ',')} $` : `$${usd.toFixed(digits)}`
105}
106
107// the locale the environment's Intl reports, often the system's
108export function systemLocale(): string | undefined {
109 try {
110 return new Intl.DateTimeFormat().resolvedOptions().locale
111 } catch {
112 return undefined
113 }
114}
115features/band.ts 16 lines1// Stacks the parts of the band above the prompt into one column, leaving out the empty ones.
2// Each feature draws its own part and places it against what the hooks beneath drew (`next(e)`):
3// the plan bars above it, the usage meters below it, so the order holds whichever runs first.
4//
5// Every part sits in a box of its own, keyed by its name and kept even when it is alone, so the tree
6// keeps one shape: a part showing or going leaves the others where they are, not drawn anew.
7export function stack(Box: (props: any) => any, parts: [key: string, part: unknown][]): any {
8 const shown = parts.filter(([, part]) => Boolean(part))
9 if (shown.length === 0) return undefined
10 return Box({
11 flexDirection: 'column',
12 gap: 1,
13 children: shown.map(([key, part]) => Box({ key, flexDirection: 'column', children: [part] })),
14 })
15}
16types/index.d.ts 42 lines1export type StepStatus = 'pending' | 'active' | 'done' | 'error' | 'skipped'
2export type PlanSubstep = { title: string; status: StepStatus }
3export type PlanStep = { title: string; status: StepStatus; substeps: PlanSubstep[] }
4export type PlanStage = { name: string; steps: PlanStep[] }
5export type PlanState = 'running' | 'needs_input' | 'error' | 'done'
6// one subagent shown as a state strip under a bar; depth 1 sits under its parent agent
7export type AgentRun = {
8 id: string
9 title: string
10 state: 'running' | 'waiting' | 'done' | 'error'
11 tool: string
12 startedAt: number
13 endedAt: number | null
14 depth: number
15}
16export type Plan = {
17 id: string
18 title: string
19 kind: 'plan' | 'todo'
20 stages: PlanStage[]
21 state: PlanState
22 note: string | null
23 startedAt: number
24 // the session's cost when the bar opened, and what the task cost once it is done (US dollars)
25 costStart?: number | null
26 cost?: number | null
27 agents?: AgentRun[]
28 // when the current batch of agents all finished; their strips fold a few seconds later
29 agentsDoneAt?: number | null
30}
31
32declare module 'claude-code' {
33 interface PluginState {
34 'still-mods': {
35 plans: Plan[]
36 isOpen: boolean
37 // bumped every second while agents run, so elapsed times and folding redraw
38 tick: number
39 }
40 }
41}
42