SLOPSHOPPER

still-mods

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

newbandspinnerguardcommandtoast
v0.11.0MITupdated 2026-10-03stillgbx/still-mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · still-mods
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /still-mods ⎿ still-mods: still-mods options ⎿ still-mods: planProgress on Plan progress bars (on | off) ⎿ still-mods: planEnforcement soft How hard Claude is held to its bars (strict | soft | off) ⎿ still-mods: sounds soft Sounds for decision, error, done (soft | classic | off) ⎿ still-mods: turnNotify on Sound and toast when a long turn ends (on | off) ⎿ still-mods: turnNotifySeconds 60 Seconds a turn must last to notify (a number) ⟨Claude Code's own drawing⟩ Context 49% $0.42 · 97k / 200k Cache — waiting for a response ███████████████████████░┊░░░┊░░░░┊░░░░┊░░░┊░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ Session 31% ↻ NaN min · NaN:NaN Week — waiting for a response ███████████████░░░░┊░░░░░░░░┊░░░░░░░░░┊░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ still-mods: ⎇ · 1 changed · 1 new · no upstream [ Progress ] ⟨Claude Code's own drawing⟩

Draws

Band
⟨Claude Code's own drawing⟩ Context 49% $0.42 · 97k / 200k Cache — waiting for a response ███████████████████████░┊░░░┊░░░░┊░░░░┊░░░┊░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ Session 31% ↻ NaN min · NaN:NaN Week — waiting for a response ███████████████░░░░┊░░░░░░░░┊░░░░░░░░░┊░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
README

still-mods

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

Plan bars with their costs over the context, cache, session and weekly meters, in Claude Desktop

FeatureOptionWhat it draws
Plan progressplanProgressProgress bars for multi-step tasks (stages, steps, subagent strips, sounds), above the prompt, on top
End-of-turn noticeturnNotifyWhen 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 statusgitStatusThe status line under the prompt: branch, changed and new files, ahead/behind its upstream (⎇ main · 3 modifiés · 1 nouveau · ↑1)
Usage metersusageMetersContext, 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 } } }

Palettes

The palette option colours what the mod draws, from popular editor themes:

PaletteVariantSource
defaultstill-mods' own colours
catppuccin-mochadarkcatppuccin/palette
catppuccin-lattelightcatppuccin/palette
draculadarkDracula spec
alucardlight (Dracula's)Dracula spec
night-owldarksdras/night-owl-vscode-theme
synthwave-84darkrobb0wen/synthwave-vscode
tokyo-nightdark (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.

  • Only the mod's drawings change: a mod cannot restyle the app, so its text and background stay.
  • A mod cannot tell whether the app is light or dark, so each palette names its variant: pick the one that matches the app.
  • A knob's text stays white while it keeps a 3:1 contrast, and turns near black on a lighter fill (pastel palettes such as Catppuccin Mocha).

Plan bars

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.

  • The fill is the share of finished steps; the pixel texture in it is decoration.
  • Full-height lines mark stage boundaries, short ticks the steps.
  • The knob names the stage at work, or the step at work for a one-stage todo list, with its count.
  • A bar shows what its task cost: the session's cost since the bar opened, fixed when it is done, subagents included (two bars open at once each count the whole interval). The samples of /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.
  • The colour is the state: running, waiting for a decision, error, done (each with a sound, see sounds).

Usage meters

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.

Layout

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

Installing

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.

Loading for development

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

Commands

  • /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.

Credits

still-mods started from the ideas and the work of two mods, combined here and extended:

  • plan-progress by Kirill Serditov (MIT): the plan progress bars, their drawing, the plan_progress tool, the agent strips, the sounds and the rules that keep the bars up to date.
  • usage-meter by HolyGrail: the context and limit meters, the readings shared between sessions, the pace colours and the time marker.

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

Source 10 files
hooks/register.tsx 22 lines
1// 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}
22
features/git-status.ts 96 lines
1// 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}
96
features/turn-notify.ts 135 lines
1// 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}
135
features/palettes.ts 75 lines
1// 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}
75
features/plan-progress.tsx 1109 lines
1// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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}
1109
features/settings.ts 192 lines
1// 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}
192
features/usage-meters.tsx 557 lines
1import 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}
557
features/i18n.ts 115 lines
1// 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}
115
features/band.ts 16 lines
1// 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}
16
types/index.d.ts 42 lines
1export 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