SLOPSHOPPER

effortless

Sets the reasoning effort for every prompt automatically, shows it under the prompt box, and counts down how long the prompt cache stays warm.

newpanebandspinnerguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · effortless
│ ┃ effortless-agents ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ╭──────────────────────────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │ ✦ Quiet This chat │ ⎿ Read 6 lines │ ┃ ╰──────────────────────────────────────────╯ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ╭─────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ │ Map1 part touched ⎿ 3 pass, 1 fail │ ┃ │ ▸ src 3changed │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰─────────────────────────────────────────── │ ┃ Worked 42s ✦ Auto │ ┃ Agents show here when Claude sends some │ ┃ off. › /effortless │ ┃ ⎿ effortless: No model suggestion right now. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ✦ effortless setup 1/4 │ │ Haiku judges. Add Jev too? │ │ ⟨undefined⟩ [ Just Haiku (no key) ] ⟨undefined⟩ [ Add Jev (needs a key) ] Skip ✕ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ ✦ effortless setup 1/4 │ │ Haiku judges. Add Jev too? │ │ ⟨undefined⟩ [ Just Haiku (no key) ] ⟨undefined⟩ [ Add Jev (needs a key) ] Skip ✕ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Pane · effortless-agents
╭──────────────────────────────────────────────────────────╮ │ ✦ Quiet This chat │ ╰──────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────── │ Map1 part touched │ ▸ src 3changed │ ╰─────────────────────────────────────────────────────────── Agents show here when Claude sends some off.
Turn line
Worked 42s ✦ Auto
README

<h3 align="center">Two mods for Claude Code, by HeyCubit</h3>

<table> <tr> <td width="50%" align="center" valign="top"> <a href="#effortless"><img src="docs/readme/banner.png" alt="effortless" width="100%"></a> </td> <td width="50%" align="center" valign="top"> <a href="#textless"><img src="textless/brand/readme-banner.png" alt="textless" width="100%"></a> </td> </tr> </table>

effortless

<a href="#install"><b>Install</b></a> · <a href="#what-the-bar-shows"><b>What it shows</b></a> · <a href="#pick-your-judge"><b>Judges</b></a>

https://github.com/user-attachments/assets/9b5af48e-2ad8-4826-9443-4aeea1306664

A Claude Code mod that picks the reasoning effort for every prompt. Quick questions get Low, hard jobs get High, and you never touch the Effort control. On real coding tasks it cut the cost of Opus by 21% against Anthropic's recommended medium, 29% against high and 42% against xhigh, with every answer still right (the benchmark). One bar above the prompt also shows how full the chat is, how long the prompt cache stays warm, and hands off or compacts in one click when a chat gets heavy.

<img src="docs/readme/story.gif" alt="effortless in a Claude Code chat: effort per prompt, Handoff, cold cache, heavy chat, limits and settings" width="100%">

Powered by Haiku 5.5

Haiku 5.5 costs about 75% less than Haiku 4.5, and Anthropic names compaction and quick, well-defined work among what it is built for. effortless uses it in three places:

The judgereads each prompt and picks the effort, in about a second, on your own Claude login
Cheaper model when it canexperimental, off by default. When on, a prompt the judge calls simple may run on Haiku 5.5 (or Sonnet), never above your chat's model, but only when the switch pays: each model keeps its own prompt cache, so in a chat that is in use the switch usually costs more than it saves (why)
Compactionevery compaction, /compact and the automatic one included, is summarized by Haiku 5.5. If Haiku fails, Claude Code compacts as usual

The cheaper model is switched on in Settings → Model; compaction is switched in Settings → Handoff → Compact with.

Install

Two ways. Both take a minute and need no terminal.

1. Ask Claude (easiest). Paste this into Claude Code and press Enter:

Install the effortless plugin for me: run `claude plugin marketplace add HeyCubit/effortless` and then `claude plugin install effortless@effortless`. When both succeed, tell me to run /reload-plugins.

2. Commands. Type these two lines in Claude Code's chat box:

/plugin marketplace add HeyCubit/effortless
/plugin install effortless@effortless

Or in a terminal, in one line:

claude plugin marketplace add HeyCubit/effortless; claude plugin install effortless@effortless

Then run /reload-plugins (or restart Claude Code). A short setup opens above the prompt: keep Haiku alone (one click, no key, runs on your own Claude login) or add Jev (a TypeSafe key, about 4x faster effort calls), lean cheaper or smarter, and pick how handoffs are written. Run /effortless setup to go through it again, or change any of it in ⚙.

Updates come to you: when a new version is out, a card above the prompt offers Update or Later. Update loads the new version in the chat you pressed it in, with nothing to type.

What the bar shows

On the barMeans
High · Opusthe effort Auto picked for this prompt, and the model the chat runs on. Deciding while the judge thinks; a switch flashes violet and fades to white
◔ 38%how full the chat's context is
cache 59:00time until the prompt cache goes cold, after which the next message pays full price to re-read the chat
Haiku: a refactor…who judged and why
Autoswitches the judge on and off. Changing effort in the app yourself also turns Auto off at your next message, and says so: you always win
Compactcompacts the chat. It turns into a glowing Handoff when Haiku says a fresh chat would pay off, with Compact as quiet text beside it. Set Settings → Handoff → Handoff button to Always to keep Handoff on the bar
⚙settings

Prefer it quiet? Settings → Customize has a Minimal look with no bar. It also sets the Appearance (match Claude Code, dark or light) and the Theme (violet, Claude orange or cherry blossom).

When a chat gets heavy

When the cache has gone cold on a big chat, the bar turns to ice with Compact and Handoff:

Handoff writes a summary of the chat and carries on in a clean one. Quick takes a few seconds; Full checks git and saves HANDOFF.md (or runs your own skill). Then clear and carry on, clear and wait, or keep the chat and copy.

Compact takes an optional note for what the summary should keep. Every compaction, also /compact and the automatic one, is written by Haiku 5.5 by default, which Anthropic recommends for compaction at a fraction of the chat model's price; if Haiku fails, Claude Code compacts as usual. Switch it in Settings → Handoff → Compact with.

The bar also warns when a chat is getting swamped (each message re-reads a lot of context), when a 5-hour or weekly limit passes 80% (with a Save mode that caps effort at Medium), and when your judge stops answering.

Settings

Haiku, and Jev if you add it

Haiku 5.5 runs on your own Claude login and needs no key. It always makes the handoff call: from 30% of context, every second message, it reads what the chat was for, the trail of topics, the last reply and how full the context is, and says a fresh chat would suit only for a clear reason. Then the Compact button turns into a lit Handoff with the reason. Want Handoff on the bar all the time? Settings → Handoff → Handoff button → Always.

Haiku also picks the effort, unless you add Jev, TypeSafe's faster judge (about 0.25 s against about 1 s). With a key, Jev answers the effort first and Haiku steps in whenever Jev is unsure. Run /plugin configure effortless@effortless in Claude Code, or use the setup guide or the Judge card in Settings.

SettingWhat it does
auto (default)Haiku, and Jev too when a TypeSafe key is set in the settings or TYPESAFE_API_KEY
haikuHaiku only, a key is never used
jevHaiku and Jev, and the key may also come from ~/.config/jev/.env

A key set with /plugin configure is kept in Claude Code's secure storage. A key pasted in the setup guide or the Judge card is written in plain text to ~/.config/jev/.env (the file the jev skills read), never to a file of this repo. If Jev fails or takes longer than 3 seconds, Haiku judges that prompt and effortless tells you why once per session (out of credits, key rejected, no answer).

Short follow-ups such as "go", "ok" or "yes" keep the effort already picked and ask no judge.

Keys and trust

  • The default judge needs no key: Haiku runs on your own Claude login.
  • A key is only needed to add Jev. Enter it with /plugin configure, never on the command line, so it stays out of your shell history: Claude Code then keeps it in its secure storage. A key pasted in the setup guide or the Judge card is saved in plain text to ~/.config/jev/.env instead, so prefer /plugin configure on a shared machine.
  • A key is sent only to TypeSafe, and to nothing else. Use a key with a spending limit if your provider offers one.
  • Like any Claude Code plugin, this mod runs code on your machine. Its code is in hooks/: read it before you install if you do not know the author. The programs it starts are claude itself (to update or uninstall the mod when you press those buttons), git (to see if a new version is out) and, on Update, a plain file copy of the new version into the folder your open chat runs from.

What it saves, measured

Four real coding tasks in a fresh copy of this repo, each run 3 times with effortless on and with it off, through Anthropic's API so the cost is real. Every answer was checked. Run 2026-10-10, 96 runs:

Opus 5.5CorrectCost per taskRequestsTime
medium (recommended), no effortless12 of 12$0.1907.324 s
high, no effortless12 of 12$0.2098.027 s
xhigh, no effortless12 of 12$0.2579.944 s
effortless on12 of 12$0.1495.820 s

That is 21% less than medium, 29% less than high and 42% less than xhigh. On Sonnet it came out even against medium (Sonnet at medium is already lean) and saved 10% against high and 35% against xhigh.

The saving comes from fewer steps: every request rereads the whole chat, and at a lower effort the model makes fewer of them. Over 80k requests of real Claude Code use, about 76% of the cost was that reread, 16% cache writes and 8% output. Keeping chats short and compacting before the cache goes cold saves on the same 76%; that is what the countdown is for.

Limits: four tasks, three runs each, mostly easy work where effortless goes low. On hard work it picks more effort and saves less. Fresh chats through the API, not long chats on a plan. Run it again with bash bench/agentic/run.sh; all numbers and limits are in bench/RESULTS.md.

Why model switching is off

Moving a simple prompt to Haiku looks cheap on a single prompt: the cheaper answers were good enough in 27 of 30 and cost 60 to 77% less. But each model keeps its own prompt cache, so a prompt moved mid-chat has to write the whole chat into the other model's cache first. Measured on an 80k-token chat, that costs about 5 times what staying on a warm Opus does (10 times for Sonnet). So Cheaper model when it can is off by default and marked experimental. Details.

How often the judge is right

/effortless bench runs labelled prompts through each judge you have set up, using the same code a real prompt goes through, and scores them against a fixed effort. Each case lists the efforts a careful person would accept for that message on that model. The cases are in bench/judge-cases.json; run it yourself.

Two runs on version 1.5.1, 73 cases, Sonnet and Opus 5.5:

JudgeRight, all 73Right, 20 held outToo lowToo highMedian time
Always medium44%40%1724-
Always high51%50%036-
Haiku92-93%95%14-50.75 s
Jev99%100%010.24 s

What this does and does not show:

  • It measures whether the judge picks a sensible effort, not how much a session costs or how good the answers are.
  • 73 cases is a small set, and the judges are not fully deterministic: runs differ by a few points.
  • The judge prompts were tuned on 53 of the cases. The 20 held-out cases were never tuned against, so that column is the honest one. They were written after the first run showed which kinds of message miss, so they are not blind.
  • Haiku's remaining misses are mostly "yes" or "thanks" after hard work, where it keeps the effort high.

Models

Effort only changes on Opus 5.5 and Sonnet 5.5. On Fable 5.1 and older models a change of effort between requests rewrote most of the prompt cache, which costs more than it saves, so Auto pauses there and the footer says Paused. Haiku takes no effort setting.

Split view

In the desktop app's split view, Claude Code draws plugin bars only in the left pane. The right pane still draws replies, so when a chat went cold, is getting swamped or runs hot, a small card in the bar's colours hangs under the newest reply, naming the command that does what the bar's button would: /compact, /effortless handoff or /effortless save.

In the terminal

effortless draws in the terminal CLI too, with moving pixel art in its bands. Claude Code may not load a plugin's code there yet: if /effortless says the mod is not loaded after a restart, add this to ~/.claude/settings.json and restart:

{ "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }

Tested on Claude Code 2.1.285 (stable), 2.1.286 and 2.1.288 at 80 and 120 columns. Below 90 columns the bands leave their art out.

Commands

CommandDoes
/effortless settingsopens the settings panel
/effortless setupruns the setup again
/effortless autoAuto on or off
/effortless handoff / handoff fullhands off without the bar, with your last choice of what follows
/effortless statswhat the prompts Auto steered cost this session, per effort, and what the judge took
/effortless cold, swamp, hot, downshows that bar now, to try it
/effortless benchscores each judge you have on the labelled prompts in bench/judge-cases.json
/effortless debugwhat the mod sees, and on its last line the model and effort the last request ran on
/effortless updatechecks for a new version now

textless

While Claude works, Claude Code shows every step it takes. textless puts one picture there instead, and when Claude is done you get the answer, with a pixel word that says whether it is done, needs you or stopped.

  • Pick what shows: a set (Clawd, Scenery, Paintings, Abstract, Working), one favourite scene, or your own art in ~/.claude/textless/scenes.
  • The Marketplace: scenes other people made, checked before they go live. One command puts one on: /textless get <id>.
  • One switch per chat: the ⏻ next to the model name, or /textless.

Website, scenes and Marketplace: heycubit.github.io/effortless/textless. The code is in textless/.

Open source

effortless is MIT licensed. Like any Claude Code plugin it runs code on your machine, so the whole mod is here to read: hooks/. It sends nothing anywhere except your prompt to the judge you picked.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .

tools/render-band draws the bar the way the desktop app does, without opening it; the images in this README come from it.

License

MIT

Source 7 files
hooks/register.tsx 5401 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelForkResult, Register, RenderInput } from 'claude-code'
3
4import type { AgentRec, Effort, HandoffAfter, HandoffChoice, ModelKey, Pick, SettingsDraft, Spent } from '../types'
5import { agentsPane, demoAgents, demoFiles, demoSteps, importsOf, relPath, toolLine, touchOf, withTouch, withWaits } from './agents'
6import { ART_COLUMNS, ART_FRAME_MS, ART_MIN_WIDTH, ART_ROWS, type ArtKind, artFrame, MOVING } from './art'
7import { MARK_SVG } from './brand-mark'
8import { accent, setLight, setTheme, themedEls, type ThemeName, THEMES, tintHex } from './theme'
9import { PILL_H, stepsFromTodos, thinkingSvg, THINK_W, withTaskCreated, withTaskUpdated } from './progress'
10
11// The ladders the two sliders walk, cheapest first.
12export const MODELS: { key: ModelKey; label: string; long: string; id: string }[] = [
13  { key: 'haiku', label: 'Haiku', long: 'Haiku 5.5', id: 'claude-haiku-5-5' },
14  { key: 'sonnet', label: 'Sonnet', long: 'Sonnet 5.5', id: 'claude-sonnet-5-5' },
15  { key: 'opus', label: 'Opus', long: 'Opus 5.5', id: 'claude-opus-5-5' },
16  { key: 'fable', label: 'Fable', long: 'Fable 5.1', id: 'claude-fable-5-1' },
17]
18export const EFFORTS: Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']
19// What one token costs relative to an uncached input token, the same ratios on every Claude model. Measured over
20// 80k requests of real Claude Code use, cache reads are about 76% of a session's cost, cache writes 16% and output 8%: effort
21// mostly changes how many tool calls a prompt makes, not what one answer writes, so all four are counted.
22const WEIGHT = { input: 1, write: 1.25, read: 0.1, out: 5 }
23// Changing effort between two requests keeps the prompt cache on these models only. On Fable 5.1 and older Opus
24// the next request rewrote 56-100% of the cache (measured), which costs more than any effort can save.
25export const cacheSafe = (modelId: string) => /(opus|sonnet)-5-5/.test(modelId)
26// The accent the effort in the footer is written in: the brand violet, or Claude orange when the theme says so.
27let ACCENT = accent()
28/** Takes the theme the settings name; the accent and every drawing follow it. */
29function applyTheme() {
30  setTheme(config.theme)
31  ACCENT = accent()
32  BRAND_BG = tintHex('#15121f')
33  BRAND_EDGE = tintHex('#4a3f80')
34  BRAND_HEAD = tintHex('#221c3a')
35  FLASH_COLOR = tintHex('#9b7bff')
36  // The panel greys lean blue beside the violet; with Claude orange they are the app's own neutral greys.
37  const neutral = config.theme !== 'violet'
38  DASH_BG = neutral ? '#151515' : '#141416'
39  DASH_EDGE = neutral ? '#2a2a2a' : '#2a2a2f'
40  DASH_HEAD = neutral ? '#191919' : '#18181b'
41  CARD_HOVER = neutral ? '#1c1c1c' : '#1c1c20'
42  CARD_HOVER_EDGE = neutral ? '#3b3b3b' : '#3b3b42'
43  HOVER_BOX = neutral ? '#2b2b2b' : '#2b2b2f'
44  applyAppearance()
45}
46
47// Claude Code's own theme, read from its /config row: a light one turns everything light when appearance is auto.
48let claudeIsLight = false
49let appearanceCheckedAt = -Infinity
50function applyAppearance() {
51  setLight(config.appearance === 'light' || (config.appearance === 'auto' && claudeIsLight))
52}
53/** Reads Claude Code's theme row (at most every few seconds) and redraws when light or dark changed. */
54async function refreshAppearance($: EngineInterface) {
55  const now = await $.clock.now().catch(() => Date.now())
56  if (now - appearanceCheckedAt < 4000) return
57  appearanceCheckedAt = now
58  const rows = await $.config.list().catch(() => [])
59  const value = rows.find(row => row.key === 'theme')?.value
60  const lightNow = typeof value === 'string' && value.startsWith('light')
61  if (lightNow === claudeIsLight) return
62  claudeIsLight = lightNow
63  applyAppearance()
64  $.ui.invalidate('ui.render')
65}
66let BRAND_BG = '#15121f'
67let BRAND_EDGE = '#4a3f80'
68// The settings panel's header bar: a shade lighter than the panel, so it reads as a title bar.
69let BRAND_HEAD = '#221c3a'
70// The box behind the level while it is hovered: the grey of the app's own pills.
71let HOVER_BOX = '#2b2b2f'
72const EFFORT_LABELS: Record<Effort, string> = { low: 'Low', medium: 'Medium', high: 'High', xhigh: 'XHigh', max: 'Max' }
73
74// Auto is two switches. `isAuto` is effort (the name stays: it is what the store and state hold);
75// `isAutoModel` lets the judge suggest another model and starts off.
76const isAuto = atom({ plugin: 'effortless', key: 'isAuto' } as const, true)
77const JEV_TIMEOUT_MS = 3000
78const JEV_URL = 'https://api.typesafe.ai/v1/systemone'
79const EMPTY_SPENT: Spent = { prompts: 0, requests: 0, input: 0, write: 0, read: 0, out: 0, byEffort: {}, judge: { jev: 0, haiku: 0, ms: 0, tokens: 0 }, moved: 0, redone: 0 }
80const SWITCHED_MS = 2500
81// The prompt cache lives this long after the last request read or wrote it. A response may say which lifetime its
82// cache writes got (usage.cache_creation: ephemeral_1h / ephemeral_5m); until one does, 1 hour is assumed: 99% of
83// the cache writes in 80k requests of real Claude Code use (289k of 292k) were 1 hour.
84const CACHE_TTL = { '5m': 5 * 60_000, '1h': 60 * 60_000 } as const
85const CACHE_TICK_MS = 15_000
86// Under this much time left the countdown turns amber: send now, or pay to write the whole context again.
87// Shown minutes at or under these turn the countdown yellow, then red: grey while there is time, yellow at 20, red at 5.
88const CACHE_YELLOW_MIN = 20
89const CACHE_RED_MIN = 5
90const ICE = '#7cc4ff'
91const ICE_BG = '#0e1820'
92const ICE_EDGE = '#2f5c80'
93const BOG = '#a7c98f'
94const BOG_BG = '#111710'
95const BOG_EDGE = '#3e5a33'
96const EMBER = '#f08a3c'
97const EMBER_BG = '#1a110c'
98const EMBER_EDGE = '#6a3418'
99const SLATE = '#b4b8c4'
100const SLATE_BG = '#12141b'
101const SLATE_EDGE = '#39415a'
102const YELLOW = '#e0a33a'
103const RED = '#e5534b'
104const isAutoModel = atom({ plugin: 'effortless', key: 'isAutoModel' } as const, false)
105const pick = atom({ plugin: 'effortless', key: 'pick' } as const, null)
106const isJudging = atom({ plugin: 'effortless', key: 'isJudging' } as const, false)
107const suggestion = atom({ plugin: 'effortless', key: 'suggestion' } as const, null)
108const appEffort = atom({ plugin: 'effortless', key: 'appEffort' } as const, null)
109const model = atom({ plugin: 'effortless', key: 'model' } as const, null)
110// The last change Auto made to the effort, shown for a moment as "Low → High" and then cleared.
111const switched = atom({ plugin: 'effortless', key: 'switched' } as const, null)
112// What the prompts Auto steered cost this session, measured, and what judging them took.
113const saved = atom({ plugin: 'effortless', key: 'saved' } as const, EMPTY_SPENT)
114// True while the session runs a model where Auto must not change effort (see cacheSafe).
115const paused = atom({ plugin: 'effortless', key: 'paused' } as const, false)
116// How long the main conversation's prompt cache stays warm, in whole minutes left: null before the first response,
117// 0 once it has gone cold. Updated only when the minute changes, so the footer redraws once a minute at most.
118const cacheLeft = atom({ plugin: 'effortless', key: 'cacheLeft' } as const, null)
119const cacheMemo = atom({ plugin: 'effortless', key: 'cacheMemo' } as const, null)
120const isCompacting = atom({ plugin: 'effortless', key: 'isCompacting' } as const, false)
121// The compact bar: open after Compact is pressed, with a field for what the summary should keep.
122const compactAsk = atom({ plugin: 'effortless', key: 'compactAsk' } as const, false)
123// What is typed in that field. Kept here, not in state: a write per key would redraw the bar under the cursor.
124let compactNote = ''
125// When the compact bar opened: its first half second fades the swamped band's colours out (see compactFadeSvg).
126let compactOpenedAt = 0
127const COMPACT_FADE_MS = 450
128/** The swamped band's colours over the compact bar, fading to nothing: the band seems to turn from green to violet. */
129function compactFadeSvg(elapsedMs: number): string {
130  return inPhase(`<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="240" viewBox="0 0 1600 240" preserveAspectRatio="none"><style>.f{animation:f ${COMPACT_FADE_MS / 1000}s ease-out forwards}@keyframes f{from{opacity:1}to{opacity:0}}</style><rect class="f" width="1600" height="240" fill="${BOG_BG}"/></svg>`, elapsedMs)
131}
132// The person closed the cold band; it comes back the next time the cache goes cold.
133// The test pane of /effortless try pane.
134// Where a handoff is: null idle, writing (the handoff turn runs), clearing (clear and resend).
135const handoffStage = atom({ plugin: 'effortless', key: 'handoffStage' } as const, null)
136// The handoff bar above the prompt, open with the choice shown in it, or null.
137const handoffPick = atom({ plugin: 'effortless', key: 'handoffPick' } as const, null)
138// The context is swamped: tokens read per request, or null below the line. Drives the swamp band.
139const swamped = atom({ plugin: 'effortless', key: 'swamped' } as const, null)
140// Why Haiku thinks a handoff would suit now, or null. It lights Handoff and says why; there is no percent threshold.
141const handoffAdvice = atom({ plugin: 'effortless', key: 'handoffAdvice' } as const, null)
142// The swamp band was closed at this many tokens; it comes back once the context has grown well past it.
143// The handoff card above the prompt: shown from the start of a handoff, and once it lands ('done' in the
144// cleared chat, 'copied'). It goes with the next message, its ✕, or HANDOFF_CARD_MS after it was set.
145type HandoffCard = { kind: 'writing' | 'done' | 'copied' | 'compacting' | 'compacted'; full: boolean; at: number; seen: boolean }
146// Kinds still under way: they stay until they land, with moving art. Compacting shares the card with the handoff.
147const cardRunning = (kind: HandoffCard['kind']) => kind === 'writing' || kind === 'compacting'
148// Kinds that landed well: the card turns green with a checkmark.
149const cardLanded = (kind: HandoffCard['kind']) => kind === 'done' || kind === 'compacted'
150const handoffCard = atom({ plugin: 'effortless', key: 'handoffCard' } as const, null)
151const updateCard = atom({ plugin: 'effortless', key: 'updateCard' } as const, null)
152const HANDOFF_CARD_MS = 2 * 60_000
153// The handoff card's art while it is written: sparkles carried from left to right.
154const HANDOFF_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="440" height="64" viewBox="0 0 360 30" preserveAspectRatio="xMaxYMid slice"><style>:root{color-scheme:light dark}html,body{margin:0}svg{background:transparent;display:block}.d{fill:#d9d1ff;opacity:0;animation-name:go;animation-timing-function:ease-in-out;animation-iteration-count:infinite}@keyframes go{0%{opacity:0;transform:translate(0,0)}15%{opacity:.9}85%{opacity:.7}100%{opacity:0;transform:translate(190px,0)}}.br{animation:br 2.4s ease-in-out infinite}@keyframes br{0%,100%{opacity:.75}50%{opacity:1}}</style><defs><linearGradient id="bg" x1="0" x2="1"><stop offset=".43" stop-color="#7c6cf0" stop-opacity="0"/><stop offset=".7" stop-color="#7c6cf0" stop-opacity=".2"/><stop offset="1" stop-color="#b3a6ff" stop-opacity=".46"/></linearGradient><linearGradient id="fade" x1="0" x2="1"><stop offset=".43" stop-color="#fff" stop-opacity="0"/><stop offset=".7" stop-color="#fff" stop-opacity="1"/></linearGradient><mask id="m"><rect width="360" height="30" fill="url(#fade)"/></mask><pattern id="grain" width="2" height="2" patternUnits="userSpaceOnUse"><rect width=".6" height=".6" fill="#fff" fill-opacity=".07"/></pattern></defs><g mask="url(#m)"><rect class="br" width="360" height="30" fill="url(#bg)"/><circle class="d" cx="170" cy="9.2" r="0.83" style="animation-duration:2.7s;animation-delay:-2.2s"/><circle class="d" cx="170" cy="17.8" r="0.54" style="animation-duration:2.2s;animation-delay:-3.0s"/><circle class="d" cx="170" cy="9.7" r="0.64" style="animation-duration:3.6s;animation-delay:-1.7s"/><circle class="d" cx="170" cy="22.4" r="0.79" style="animation-duration:3.1s;animation-delay:-0.5s"/><circle class="d" cx="170" cy="18.0" r="1.02" style="animation-duration:2.9s;animation-delay:-2.7s"/><circle class="d" cx="170" cy="18.8" r="0.54" style="animation-duration:3.3s;animation-delay:-2.1s"/><circle class="d" cx="170" cy="10.6" r="0.52" style="animation-duration:3.4s;animation-delay:-1.7s"/><circle class="d" cx="170" cy="19.8" r="1.03" style="animation-duration:3.2s;animation-delay:-3.3s"/><circle class="d" cx="170" cy="12.7" r="0.98" style="animation-duration:2.8s;animation-delay:-3.4s"/><circle class="d" cx="170" cy="23.3" r="0.56" style="animation-duration:2.4s;animation-delay:-0.8s"/><circle class="d" cx="170" cy="25.2" r="0.76" style="animation-duration:3.1s;animation-delay:-1.1s"/><circle class="d" cx="170" cy="15.2" r="0.73" style="animation-duration:2.7s;animation-delay:-2.1s"/><circle class="d" cx="170" cy="16.9" r="1.04" style="animation-duration:3.2s;animation-delay:-3.3s"/><circle class="d" cx="170" cy="22.8" r="1.09" style="animation-duration:3.1s;animation-delay:-0.6s"/><rect width="360" height="30" fill="url(#grain)"/></g></svg>`
155
156async function setHandoffCard($: EngineInterface, kind: HandoffCard['kind'], full: boolean, seen = false) {
157  const at = await $.clock.now()
158  await update($, handoffAdvice, () => null)
159  await update($, handoffCard, () => ({ kind, full, at, seen }))
160  // Gone after a while even with no reply; a timer that dies with its request leaves the next reply to clear it.
161  if (!cardRunning(kind)) {
162    try {
163      $.clock.after(HANDOFF_CARD_MS, () => void update($, handoffCard, card => (card && card.at === at ? null : card)))
164    } catch {}
165  }
166}
167
168// The newest reply's text, so the warning card goes under its last block and nowhere else.
169const lastAnswer = atom({ plugin: 'effortless', key: 'lastAnswer' } as const, '')
170const lastTurn = atom({ plugin: 'effortless', key: 'lastTurn' } as const, null)
171// The weighted tokens of the main thread's requests since the prompt; kept in lastTurn when the turn ends.
172let turnCost = 0
173const swampHiddenAt = atom({ plugin: 'effortless', key: 'swampHiddenAt' } as const, null)
174// The first-run setup is not done: the footer offers "Setup".
175const setupPending = atom({ plugin: 'effortless', key: 'setupPending' } as const, false)
176// Why the judge the person picked is failing (Haiku stands in), or null when it works.
177// The effortless settings panel is open above the prompt.
178const settingsOpen = atom({ plugin: 'effortless', key: 'settingsOpen' } as const, false)
179// What was changed in the panel and not saved yet, by field; Save applies it all, the cross drops it.
180// The user and plugin skills, read when the settings panel opens.
181const installedSkills = atom({ plugin: 'effortless', key: 'installedSkills' } as const, [])
182const settingsDraft = atom({ plugin: 'effortless', key: 'settingsDraft' } as const, {})
183const judgeDown = atom({ plugin: 'effortless', key: 'judgeDown' } as const, null)
184// Which part of the settings panel is open: null for the cards.
185type SettingsCard = 'effort' | 'model' | 'judge' | 'handoff' | 'show'
186const settingsCard = atom({ plugin: 'effortless', key: 'settingsCard' } as const, null)
187/** The settings panel's parts: a card each on the overview, and what the part is for, said once it is open. */
188const CARDS: readonly { id: SettingsCard; title: string; about: string }[] = [
189  { id: 'effort', title: 'Effort', about: 'How hard Claude thinks. The slider tips close calls; Min and Max are hard limits.' },
190  { id: 'model', title: 'Model', about: "Whether a simple prompt may run on a cheaper model, and which model writes a compaction." },
191  { id: 'handoff', title: 'Handoff & alerts', about: 'The full handoff skill, when to suggest compacting, and which alerts show.' },
192  { id: 'show', title: 'Customize', about: 'How effortless looks and which parts it shows. Uninstall removes it.' },
193  { id: 'judge', title: 'Judge', about: 'Haiku judges prompts and handoffs. Add Jev for quicker effort calls. Test checks it answers.' },
194]
195// The Model card: the two ways to run. Effort follows the judge in both.
196const MODEL_CHOICES = [
197  { value: 'on', label: 'Cheaper when it can', tag: 'experimental', about: "A simple prompt may run on Haiku or Sonnet. Rarely pays: the other model has to reread the whole chat." },
198  { value: 'off', label: "Always the chat's", tag: '', about: "Every prompt keeps the model you picked. Only effort changes." },
199] as const
200const BIAS_WORDS = ['Cheapest', 'Cheaper', 'Balanced', 'Smarter', 'Smartest'] as const
201// The settings panel's judge test: running, or what it found. Null before a test and once the panel closes.
202const judgeTest = atom({ plugin: 'effortless', key: 'judgeTest' } as const, null)
203// The judge-down band was closed for this reason; a new reason shows it again.
204const judgeDownHidden = atom({ plugin: 'effortless', key: 'judgeDownHidden' } as const, null)
205// A usage limit is close: which window, how much is used, when it resets. Null below the line.
206const hot = atom({ plugin: 'effortless', key: 'hot' } as const, null)
207// The running-hot band was closed at this many percent; it returns ten points later or in a new window.
208// The running-hot band closed with its cross: in which window and how full. Kept in the store too, so a new chat keeps it closed.
209const hotHidden = atom({ plugin: 'effortless', key: 'hotHidden' } as const, null as HotClosed | null)
210// The agent panel: this chat's subagents, and the card opened in it (hooks/agents.tsx).
211const agentsState = atom({ plugin: 'effortless', key: 'agents' } as const, [])
212const agentsOpen = atom({ plugin: 'effortless', key: 'agentsOpen' } as const, null)
213// The panel's map: the files touched, the main chat's own steps, the module opened in its list.
214const agentFiles = atom({ plugin: 'effortless', key: 'agentFiles' } as const, [])
215const agentSteps = atom({ plugin: 'effortless', key: 'agentSteps' } as const, null)
216const agentsModule = atom({ plugin: 'effortless', key: 'agentsModule' } as const, null)
217// Save mode: Auto picks at most medium until this time (ms), when the limit resets.
218const saveUntil = atom({ plugin: 'effortless', key: 'saveUntil' } as const, null)
219const isColdHidden = atom({ plugin: 'effortless', key: 'isColdHidden' } as const, false)
220// The setup guide above the prompt: which step it shows, or null when it is closed.
221const setupStep = atom({ plugin: 'effortless', key: 'setupStep' } as const, null)
222// What was picked in the setup guide and not saved yet: it is saved in one go at Done or ✕. Each saved setting reloads
223// the plugin, and the app says so in the chat, so a save per click filled the chat with notices.
224const setupDraft = atom({ plugin: 'effortless', key: 'setupDraft' } as const, {})
225
226// The band above the prompt when the cache has gone cold: an icy gradient with snowflakes drifting down.
227const FROST_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="440" height="64" viewBox="0 0 360 30" preserveAspectRatio="xMaxYMid slice"><style>:root{color-scheme:light dark}html,body{margin:0}svg{background:transparent;display:block}.f path{stroke:#eaf6ff;stroke-width:.5;stroke-linecap:round;fill:none}.f{transform-box:fill-box;transform-origin:center;animation:spin linear infinite;opacity:.75}@keyframes spin{to{transform:rotate(360deg)}}.fr{stroke:#dff1ff;stroke-width:.4;fill:none;stroke-linecap:round;opacity:.5}.gl{fill:#fff;opacity:0;animation:tw 3.6s ease-in-out infinite}@keyframes tw{0%,70%,100%{opacity:0}80%{opacity:.9}}.br{animation:br 6s ease-in-out infinite}@keyframes br{0%,100%{opacity:.85}50%{opacity:1}}</style><defs><linearGradient id="ice" x1="0" x2="1"><stop offset=".43" stop-color="#5aa9e6" stop-opacity="0"/><stop offset=".62" stop-color="#5aa9e6" stop-opacity=".12"/><stop offset=".85" stop-color="#8fd0ff" stop-opacity=".28"/><stop offset="1" stop-color="#cdeaff" stop-opacity=".42"/></linearGradient><radialGradient id="cold" cx="330" cy="15" r="60" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e9f6ff" stop-opacity=".22"/><stop offset="1" stop-color="#e9f6ff" stop-opacity="0"/></radialGradient><linearGradient id="rime" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity=".18"/><stop offset=".22" stop-color="#fff" stop-opacity="0"/><stop offset=".78" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity=".15"/></linearGradient><linearGradient id="fade" x1="0" x2="1"><stop offset=".43" stop-color="#fff" stop-opacity="0"/><stop offset=".7" stop-color="#fff" stop-opacity="1"/></linearGradient><mask id="m"><rect width="360" height="30" fill="url(#fade)"/></mask><pattern id="grain" width="2" height="2" patternUnits="userSpaceOnUse"><rect width=".6" height=".6" fill="#fff" fill-opacity=".07"/></pattern></defs><g mask="url(#m)"><rect class="br" width="360" height="30" fill="url(#ice)"/><rect width="360" height="30" fill="url(#cold)"/><rect width="360" height="30" fill="url(#rime)"/><rect width="360" height="30" fill="url(#grain)"/><path class="fr" d="M360.0 3.0L354.1 2.1M354.1 2.1L349.8 1.8M349.8 1.8L346.7 2.1M346.7 2.1L344.4 2.2M348.2 1.9L347.5 3.2M351.9 1.9L350.3 3.1M350.3 3.1L349.2 3.8M357.0 2.5L355.1 0.6M355.1 0.6L353.5 -0.5M353.5 -0.5L352.4 -1.4M356.1 1.6L354.9 1.4M360.0 27.0L354.1 27.9M354.1 27.9L349.8 28.5M349.8 28.5L346.9 29.6M346.9 29.6L345.0 30.8M348.3 29.0L347.1 28.4M351.9 28.2L350.9 29.9M350.9 29.9L350.3 31.1M357.0 27.5L355.2 29.4M355.2 29.4L353.9 30.9M353.9 30.9L352.8 31.8M356.1 28.4L354.9 28.5M350.0 30.0L348.8 26.2M348.8 26.2L347.9 23.4M347.9 23.4L347.6 21.4M347.6 21.4L347.7 19.9M348.4 24.8L348.9 23.6M349.4 28.1L350.2 26.5M350.2 26.5L350.8 25.3M352.0 0.0L351.0 3.9M351.0 3.9L350.4 6.7M350.4 6.7L349.6 8.6M349.6 8.6L349.0 10.0M350.7 5.3L349.6 5.9M351.5 1.9L352.2 3.6M352.2 3.6L352.9 4.7"/><circle class="gl" cx="228" cy="26" r="0.5" style="animation-delay:0s"/><circle class="gl" cx="205" cy="4" r="0.45" style="animation-delay:1.3s"/><circle class="gl" cx="186" cy="26" r="0.4" style="animation-delay:2.4s"/><circle class="gl" cx="262" cy="6" r="0.5" style="animation-delay:0.7s"/><circle class="gl" cx="300" cy="25" r="0.45" style="animation-delay:3.1s"/><circle class="gl" cx="330" cy="6" r="0.5" style="animation-delay:1.9s"/><g class="f" style="animation-duration:10s;animation-delay:0s"><path d="M236.0 9.0L236.0 13.2M236.0 10.9L237.0 11.8M236.0 10.9L235.0 11.8M236.0 11.9L236.7 12.6M236.0 11.9L235.3 12.6M236.0 9.0L232.4 11.1M234.4 9.9L234.0 11.2M234.4 9.9L233.1 9.6M233.5 10.5L233.2 11.4M233.5 10.5L232.6 10.2M236.0 9.0L232.4 6.9M234.4 8.1L233.1 8.4M234.4 8.1L234.0 6.8M233.5 7.5L232.6 7.8M233.5 7.5L233.2 6.6M236.0 9.0L236.0 4.8M236.0 7.1L235.0 6.2M236.0 7.1L237.0 6.2M236.0 6.1L235.3 5.4M236.0 6.1L236.7 5.4M236.0 9.0L239.6 6.9M237.6 8.1L238.0 6.8M237.6 8.1L238.9 8.4M238.5 7.5L238.8 6.6M238.5 7.5L239.4 7.8M236.0 9.0L239.6 11.1M237.6 9.9L238.9 9.6M237.6 9.9L238.0 11.2M238.5 10.5L239.4 10.2M238.5 10.5L238.8 11.4"/></g><g class="f" style="animation-duration:12s;animation-delay:-4s"><path d="M214.0 21.0L214.0 24.2M214.0 22.4L214.7 23.2M214.0 22.4L213.3 23.2M214.0 23.2L214.5 23.7M214.0 23.2L213.5 23.7M214.0 21.0L211.2 22.6M212.8 21.7L212.5 22.7M212.8 21.7L211.8 21.5M212.1 22.1L211.9 22.8M212.1 22.1L211.4 21.9M214.0 21.0L211.2 19.4M212.8 20.3L211.8 20.5M212.8 20.3L212.5 19.3M212.1 19.9L211.4 20.1M212.1 19.9L211.9 19.2M214.0 21.0L214.0 17.8M214.0 19.6L213.3 18.8M214.0 19.6L214.7 18.8M214.0 18.8L213.5 18.3M214.0 18.8L214.5 18.3M214.0 21.0L216.8 19.4M215.2 20.3L215.5 19.3M215.2 20.3L216.2 20.5M215.9 19.9L216.1 19.2M215.9 19.9L216.6 20.1M214.0 21.0L216.8 22.6M215.2 21.7L216.2 21.5M215.2 21.7L215.5 22.7M215.9 22.1L216.6 21.9M215.9 22.1L216.1 22.8"/></g><g class="f" style="animation-duration:11s;animation-delay:-7s"><path d="M194.0 8.0L194.0 10.6M194.0 9.2L194.6 9.8M194.0 9.2L193.4 9.8M194.0 9.8L194.4 10.2M194.0 9.8L193.6 10.2M194.0 8.0L191.7 9.3M193.0 8.6L192.8 9.4M193.0 8.6L192.2 8.4M192.4 8.9L192.3 9.5M192.4 8.9L191.9 8.8M194.0 8.0L191.7 6.7M193.0 7.4L192.2 7.6M193.0 7.4L192.8 6.6M192.4 7.1L191.9 7.2M192.4 7.1L192.3 6.5M194.0 8.0L194.0 5.4M194.0 6.8L193.4 6.2M194.0 6.8L194.6 6.2M194.0 6.2L193.6 5.8M194.0 6.2L194.4 5.8M194.0 8.0L196.3 6.7M195.0 7.4L195.2 6.6M195.0 7.4L195.8 7.6M195.6 7.1L195.7 6.5M195.6 7.1L196.1 7.2M194.0 8.0L196.3 9.3M195.0 8.6L195.8 8.4M195.0 8.6L195.2 9.4M195.6 8.9L196.1 8.8M195.6 8.9L195.7 9.5"/></g><g class="f" style="animation-duration:13s;animation-delay:-2s"><path d="M176.0 19.0L176.0 21.2M176.0 20.0L176.5 20.5M176.0 20.0L175.5 20.5M176.0 20.5L176.3 20.9M176.0 20.5L175.7 20.9M176.0 19.0L174.1 20.1M175.1 19.5L175.0 20.2M175.1 19.5L174.5 19.3M174.7 19.8L174.5 20.2M174.7 19.8L174.2 19.6M176.0 19.0L174.1 17.9M175.1 18.5L174.5 18.7M175.1 18.5L175.0 17.8M174.7 18.2L174.2 18.4M174.7 18.2L174.5 17.8M176.0 19.0L176.0 16.8M176.0 18.0L175.5 17.5M176.0 18.0L176.5 17.5M176.0 17.5L175.7 17.1M176.0 17.5L176.3 17.1M176.0 19.0L177.9 17.9M176.9 18.5L177.0 17.8M176.9 18.5L177.5 18.7M177.3 18.2L177.5 17.8M177.3 18.2L177.8 18.4M176.0 19.0L177.9 20.1M176.9 19.5L177.5 19.3M176.9 19.5L177.0 20.2M177.3 19.8L177.8 19.6M177.3 19.8L177.5 20.2"/></g><g class="f" style="animation-duration:14s;animation-delay:-9s"><path d="M252.0 22.0L252.0 24.4M252.0 23.1L252.5 23.6M252.0 23.1L251.5 23.6M252.0 23.7L252.4 24.1M252.0 23.7L251.6 24.1M252.0 22.0L249.9 23.2M251.1 22.5L250.9 23.3M251.1 22.5L250.3 22.3M250.5 22.8L250.4 23.4M250.5 22.8L250.0 22.7M252.0 22.0L249.9 20.8M251.1 21.5L250.3 21.7M251.1 21.5L250.9 20.7M250.5 21.2L250.0 21.3M250.5 21.2L250.4 20.6M252.0 22.0L252.0 19.6M252.0 20.9L251.5 20.4M252.0 20.9L252.5 20.4M252.0 20.3L251.6 19.9M252.0 20.3L252.4 19.9M252.0 22.0L254.1 20.8M252.9 21.5L253.1 20.7M252.9 21.5L253.7 21.7M253.5 21.2L253.6 20.6M253.5 21.2L254.0 21.3M252.0 22.0L254.1 23.2M252.9 22.5L253.7 22.3M252.9 22.5L253.1 23.3M253.5 22.8L254.0 22.7M253.5 22.8L253.6 23.4"/></g></g></svg>`
228// The frost is drawn larger than the band and cut by it: wide enough for the right side, tall enough for any band.
229const FROST_WIDTH = 440
230const FROST_HEIGHT = 64
231// TypeSafe's mark (from typesafe.ai), drawn beside the Jev button: a Button holds text only.
232const TYPESAFE_MARK = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="18" viewBox="0 0 16.487 24"><style>svg{background:transparent;display:block}</style><path d="M 12.756 2.928 L 12.756 7.067 L 16.486 9.487 L 16.487 18.652 L 8.244 24 L 3.732 21.073 L 3.732 16.82 L 0 14.399 L 0 5.35 L 0.355 5.118 L 8.244 0 Z M 5.94 20.65 L 8.242 22.144 L 14.275 18.227 L 11.975 16.735 Z M 9.022 10.332 L 9.022 14.4 L 5.29 16.822 L 5.29 19.216 L 11.197 15.383 L 11.197 8.921 Z M 12.756 15.384 L 14.928 16.794 L 14.928 10.332 L 12.756 8.922 Z M 2.21 13.976 L 4.511 15.47 L 6.812 13.976 L 4.512 12.485 Z M 1.559 6.193 L 1.559 12.544 L 3.731 11.134 L 3.731 7.066 L 7.464 4.643 L 7.464 2.36 L 1.56 6.193 Z M 5.291 11.132 L 7.463 12.542 L 7.463 10.332 L 5.292 8.921 L 5.292 11.132 Z M 5.94 7.487 L 8.244 8.981 L 10.544 7.488 L 8.244 5.994 Z M 9.024 4.643 L 11.196 6.054 L 11.196 3.774 L 9.024 2.359 Z" fill="#ffffff"/></svg>`
233// Claude's mark (from claude.ai) in white, drawn beside the Haiku button.
234const CLAUDE_MARK = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 248 248"><style>svg{background:transparent;display:block}</style><path d="M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z" fill="#ffffff"/></svg>`
235// The band when the context is swamped: murky green, bubbles rising, slow ripples.
236const SWAMP_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="440" height="64" viewBox="0 0 360 30" preserveAspectRatio="xMaxYMid slice"><style>:root{color-scheme:light dark}html,body{margin:0}svg{background:transparent;display:block}.b{fill:none;stroke:#cfe8b8;stroke-width:.5;opacity:0;animation-name:rise;animation-timing-function:ease-in;animation-iteration-count:infinite}@keyframes rise{0%{opacity:0;transform:translate(0,0)}15%{opacity:.7}80%{opacity:.5}100%{opacity:0;transform:translate(3px,-30px)}}.rp{fill:none;stroke:#a7c98f;stroke-width:.4;stroke-linecap:round;opacity:.25;animation:drift 7s ease-in-out infinite}@keyframes drift{0%,100%{transform:translate(0,0);opacity:.15}50%{transform:translate(4px,0);opacity:.4}}.mk{animation:mk 8s ease-in-out infinite}@keyframes mk{0%,100%{opacity:.85}50%{opacity:1}}</style><defs><linearGradient id="bog" x1="0" x2="1"><stop offset=".43" stop-color="#4f7a3a" stop-opacity="0"/><stop offset=".62" stop-color="#4f7a3a" stop-opacity=".16"/><stop offset=".85" stop-color="#6f9a4f" stop-opacity=".32"/><stop offset="1" stop-color="#9cc27a" stop-opacity=".42"/></linearGradient><linearGradient id="silt" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#000" stop-opacity="0"/><stop offset=".6" stop-color="#2b3a1f" stop-opacity=".18"/><stop offset="1" stop-color="#1c2614" stop-opacity=".45"/></linearGradient><linearGradient id="fade" x1="0" x2="1"><stop offset=".43" stop-color="#fff" stop-opacity="0"/><stop offset=".7" stop-color="#fff" stop-opacity="1"/></linearGradient><mask id="m"><rect width="360" height="30" fill="url(#fade)"/></mask><pattern id="grain" width="2" height="2" patternUnits="userSpaceOnUse"><rect width=".6" height=".6" fill="#fff" fill-opacity=".06"/></pattern></defs><g mask="url(#m)"><rect class="mk" width="360" height="30" fill="url(#bog)"/><rect width="360" height="30" fill="url(#silt)"/><rect width="360" height="30" fill="url(#grain)"/><path class="rp" style="animation-delay:-0s" d="M190 22 q6 -2 12 0 t12 0"/><path class="rp" style="animation-delay:-2.5s" d="M226 9 q6 -2 12 0 t12 0"/><path class="rp" style="animation-delay:-5s" d="M250 18 q6 -2 12 0 t12 0"/><path class="rp" style="animation-delay:-1.5s" d="M300 24 q6 -2 12 0 t12 0"/><path class="rp" style="animation-delay:-3.8s" d="M322 7 q6 -2 12 0 t12 0"/><circle class="b" cx="168" cy="32" r="1.6" style="animation-duration:6.5s;animation-delay:-0s"/><circle class="b" cx="182" cy="32" r="1.1" style="animation-duration:8s;animation-delay:-2.1s"/><circle class="b" cx="197" cy="32" r="2.0" style="animation-duration:7s;animation-delay:-4.2s"/><circle class="b" cx="210" cy="32" r="1.3" style="animation-duration:9s;animation-delay:-1.0s"/><circle class="b" cx="224" cy="32" r="1.7" style="animation-duration:7.5s;animation-delay:-3.3s"/><circle class="b" cx="238" cy="32" r="1.0" style="animation-duration:8.5s;animation-delay:-5.1s"/><circle class="b" cx="252" cy="32" r="1.4" style="animation-duration:6.8s;animation-delay:-2.6s"/><circle class="b" cx="176" cy="32" r="0.9" style="animation-duration:9.5s;animation-delay:-6.0s"/><circle class="b" cx="232" cy="32" r="0.8" style="animation-duration:10s;animation-delay:-0.5s"/></g></svg>`
237// The band when a usage limit is close: embers, sparks rising.
238const EMBER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="440" height="64" viewBox="0 0 360 30" preserveAspectRatio="xMaxYMid slice"><style>:root{color-scheme:light dark}html,body{margin:0}svg{background:transparent;display:block}.s{fill:#ffb06a;opacity:0;animation-name:up;animation-timing-function:ease-out;animation-iteration-count:infinite}@keyframes up{0%{opacity:0;transform:translate(0,0)}12%{opacity:.95}70%{opacity:.6}100%{opacity:0;transform:translate(4px,-30px)}}.br{animation:br 3.4s ease-in-out infinite}@keyframes br{0%,100%{opacity:.8}50%{opacity:1}}.br2{animation:br2 2.2s ease-in-out infinite}@keyframes br2{0%,100%{opacity:.7}40%{opacity:1}70%{opacity:.8}}</style><defs><linearGradient id="heat" x1="0" x2="1"><stop offset=".43" stop-color="#b8461b" stop-opacity="0"/><stop offset=".62" stop-color="#b8461b" stop-opacity=".16"/><stop offset=".85" stop-color="#d9622a" stop-opacity=".32"/><stop offset="1" stop-color="#f08a3c" stop-opacity=".42"/></linearGradient><linearGradient id="glow" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#000" stop-opacity="0"/><stop offset=".55" stop-color="#e2581f" stop-opacity=".08"/><stop offset="1" stop-color="#ff7a2e" stop-opacity=".32"/></linearGradient><linearGradient id="fade" x1="0" x2="1"><stop offset=".43" stop-color="#fff" stop-opacity="0"/><stop offset=".7" stop-color="#fff" stop-opacity="1"/></linearGradient><mask id="m"><rect width="360" height="30" fill="url(#fade)"/></mask><pattern id="grain" width="2" height="2" patternUnits="userSpaceOnUse"><rect width=".6" height=".6" fill="#fff" fill-opacity=".06"/></pattern></defs><g mask="url(#m)"><rect class="br" width="360" height="30" fill="url(#heat)"/><rect class="br2" width="360" height="30" fill="url(#glow)"/><circle class="s" cx="166" cy="31" r="0.7" style="animation-duration:3.2s;animation-delay:-0s"/><circle class="s" cx="178" cy="31" r="0.5" style="animation-duration:4.1s;animation-delay:-1.2s"/><circle class="s" cx="191" cy="31" r="0.8" style="animation-duration:3.6s;animation-delay:-2.4s"/><circle class="s" cx="203" cy="31" r="0.5" style="animation-duration:4.6s;animation-delay:-0.7s"/><circle class="s" cx="216" cy="31" r="0.7" style="animation-duration:3.9s;animation-delay:-3.0s"/><circle class="s" cx="228" cy="31" r="0.6" style="animation-duration:4.3s;animation-delay:-1.8s"/><circle class="s" cx="241" cy="31" r="0.8" style="animation-duration:3.4s;animation-delay:-0.4s"/><circle class="s" cx="254" cy="31" r="0.5" style="animation-duration:4.8s;animation-delay:-2.9s"/><circle class="s" cx="184" cy="31" r="0.4" style="animation-duration:5.0s;animation-delay:-3.6s"/><circle class="s" cx="236" cy="31" r="0.4" style="animation-duration:4.4s;animation-delay:-2.1s"/><rect width="360" height="30" fill="url(#grain)"/></g></svg>`
239// The band when the judge the person picked is failing: signal lost, a blip sweeping along a flat line.
240const DOWN_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="440" height="64" viewBox="0 0 360 30" preserveAspectRatio="xMaxYMid slice"><style>:root{color-scheme:light dark}html,body{margin:0}svg{background:transparent;display:block}.tr{fill:none;stroke:#e6ecf8;stroke-width:.6;stroke-linecap:round;stroke-linejoin:round}.gw{fill:none;stroke:#9fb0d6;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;opacity:.35}.hd{animation:hd 4.8s linear infinite}@keyframes hd{0%{transform:translate(150px,0)}88%,100%{transform:translate(372px,0)}}.gl{animation:gl 4.8s ease-in-out infinite}@keyframes gl{0%,100%{opacity:.75}50%{opacity:1}}</style><defs><linearGradient id="steel" x1="0" x2="1"><stop offset=".43" stop-color="#5d6a86" stop-opacity="0"/><stop offset=".65" stop-color="#5d6a86" stop-opacity=".16"/><stop offset=".88" stop-color="#7d8aa8" stop-opacity=".32"/><stop offset="1" stop-color="#a3afca" stop-opacity=".42"/></linearGradient><radialGradient id="glow" cx="330" cy="15" r="80" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#c4cee6" stop-opacity=".22"/><stop offset="1" stop-color="#8d9ab8" stop-opacity="0"/></radialGradient><pattern id="grid" width="7.5" height="7.5" patternUnits="userSpaceOnUse"><path d="M7.5 0 L0 0 L0 7.5" fill="none" stroke="#fff" stroke-opacity=".06" stroke-width=".25"/></pattern><linearGradient id="trail" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".85" stop-color="#fff" stop-opacity=".55"/><stop offset="1" stop-color="#fff" stop-opacity="1"/></linearGradient><linearGradient id="fade" x1="0" x2="1"><stop offset=".43" stop-color="#fff" stop-opacity="0"/><stop offset=".7" stop-color="#fff" stop-opacity="1"/></linearGradient><mask id="m"><rect width="360" height="30" fill="url(#fade)"/></mask><mask id="sweep"><g class="hd"><rect x="-110" y="0" width="110" height="30" fill="url(#trail)"/></g></mask></defs><g mask="url(#m)"><rect class="gl" width="360" height="30" fill="url(#steel)"/><rect width="360" height="30" fill="url(#glow)"/><rect width="360" height="30" fill="url(#grid)"/><g mask="url(#sweep)"><g transform="translate(0 3.5)"><path class="gw" d="M150 15 L196 15 L196.0 15.0 L199.0 13.8 L202.0 15.0 L204.0 15.0 L205.5 16.5 L208.0 5.0 L210.5 19.0 L212.0 15.0 L216.0 15.0 L220.0 13.0 L224.0 15.0 L238 15 L238.0 15.0 L241.0 14.2 L244.0 15.0 L246.0 15.0 L247.5 16.1 L250.0 8.0 L252.5 17.8 L254.0 15.0 L258.0 15.0 L262.0 13.6 L266.0 15.0 L278 15 L278.0 15.0 L281.0 14.5 L284.0 15.0 L286.0 15.0 L287.5 15.6 L290.0 11.2 L292.5 16.5 L294.0 15.0 L298.0 15.0 L302.0 14.2 L306.0 15.0 L316 15 L316.0 15.0 L319.0 14.9 L322.0 15.0 L324.0 15.0 L325.5 15.2 L328.0 13.8 L330.5 15.5 L332.0 15.0 L336.0 15.0 L340.0 14.8 L344.0 15.0 L360 15"/><path class="tr" d="M150 15 L196 15 L196.0 15.0 L199.0 13.8 L202.0 15.0 L204.0 15.0 L205.5 16.5 L208.0 5.0 L210.5 19.0 L212.0 15.0 L216.0 15.0 L220.0 13.0 L224.0 15.0 L238 15 L238.0 15.0 L241.0 14.2 L244.0 15.0 L246.0 15.0 L247.5 16.1 L250.0 8.0 L252.5 17.8 L254.0 15.0 L258.0 15.0 L262.0 13.6 L266.0 15.0 L278 15 L278.0 15.0 L281.0 14.5 L284.0 15.0 L286.0 15.0 L287.5 15.6 L290.0 11.2 L292.5 16.5 L294.0 15.0 L298.0 15.0 L302.0 14.2 L306.0 15.0 L316 15 L316.0 15.0 L319.0 14.9 L322.0 15.0 L324.0 15.0 L325.5 15.2 L328.0 13.8 L330.5 15.5 L332.0 15.0 L336.0 15.0 L340.0 14.8 L344.0 15.0 L360 15"/></g></g></g></svg>`
241// The right of the setup guide, pure decoration (the name is on the left): a purple gradient with a soft glow,
242// faint light streaks and grain, a still star, and small sparkles that twinkle in and out here and there. One constant source, so the app never rebuilds its frame (a changing source flickers); the
243// motion is CSS inside it.
244const BRAND_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="440" height="64" viewBox="0 0 360 30" preserveAspectRatio="xMaxYMid slice"><style>:root{color-scheme:light dark}html,body{margin:0}svg{background:transparent;display:block}.sp{fill:#fff;opacity:0;transform:scale(0);animation-name:gl;animation-timing-function:ease-in-out;animation-iteration-count:infinite}@keyframes gl{0%,72%,100%{opacity:0;transform:scale(0) rotate(0deg)}82%{opacity:.9;transform:scale(1) rotate(30deg)}92%{opacity:0;transform:scale(.2) rotate(60deg)}}.br{animation:br 6s ease-in-out infinite}@keyframes br{0%,100%{opacity:.85}50%{opacity:1}}</style><defs><linearGradient id="bg" x1="0" x2="1"><stop offset=".43" stop-color="#7c6cf0" stop-opacity="0"/><stop offset=".62" stop-color="#7c6cf0" stop-opacity=".16"/><stop offset=".85" stop-color="#8f7ff0" stop-opacity=".34"/><stop offset="1" stop-color="#b3a6ff" stop-opacity=".48"/></linearGradient><radialGradient id="glow" cx="320" cy="15" r="70" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#c9bdff" stop-opacity=".28"/><stop offset="1" stop-color="#9a86ff" stop-opacity="0"/></radialGradient><linearGradient id="fade" x1="0" x2="1"><stop offset=".43" stop-color="#fff" stop-opacity="0"/><stop offset=".7" stop-color="#fff" stop-opacity="1"/></linearGradient><mask id="m"><rect width="360" height="30" fill="url(#fade)"/></mask><pattern id="grain" width="2" height="2" patternUnits="userSpaceOnUse"><rect width=".6" height=".6" fill="#fff" fill-opacity=".07"/></pattern></defs><g mask="url(#m)"><rect class="br" width="360" height="30" fill="url(#bg)"/><rect width="360" height="30" fill="url(#glow)"/><rect width="360" height="30" fill="url(#grain)"/><line x1="186" y1="32" x2="198" y2="-2" stroke="#fff" stroke-opacity="0.04" stroke-width="3"/><line x1="204" y1="32" x2="216" y2="-2" stroke="#fff" stroke-opacity="0.05" stroke-width="1.2"/><line x1="226" y1="32" x2="238" y2="-2" stroke="#fff" stroke-opacity="0.05" stroke-width="4"/><line x1="262" y1="32" x2="274" y2="-2" stroke="#fff" stroke-opacity="0.04" stroke-width="1.5"/><line x1="290" y1="32" x2="302" y2="-2" stroke="#fff" stroke-opacity="0.05" stroke-width="3"/><line x1="320" y1="32" x2="332" y2="-2" stroke="#fff" stroke-opacity="0.04" stroke-width="1.2"/><path class="sp" style="transform-origin:168px 8px;animation-duration:4.2s;animation-delay:0.3s" d="M168 6.4 L168.34 7.66 L169.6 8 L168.34 8.34 L168 9.6 L167.66 8.34 L166.4 8 L167.66 7.66 Z"/><path class="sp" style="transform-origin:182px 22px;animation-duration:5.1s;animation-delay:2.1s" d="M182 20.7 L182.27 21.73 L183.3 22 L182.27 22.27 L182 23.3 L181.73 22.27 L180.7 22 L181.73 21.73 Z"/><path class="sp" style="transform-origin:196px 6px;animation-duration:3.8s;animation-delay:1.2s" d="M196 4.2 L196.38 5.62 L197.8 6 L196.38 6.38 L196 7.8 L195.62 6.38 L194.2 6 L195.62 5.62 Z"/><path class="sp" style="transform-origin:208px 19px;animation-duration:4.6s;animation-delay:3.4s" d="M208 17.8 L208.25 18.75 L209.2 19 L208.25 19.25 L208 20.2 L207.75 19.25 L206.8 19 L207.75 18.75 Z"/><path class="sp" style="transform-origin:221px 9px;animation-duration:5.4s;animation-delay:0.9s" d="M221 7.5 L221.31 8.69 L222.5 9 L221.31 9.31 L221 10.5 L220.69 9.31 L219.5 9 L220.69 8.69 Z"/><path class="sp" style="transform-origin:232px 23px;animation-duration:4.0s;animation-delay:2.7s" d="M232 21.9 L232.23 22.77 L233.1 23 L232.23 23.23 L232 24.1 L231.77 23.23 L230.9 23 L231.77 22.77 Z"/><path class="sp" style="transform-origin:244px 7px;animation-duration:4.8s;animation-delay:1.8s" d="M244 5.7 L244.27 6.73 L245.3 7 L244.27 7.27 L244 8.3 L243.73 7.27 L242.7 7 L243.73 6.73 Z"/><path class="sp" style="transform-origin:176px 15px;animation-duration:5.8s;animation-delay:4.0s" d="M176 14.0 L176.21 14.79 L177.0 15 L176.21 15.21 L176 16.0 L175.79 15.21 L175.0 15 L175.79 14.79 Z"/><path class="sp" style="transform-origin:214px 26px;animation-duration:4.4s;animation-delay:3.0s" d="M214 25.0 L214.21 25.79 L215.0 26 L214.21 26.21 L214 27.0 L213.79 26.21 L213.0 26 L213.79 25.79 Z"/><path class="sp" style="transform-origin:238px 15px;animation-duration:3.6s;animation-delay:0.1s" d="M238 14.0 L238.21 14.79 L239.0 15 L238.21 15.21 L238 16.0 L237.79 15.21 L237.0 15 L237.79 14.79 Z"/></g></svg>`
245
246// The handoff card once it has landed: the brand's art in green, with a big dark checkmark on the right.
247// The dashboard sits there all the time, so it is grey: purple is kept for the moments that ask for attention. Its
248// art is the brand's, in grey and at 40% of its strength; only the ✦ keeps the accent.
249const DASH_SVG = BRAND_SVG.replace(/stop-opacity="([0-9.]+)"/g, (_m, v: string) => `stop-opacity="${(Number(v) * 0.4).toFixed(3)}"`)
250  .replace(/#(7c6cf0|8f7ff0|b3a6ff|c9bdff|9a86ff)/g, '#9a9aa2')
251let DASH_BG = '#141416'
252let DASH_EDGE = '#2a2a2f'
253/** The settings title's word in the website hero's style: crisp, going soft and grainy at its bottom right (a radial
254 * mask fades the crisp copy out there and a copy through the site's #grainy filter takes over). The app gives the image
255 * the band's font. */
256const SETTINGS_TITLE = `<svg xmlns="http://www.w3.org/2000/svg" width="92" height="28" viewBox="0 0 92 28"><defs><radialGradient id="c" cx="1" cy="1" r=".75" gradientTransform="matrix(.7 0 0 2.3 .3 -1.3)"><stop offset=".35" stop-color="#000"/><stop offset="1" stop-color="#fff"/></radialGradient><radialGradient id="s" cx=".9" cy=".85" r=".55" gradientTransform="matrix(.7 0 0 2.3 .27 -1.1)"><stop offset=".4" stop-color="#fff"/><stop offset="1" stop-color="#000"/></radialGradient><mask id="mc"><rect width="92" height="28" fill="url(#c)"/></mask><mask id="ms"><rect width="92" height="28" fill="url(#s)"/></mask><filter id="g" x="-20%" y="-40%" width="140%" height="180%"><feTurbulence type="fractalNoise" baseFrequency="1.4" numOctaves="1" seed="7" result="noise"/><feDisplacementMap in="SourceGraphic" in2="noise" scale="2.5" xChannelSelector="R" yChannelSelector="G" result="moved"/><feGaussianBlur in="moved" stdDeviation="0.35" result="soft"/><feComponentTransfer in="noise" result="dots"><feFuncA type="discrete" tableValues="0 1 1 1 1"/></feComponentTransfer><feComposite in="soft" in2="dots" operator="in"/></filter></defs><g font-size="21" font-weight="600" letter-spacing="-.5"><text x="1" y="20" fill="#ffffff" mask="url(#mc)">effortless</text><g mask="url(#ms)"><text x="1" y="20" fill="#ffffff" filter="url(#g)">effortless</text></g></g></svg>`
257/** The mark as the settings bar shows it: big, tilted and faint, cut off by the bar. */
258const SETTINGS_MARK = MARK_SVG.replace('<g mask=', '<g opacity=".2" transform="rotate(9 50 50)" mask=')
259// The dashboard's mark (see dash-mark): its size, and its look with Auto off.
260const DASH_MARK_SIZE = 18
261const DASH_MARK_OFF = MARK_SVG.replace('<g mask=', '<g opacity=".35" mask=')
262/** How long a band's entrance runs: it is drawn only this long after the band appears, since every redraw of the band
263 * (a choice, the minute tick) would replay it. */
264const INTRO_MS = 1300
265// The band last drawn above the prompt, and when it appeared (see introShows).
266let introKind = ''
267let introAt = 0
268/** Whether the band of `kind` is still in its entrance: true for INTRO_MS after it replaced another band. */
269/** An animated Svg drawn `elapsedMs` into its animations. Every redraw rebuilds the band and starts its images' CSS
270 * animations over; with this, a redraw mid-animation picks it up where it was instead of jumping back. */
271export function inPhase(svg: string, elapsedMs: number): string {
272  return svg.replace('</svg>', `<style>svg *{animation-delay:${(-Math.max(0, elapsedMs) / 1000).toFixed(3)}s !important}</style></svg>`)
273}
274// How far the entrance on screen is, for introLayer.
275let introElapsed = 0
276// Set when a band replaces another, taken by the first draw of the new one.
277let introFresh = false
278async function introShows($: EngineInterface, kind: string): Promise<boolean> {
279  const now = await $.clock.now()
280  if (kind !== introKind) {
281    introKind = kind
282    introAt = now
283    introFresh = true
284    $.clock.after(INTRO_MS + 100, () => $.ui.invalidate('ui.render'))
285  }
286  introElapsed = now - introAt
287  return introElapsed < INTRO_MS
288}
289/** The entrance layer (INTRO_SVG) in a band's own colours, or nothing once the entrance is over. */
290// The mark beside a card's title, sized to one line of text.
291const TITLE_MARK_SIZE = 16
292
293/** A card's title with the effortless mark where the ✦ stood; the ✦ stays where the surface draws no images. */
294function markTitle(els: { Box: unknown; Text: unknown; Svg?: unknown }, key: string, color: string, title: string, mark = MARK_SVG) {
295  const { Box: B, Text: T, Svg: S } = els as unknown as { Box: (p: Record<string, unknown>) => unknown; Text: (p: Record<string, unknown>) => unknown; Svg?: (p: Record<string, unknown>) => unknown }
296  if (!S) return <T key={key} color={color} bold wrap="truncate">{title}</T>
297  return (
298    <B key={key} flexDirection="row" alignItems="center" flexShrink={1} minWidth={0}>
299      <B flexShrink={0} marginRight={1} alignItems="center">
300        <S source={mark} alt="effortless" width={TITLE_MARK_SIZE} height={TITLE_MARK_SIZE} />
301      </B>
302      <T color={color} bold wrap="truncate">{title.replace(/^✦ /, '')}</T>
303    </B>
304  )
305}
306
307function introLayer(els: { Box: unknown; Svg?: unknown }, key: string, show: boolean, wash = '#8b6cff', light = '#b9a7ff') {
308  if (!els.Svg || !show) return null
309  const { Box: B, Svg: S } = els as unknown as { Box: (p: Record<string, unknown>) => unknown; Svg: (p: Record<string, unknown>) => unknown }
310  return (
311    <B key={`${key}-intro`} position="absolute" top={-1} left={0} right={0} bottom={-1}>
312      <S source={inPhase(INTRO_SVG.replace('#8b6cff', wash).replace('#b9a7ff', light), introElapsed)} alt="" width={1600} height={240} />
313    </B>
314  )
315}
316/** The settings panel's top bar: a shade above the band. */
317let DASH_HEAD = '#18181b'
318const DASH_TEXT = '#d4d4d8'
319/** The dashboard's quieter figures: the cache countdown while it has time left. */
320const DASH_DIM = '#8b8b93'
321// A settings card under the pointer: a shade up from the band, its edge a shade up from that.
322let CARD_HOVER = '#1c1c20'
323let CARD_HOVER_EDGE = '#3b3b42'
324const DONE_SVG = BRAND_SVG
325  .replace(/#7c6cf0/g, '#2fae62').replace(/#8f7ff0/g, '#3cc472').replace(/#b3a6ff/g, '#7fe0a4')
326  .replace(/#c9bdff/g, '#b4f0c8').replace(/#9a86ff/g, '#4fd486')
327  .replace('</g></svg>', '<path d="M323 15.5 L330 22 L345 7.5" fill="none" stroke="#0c3a20" stroke-opacity=".8" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></g></svg>')
328const DONE_ACCENT = '#7fe0a4'
329const DONE_BG = '#0f1c15'
330// The Updated card: near black, a dim green edge; the green is in its title, the check and the sparkles.
331const UPDATED_BG = '#08090a'
332const UPDATED_EDGE = '#1f4a33'
333// The brand art of the offer card, mirrored to the left and turned green: the landed card keeps its texture and sparkles
334// behind the mark and title, while the links on the right stay on near black.
335const UPDATED_GLOW_SVG = BRAND_SVG
336  .replace('preserveAspectRatio="xMaxYMid slice"', 'preserveAspectRatio="xMinYMid slice"')
337  .replace('</defs>', '</defs><g transform="translate(360 0) scale(-1 1)">').replace('</svg>', '</g></svg>')
338  .replace(/#7c6cf0/g, '#2f9a5e').replace(/#8f7ff0/g, '#3fae6e').replace(/#b3a6ff/g, '#7fe0a4').replace(/#c9bdff/g, '#a8ecc2').replace(/#9a86ff/g, '#4cbf7c')
339  .replace(/stop-opacity="([0-9.]+)"/g, (_m, v: string) => `stop-opacity="${(Number(v) * 0.6).toFixed(3)}"`)
340// The Share icon: an arrow leaving a tray, white like the GitHub mark beside it.
341const SHARE_MARK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16"><path fill="none" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" d="M8 10V1.8M4.8 4.8L8 1.6l3.2 3.2M2.5 8.5v4.8c0 .4.3.7.7.7h9.6c.4 0 .7-.3.7-.7V8.5"/></svg>`
342// A thin vertical line between the links on the Updated card.
343const DIVIDER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="1" height="18" viewBox="0 0 1 18"><rect width="1" height="18" fill="#ffffff" fill-opacity=".16"/></svg>`
344// Hover on the near-black card: a light veil (the dark one used elsewhere is invisible here).
345const UPDATED_HOVER = '#ffffff1f'
346const DONE_EDGE = '#2f7a4c'
347// The mark in the done green, for the titles of cards that have landed.
348const DONE_MARK_SVG = MARK_SVG.replace(/#7566d8/g, '#2f9a5e').replace(/#a79cf7/g, '#7fe0a4').replace(/#cfc7ff/g, '#c8f4d8')
349
350const JUDGE_SYSTEM = `You choose which Claude model and reasoning effort an agentic assistant (it reads files, runs tools and edits things, not only code) should use for the user's next message. Pick the cheapest pair that will still do the job well.
351
352Models, cheapest first:
353- haiku: quick questions, lookups, renames, small edits in one or two files, summaries, chit-chat. It can read files and run tools; pick it for any small, well-defined job.
354- sonnet: normal coding, edits across a few files, explanations, writing.
355- opus: hard debugging, architecture, large refactors, careful reviews.
356- fable: the hardest long-horizon or research-level work.
357
358Effort: low for quick answers, medium for normal work, high for hard problems, xhigh or max only for very hard ones.
359
360Effort is relative to the model in use ("Current" names it): a stronger model needs less effort for the same job. Opus at medium does about what Sonnet does at high, and Fable is stronger again. So for one and the same task pick one step lower on Opus than on Sonnet, and lower still on Fable; on Sonnet, go one step higher for hard work than you would on Opus.
361
362Judge the SCOPE and the amount of work, not whether it is code. A short message can ask for a lot: "go through my whole drive and clean it up", "review the entire repo", "migrate everything" are big, multi-step, tool-heavy jobs where mistakes are costly: never low, usually high. Low is only for answers that need no tools and no planning.
363
364If the message answers a question in the assistant's last reply (picks an option, says which one, confirms a plan), judge only the work that answer starts, as the reply describes it, not the length of the answer and not the work done before the question: "B" can mean "build the complicated section B" (high), while "yes" or "no" to one small action ("should I archive this?", "delete the old ones too?") is low, and picking a value for one setting (a log level, a colour, a font) is low. But picking which way to build something ("option 1", "the same shapes", "B") starts that build: judge the build. Approving a whole plan or several steps takes the effort of that plan. A plain "yes", "ok" or "do it" after "Is that OK? Then I'll build X" or "Should I build X?" approves building X: judge X, not the word.
365
366A message that pushes back on, corrects or adds to a plan or claim under discussion continues that work: keep at least the effort that work had, never drop to low for it. Thanks, praise or a closing remark with no new request is low. A question about how to do something, or about effort itself, that needs no tools is low.
367
368If the person asks for deep thought ("think hard", "ultrathink", "be thorough"), pick at least high; if they ask for a quick answer, pick low.
369
370If the message is a short follow-up to ongoing work ("yes", "go", "ok", "continue", or the same in any language), keep the current pair.
371
372Reply with JSON only: {"model":"haiku|sonnet|opus|fable","effort":"low|medium|high|xhigh|max","sure":0.0-1.0 how sure you are of the effort,"why":"at most 6 words, in the user's language"}`
373
374/** Reads `{ model, effort, why }` out of a reply, or nothing when it doesn't hold one. */
375export function parseVerdict(text: string): { model: ModelKey; effort: Effort; why: string; sure?: number; handoff?: string } | undefined {
376  const match = text.match(/\{[\s\S]*\}/)
377  if (!match) return undefined
378  let raw: unknown
379  try {
380    raw = JSON.parse(match[0])
381  } catch {
382    return undefined
383  }
384  if (typeof raw !== 'object' || raw === null) return undefined
385  const { model, effort, why, sure, handoff } = raw as Record<string, unknown>
386  const key = typeof model === 'string' ? model.toLowerCase() : ''
387  const found = MODELS.find(m => key === m.key || key === m.id || key.includes(m.key))
388  if (!found || !EFFORTS.includes(effort as Effort)) return undefined
389  const how = typeof sure === 'number' && sure >= 0 && sure <= 1 ? { sure } : {}
390  // Only a reason counts: "handoff":"" or false is no advice.
391  const advice = typeof handoff === 'string' && handoff.trim() ? { handoff: handoff.trim().slice(0, 70) } : {}
392  return { model: found.key, effort: effort as Effort, why: typeof why === 'string' ? why.slice(0, 60) : '', ...how, ...advice }
393}
394
395/**
396 * What the judge reads besides the new message: the person's previous message and the assistant's last reply. The
397 * reply is kept long and from its end, where a question with options ("A, B or C?") sits: "B" alone looks like a
398 * small job, the reply says what B sets off.
399 */
400async function recentContext($: EngineInterface): Promise<string> {
401  return contextFrom(await $.session.messages())
402}
403
404/** The context text from the conversation so far: the last user message, short, and the assistant's last reply's end. */
405export function contextFrom(messages: readonly { role: string; text: string }[]): string {
406  const lastUser = [...messages].reverse().find(m => m.role === 'user')
407  const lastAssistant = lastAssistantText(messages)
408  return [lastUser ? `user: ${lastUser.text.slice(0, 400)}` : '', lastAssistant ? `assistant: ${lastAssistant.slice(-2000)}` : '']
409    .filter(Boolean)
410    .join('\n')
411}
412
413/** The assistant's last reply, or nothing. */
414function lastAssistantText(messages: readonly { role: string; text: string }[]): string {
415  return [...messages].reverse().find(m => m.role === 'assistant' && m.text.trim())?.text ?? ''
416}
417
418
419// Jev answers typed questions with probabilities; these are the options it picks between.
420const JEV_EFFORTS: Record<Effort, string> = {
421  low: 'quick answer, no tools, no planning',
422  medium: 'normal work: a few tool calls or edits',
423  high: 'hard or large multi-step job: many tool calls, costly mistakes',
424  xhigh: 'very hard: a long investigation',
425  max: 'the hardest research-level work',
426}
427const JEV_MODELS: Record<ModelKey, string> = {
428  haiku: 'trivial questions, lookups, renames, chit-chat',
429  sonnet: 'normal coding, edits across a few files, explanations, writing',
430  opus: 'hard debugging, architecture, large refactors, careful reviews',
431  fable: 'the hardest long-horizon or research-level work',
432}
433const JEV_TASK =
434  'Choose the reasoning effort (and model) an agentic assistant should use for the next user message. It reads files, ' +
435  'runs tools and edits things, not only code. Pick the cheapest that still does the job well. Judge the scope and the ' +
436  'amount of work, not whether it is code: "go through my whole drive and clean it up" is a big tool-heavy job. ' +
437  'Effort is relative to current_model: a stronger model needs less for the same job. Opus at medium does about what ' +
438  'Sonnet does at high, so for one task pick one step lower on Opus than on Sonnet. ' +
439  'A short follow-up ("yes", "go", "ok", in any language) keeps the current effort. When the message answers a ' +
440  "question in the assistant's last reply (picks an option), judge only the work that answer starts, not its length and " +
441  'not the work before the question: yes/no to one small action, or picking a value for one setting (a log level, a colour), is low; picking which way to build something starts that build, so judge the build; ' +
442  'approving a whole plan takes the effort of that plan; a plain yes to "Then I will build X" means judge X. Pushing back on or adding to a plan under discussion continues that work: keep its effort, never low. Thanks or a closing remark with no new request is low. A ' +
443  'question about how to do something that needs no tools is low. "think hard", "ultrathink" or "be thorough" means ' +
444  'at least high; "quick question" means low.'
445
446/** The skill or custom command a typed "/name args" runs, or nothing for the app's own commands and effortless's. */
447async function typedSkill($: EngineInterface, text: string): Promise<{ name: string; description: string } | undefined> {
448  const name = text.trim().slice(1).split(/\s+/)[0]
449  if (!name) return undefined
450  const found = (await $.command.list().catch(() => [])).find(c => c.name === name)
451  if (!found || !['user', 'plugin', 'mcp'].includes(found.source) || found.plugin === 'effortless') return undefined
452  return { name, description: found.description ?? '' }
453}
454
455const LEVEL_WORD = '(low|medium|high|xhigh|x-high|extra[- ]high|max|maximum)'
456const NAMED_LEVEL = new RegExp(`\\b${LEVEL_WORD}\\s+effort\\b|\\beffort(?:\\s+level)?\\s*(?:[:=]|to|at|of|on)?\\s*${LEVEL_WORD}\\b|\\bxhigh\\b`, 'gi')
457const LEVEL_OF: Record<string, Effort> = { low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', 'x-high': 'xhigh', 'extra high': 'xhigh', 'extra-high': 'xhigh', max: 'max', maximum: 'max' }
458
459/**
460 * The level a prompt asks for by name ("max effort", "effort: high", "xhigh"), or nothing. The word effort must sit
461 * beside the level, so "a medium sized image" names nothing, and "don't use max effort" is a no. A named level runs
462 * without the judge: effort-auto found Haiku also calls prompts that only describe hard work "named".
463 */
464export function namedEffort(text: string): Effort | undefined {
465  for (const m of text.matchAll(NAMED_LEVEL)) {
466    const before = text.slice(Math.max(0, m.index - 20), m.index).toLowerCase()
467    if (/\b(not|no|don'?t|never|without|instead of)\b/.test(before)) continue
468    return LEVEL_OF[(m[1] ?? m[2] ?? 'xhigh').toLowerCase().replace(/\s+/, ' ')]
469  }
470  return undefined
471}
472
473/** A short follow-up such as "go", "ok", "yes", "continue": two words and a dozen characters at most. */
474export function isFollowUp(text: string): boolean {
475  const t = text.trim()
476  return t.length > 0 && t.length <= 12 && t.split(/\s+/).length <= 2
477}
478
479/**
480 * Whether the assistant's last reply ended on a question ("Should I archive it?", "A, B or C?"). A short message
481 * after one answers it, and the answer can start a small or a big job, so it goes to the judge instead of keeping
482 * the effort.
483 */
484export function endsOnQuestion(context: string): boolean {
485  const reply = context.split('\nassistant: ').pop() ?? ''
486  return context.includes('assistant: ') && reply.slice(-400).includes('?')
487}
488
489/** The message as the judge reads it: images and files it carries are named, since the judge sees only text. */
490export function withAttachments(text: string, attachments?: readonly { type: string }[]): string {
491  if (!attachments?.length) return text
492  const counts = new Map<string, number>()
493  for (const a of attachments) counts.set(a.type, (counts.get(a.type) ?? 0) + 1)
494  const said = [...counts].map(([type, n]) => `${n} ${type}${n > 1 ? 's' : ''}`).join(', ')
495  return `${text}
496
497[The message comes with ${said} to look at.]`
498}
499
500/** The message as the judge reads it when it was typed while a task was still running. */
501export function withMidTurn(text: string, effort: Effort): string {
502  return `${text}
503
504[Sent while the assistant was still working on the last task at ${effort} effort. If it adds to, corrects or steers that task, keep ${effort}; judge it on its own only if it is a separate new request.]`
505}
506// A lower verdict for a message typed mid-turn, applied when that turn ends (see prompt.submit).
507let heldPick: Pick | null = null
508
509/** A short follow-up that keeps the current effort: "go" between two steps, not an answer to a question. */
510export function keepsEffort(message: string, context: string): boolean {
511  return isFollowUp(message) && !endsOnQuestion(context)
512}
513
514/** The TypeSafe key in a ~/.config/jev/.env file's text, the same file the jev-* skills read. */
515export function parseJevKey(text: string): string | undefined {
516  const value = text.match(/^\s*TYPESAFE_API_KEY\s*=\s*(.*?)\s*$/m)?.[1].replace(/^['"]|['"]$/g, '')
517  return value || undefined
518}
519
520/**
521 * Jev's answer as a verdict. Below even odds on the effort it is unsure, and an unsure call keeps the
522 * current effort: that is what a "go" between two steps of work should do.
523 */
524const UNSURE = 'unsure, keeping'
525export function parseJevAnswer(text: string, current: Pick | null): { model: ModelKey; effort: Effort; why: string; sure?: number } | undefined {
526  let json: Record<string, unknown>
527  try {
528    json = JSON.parse(text)
529  } catch {
530    return undefined
531  }
532  const answers = (json.answers ?? (json.result as Record<string, unknown> | undefined)?.answers) as
533    | Record<string, { choice?: string; confidence?: number; probabilities?: Record<string, number> }>
534    | undefined
535  const effort = answers?.effort?.choice as Effort | undefined
536  if (!effort || !EFFORTS.includes(effort)) return undefined
537  const model = MODELS.find(m => m.key === answers?.model?.choice)?.key ?? current?.model ?? 'sonnet'
538  const sure = answers?.effort?.confidence ?? answers?.effort?.probabilities?.[effort]
539  if (current && typeof sure === 'number' && sure < 0.5) return { model, effort: current.effort, why: UNSURE, sure }
540  if (typeof sure !== 'number') return { model, effort, why: '' }
541  return { model, effort, why: `${Math.round(sure * 100)}% sure`, sure }
542}
543
544let askJevFile: EnvAsk | undefined
545// A key was saved from the settings panel into ~/.config/jev/.env: that file wins over an older key in the settings.
546let keyFromFile = false
547/**
548 * The TypeSafe key: from the settings, else TYPESAFE_API_KEY. Only when the person picked the jev judge outright is
549 * ~/.config/jev/.env read as well: a mod should not open a file holding a secret it was not asked to use.
550 */
551async function jevKey($: EngineInterface): Promise<string | undefined> {
552  if (config.typesafeKey && !keyFromFile) return config.typesafeKey
553  const fromEnv = await envJevKey($)
554  if (fromEnv) return fromEnv
555  if (config.judge !== 'jev' && !keyFromFile) return undefined
556  askJevFile =
557    askJevFile ??
558    (async () => {
559      const home = (await envUserProfile($)) ?? (await envHome($))
560      if (!home) return undefined
561      const text = await $.fs.read(`${home}/.config/jev/.env`).catch(() => '')
562      return parseJevKey(typeof text === 'string' ? text : '')
563    })()
564  return askJevFile
565}
566
567type Judged = { verdict?: Pick; tokens: number }
568
569/** Why a judge the person picked could not answer, in words: what a status code means for them. */
570export function judgeFailure(name: string, status: number | 'timeout'): string {
571  if (status === 'timeout') return `${name} did not answer in time`
572  if (status === 401 || status === 403) return `${name} rejected the key (${status})`
573  if (status === 402) return `${name} is out of credits (${status})`
574  if (status === 429) return `${name} is rate limited (${status})`
575  return `${name} failed (${status})`
576}
577const warned = new Set<string>()
578/** Tells the person once per session and reason that their judge failed and Haiku stands in. */
579function warnJudge($: EngineInterface, reason: string) {
580  // A test from the settings panel takes the reason for its own line, with no toast and no judge-down band.
581  if (judgeTesting) {
582    judgeTesting.reason = reason
583    return
584  }
585  void proof($, `judge fallback: ${reason}`)
586  void update($, judgeDown, () => reason).then(() => $.ui.invalidate('ui.render'))
587  if (warned.has(reason)) return
588  warned.add(reason)
589  $.ui.toast(`effortless: ${reason}. Haiku judges for now.`)
590}
591
592/** Which judge the person picked in the plugin's settings, and what it needs. */
593export type JudgeConfig = {
594  judge: 'auto' | 'haiku' | 'jev'
595  typesafeKey: string
596  /** A skill or slash command that writes the handoff instead of the built-in prompt, e.g. "session-handoff". */
597  handoffSkill: string
598  /** What follows the handoff: clear and carry on, clear and wait, or keep the chat and copy the handoff. The handoff
599   *  bar opens on the choice made last; this is the first one. */
600  handoffAfter: HandoffAfter
601  /** The effort slider, -2 (cheaper) to 2 (smarter): tips the judge's close calls that way. */
602  bias: number
603  /** Auto never goes below this effort. */
604  floor: Effort
605  /** Auto never goes above this effort. */
606  ceiling: Effort
607  /** What the person switched off: the footer's handoff button and any of the alert bands. */
608  hide: Hideable[]
609  /** The swamp band shows once the context fills this share of the window, in percent. */
610  swampAt: number
611  /** default: the dashboard band above the prompt; minimal: the footer's small buttons and no band. */
612  layout: 'default' | 'minimal'
613  /** Who writes a compaction's summary: Haiku 5.5 (the default, a fraction of the price) or the chat's own model. */
614  compactWith: 'haiku' | 'session'
615  /** advised: the footer slot is Compact until Haiku advises a handoff. always: it is always the Handoff button. */
616  handoffButton: 'advised' | 'always'
617  /** A prompt the judge calls simple runs on a cheaper model than the chat's (never a dearer one). */
618  modelAuto: 'on' | 'off'
619  /** on: every subagent the chat sends off gets an effort picked by the judge before it starts. */
620  helpers: 'on' | 'off'
621  /** The accent colour of everything effortless draws: the brand violet, Claude orange or cherry-blossom rose. */
622  theme: ThemeName
623  /** auto: light when Claude Code's own theme is light. dark and light fix it. */
624  appearance: 'auto' | 'dark' | 'light'
625}
626
627/** The swamp thresholds the settings offer, in percent of the context window. */
628export const SWAMP_STEPS = [10, 20, 30, 40, 50, 60, 70, 80] as const
629
630/** The parts of effortless a person can switch off: the footer's (setup) and the progress bar's (setup, settings).
631 * The context (swamp), running-low (hot) and cold-cache alerts can be switched off for good in Customize; the judge-down
632 * alert always shows. Each alert also has its own ✕. */
633export const HIDEABLE = ['handoff', 'timer', 'reason', 'progress', 'sounds', 'swamp', 'hot', 'cold'] as const
634export type Hideable = (typeof HIDEABLE)[number]
635// What the app passed to register, so settings kept in the store can be laid over it at session start.
636let pluginOptions: Record<string, unknown> = {}
637let config: JudgeConfig = {
638  judge: 'auto',
639  typesafeKey: '',
640  handoffSkill: '',
641  handoffAfter: 'continue',
642  bias: 0,
643  floor: 'low',
644  ceiling: 'max',
645  hide: ['reason'],
646  swampAt: 70,
647  layout: 'default',
648  compactWith: 'haiku',
649  handoffButton: 'advised',
650  modelAuto: 'off',
651  helpers: 'off',
652  theme: 'violet',
653  appearance: 'auto',
654}
655
656/** The settings as the engine hands them over (defaults filled in), cleaned to the shape the judge reads. */
657export function readConfig(options: unknown): JudgeConfig {
658  const o = (options ?? {}) as Record<string, unknown>
659  const str = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
660  const picked = str(o.judge)
661  return {
662    judge: picked === 'haiku' || picked === 'jev' ? picked : 'auto',
663    typesafeKey: str(o.typesafeKey),
664    handoffSkill: str(o.handoffSkill).replace(/^\//, ''),
665    handoffAfter: (['confirm', 'copy'] as const).find(a => a === str(o.handoffAfter)) ?? 'continue',
666    bias: Math.max(-2, Math.min(2, Math.round(Number(str(o.effortBias)) || 0))),
667    floor: EFFORTS.includes(str(o.effortFloor) as Effort) ? (str(o.effortFloor) as Effort) : 'low',
668    ceiling: EFFORTS.includes(str(o.effortCeiling) as Effort) ? (str(o.effortCeiling) as Effort) : 'max',
669    swampAt: SWAMP_STEPS.includes(Number(str(o.swampAt)) as (typeof SWAMP_STEPS)[number]) ? Number(str(o.swampAt)) : 70,
670    layout: str(o.layout) === 'minimal' ? 'minimal' : 'default',
671    compactWith: str(o.compactWith) === 'session' ? 'session' : 'haiku',
672    handoffButton: str(o.handoffButton) === 'always' ? 'always' : 'advised',
673    // Off unless switched on: one prompt on another model rewrites the whole chat into that model's cache, which costs
674    // more than it saves in a chat that is in use (measured 2026-10-10).
675    modelAuto: str(o.modelAuto) === 'on' ? 'on' : 'off',
676    helpers: str(o.helpers) === 'on' ? 'on' : 'off',
677    theme: THEMES.find(t => t === str(o.theme)) ?? 'violet',
678    appearance: (['dark', 'light'] as const).find(a => a === str(o.appearance)) ?? 'auto',
679    // The judge's line is off until switched on; an empty string saved from the panel means everything shows.
680    hide: (o.hide === undefined ? 'reason' : str(o.hide))
681      .split(',')
682      .map(part => part.trim())
683      .filter((part): part is Hideable => (HIDEABLE as readonly string[]).includes(part)),
684  }
685}
686
687/** The text the judge reads: the current pair, the last turns and the next message. */
688function judgeQuestion(prompt: string, current: Pick | null, context: string): string {
689  return [
690    current ? `Current: ${current.model} / ${current.effort}` : 'Current: none',
691    context ? `Recent conversation:\n${context}` : '',
692    `Next message:\n${prompt.slice(0, 2000)}`,
693  ]
694    .filter(Boolean)
695    .join('\n\n')
696}
697
698/**
699 * Haiku always judges; Jev is the optional quick one. Jev is asked first when a TypeSafe key is found (unless the
700 * setting is haiku), and Haiku makes the call whenever Jev is unsure, down or slow. Any judge that fails or takes longer than JEV_TIMEOUT_MS falls back to
701 * Haiku, which needs nothing but the session's own login. Never throws.
702 */
703async function judge($: EngineInterface, prompt: string, current: Pick | null): Promise<Judged> {
704  const context = await recentContext($).catch(() => '')
705  const key = config.judge !== 'haiku' ? await jevKey($).catch(() => undefined) : undefined
706  if (key) {
707    const jev = await askJev($, key, prompt, current, context)
708    if (jev) return jev.verdict?.why === UNSURE ? haikuAfter($, jev, prompt, current, context) : jev
709  }
710  return askHaiku($, prompt, current, context)
711}
712
713/** Jev was unsure: Haiku makes the call, and both judges' tokens count. */
714async function haikuAfter($: EngineInterface, jev: Judged, prompt: string, current: Pick | null, context: string): Promise<Judged> {
715  const haiku = await askHaiku($, prompt, current, context)
716  return haiku.verdict ? { verdict: haiku.verdict, tokens: haiku.tokens + jev.tokens } : jev
717}
718
719/** Jev on TypeSafe: an answer, or nothing when it fails, is unsure of its own format or takes too long. */
720async function askJev($: EngineInterface, key: string, prompt: string, current: Pick | null, context: string): Promise<Judged | undefined> {
721  try {
722    // A stalled endpoint must not hold the prompt: after JEV_TIMEOUT_MS the judge falls through to Haiku.
723    const res = await Promise.race([
724      $.http.fetch((await envJevUrl($)) ?? JEV_URL, {
725        method: 'POST',
726        headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
727        body: JSON.stringify({
728          model: 'jev-latest',
729          state: {
730            task: JEV_TASK,
731            current_model: current?.model ?? null,
732            current_effort: current?.effort ?? null,
733            recent_conversation: context,
734            next_message: prompt.slice(0, 4000),
735          },
736          questions: {
737            effort: { type: 'choice', instructions: 'Which effort fits the next message?', criteria: JEV_EFFORTS },
738            model: { type: 'choice', instructions: 'Which model fits the next message?', criteria: JEV_MODELS },
739          },
740        }),
741      }),
742      $.clock.sleep(JEV_TIMEOUT_MS).then(() => {
743        throw new Error('jev timeout')
744      }),
745    ])
746    if (!res.ok) warnJudge($, judgeFailure('Jev', res.status))
747    const verdict = res.ok ? parseJevAnswer(res.text, current) : undefined
748    if (verdict) {
749      let used = 0
750      try {
751        const usage = (JSON.parse(res.text) as { usage?: { input_tokens?: number; output_tokens?: number } }).usage
752        used = (usage?.input_tokens ?? 0) + (usage?.output_tokens ?? 0)
753      } catch {
754        // No usage in the reply: counted as 0.
755      }
756      if (await read($, judgeDown)) await update($, judgeDown, () => null)
757      return { verdict: { ...verdict, by: 'jev' }, tokens: used }
758    }
759  } catch (error) {
760    // Jev down or slow: fall through to Haiku, and say so.
761    warnJudge($, String(error).includes('timeout') ? judgeFailure('Jev', 'timeout') : 'Jev could not be reached')
762  }
763  return undefined
764}
765
766// Set while the settings panel tests a judge: warnJudge leaves its reason here instead of warning.
767let judgeTesting: { reason?: string } | null = null
768/** The prompt a judge test sends: small and plain, so any working judge answers it. */
769const JUDGE_TEST_PROMPT = 'rename one variable in a single file'
770
771/** Asks the judge picked in the panel (its unsaved key, URL and model included) one sample prompt, and says in a line
772 * whether it answered, with what and how fast, or why not. Never falls back to Haiku: the point is the judge itself. */
773async function testJudge($: EngineInterface, judgeKind: string, draftKey: string): Promise<{ ok: boolean; text: string }> {
774  const start = await $.clock.now()
775  const took = async () => `${(((await $.clock.now()) - start) / 1000).toFixed(1)} s`
776  const said = (name: string, j: Judged | undefined) =>
777    j?.verdict?.why === UNSURE ? `${name} answered, unsure on the sample` : `${name} answered: ${j?.verdict?.effort}`
778  judgeTesting = {}
779  try {
780    if (judgeKind === 'jev' || judgeKind === 'auto') {
781      const key = parseJevKey(draftKey) ?? (draftKey.trim() || undefined) ?? (await jevKey($).catch(() => undefined)) ?? (await typesafeKeyAnywhere($).catch(() => undefined))
782      if (!key && judgeKind === 'jev') return { ok: false, text: 'No TypeSafe key found' }
783      if (key) {
784        const j = await askJev($, key, JUDGE_TEST_PROMPT, null, '')
785        if (j?.verdict) return { ok: true, text: `${said('Jev', j)} in ${await took()}` }
786        return { ok: false, text: judgeTesting.reason ?? 'Jev gave no usable answer' }
787      }
788    }
789    const h = await askHaiku($, JUDGE_TEST_PROMPT, null, '')
790    return h.verdict ? { ok: true, text: `${said('Haiku', h)} in ${await took()}` } : { ok: false, text: 'Haiku gave no answer' }
791  } catch (error) {
792    return { ok: false, text: String(error).slice(0, 80) }
793  } finally {
794    judgeTesting = null
795  }
796}
797
798/** Haiku through the session's own login: the judge that needs no key, and the fallback for the others. */
799async function askHaiku($: EngineInterface, prompt: string, current: Pick | null, context: string): Promise<Judged> {
800  const used = lastContext && lastContext.window ? `Context used: ${Math.round(lastContext.percent)}% of the window` : ''
801  const asked = [judgeQuestion(prompt, current, context), used].filter(Boolean).join('\n\n')
802  const ask = (model: string) => $.model.complete({ model, system: JUDGE_SYSTEM, prompt: asked, maxTokens: 160, effort: 'low', timeoutMs: 6000 })
803  // Haiku 5.5 by name: Claude Code builds that predate it map the plain alias to Haiku 4.5, at ten times the price.
804  // Where 5.5 does not answer, the alias stands in.
805  let r = await ask(COMPACT_MODEL)
806  if (!r.isAnswered) r = await ask('haiku')
807  const verdict = r.isAnswered ? parseVerdict(r.text) : undefined
808  const tokens = r.isAnswered && r.usage ? r.usage.input_tokens + r.usage.output_tokens : 0
809  return { verdict: verdict && { ...verdict, by: 'haiku' }, tokens }
810}
811
812// Whether a fresh chat would now serve better than carrying on: Haiku's call, whatever judge picks the effort. It runs
813// from HANDOFF_CHECK_FROM percent of context, every HANDOFF_CHECK_EVERY messages, so it costs a cent now and then. It
814// reads more than the effort judge: what the chat was for, the trail of topics, the last turns, the context and the cache.
815const HANDOFF_CHECK_FROM = 30
816const HANDOFF_CHECK_EVERY = 2
817const HANDOFF_SYSTEM = `You decide whether the person should hand off now: start a fresh chat that carries over a short summary, instead of carrying on in this one. You are shown what the chat was for, the trail of what the person asked, the last turns, the next message, how full the context is and whether the cache is cold.
818
819Say yes only for a clear reason: the task just finished and the next message is something else, the topic has changed so the old context is dead weight, the chat keeps going in circles on the same problem, or a long chat now holds so much unrelated history that a clean start would be sharper. A normal next step of the same work is never a reason, a long chat by itself is not one, and neither is a high context share when the work is going well. When unsure, say no: a wrong yes costs the person more than a missed one.
820
821Reply with JSON only: {"handoff":"reason of at most 8 words, in the person's language"} for yes, or {"handoff":null} for no.`
822
823/** What the handoff check reads: the chat's purpose, a trail of the person's messages, the last reply's end. */
824export function handoffEvidence(messages: readonly { role: string; text: string }[], next: string, percent: number, cold: boolean): string {
825  const asked = messages.filter(m => m.role === 'user' && m.text.trim())
826  const first = asked[0]?.text.slice(0, 500) ?? ''
827  const trail = asked.slice(-8, -1).map(m => `- ${m.text.slice(0, 160).replace(/\s+/g, ' ')}`)
828  const reply = lastAssistantText(messages).slice(-700)
829  return [
830    first ? `What the chat was for (first message):\n${first}` : '',
831    trail.length ? `Trail of earlier messages:\n${trail.join('\n')}` : '',
832    reply ? `The assistant's last reply (its end):\n${reply}` : '',
833    `Next message:\n${next.slice(0, 600)}`,
834    `Context used: ${Math.round(percent)}% of the window; ${asked.length} messages so far; the cache is ${cold ? 'cold' : 'warm'}.`,
835  ]
836    .filter(Boolean)
837    .join('\n\n')
838}
839
840/** Reads Haiku's answer: a reason, or null for no (anything unreadable is no). */
841export function parseHandoffAnswer(text: string): string | null {
842  const match = text.match(/\{[\s\S]*\}/)
843  if (!match) return null
844  try {
845    const { handoff } = JSON.parse(match[0]) as { handoff?: unknown }
846    return typeof handoff === 'string' && handoff.trim() ? handoff.trim().slice(0, 70) : null
847  } catch {
848    return null
849  }
850}
851
852let promptsSinceHandoffCheck = 0
853/** Called with each message the person sends. Sets or clears the advice; never throws, never holds the message. */
854async function checkHandoff($: EngineInterface, next: string) {
855  const percent = lastContext?.percent ?? 0
856  if (percent < HANDOFF_CHECK_FROM) {
857    promptsSinceHandoffCheck = 0
858    if ((await read($, handoffAdvice)) !== null) {
859      await update($, handoffAdvice, () => null)
860      $.ui.invalidate('ui.render')
861    }
862    return
863  }
864  promptsSinceHandoffCheck++
865  if (promptsSinceHandoffCheck < HANDOFF_CHECK_EVERY) return
866  promptsSinceHandoffCheck = 0
867  try {
868    const evidence = handoffEvidence(await $.session.messages(), next, percent, (await cacheMinutes($)) === 0 && cacheExpires > 0)
869    const ask = (model: string) => $.model.complete({ model, system: HANDOFF_SYSTEM, prompt: evidence, maxTokens: 60, effort: 'low', timeoutMs: 8000 })
870    let r = await ask(COMPACT_MODEL)
871    if (!r.isAnswered) r = await ask('haiku')
872    if (!r.isAnswered) return
873    const reason = parseHandoffAnswer(r.text)
874    void proof($, `handoff check at ${Math.round(percent)}%: ${reason ?? 'no'}`)
875    if (reason !== (await read($, handoffAdvice))) {
876      await update($, handoffAdvice, () => reason)
877      $.ui.invalidate('ui.render')
878    }
879  } catch {
880    // A failed check changes nothing.
881  }
882}
883
884// The judge benchmark (/effortless bench): labelled prompts in bench/judge-cases.json, each run through the same
885// pipeline a real prompt takes (a short follow-up keeps the current effort, anything else goes to a judge).
886export type BenchCase = { id: string; kind: string; holdout?: boolean; current: Pick; context?: string; message: string; ok: Effort[] }
887export type BenchAnswer = { id: string; judge: string; effort?: Effort; by?: string; why?: string; sure?: number; model?: ModelKey; ms: number; tokens: number }
888
889/** Where an answer lands against the labels: right, too low (risks quality), too high (wastes), or no answer. */
890export function benchGrade(c: BenchCase, effort: Effort | undefined): 'hit' | 'under' | 'over' | 'none' {
891  if (!effort) return 'none'
892  if (c.ok.includes(effort)) return 'hit'
893  const rank = EFFORTS.indexOf(effort)
894  return rank < Math.min(...c.ok.map(e => EFFORTS.indexOf(e))) ? 'under' : 'over'
895}
896
897/** The benchmark's table: per judge the share it got right, how it missed, and how fast it was; then per kind. */
898export function benchReport(cases: BenchCase[], answers: BenchAnswer[]): string {
899  const byId = new Map(cases.map(c => [c.id, c]))
900  const judges = [...new Set(answers.map(a => a.judge))]
901  const kinds = [...new Set(cases.map(c => c.kind))]
902  const pct = (n: number, of: number) => (of ? `${Math.round((n / of) * 100)}%` : '-')
903  const median = (xs: number[]) => {
904    const s = [...xs].sort((a, b) => a - b)
905    return s.length ? s[Math.floor(s.length / 2)] : 0
906  }
907  const lines = ['| Judge | Right | Too low | Too high | No answer | Median ms |', '| --- | --- | --- | --- | --- | --- |']
908  for (const j of judges) {
909    const mine = answers.filter(a => a.judge === j)
910    const g = mine.map(a => benchGrade(byId.get(a.id)!, a.effort))
911    const n = (k: string) => g.filter(x => x === k).length
912    const asked = mine.filter(a => a.ms > 0).map(a => a.ms)
913    lines.push(`| ${j} | ${pct(n('hit'), g.length)} | ${n('under')} | ${n('over')} | ${n('none')} | ${asked.length ? median(asked) : '-'} |`)
914  }
915  // The held-out cases were never tuned against: the honest score. The rest shaped the judge prompts.
916  const sets = [
917    ['tuned on', cases.filter(c => !c.holdout)],
918    ['held out', cases.filter(c => c.holdout)],
919  ] as const
920  lines.push('', `| Set (cases) | ${judges.join(' | ')} |`, `| --- | ${judges.map(() => '---').join(' | ')} |`)
921  for (const [name, set] of sets) {
922    if (!set.length) continue
923    const ids = set.map(c => c.id)
924    const cells = judges.map(j => {
925      const mine = answers.filter(a => a.judge === j && ids.includes(a.id))
926      return pct(mine.filter(a => benchGrade(byId.get(a.id)!, a.effort) === 'hit').length, mine.length)
927    })
928    lines.push(`| ${name} (${set.length}) | ${cells.join(' | ')} |`)
929  }
930  lines.push('', `| Kind (cases) | ${judges.join(' | ')} |`, `| --- | ${judges.map(() => '---').join(' | ')} |`)
931  for (const k of kinds) {
932    const ids = cases.filter(c => c.kind === k).map(c => c.id)
933    const cells = judges.map(j => {
934      const mine = answers.filter(a => a.judge === j && ids.includes(a.id))
935      return pct(mine.filter(a => benchGrade(byId.get(a.id)!, a.effort) === 'hit').length, mine.length)
936    })
937    lines.push(`| ${k} (${ids.length}) | ${cells.join(' | ')} |`)
938  }
939  // Does "unsure" predict a miss? The Smarter lean and the cheaper-model guard both act on it, so it is measured here.
940  const judged = judges.filter(j => !j.startsWith('always'))
941  const sureLines = ['', '| Judge | Sure when right | Sure when wrong | Misses under 65% sure | Misses under 85% sure | Answers under 65% | Answers under 85% |', '| --- | --- | --- | --- | --- | --- | --- |']
942  for (const j of judged) {
943    const mine = answers.filter(a => a.judge === j && a.effort && typeof a.sure === 'number')
944    if (!mine.length) continue
945    const wrong = mine.filter(a => benchGrade(byId.get(a.id)!, a.effort) !== 'hit')
946    const right = mine.filter(a => benchGrade(byId.get(a.id)!, a.effort) === 'hit')
947    const avg = (xs: BenchAnswer[]) => (xs.length ? `${Math.round((xs.reduce((t, a) => t + a.sure!, 0) / xs.length) * 100)}%` : '-')
948    const under = (xs: BenchAnswer[], t: number) => xs.filter(a => a.sure! < t).length
949    sureLines.push(`| ${j} (${mine.length} with a score) | ${avg(right)} | ${avg(wrong)} | ${under(wrong, 0.65)} of ${wrong.length} | ${under(wrong, 0.85)} of ${wrong.length} | ${under(mine, 0.65)} | ${under(mine, 0.85)} |`)
950  }
951  const pickLines = ['', '| Judge | Haiku | Sonnet | Opus | Other |', '| --- | --- | --- | --- | --- |']
952  for (const j of judged) {
953    const mine = answers.filter(a => a.judge === j && a.model)
954    if (!mine.length) continue
955    const n = (k: string) => mine.filter(a => a.model === k).length
956    pickLines.push(`| ${j} | ${n('haiku')} | ${n('sonnet')} | ${n('opus')} | ${mine.length - n('haiku') - n('sonnet') - n('opus')} |`)
957  }
958  lines.push(...(sureLines.length > 3 ? sureLines : []), ...(pickLines.length > 3 ? pickLines : []))
959  const misses = answers
960    .filter(a => !a.judge.startsWith('always') && benchGrade(byId.get(a.id)!, a.effort) !== 'hit')
961    .map(a => `- ${a.judge}${a.by && a.by !== a.judge ? ` (via ${a.by})` : ''} ${a.id}: said ${a.effort ?? 'nothing'}${a.why ? ` (${a.why})` : ''}, wanted ${byId.get(a.id)!.ok.join('/')}: "${byId.get(a.id)!.message.slice(0, 60)}"`)
962  return [...lines, '', 'Misses:', ...(misses.length ? misses : ['- none'])].join('\n')
963}
964
965/** Runs every case through each available judge, a few at a time, and returns the answers. */
966async function runBench($: EngineInterface, cases: BenchCase[]): Promise<BenchAnswer[]> {
967  const key = await typesafeKeyAnywhere($).catch(() => undefined)
968  const judges: [string, (c: BenchCase) => Promise<Judged | undefined>][] = [
969    ['haiku', c => askHaiku($, c.message, c.current, c.context ?? '')],
970  ]
971  if (key)
972    judges.push([
973      'jev',
974      async c => {
975        const jev = await askJev($, key, c.message, c.current, c.context ?? '')
976        if (!jev) return askHaiku($, c.message, c.current, c.context ?? '')
977        return jev.verdict?.why === UNSURE ? haikuAfter($, jev, c.message, c.current, c.context ?? '') : jev
978      },
979    ])
980  const answers: BenchAnswer[] = []
981  // Baselines: what a fixed effort would score on the same labels.
982  for (const fixed of ['medium', 'high'] as Effort[])
983    for (const c of cases) answers.push({ id: c.id, judge: `always ${fixed}`, effort: fixed, ms: 0, tokens: 0 })
984  const jobs = judges.flatMap(([name, ask]) => cases.map(c => ({ name, ask, c })))
985  let next = 0
986  const worker = async () => {
987    while (next < jobs.length) {
988      const { name, ask, c } = jobs[next++]
989      // The mod never asks a judge about a short follow-up: it keeps the current effort.
990      if (keepsEffort(c.message, c.context ?? '')) {
991        answers.push({ id: c.id, judge: name, effort: c.current.effort, ms: 0, tokens: 0 })
992        continue
993      }
994      const started = await $.clock.now()
995      const got = await ask(c).catch(() => undefined)
996      answers.push({ id: c.id, judge: name, effort: got?.verdict?.effort, by: got?.verdict?.by, why: got?.verdict?.why, sure: got?.verdict?.sure, model: got?.verdict?.model, ms: (await $.clock.now()) - started, tokens: got?.tokens ?? 0 })
997    }
998  }
999  await Promise.all([worker(), worker(), worker(), worker()])
1000  return answers
1001}
1002
1003function keyOf(id: string): ModelKey | undefined {
1004  return MODELS.find(m => id.includes(m.key))?.key
1005}
1006
1007/** Records the model the session now runs; the pick follows it, so the band never shows a stale one. */
1008async function modelIs($: EngineInterface, id: string) {
1009  const pause = !cacheSafe(id) && keyOf(id) !== 'haiku'
1010  if (pause !== (await read($, paused))) await update($, paused, () => pause)
1011  const key = keyOf(id)
1012  if (!key || key === (await read($, model))) return
1013  await update($, model, () => key)
1014  // The engine's effort can differ per model, so a change across a switch is not the person's doing;
1015  // and a model turned down earlier may be suggested again.
1016  engineEffort = undefined
1017  declined = null
1018  const current = await read($, pick)
1019  if (current && current.model !== key) await choose($, { ...current, model: key })
1020  if ((await read($, suggestion)) === key) await update($, suggestion, () => null)
1021}
1022
1023async function sessionModel($: EngineInterface): Promise<ModelKey> {
1024  const id = await $.session.model()
1025  return MODELS.find(m => id.includes(m.key))?.key ?? 'sonnet'
1026}
1027
1028
1029// Proof log: every verdict, /effort and request effort, written to EFFORTLESS_LOG, or to
1030// %TEMP%/effortless-proof.log while the mod is loaded from a dev-mods folder. Off otherwise.
1031const proofLines: string[] = []
1032// An environment variable does not change while the session runs, so each one is asked for once. The
1033// engine wants the name spelled out in every $.env.get call, hence one small getter per variable.
1034type EnvAsk = Promise<string | undefined> | undefined
1035let askLog: EnvAsk
1036let askTemp: EnvAsk
1037let askTmpdir: EnvAsk
1038let askModelUi: EnvAsk
1039let askJevUrl: EnvAsk
1040let askJevKey: EnvAsk
1041let askUserProfile: EnvAsk
1042let askHome: EnvAsk
1043const envLog = ($: EngineInterface) => (askLog = askLog ?? $.env.get('EFFORTLESS_LOG'))
1044const envTemp = ($: EngineInterface) => (askTemp = askTemp ?? $.env.get('TEMP'))
1045const envTmpdir = ($: EngineInterface) => (askTmpdir = askTmpdir ?? $.env.get('TMPDIR'))
1046const envModelUi = ($: EngineInterface) => (askModelUi = askModelUi ?? $.env.get('EFFORTLESS_MODEL_UI'))
1047const envJevUrl = ($: EngineInterface) => (askJevUrl = askJevUrl ?? $.env.get('JEV_URL'))
1048const envJevKey = ($: EngineInterface) => (askJevKey = askJevKey ?? $.env.get('TYPESAFE_API_KEY'))
1049const envUserProfile = ($: EngineInterface) => (askUserProfile = askUserProfile ?? $.env.get('USERPROFILE'))
1050const envHome = ($: EngineInterface) => (askHome = askHome ?? $.env.get('HOME'))
1051
1052async function proofPath($: EngineInterface): Promise<string | undefined> {
1053  const named = await envLog($)
1054  if (named) return named
1055  if (!$.plugin.root.replace(/\\/g, '/').includes('/dev-mods/')) return undefined
1056  const tmp = (await envTemp($)) ?? (await envTmpdir($)) ?? '/tmp'
1057  return `${tmp}/effortless-proof.log`
1058}
1059async function proof($: EngineInterface, line: string) {
1060  const path = await proofPath($).catch(() => undefined)
1061  if (!path) return
1062  proofLines.push(`${new Date().toISOString()} ${line}`)
1063  await $.fs.write(path, proofLines.slice(-200).join('\n') + '\n').catch(() => undefined)
1064}
1065
1066// The effort the engine itself last asked for on the main loop (the app's setting; this mod only
1067// rewrites the request, never the setting). It changing means the person set it (the app's Effort
1068// control, their own /effort): that wins and Auto goes off.
1069let engineEffort: string | undefined
1070// The pick was carried over from the last chat (the store), not made in this one: the app's effort may overrule it.
1071let pickCarried = false
1072// The last main-chat request, for /effortless debug: what the app asked for and what the mod sent instead, if anything.
1073let lastRequest: { index: number; appModel: string; appEffort: unknown; model: string; effort: unknown; at: number } | null = null
1074// The slash command this mod last typed into the prompt box.
1075let lastTyped = ''
1076// The toast about pressing Enter is shown once; on every click it is only noise.
1077let toldAboutEnter = false
1078let lastFooter = ''
1079// A model the person turned down; not suggested again until they pick something else.
1080let declined: ModelKey | null = null
1081
1082/**
1083 * Types a slash command into the prompt box for the person to send with Enter. A command the person
1084 * sends is read by the app itself, so its own Model and Effort controls follow; one the mod runs
1085 * inside the engine is not. Never overwrites a draft: returns false when the box is not empty.
1086 */
1087async function typeCommand($: EngineInterface, text: string): Promise<boolean> {
1088  try {
1089    const draft = (await $.prompt.read()).text.trim()
1090    // A draft of your own is never overwritten; the mod's own earlier command is replaced.
1091    if (draft !== '' && draft !== lastTyped) return false
1092    const filled = await $.prompt.fill({ text })
1093    if (filled.isFilled) lastTyped = text
1094    if (filled.isFilled && !toldAboutEnter) {
1095      toldAboutEnter = true
1096      $.ui.toast(`Press Enter to send ${text} so the app's own control follows`)
1097    }
1098    return filled.isFilled
1099  } catch {
1100    return false
1101  }
1102}
1103
1104/** A model change reloads the context, so it only happens when the person says yes. */
1105async function switchModel($: EngineInterface, model: ModelKey) {
1106  try {
1107    await $.command.run({ command: 'model', args: model })
1108    declined = null
1109    await update($, suggestion, () => null)
1110    const current = await read($, pick)
1111    if (current) await choose($, { ...current, model })
1112  } catch {
1113    $.ui.toast('Could not switch the model right now')
1114  }
1115}
1116
1117/**
1118 * The tally in its current shape. A hot reload keeps the state an older version wrote ({ requests, actual,
1119 * baseline } before 0.2.0), so every read goes through this.
1120 */
1121export function asSpent(t: unknown): Spent {
1122  const v = (t ?? {}) as Partial<Spent>
1123  const num = (x: unknown) => (typeof x === 'number' && Number.isFinite(x) ? x : 0)
1124  const judged = (v.judge ?? {}) as Partial<Spent['judge']>
1125  return {
1126    prompts: num(v.prompts),
1127    requests: num(v.requests),
1128    input: num(v.input),
1129    write: num(v.write),
1130    read: num(v.read),
1131    out: num(v.out),
1132    byEffort: v.byEffort && typeof v.byEffort === 'object' ? v.byEffort : {},
1133    judge: { jev: num(judged.jev), haiku: num(judged.haiku), ms: num(judged.ms), tokens: num(judged.tokens) },
1134    moved: num(v.moved),
1135    redone: num(v.redone),
1136    helpers: {
1137      runs: v.helpers?.runs && typeof v.helpers.runs === 'object' ? v.helpers.runs : {},
1138      requests: num(v.helpers?.requests),
1139      cost: num(v.helpers?.cost),
1140    },
1141  }
1142}
1143
1144/** A request's tokens weighted by price, in input-token terms (see WEIGHT). */
1145export function weighted(usage: { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number | null; cache_creation_input_tokens?: number | null }): number {
1146  return (
1147    (usage.input_tokens ?? 0) * WEIGHT.input +
1148    (usage.cache_creation_input_tokens ?? 0) * WEIGHT.write +
1149    (usage.cache_read_input_tokens ?? 0) * WEIGHT.read +
1150    (usage.output_tokens ?? 0) * WEIGHT.out
1151  )
1152}
1153
1154/** Adds one request Auto steered: every kind of token it used, under the effort it ran at. */
1155async function tally(
1156  $: EngineInterface,
1157  effort: Effort,
1158  usage: { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number | null; cache_creation_input_tokens?: number | null },
1159) {
1160  const cached = usage.cache_read_input_tokens ?? 0
1161  const write = usage.cache_creation_input_tokens ?? 0
1162  const cost = usage.input_tokens * WEIGHT.input + write * WEIGHT.write + cached * WEIGHT.read + usage.output_tokens * WEIGHT.out
1163  await update($, saved, old => {
1164    const t = asSpent(old)
1165    const bucket = t.byEffort[effort] ?? { prompts: 0, cost: 0 }
1166    return {
1167      ...t,
1168      requests: t.requests + 1,
1169      input: t.input + usage.input_tokens,
1170      write: t.write + write,
1171      read: t.read + cached,
1172      out: t.out + usage.output_tokens,
1173      byEffort: { ...t.byEffort, [effort]: { ...bucket, cost: bucket.cost + cost } },
1174    }
1175  })
1176}
1177
1178/** Counts one prompt Auto judged: under its effort, and what the judge took. */
1179async function countPrompt($: EngineInterface, effort: Effort | undefined, by: Pick['by'] | undefined, ms: number, judgeTokens: number) {
1180  await update($, saved, old => {
1181    const t = asSpent(old)
1182    const judged = {
1183      jev: t.judge.jev + (by === 'jev' ? 1 : 0),
1184      haiku: t.judge.haiku + (by === 'haiku' ? 1 : 0),
1185      ms: t.judge.ms + ms,
1186      tokens: t.judge.tokens + judgeTokens,
1187    }
1188    if (!effort) return { ...t, judge: judged }
1189    const bucket = t.byEffort[effort] ?? { prompts: 0, cost: 0 }
1190    return { ...t, judge: judged, prompts: t.prompts + 1, byEffort: { ...t.byEffort, [effort]: { ...bucket, prompts: bucket.prompts + 1 } } }
1191  })
1192}
1193
1194/** The cache lifetime a response's writes got, when it says; nothing when it wrote nothing or does not say. */
1195export function cacheTtlOf(usage: unknown): keyof typeof CACHE_TTL | undefined {
1196  const c = (usage as { cache_creation?: { ephemeral_1h_input_tokens?: number; ephemeral_5m_input_tokens?: number } } | null)
1197    ?.cache_creation
1198  if ((c?.ephemeral_1h_input_tokens ?? 0) > 0) return '1h'
1199  if ((c?.ephemeral_5m_input_tokens ?? 0) > 0) return '5m'
1200  return undefined
hooks/agents.tsx 621 lines
1import type { AgentRec, AgentState, FileTouch, Progress, ProgressStep } from '../types'
2import { MARK_SVG } from './brand-mark'
3import { accent, tintHex } from './theme'
4import { ART_H, ART_W, looks, PILL_H, progressArtSvg, progressShare, progressTrackSvg, THINK_W, thinkingSvg } from './progress'
5
6// The agent panel (docs/agent-panel/): a pane on the right with this chat and the subagents it sends off. Its own file,
7// like progress.tsx: the rules and the drawing live here, and what needs $ (the hooks, the state) is a short section in
8// register.tsx, since the engine follows $ only into functions of the file that holds the hooks.
9//
10// Built from the bar's own parts, not new ones: the dashboard's card (mark, bold word, ring), the progress band's looks
11// and art per phase for each agent (planning, working, asking, done), its live track for an agent's steps, the done
12// card's green, the settings bar's big tilted mark, cut off by the corner.
13
14/** The pane's id, for $.ui.open and its ui.render hook. */
15export const AGENTS_PANE = 'effortless-agents'
16
17/** The progress band's phase each agent state is drawn in: its colours and its art. */
18const PHASE: Record<Exclude<AgentState, 'failed'>, Progress['phase']> = {
19  picking: 'planning',
20  running: 'working',
21  waiting: 'asking',
22  done: 'done',
23}
24/** The one state the progress band has no look for: a red card, as the alert bands' red. */
25const FAILED = { color: '#e5534b', bg: '#1a0f0f', edge: '#6a2a26' }
26const lookOf = (s: AgentState) => (s === 'failed' ? FAILED : looks(PHASE[s]))
27
28/** How the whole chat stands, worst news first: someone waits on a tool, someone works, all done, or nothing ran. */
29export type Mood = 'waiting' | 'working' | 'done' | 'quiet'
30export function moodOf(agents: readonly AgentRec[]): Mood {
31  if (agents.some(a => a.state === 'waiting')) return 'waiting'
32  if (agents.some(a => a.state === 'running' || a.state === 'picking')) return 'working'
33  return agents.length ? 'done' : 'quiet'
34}
35
36/** The header's word for the mood, as the bar names its effort: one bold word. */
37export function moodWord(agents: readonly AgentRec[]): string {
38  const live = agents.filter(a => a.state === 'running' || a.state === 'picking').length
39  const waiting = agents.filter(a => a.state === 'waiting').length
40  if (waiting) return `${waiting} waiting`
41  if (live) return `${live} working`
42  return agents.length ? 'All done' : 'Quiet'
43}
44
45/** The share of the chat's agent work done, 0-100: finished agents count whole, the rest by their own steps. */
46export function overallPercent(agents: readonly AgentRec[]): number {
47  if (!agents.length) return 0
48  const share = agents.map(a => (a.state === 'done' || a.state === 'failed' ? 1 : a.steps?.length ? progressShare(a.steps) : 0))
49  return Math.round((share.reduce((s, v) => s + v, 0) / agents.length) * 100)
50}
51
52/** Minutes and seconds, as the cache clock shows them. */
53export function clockText(ms: number): string {
54  const s = Math.max(0, Math.floor(ms / 1000))
55  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
56}
57
58/** How long one tool call runs before its agent counts as waiting on it (a test run, a build, a long fetch). */
59export const WAIT_MS = 8000
60
61/** The agents that have sat in one tool call past WAIT_MS turned to waiting; null when none changed. */
62export function withWaits(agents: readonly AgentRec[], nowMs: number): AgentRec[] | null {
63  let changed = false
64  const next = agents.map(a => {
65    if (a.state !== 'running' || a.toolSince === undefined || nowMs - a.toolSince < WAIT_MS) return a
66    changed = true
67    return { ...a, state: 'waiting' as const, waitingSince: a.toolSince }
68  })
69  return changed ? next : null
70}
71
72/** What an agent does now, in a few words: the tool and the one argument that says what it is about. */
73export function toolLine(tool: string, input: Record<string, unknown>): string {
74  const str = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
75  const base = (path: string) => path.split(/[\\/]/).filter(Boolean).pop() ?? path
76  const arg =
77    tool === 'Bash' ? str('command')
78    : tool === 'Read' || tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit' ? base(str('file_path') || str('notebook_path'))
79    : tool === 'Grep' || tool === 'Glob' ? str('pattern')
80    : tool === 'WebFetch' ? str('url').replace(/^https?:\/\//, '')
81    : tool === 'WebSearch' ? str('query')
82    : tool === 'Agent' ? str('description')
83    : ''
84  const name = tool.startsWith('mcp__') ? tool.split('__').pop() ?? tool : tool
85  const one = arg.split('\n')[0].trim()
86  return one ? `${name} ${one.length > 60 ? `${one.slice(0, 59)}…` : one}` : name
87}
88
89/** The agents worth a card of their own: everything not finished. Finished ones fold into one green card. */
90export function splitAgents(agents: readonly AgentRec[]): { live: AgentRec[]; done: AgentRec[] } {
91  return { live: agents.filter(a => a.state !== 'done'), done: agents.filter(a => a.state === 'done') }
92}
93
94/** The mark, big, tilted and faint, as the settings bar shows it: cut off by the pane's bottom-right corner. */
95const CORNER_MARK = MARK_SVG.replace('<g mask=', '<g opacity=".16" transform="rotate(9 50 50)" mask=')
96
97/**
98 * The pane's background, the bar's art made tall: the brand wash rising from the bottom-right corner in the mood's
99 * colour, the bar's slanted light lines and its sparkles, under the band's grain. One source per mood, so a redraw in
100 * the same mood is the same image.
101 */
102export function paneWashSvg(mood: Mood, w = 400, h = 900): string {
103  const c = mood === 'waiting' ? looks('asking').color : mood === 'done' ? looks('done').color : '#8f7ff0'
104  const light = mood === 'waiting' ? '#f2c97a' : mood === 'done' ? '#b4f0c8' : '#b3a6ff'
105  const strength = mood === 'quiet' ? 0.45 : mood === 'waiting' ? 0.6 : 1
106  const f = (v: number) => v.toFixed(1)
107  // The bar's slanted lines, as BRAND_SVG draws them, stretched to the pane's height.
108  const lines = [0, 1, 2, 3, 4, 5]
109    .map(i => {
110      const x = w * 0.18 + i * (w * 0.15)
111      return `<line x1="${f(x)}" y1="${h + 10}" x2="${f(x + h * 0.36)}" y2="-10" stroke="#fff" stroke-opacity="${i % 2 ? 0.05 : 0.04}" stroke-width="${[3, 1.2, 4, 1.5, 3, 1.2][i]}"/>`
112    })
113    .join('')
114  // The bar's sparkles: they pop, turn and go, each on its own beat; more of them while agents work.
115  const count = mood === 'quiet' ? 8 : mood === 'done' ? 12 : 18
116  const sparks = Array.from({ length: count }, (_, i) => {
117    const a = Math.sin((i + 1) * 12.9898) * 43758.5453
118    const b = Math.sin((i + 1) * 78.233) * 24634.6345
119    const x = w * 0.15 + (a - Math.floor(a)) * w * 0.85
120    const y = h * 0.3 + (b - Math.floor(b)) * h * 0.7
121    const s = 1.6 + ((a * 7) % 1) * 1.8
122    const d = `M${f(x)} ${f(y - s)}L${f(x + s * 0.21)} ${f(y - s * 0.21)}L${f(x + s)} ${f(y)}L${f(x + s * 0.21)} ${f(y + s * 0.21)}L${f(x)} ${f(y + s)}L${f(x - s * 0.21)} ${f(y + s * 0.21)}L${f(x - s)} ${f(y)}L${f(x - s * 0.21)} ${f(y - s * 0.21)}Z`
123    return `<path class="sp" style="transform-origin:${f(x)}px ${f(y)}px;animation-duration:${(3.6 + (i % 5) * 0.45).toFixed(2)}s;animation-delay:${((i * 0.83) % 4.4).toFixed(2)}s" d="${d}"/>`
124  }).join('')
125  return (
126    `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMaxYMax slice">` +
127    '<style>:root{color-scheme:light dark}html,body{margin:0}svg{background:transparent;display:block}' +
128    '.sp{fill:#fff;opacity:0;transform:scale(0);animation-name:gl;animation-timing-function:ease-in-out;animation-iteration-count:infinite}' +
129    '@keyframes gl{0%,72%,100%{opacity:0;transform:scale(0) rotate(0deg)}82%{opacity:.9;transform:scale(1) rotate(30deg)}92%{opacity:0;transform:scale(.2) rotate(60deg)}}' +
130    '.br{animation:br 6s ease-in-out infinite}@keyframes br{0%,100%{opacity:.85}50%{opacity:1}}</style>' +
131    `<defs><radialGradient id="bg" cx="${w}" cy="${h}" r="${f(h * 0.95)}" gradientUnits="userSpaceOnUse">` +
132    `<stop offset="0" stop-color="${light}" stop-opacity="${(0.42 * strength).toFixed(2)}"/>` +
133    `<stop offset=".35" stop-color="${c}" stop-opacity="${(0.24 * strength).toFixed(2)}"/>` +
134    `<stop offset=".75" stop-color="${c}" stop-opacity="${(0.06 * strength).toFixed(2)}"/>` +
135    `<stop offset="1" stop-color="${c}" stop-opacity="0"/></radialGradient>` +
136    '<linearGradient id="fade" x1="0" y1="0" x2="1" y2="1"><stop offset=".2" stop-color="#fff" stop-opacity="0"/><stop offset=".7" stop-color="#fff"/></linearGradient>' +
137    `<mask id="m"><rect width="${w}" height="${h}" fill="url(#fade)"/></mask>` +
138    '<pattern id="grain" width="2" height="2" patternUnits="userSpaceOnUse"><rect width=".6" height=".6" fill="#fff" fill-opacity=".07"/></pattern></defs>' +
139    `<rect class="br" width="${w}" height="${h}" fill="url(#bg)"/>` +
140    `<g mask="url(#m)">${lines}${sparks}<rect width="${w}" height="${h}" fill="url(#grain)"/></g>` +
141    '</svg>'
142  )
143}
144
145// --- The map: the parts of the code this chat touched, and how they import each other ---------------------------------
146
147/** The file a tool call reads or changes, and whether it changes it; null for a call that touches no one file. */
148export function touchOf(tool: string, input: Record<string, unknown>): { path: string; edited: boolean } | null {
149  const path = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : ''
150  if (!path) return null
151  if (tool === 'Read') return { path, edited: false }
152  if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit' || tool === 'NotebookEdit') return { path, edited: true }
153  return null
154}
155
156/** A path relative to the project root, with forward slashes; null when it lies outside the root. */
157export function relPath(path: string, root: string): string | null {
158  const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
159  const p = norm(path)
160  const r = norm(root)
161  if (!/^([a-zA-Z]:)?\//.test(p)) return p.replace(/^\.\//, '')
162  if (p.toLowerCase() === r.toLowerCase()) return null
163  return p.toLowerCase().startsWith(`${r.toLowerCase()}/`) ? p.slice(r.length + 1) : null
164}
165
166/** A path without its extension (and without a trailing /index): what an import names. */
167export function stemOf(path: string): string {
168  return path.replace(/\.(d\.ts|tsx?|jsx?|mjs|cjs|py)$/, '').replace(/\/index$/, '')
169}
170
171/** Joins a relative import onto the importing file's folder. */
172function joinRel(from: string, spec: string): string {
173  const parts = from.split('/').slice(0, -1)
174  for (const seg of spec.split('/')) {
175    if (seg === '..') parts.pop()
176    else if (seg !== '.' && seg !== '') parts.push(seg)
177  }
178  return parts.join('/')
179}
180
181/** The project files a source file imports, as stems relative to the root: relative JS/TS imports, Python modules. */
182export function importsOf(path: string, text: string): string[] {
183  const out = new Set<string>()
184  if (/\.(tsx?|jsx?|mjs|cjs)$/.test(path)) {
185    const re = /(?:from\s*|import\s*\(?\s*|require\(\s*)['"](\.{1,2}\/[^'"]+)['"]/g
186    for (const m of text.matchAll(re)) out.add(stemOf(joinRel(path, m[1])))
187  } else if (/\.py$/.test(path)) {
188    for (const m of text.matchAll(/^\s*from\s+(\.*)([\w.]*)\s+import/gm)) {
189      const dots = m[1].length
190      const mod = m[2].replace(/\./g, '/')
191      if (dots) out.add(joinRel(path, `${'../'.repeat(dots - 1)}./${mod}`))
192      else if (mod) out.add(mod)
193    }
194    for (const m of text.matchAll(/^\s*import\s+([\w.]+)/gm)) out.add(m[1].replace(/\./g, '/'))
195  }
196  out.delete(stemOf(path))
197  return [...out]
198}
199
200/** The module a file belongs to: its folder, at most two levels deep; files at the root are "project". */
201export function moduleOf(path: string): string {
202  const dirs = path.split('/').slice(0, -1)
203  return dirs.length ? dirs.slice(0, 2).join('/') : 'project'
204}
205
206export type Module = { key: string; files: FileTouch[]; edited: boolean; live: AgentRec[] }
207
208/** The modules the chat touched, most touched first, with the agents working in each right now. */
209export function modulesOf(files: readonly FileTouch[], agents: readonly AgentRec[]): Module[] {
210  const map = new Map<string, Module>()
211  for (const f of files) {
212    const key = moduleOf(f.path)
213    const m = map.get(key) ?? { key, files: [], edited: false, live: [] }
214    m.files.push(f)
215    m.edited ||= f.edited
216    map.set(key, m)
217  }
218  for (const a of agents) if (a.file && (a.state === 'running' || a.state === 'waiting')) map.get(moduleOf(a.file))?.live.push(a)
219  return [...map.values()].sort((x, y) => y.files.length - x.files.length || x.key.localeCompare(y.key))
220}
221
222/** Which modules import which, as "from>to" pairs, among the files the chat touched. */
223export function moduleLinks(files: readonly FileTouch[]): string[] {
224  const byStem = new Map(files.map(f => [stemOf(f.path), f.path]))
225  const links = new Set<string>()
226  for (const f of files)
227    for (const stem of f.imports) {
228      const to = byStem.get(stem)
229      if (to && moduleOf(to) !== moduleOf(f.path)) links.add(`${moduleOf(f.path)}>${moduleOf(to)}`)
230    }
231  return [...links]
232}
233
234/** The file list with one more touch: who, read or changed, and its imports once they are known. */
235export function withTouch(files: readonly FileTouch[], path: string, edited: boolean, by: string, nowMs: number, imports?: string[]): FileTouch[] {
236  const old = files.find(f => f.path === path)
237  const next: FileTouch = {
238    path,
239    edited: edited || Boolean(old?.edited),
240    by: old ? (old.by.includes(by) ? old.by : [...old.by, by]) : [by],
241    lastAt: nowMs,
242    imports: imports ?? old?.imports ?? [],
243  }
244  return [...files.filter(f => f.path !== path), next].slice(-80)
245}
246
247const MAP_W = 400
248const MAP_H = 190
249/**
250 * The map: each module a node on a ring, sized by the files touched in it; a line wherever one imports another.
251 * Changed modules are solid, read ones an outline; where an agent works now the node glows violet and light runs along
252 * its links. In the bar's look: its dot grid, its violet, its breathing glow.
253 */
254export function mapSvg(modules: readonly Module[], links: readonly string[]): string {
255  const f = (v: number) => v.toFixed(1)
256  const shown = modules.slice(0, 10)
257  const cx = MAP_W / 2
258  const cy = MAP_H / 2
259  const pos = new Map<string, { x: number; y: number; r: number }>()
260  shown.forEach((m, i) => {
261    const n = shown.length
262    const a = -Math.PI / 2 + (i / Math.max(1, n)) * Math.PI * 2
263    const ring = n === 1 ? 0 : 1
264    pos.set(m.key, { x: cx + Math.cos(a) * 150 * ring, y: cy + Math.sin(a) * 62 * ring, r: 4 + Math.min(7, Math.sqrt(m.files.length) * 2.6) })
265  })
266  const live = new Set(shown.filter(m => m.live.length).map(m => m.key))
267  const edges = links
268    .map(l => l.split('>'))
269    .filter(([a, b]) => pos.has(a) && pos.has(b))
270    .map(([a, b]) => {
271      const p = pos.get(a)!
272      const q = pos.get(b)!
273      const mx = (p.x + q.x) / 2 + (q.y - p.y) * 0.15
274      const my = (p.y + q.y) / 2 - (q.x - p.x) * 0.15
275      const hot = live.has(a) || live.has(b)
276      return `<path d="M${f(p.x)} ${f(p.y)} Q${f(mx)} ${f(my)} ${f(q.x)} ${f(q.y)}" fill="none" stroke="${hot ? '#a79cf7' : '#4a3f80'}" stroke-opacity="${hot ? 0.9 : 0.7}" stroke-width="1.2"${hot ? ' stroke-dasharray="3 5" class="fl"' : ''}/>`
277    })
278    .join('')
279  const nodes = shown
280    .map(m => {
281      const p = pos.get(m.key)!
282      const isLive = live.has(m.key)
283      const name = m.key.split('/').pop() ?? m.key
284      const halo = isLive ? `<circle class="br" cx="${f(p.x)}" cy="${f(p.y)}" r="${f(p.r + 9)}" fill="url(#halo)"/>` : ''
285      const body = m.edited
286        ? `<circle cx="${f(p.x)}" cy="${f(p.y)}" r="${f(p.r)}" fill="${isLive ? '#a79cf7' : '#d4d4d8'}"/>`
287        : `<circle cx="${f(p.x)}" cy="${f(p.y)}" r="${f(p.r)}" fill="#15121f" stroke="${isLive ? '#a79cf7' : '#8b8b93'}" stroke-width="1.4"/>`
288      const label = `<text x="${f(p.x)}" y="${f(p.y + p.r + 13)}" text-anchor="middle" class="${isLive ? 'tl' : 't'}">${name}</text>`
289      return halo + body + label
290    })
291    .join('')
292  const dots: string[] = []
293  for (let y = 6; y < MAP_H; y += 12) for (let x = 6; x < MAP_W; x += 12) dots.push(`<circle cx="${x}" cy="${y}" r=".6"/>`)
294  return (
295    `<svg xmlns="http://www.w3.org/2000/svg" width="${MAP_W}" height="${MAP_H}" viewBox="0 0 ${MAP_W} ${MAP_H}">` +
296    '<style>:root{color-scheme:light dark}html,body{margin:0}svg{background:transparent;display:block}' +
297    ".t{font:11px system-ui,'Segoe UI',sans-serif;fill:#8b8b93}.tl{font:600 11px system-ui,'Segoe UI',sans-serif;fill:#ececf0}" +
298    '.fl{animation:fl 1s linear infinite}@keyframes fl{to{stroke-dashoffset:-8}}' +
299    '.br{animation:br 2.4s ease-in-out infinite}@keyframes br{0%,100%{opacity:.5}50%{opacity:1}}</style>' +
300    '<defs><radialGradient id="halo"><stop offset="0" stop-color="#a79cf7" stop-opacity=".55"/><stop offset="1" stop-color="#a79cf7" stop-opacity="0"/></radialGradient></defs>' +
301    `<g fill="#ffffff" fill-opacity=".06">${dots.join('')}</g>${edges}${nodes}</svg>`
302  )
303}
304
305/** The map card: the picture, then a row per module that opens to its files. */
306function mapCard(d: AgentsDraw, modules: readonly Module[], links: readonly string[]) {
307  const { Box, Text, Button, Svg } = d
308  return (
309    <Box key="agents-map" position="relative" flexDirection="column" paddingX={1} backgroundColor={CARD_BG} borderStyle="round" borderColor={CARD_EDGE}>
310      <Box flexDirection="row">
311        <Text color={TEXT} bold>Map</Text>
312        <Box flexGrow={1} />
313        <Text dimColor>{`${modules.length} part${modules.length === 1 ? '' : 's'} touched`}</Text>
314      </Box>
315      {Svg ? <Svg source={mapSvg(modules, links)} alt={`map of ${modules.length} parts of the code`} width={MAP_W} height={MAP_H} /> : null}
316      {modules.map(m => {
317        const isOpen = d.module === m.key
318        const lead = m.live.length ? m.live.map(a => a.type).join(', ') : m.edited ? 'changed' : 'read'
319        return (
320          <Box key={`mod-${m.key}`} position="relative" flexDirection="column">
321            <Box flexDirection="row">
322              <Text color={m.live.length ? accent() : DIM}>{isOpen ? '▾ ' : '▸ '}</Text>
323              <Text color={TEXT} bold={m.live.length > 0}>{m.key}</Text>
324              <Text dimColor>{`  ${m.files.length}`}</Text>
325              <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden" />
326              <Text color={m.live.length ? accent() : DIM}>{lead}</Text>
327            </Box>
328            {isOpen
329              ? m.files.map(file => {
330                  const worker = m.live.find(a => a.file === file.path)
331                  return (
332                    <Box key={`file-${file.path}`} flexDirection="row" paddingLeft={2}>
333                      <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
334                        <Text color={worker ? '#ececf0' : TEXT} wrap="truncate">{file.path.split('/').pop()}</Text>
335                      </Box>
336                      <Text color={worker ? accent() : DIM}>{worker ? `  ${worker.type}` : file.edited ? '  changed' : '  read'}</Text>
337                    </Box>
338                  )
339                })
340              : null}
341            <Box position="absolute" top={0} left={0} right={0} height={1}>
342              <Button key={`mod-${m.key}-press`} plain label={' '.repeat(60)} hover={{ backgroundColor: '#00000000' }} onPress={() => d.onModule(m.key)} />
343            </Box>
344          </Box>
345        )
346      })}
347    </Box>
348  )
349}
350
351/** What drawing the pane takes besides the agents: the elements, the bar's parts from register.tsx, the presses. */
352export type AgentsDraw = {
353  // The surface's element table, as $.ui.resolve(e) gives it.
354  // eslint-disable-next-line @typescript-eslint/no-explicit-any
355  Box: any
356  // eslint-disable-next-line @typescript-eslint/no-explicit-any
357  Text: any
358  // eslint-disable-next-line @typescript-eslint/no-explicit-any
359  Button: any
360  // eslint-disable-next-line @typescript-eslint/no-explicit-any
361  Svg?: any
362  /** Rows of the pane's body, so the background fills it. */
363  rows: number
364  nowMs: number
365  /** The chat's own title for the header. */
366  title: string
367  /** The opened card: an agent's id, 'done' for the finished ones, or null. */
368  open: string | null
369  /** The bar's parts: its context ring, its card art (DASH_SVG) and its size. */
370  ringSvg: (percent: number, color: string) => string
371  cardArt: { source: string; width: number; height: number }
372  onOpen: (id: string) => unknown
373  /** The files touched, the main chat's own steps, the module opened in the list. */
374  files: readonly FileTouch[]
375  steps: readonly ProgressStep[] | null
376  module: string | null
377  onModule: (key: string) => unknown
378}
379
380const TEXT = '#d4d4d8'
381/** The pane's ground: the brand card's deep violet, as the bar's brand band (BRAND_BG). */
382const PANE_BG = '#15121f'
383const DIM = '#8b8b93'
384const CARD_BG = '#141416'
385const CARD_EDGE = '#2a2a2f'
386
387/** The header: the dashboard's card, saying how the chat's agents stand. Mark, one bold word, the ring and its share. */
388function header(d: AgentsDraw, agents: readonly AgentRec[]) {
389  const { Box, Text, Svg } = d
390  const mood = moodOf(agents)
391  const color = mood === 'waiting' ? looks('asking').color : mood === 'done' ? looks('done').color : TEXT
392  const pct = d.steps && d.steps.length > 1 ? Math.round(progressShare(d.steps) * 100) : overallPercent(agents)
393  return (
394    <Box key="agents-head" position="relative" flexDirection="column" paddingX={1} overflow="hidden"
395      backgroundColor={CARD_BG} borderStyle="round" borderColor={CARD_EDGE}>
396      {Svg ? (
397        <Box key="agents-head-art" position="absolute" top={-1} right={0} bottom={-1}>
398          <Svg source={d.cardArt.source} alt="effortless" width={d.cardArt.width} height={d.cardArt.height} />
399        </Box>
400      ) : null}
401      <Box position="relative" flexDirection="row" alignItems="center" minWidth={0}>
402        <Box position="absolute" top={0} left={0} />
403        {Svg ? (
404          <Box flexShrink={0} marginRight={1} alignItems="center">
405            <Svg source={MARK_SVG} alt="effortless" width={18} height={18} />
406          </Box>
407        ) : <Text color={accent()} bold>✦ </Text>}
408        <Text color={color} bold>{moodWord(agents)}</Text>
409        {Svg && agents.length ? (
410          <Box flexShrink={0} marginLeft={2} flexDirection="row" gap={1} alignItems="center">
411            <Svg source={d.ringSvg(pct, color)} alt={`${pct}% done`} width={16} height={16} />
412            <Text color={TEXT}>{`${pct}%`}</Text>
413          </Box>
414        ) : null}
415        <Box flexGrow={1} flexShrink={1} minWidth={0} marginLeft={2} overflow="hidden">
416          <Text dimColor wrap="truncate">{agents.length ? `${agents.length} agent${agents.length > 1 ? 's' : ''}` : d.title}</Text>
417        </Box>
418      </Box>
419      {Svg && d.steps && d.steps.length > 1 ? (
420        <Box position="relative" flexDirection="column">
421          <Box position="absolute" top={0} left={0} />
422          <Svg source={progressTrackSvg({ phase: mood === 'done' ? 'done' : 'working', steps: [...d.steps] })}
423            alt={`${d.steps.filter(s => s.status === 'completed').length} of ${d.steps.length} steps`} width={1000} height={24} />
424        </Box>
425      ) : null}
426    </Box>
427  )
428}
429
430/** The line under an agent's name: its live track when it keeps a list, else what it does now, else its time. */
431function agentLine(d: AgentsDraw, a: AgentRec) {
432  const { Box, Text, Svg } = d
433  const look = lookOf(a.state)
434  if (a.state === 'picking')
435    return (
436      <Box flexDirection="row" alignItems="center" gap={1}>
437        {Svg ? <Svg source={thinkingSvg('planning')} alt="picking" width={THINK_W} height={PILL_H} /> : null}
438        <Text color={look.color}>picking a model</Text>
439      </Box>
440    )
441  if (a.state === 'waiting')
442    return <Text color={look.color} wrap="truncate">{`waiting on ${a.now ?? 'a tool'}`}</Text>
443  if (a.state === 'failed') return <Text color={look.color}>{`failed after ${clockText((a.endedAt ?? d.nowMs) - a.startedAt)}`}</Text>
444  if (Svg && a.steps && a.steps.length > 1)
445    return <Svg source={progressTrackSvg({ phase: 'working', steps: a.steps })} alt={`${a.steps.filter(s => s.status === 'completed').length} of ${a.steps.length}`} width={1000} height={24} />
446  return <Text dimColor wrap="truncate">{a.now ?? 'starting'}</Text>
447}
448
449/** One agent: a small progress band in its state's look and art. Its name in the look's colour, the task dim, its time. */
450function agentCard(d: AgentsDraw, a: AgentRec) {
451  const { Box, Text, Button, Svg } = d
452  const look = lookOf(a.state)
453  const phase = a.state === 'failed' ? null : PHASE[a.state]
454  const isOpen = d.open === a.id
455  return (
456    <Box key={`agent-${a.id}`} position="relative" flexDirection="column" paddingX={1} overflow="hidden"
457      backgroundColor={look.bg} borderStyle="round" borderColor={look.edge}>
458      {Svg && phase ? (
459        <Box key={`agent-${a.id}-art`} position="absolute" top={0} left={0} right={0} bottom={0}>
460          <Svg source={cardArtSvg(phase)} alt={`${a.type} ${a.state}`} width={ART_W} height={CARD_ART_H} />
461        </Box>
462      ) : null}
463      <Box position="relative" flexDirection="column">
464        <Box position="absolute" top={0} left={0} />
465        <Box flexDirection="row">
466          <Text color={look.color} bold>{a.parentId ? `└ ${a.type}` : a.type}</Text>
467          <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
468            <Text color={TEXT} wrap="truncate">{`  ${a.task}`}</Text>
469          </Box>
470          {a.endedAt ? <Text color={DIM}>{`  ${clockText(a.endedAt - a.startedAt)}`}</Text> : null}
471        </Box>
472        {agentLine(d, a)}
473        {isOpen ? (
474          <Box flexDirection="column" marginTop={1}>
475            {a.model ? (
476              <Box flexDirection="row">
477                <Text color={look.color} bold>{a.effort ? `${a.model} ${a.effort}` : a.model}</Text>
478                {a.why ? <Text dimColor wrap="truncate">{`  ${a.why}`}</Text> : null}
479              </Box>
480            ) : null}
481            {a.now && a.state !== 'waiting' ? <Text dimColor wrap="truncate">{a.now}</Text> : null}
482          </Box>
483        ) : null}
484      </Box>
485      {/* The whole card takes the press: a blank button in a layer over it, as the settings cards do. */}
486      <Box position="absolute" top={0} left={0} right={0} bottom={0}>
487        <Button key={`agent-${a.id}-press`} plain label={' '.repeat(60)} hover={{ backgroundColor: '#00000000' }} onPress={() => d.onOpen(a.id)} />
488      </Box>
489    </Box>
490  )
491}
492
493/** A card's height in px, for its art: two lines and the borders, with room to spare. */
494const CARD_ART_H = ART_H
495/** The progress band's art for a phase, filling a card: sliced to the card's height rather than drawn as a strip. */
496const cardArtCache = new Map<string, string>()
497export function cardArtSvg(phase: Progress['phase']): string {
498  let svg = cardArtCache.get(phase)
499  if (!svg) {
500    svg = progressArtSvg(phase).replace(
501      `width="${ART_W}" height="${ART_H}" viewBox="0 0 ${ART_W} ${ART_H}">`,
502      `width="${ART_W}" height="${CARD_ART_H}" viewBox="0 0 ${ART_W} ${ART_H}" preserveAspectRatio="xMinYMin slice">`,
503    )
504    cardArtCache.set(phase, svg)
505  }
506  return svg
507}
508
509/** The finished agents, folded into one green card: the done band's look. Opens to list them. */
510function doneCard(d: AgentsDraw, done: readonly AgentRec[]) {
511  const { Box, Text, Button, Svg } = d
512  const look = looks('done')
513  const isOpen = d.open === 'done'
514  return (
515    <Box key="agents-done" position="relative" flexDirection="column" paddingX={1} overflow="hidden"
516      backgroundColor={look.bg} borderStyle="round" borderColor={look.edge}>
517      {Svg ? (
518        <Box position="absolute" top={0} left={0} right={0} bottom={0}>
519          <Svg source={cardArtSvg('done')} alt="finished agents" width={ART_W} height={CARD_ART_H} />
520        </Box>
521      ) : null}
522      <Box position="relative" flexDirection="column">
523        <Box position="absolute" top={0} left={0} />
524        <Box flexDirection="row">
525          <Text color={look.color} bold>{`✓ ${done.length} done`}</Text>
526          <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
527            <Text dimColor wrap="truncate">  reported back</Text>
528          </Box>
529        </Box>
530        {isOpen
531          ? done.map(a => (
532              <Box key={`done-${a.id}`} flexDirection="row">
533                <Text color={TEXT}>{a.parentId ? `└ ${a.type}` : a.type}</Text>
534                <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
535                  <Text dimColor wrap="truncate">{`  ${a.task}`}</Text>
536                </Box>
537                <Text color={DIM}>{`  ${clockText((a.endedAt ?? d.nowMs) - a.startedAt)}`}</Text>
538              </Box>
539            ))
540          : null}
541      </Box>
542      <Box position="absolute" top={0} left={0} right={0} bottom={0}>
543        <Button key="agents-done-press" plain label={' '.repeat(60)} hover={{ backgroundColor: '#00000000' }} onPress={() => d.onOpen('done')} />
544      </Box>
545    </Box>
546  )
547}
548
549/**
550 * The whole pane: the mood's wash and the corner mark behind, the header, a card per agent still going, the finished
551 * ones folded into one. Nothing else unless pressed open.
552 */
553export function agentsPane(d: AgentsDraw, agents: readonly AgentRec[]) {
554  const { Box, Text, Svg } = d
555  const { live, done } = splitAgents(agents)
556  const mood = moodOf(agents)
557  // One row of the desktop pane is about 19 px; the background is drawn a little taller than the body.
558  const tall = Math.max(240, (d.rows + 2) * 19)
559  return (
560    <Box key="agents" position="relative" flexDirection="column" height={d.rows} overflow="hidden" backgroundColor={tintHex(PANE_BG)}>
561      {Svg ? (
562        <Box key="agents-wash" position="absolute" top={0} left={0} right={0} bottom={0}>
563          <Svg source={paneWashSvg(mood, 400, tall)} alt={`agents ${mood}`} width={400} height={tall} />
564        </Box>
565      ) : null}
566      {Svg ? (
567        <Box key="agents-mark" position="absolute" right={-7} bottom={-4}>
568          <Svg source={CORNER_MARK} alt="effortless" width={230} height={230} />
569        </Box>
570      ) : null}
571      <Box position="relative" flexDirection="column" gap={1}>
572        <Box position="absolute" top={0} left={0} />
573        {header(d, agents)}
574        {d.files.length ? mapCard(d, modulesOf(d.files, agents), moduleLinks(d.files)) : null}
575        {live.map(a => agentCard(d, a))}
576        {done.length ? doneCard(d, done) : null}
577        {!agents.length ? <Text dimColor>  Agents show here when Claude sends some off.</Text> : null}
578      </Box>
579    </Box>
580  )
581}
582
583/** Sample agents for `/effortless agents demo`: one of each state, so the pane can be seen without a real run. */
584export function demoAgents(nowMs: number): AgentRec[] {
585  const step = (label: string, status: 'completed' | 'in_progress' | 'pending', i: number) => ({ id: `s${i}`, label, doing: label, status })
586  return [
587    { id: 'demo-1', type: 'Explore', task: 'find the pane and agent hooks', state: 'running', startedAt: nowMs - 103_000, model: 'Haiku', effort: 'Low', why: 'read-only search, wide but shallow', file: 'hooks/agents.tsx',
588      now: 'Grep "agentId" in hooks/', steps: [step('List hooks', 'completed', 0), step('Read types', 'completed', 1), step('Find spawn', 'in_progress', 2), step('Report', 'pending', 3)] },
589    { id: 'demo-2', type: 'code-reviewer', task: 'review the agent-panel branch', state: 'waiting', file: 'tests/effortless.test.ts', startedAt: nowMs - 251_000, waitingSince: nowMs - 38_000, now: 'Bash npm test', model: 'Opus', effort: 'High', why: 'a review before merge: misses cost more' },
590    { id: 'demo-3', type: 'Plan', task: 'outline the release notes', state: 'picking', startedAt: nowMs - 2_000 },
591    { id: 'demo-4', type: 'general-purpose', task: 'add state for the panel', state: 'done', startedAt: nowMs - 400_000, endedAt: nowMs - 208_000, model: 'Sonnet', effort: 'Medium' },
592    { id: 'demo-5', parentId: 'demo-4', type: 'Explore', task: 'read the tests', state: 'done', startedAt: nowMs - 380_000, endedAt: nowMs - 340_000, model: 'Haiku', effort: 'Low' },
593  ]
594}
595
596/** The files the sample agents touched, for `/effortless agents demo`: three parts of the code and how they import. */
597export function demoFiles(nowMs: number): FileTouch[] {
598  const f = (path: string, edited: boolean, imports: string[]): FileTouch => ({ path, edited, by: ['main'], lastAt: nowMs, imports })
599  return [
600    f('hooks/register.tsx', true, ['hooks/agents', 'hooks/progress', 'hooks/art', 'hooks/brand-mark', 'types']),
601    f('hooks/agents.tsx', true, ['hooks/progress', 'hooks/brand-mark', 'types']),
602    f('hooks/progress.tsx', false, ['types']),
603    f('types/index.d.ts', true, []),
604    f('tests/effortless.test.ts', false, ['hooks/register', 'hooks/progress']),
605    f('docs/agent-panel/PLAN.md', false, []),
606    f('tools/render-band/render.mjs', false, []),
607  ]
608}
609
610/** The main chat's own list for the demo: what the whole job is, step by step. */
611export function demoSteps(): ProgressStep[] {
612  const step = (label: string, status: ProgressStep['status'], i: number): ProgressStep => ({ id: `m${i}`, label, doing: label, status })
613  return [
614    step('Find the hooks', 'completed', 0),
615    step('Add the state', 'completed', 1),
616    step('Draw the pane', 'in_progress', 2),
617    step('Review the branch', 'pending', 3),
618    step('Release', 'pending', 4),
619  ]
620}
621
hooks/art.ts 119 lines
1import { getTheme, tintHex } from './theme'
2
3// Art for the terminal's bands: a small Raster of `▀` cells, each two pixels tall (foreground on top, background
4// below). Pure functions of the kind and a frame count, so the art can be tested and blitted from a timer.
5
6export type ArtKind = 'cold' | 'swamp' | 'hot' | 'down' | 'brand' | 'calm' | 'compacting' | 'done'
7
8/** The size every band's art is drawn at: 16 columns by 2 rows, 16 by 4 pixels. */
9export const ART_COLUMNS = 16
10export const ART_ROWS = 2
11/** Below this many columns the band leaves its art out, so the words keep their room. */
12export const ART_MIN_WIDTH = 90
13/** One frame every this many milliseconds while a moving band shows. */
14export const ART_FRAME_MS = 100
15
16/** The kinds that move; the rest are drawn once. */
17export const MOVING: ReadonlySet<ArtKind> = new Set(['cold', 'swamp', 'hot', 'down', 'compacting'])
18
19type Rgb = readonly [number, number, number]
20
21const hex = (h: string): Rgb => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]
22const mix = (a: Rgb, b: Rgb, k: number): Rgb => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k]
23const pack = (c: Rgb) => ((Math.round(c[0]) << 16) | (Math.round(c[1]) << 8) | Math.round(c[2])) >>> 0
24// A fixed pseudo-random number per seed, so particles keep their lanes from frame to frame.
25const rand = (seed: number) => {
26  const s = Math.sin(seed * 12.9898) * 43758.5453
27  return s - Math.floor(s)
28}
29
30// Each kind's ground (the band's background) and its light colour.
31const PALETTE: Record<ArtKind, { ground: Rgb; deep: Rgb; light: Rgb; glow: Rgb }> = {
32  cold: { ground: hex('#0e1820'), deep: hex('#2f5c80'), light: hex('#dff1ff'), glow: hex('#7cc4ff') },
33  swamp: { ground: hex('#111710'), deep: hex('#22351c'), light: hex('#cfe8b8'), glow: hex('#a7c98f') },
34  hot: { ground: hex('#1a110c'), deep: hex('#b8461b'), light: hex('#ffb06a'), glow: hex('#f08a3c') },
35  down: { ground: hex('#12141b'), deep: hex('#39415a'), light: hex('#b4b8c4'), glow: hex('#6b7288') },
36  brand: { ground: hex('#15121f'), deep: hex('#4a3f80'), light: hex('#e4dfff'), glow: hex('#a79cf7') },
37  // The dashboard's: the brand's sparkles in grey, since it shows all the time.
38  calm: { ground: hex('#141416'), deep: hex('#2a2a2f'), light: hex('#d4d4d8'), glow: hex('#6e6e76') },
39  compacting: { ground: hex('#15121f'), deep: hex('#4a3f80'), light: hex('#e4dfff'), glow: hex('#a79cf7') },
40  done: { ground: hex('#0f1c15'), deep: hex('#2f7a4c'), light: hex('#d8f5e3'), glow: hex('#5ec48a') },
41}
42
43// The violet kinds in the current theme; the rest are not touched by it. Cached per theme so a pixel does not recolour.
44const themed = new Map<string, (typeof PALETTE)[ArtKind]>()
45function paletteOf(kind: ArtKind) {
46  const base = PALETTE[kind]
47  if (getTheme() === 'violet' || (kind !== 'brand' && kind !== 'compacting')) return base
48  const key = `${getTheme()}-${kind}`
49  let p = themed.get(key)
50  if (!p) {
51    const turn = (c: Rgb): Rgb => hex(tintHex(`#${c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`))
52    p = { ground: turn(base.ground), deep: turn(base.deep), light: turn(base.light), glow: turn(base.glow) }
53    themed.set(key, p)
54  }
55  return p
56}
57
58// The done tick, as pixels on the 16 by 4 grid (x, y).
59const TICK: ReadonlyArray<readonly [number, number]> = [[9, 2], [10, 3], [11, 2], [12, 1], [13, 0]]
60
61/** The colour of one pixel of `kind` at frame `t`: x from 0 to columns - 1, y from 0 (top) to rows * 2 - 1. */
62export function artPixel(kind: ArtKind, t: number, x: number, y: number, cols = ART_COLUMNS, rows = ART_ROWS): number {
63  const p = PALETTE[kind]
64  const h = rows * 2
65  // The scene, then faded in from the left over six columns like the desktop art's mask.
66  let c: Rgb = mix(p.ground, p.deep, (y / (h - 1)) * 0.45)
67  if (kind === 'cold') {
68    // Frost along the bottom; flakes falling, each in its own lane, drifting a little.
69    if (y === h - 1) c = mix(c, p.glow, 0.35 + 0.15 * rand(x))
70    for (let i = 0; i < 5; i++) {
71      const lane = 4 + Math.floor(rand(i + 1) * (cols - 4))
72      const fy = (t / (5 + i) + rand(i + 9) * h) % (h + 1)
73      const fx = lane + Math.round(Math.sin(t / 7 + i))
74      if (x === fx && y === Math.floor(fy)) c = p.light
75    }
76  } else if (kind === 'swamp') {
77    // Bubbles rising, each lane on its own pace; they pale as they near the top.
78    for (let i = 0; i < 4; i++) {
79      const lane = 5 + Math.floor(rand(i + 3) * (cols - 5))
80      const by = h - 1 - ((t / (6 + i * 2) + rand(i + 5) * h) % (h + 1))
81      if (x === lane && y === Math.round(by)) c = mix(p.glow, p.light, 1 - by / h)
82    }
83  } else if (kind === 'hot') {
84    // A glowing bottom row that flickers; embers rising and dimming.
85    if (y === h - 1) c = mix(p.deep, p.glow, 0.5 + 0.5 * Math.abs(Math.sin(t / 3 + x)))
86    for (let i = 0; i < 5; i++) {
87      const lane = 4 + Math.floor(rand(i + 7) * (cols - 4))
88      const ey = h - 1 - ((t / (3 + i) + rand(i + 2) * h) % h)
89      if (x === lane && y === Math.round(ey) && y < h - 1) c = mix(p.light, p.glow, 1 - ey / h)
90    }
91  } else if (kind === 'down') {
92    // Still flecks under a slow pulse: the judge is not answering.
93    const pulse = 0.5 + 0.5 * Math.sin(t / 8)
94    if (rand(x * 7 + y * 13) > 0.82) c = mix(c, p.light, 0.25 + 0.35 * pulse)
95  } else if (kind === 'brand' || kind === 'calm' || kind === 'compacting') {
96    // Sparkles in fixed places: still for brand, twinkling while compacting.
97    if (rand(x * 5 + y * 11 + 3) > 0.84) {
98      const twinkle = kind === 'compacting' ? 0.5 + 0.5 * Math.sin(t / 2 + x * 1.7 + y) : 0.8
99      c = mix(p.glow, p.light, twinkle)
100    }
101  } else if (kind === 'done') {
102    if (TICK.some(([tx, ty]) => tx === x && ty === y)) c = p.light
103  }
104  return pack(mix(p.ground, c, Math.min(1, x / 6)))
105}
106
107/** One frame of `kind`'s art as Raster cells: base64 of `[codePoint, top, bottom]` per cell, row-major. */
108export function artFrame(kind: ArtKind, t: number, cols = ART_COLUMNS, rows = ART_ROWS): string {
109  const words = new Uint32Array(cols * rows * 3)
110  for (let row = 0; row < rows; row++)
111    for (let x = 0; x < cols; x++) {
112      const i = (row * cols + x) * 3
113      words[i] = 0x2580 // ▀: the foreground paints the top pixel, the background the bottom one
114      words[i + 1] = artPixel(kind, t, x, row * 2, cols, rows)
115      words[i + 2] = artPixel(kind, t, x, row * 2 + 1, cols, rows)
116    }
117  return new Uint8Array(words.buffer).toBase64()
118}
119
hooks/brand-mark.ts 4 lines
1// The effortless mark (the four-point star), from brand/mark.svg. Generated: node -e in the commit that added it;
2// regenerate when the mark changes.
3export const MARK_SVG = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\"><defs><mask id=\"kem\"><path fill=\"#fff\" d=\"M49.84 12.97 L49.84 13.13 L49.84 13.29 L49.78 13.45 L49.78 13.60 L49.78 13.76 L49.73 13.91 L49.73 14.07 L49.67 14.23 L49.67 14.39 L49.67 14.55 L49.62 14.71 L49.62 14.86 L49.57 15.02 L49.57 15.17 L49.51 15.33 L49.51 15.49 L49.46 15.65 L49.46 15.81 L49.40 15.96 L49.35 16.12 L49.35 16.28 L49.29 16.43 L49.29 16.59 L49.24 16.75 L49.24 16.90 L49.18 17.06 L49.13 17.22 L49.13 17.38 L49.07 17.54 L49.02 17.69 L49.02 17.85 L48.96 18.01 L48.91 18.17 L48.91 18.32 L48.85 18.48 L48.80 18.64 L48.74 18.80 L48.74 18.95 L48.69 19.11 L48.64 19.26 L48.64 19.42 L48.58 19.58 L48.53 19.74 L48.47 19.90 L48.42 20.05 L48.42 20.21 L48.36 20.37 L48.31 20.52 L48.25 20.68 L48.20 20.84 L48.14 20.99 L48.14 21.15 L48.09 21.31 L48.03 21.47 L47.98 21.63 L47.92 21.78 L47.87 21.94 L47.82 22.10 L47.76 22.26 L47.71 22.41 L47.65 22.57 L47.60 22.73 L47.54 22.89 L47.49 23.04 L47.43 23.20 L47.38 23.35 L47.32 23.51 L47.27 23.67 L47.21 23.83 L47.16 23.99 L47.10 24.15 L47.05 24.30 L46.99 24.46 L46.94 24.61 L46.89 24.77 L46.78 24.93 L46.72 25.09 L46.67 25.24 L46.61 25.40 L46.56 25.56 L46.45 25.72 L46.39 25.87 L46.34 26.03 L46.28 26.19 L46.23 26.35 L46.12 26.50 L46.07 26.66 L46.01 26.82 L45.96 26.98 L45.85 27.13 L45.79 27.29 L45.69 27.44 L45.63 27.60 L45.58 27.76 L45.47 27.92 L45.41 28.08 L45.30 28.24 L45.25 28.39 L45.14 28.56 L45.09 28.72 L44.98 28.88 L44.92 29.05 L44.81 29.22 L44.76 29.39 L44.65 29.56 L44.59 29.73 L44.48 29.89 L44.38 30.07 L44.32 30.25 L44.21 30.43 L44.10 30.61 L44.05 30.79 L43.94 30.97 L43.83 31.16 L43.72 31.35 L43.61 31.54 L43.50 31.73 L43.39 31.93 L43.34 32.11 L43.23 32.31 L43.12 32.51 L42.95 32.72 L42.84 32.92 L42.73 33.12 L42.63 33.33 L42.52 33.54 L42.41 33.75 L42.30 33.96 L42.13 34.18 L42.02 34.39 L41.86 34.61 L41.75 34.82 L41.64 35.03 L41.48 35.25 L41.32 35.47 L41.21 35.69 L41.04 35.91 L40.88 36.13 L40.77 36.34 L40.61 36.56 L40.44 36.78 L40.28 37.00 L40.11 37.22 L39.95 37.44 L39.73 37.65 L39.57 37.87 L39.40 38.09 L39.19 38.31 L39.02 38.53 L38.80 38.75 L38.64 38.96 L38.42 39.18 L38.20 39.40 L37.98 39.62 L37.76 39.84 L37.55 40.06 L37.33 40.28 L37.11 40.50 L36.84 40.72 L36.62 40.93 L36.35 41.15 L36.13 41.37 L35.85 41.59 L35.58 41.81 L35.31 42.02 L35.03 42.24 L34.76 42.46 L34.43 42.68 L34.16 42.90 L33.83 43.12 L33.50 43.33 L33.18 43.55 L32.85 43.77 L32.47 43.99 L32.09 44.21 L31.76 44.43 L31.32 44.65 L30.94 44.87 L30.50 45.09 L30.06 45.30 L29.63 45.52 L29.19 45.74 L28.70 45.96 L28.15 46.18 L27.66 46.40 L27.12 46.61 L26.51 46.83 L25.91 47.05 L25.26 47.27 L24.60 47.49 L23.89 47.71 L23.13 47.92 L22.36 48.14 L21.49 48.36 L20.61 48.58 L19.63 48.80 L18.59 49.02 L17.39 49.23 L16.14 49.45 L14.66 49.67 L12.91 49.89 L7.78 50.00 L11.88 50.00 L13.84 50.22 L15.43 50.44 L16.84 50.66 L18.05 50.88 L19.14 51.09 L20.18 51.31 L21.11 51.53 L21.93 51.75 L22.75 51.97 L23.51 52.19 L24.28 52.40 L24.93 52.62 L25.58 52.84 L26.24 53.06 L26.79 53.28 L27.39 53.50 L27.94 53.71 L28.43 53.93 L28.92 54.15 L29.41 54.37 L29.84 54.59 L30.34 54.80 L30.72 55.02 L31.16 55.24 L31.54 55.46 L31.92 55.68 L32.31 55.90 L32.63 56.12 L33.01 56.34 L33.34 56.56 L33.67 56.77 L34.00 56.99 L34.27 57.21 L34.60 57.43 L34.87 57.65 L35.20 57.87 L35.47 58.08 L35.75 58.30 L35.96 58.52 L36.24 58.74 L36.51 58.96 L36.73 59.18 L37.00 59.39 L37.22 59.61 L37.44 59.83 L37.65 60.05 L37.87 60.27 L38.09 60.49 L38.31 60.71 L38.53 60.93 L38.75 61.15 L38.91 61.36 L39.13 61.58 L39.29 61.80 L39.46 62.02 L39.68 62.24 L39.84 62.46 L40.01 62.67 L40.17 62.89 L40.33 63.11 L40.50 63.33 L40.66 63.55 L40.83 63.76 L40.99 63.98 L41.10 64.20 L41.26 64.42 L41.43 64.64 L41.53 64.86 L41.70 65.07 L41.81 65.28 L41.97 65.50 L42.08 65.72 L42.19 65.93 L42.35 66.15 L42.46 66.36 L42.57 66.57 L42.68 66.78 L42.79 66.97 L42.90 67.18 L43.06 67.38 L43.17 67.59 L43.28 67.79 L43.39 67.99 L43.45 68.17 L43.55 68.37 L43.66 68.56 L43.77 68.75 L43.88 68.94 L43.99 69.12 L44.05 69.30 L44.16 69.48 L44.27 69.66 L44.38 69.84 L44.43 70.02 L44.54 70.19 L44.59 70.36 L44.70 70.53 L44.81 70.70 L44.87 70.87 L44.98 71.03 L45.03 71.20 L45.14 71.36 L45.20 71.53 L45.30 71.68 L45.36 71.84 L45.47 72.00 L45.52 72.16 L45.58 72.32 L45.69 72.48 L45.74 72.63 L45.79 72.79 L45.90 72.95 L45.96 73.10 L46.01 73.26 L46.12 73.42 L46.17 73.57 L46.23 73.73 L46.28 73.89 L46.39 74.05 L46.45 74.21 L46.50 74.36 L46.56 74.52 L46.61 74.68 L46.72 74.84 L46.78 74.99 L46.83 75.15 L46.89 75.31 L46.94 75.47 L46.99 75.62 L47.05 75.78 L47.10 75.93 L47.21 76.09 L47.27 76.25 L47.32 76.41 L47.38 76.57 L47.43 76.73 L47.49 76.88 L47.54 77.03 L47.60 77.19 L47.65 77.35 L47.71 77.51 L47.76 77.67 L47.82 77.82 L47.87 77.98 L47.92 78.14 L47.92 78.30 L47.98 78.45 L48.03 78.61 L48.09 78.77 L48.14 78.93 L48.20 79.08 L48.25 79.24 L48.25 79.40 L48.31 79.56 L48.36 79.71 L48.42 79.87 L48.47 80.02 L48.53 80.18 L48.53 80.34 L48.58 80.50 L48.64 80.66 L48.69 80.82 L48.69 80.97 L48.74 81.12 L48.80 81.28 L48.85 81.44 L48.85 81.60 L48.91 81.76 L48.96 81.91 L48.96 82.07 L49.02 82.23 L49.07 82.38 L49.07 82.54 L49.13 82.70 L49.18 82.86 L49.18 83.02 L49.24 83.18 L49.24 83.33 L49.29 83.49 L49.35 83.64 L49.35 83.80 L49.40 83.96 L49.40 84.12 L49.46 84.27 L49.46 84.43 L49.51 84.59 L49.51 84.75 L49.57 84.90 L49.57 85.06 L49.62 85.21 L49.62 85.37 L49.67 85.53 L49.67 85.69 L49.73 85.85 L49.73 86.01 L49.73 86.16 L49.78 86.32 L49.78 86.47 L49.84 86.63 L49.84 86.79 L49.84 86.95 L50.16 87.03 L50.16 86.87 L50.16 86.71 L50.22 86.55 L50.22 86.40 L50.22 86.24 L50.27 86.09 L50.27 85.93 L50.33 85.77 L50.33 85.61 L50.33 85.45 L50.38 85.29 L50.38 85.14 L50.43 84.98 L50.43 84.83 L50.49 84.67 L50.49 84.51 L50.54 84.35 L50.54 84.19 L50.60 84.04 L50.65 83.88 L50.65 83.72 L50.71 83.57 L50.71 83.41 L50.76 83.25 L50.76 83.10 L50.82 82.94 L50.87 82.78 L50.87 82.62 L50.93 82.46 L50.98 82.31 L50.98 82.15 L51.04 81.99 L51.09 81.83 L51.09 81.68 L51.15 81.52 L51.20 81.36 L51.26 81.20 L51.26 81.05 L51.31 80.89 L51.36 80.74 L51.36 80.58 L51.42 80.42 L51.47 80.26 L51.53 80.10 L51.58 79.95 L51.58 79.79 L51.64 79.63 L51.69 79.48 L51.75 79.32 L51.80 79.16 L51.86 79.01 L51.86 78.85 L51.91 78.69 L51.97 78.53 L52.02 78.37 L52.08 78.22 L52.13 78.06 L52.18 77.90 L52.24 77.74 L52.29 77.59 L52.35 77.43 L52.40 77.27 L52.46 77.11 L52.51 76.96 L52.57 76.80 L52.62 76.65 L52.68 76.49 L52.73 76.33 L52.79 76.17 L52.84 76.01 L52.90 75.85 L52.95 75.70 L53.01 75.54 L53.06 75.39 L53.11 75.23 L53.22 75.07 L53.28 74.91 L53.33 74.76 L53.39 74.60 L53.44 74.44 L53.55 74.28 L53.61 74.13 L53.66 73.97 L53.72 73.81 L53.77 73.65 L53.88 73.50 L53.93 73.34 L53.99 73.18 L54.04 73.02 L54.15 72.87 L54.21 72.71 L54.31 72.56 L54.37 72.40 L54.42 72.24 L54.53 72.08 L54.59 71.92 L54.70 71.76 L54.75 71.61 L54.86 71.44 L54.91 71.28 L55.02 71.12 L55.08 70.95 L55.19 70.78 L55.24 70.61 L55.35 70.44 L55.41 70.27 L55.52 70.11 L55.62 69.93 L55.68 69.75 L55.79 69.57 L55.90 69.39 L55.95 69.21 L56.06 69.03 L56.17 68.84 L56.28 68.65 L56.39 68.46 L56.50 68.27 L56.61 68.07 L56.66 67.89 L56.77 67.69 L56.88 67.49 L57.05 67.28 L57.16 67.08 L57.27 66.88 L57.37 66.67 L57.48 66.46 L57.59 66.25 L57.70 66.04 L57.87 65.82 L57.98 65.61 L58.14 65.39 L58.25 65.18 L58.36 64.97 L58.52 64.75 L58.68 64.53 L58.79 64.31 L58.96 64.09 L59.12 63.87 L59.23 63.66 L59.39 63.44 L59.56 63.22 L59.72 63.00 L59.89 62.78 L60.05 62.56 L60.27 62.35 L60.43 62.13 L60.60 61.91 L60.81 61.69 L60.98 61.47 L61.20 61.25 L61.36 61.04 L61.58 60.82 L61.80 60.60 L62.02 60.38 L62.24 60.16 L62.45 59.94 L62.67 59.72 L62.89 59.50 L63.16 59.28 L63.38 59.07 L63.65 58.85 L63.87 58.63 L64.15 58.41 L64.42 58.19 L64.69 57.98 L64.97 57.76 L65.24 57.54 L65.57 57.32 L65.84 57.10 L66.17 56.88 L66.50 56.67 L66.82 56.45 L67.15 56.23 L67.53 56.01 L67.91 55.79 L68.24 55.57 L68.68 55.35 L69.06 55.13 L69.50 54.91 L69.94 54.70 L70.37 54.48 L70.81 54.26 L71.30 54.04 L71.85 53.82 L72.34 53.60 L72.88 53.39 L73.49 53.17 L74.09 52.95 L74.74 52.73 L75.40 52.51 L76.11 52.29 L76.87 52.08 L77.64 51.86 L78.51 51.64 L79.39 51.42 L80.37 51.20 L81.41 50.98 L82.61 50.77 L83.86 50.55 L85.34 50.33 L87.09 50.11 L92.22 50.00 L88.12 50.00 L86.16 49.78 L84.57 49.56 L83.16 49.34 L81.95 49.12 L80.86 48.91 L79.82 48.69 L78.89 48.47 L78.07 48.25 L77.25 48.03 L76.49 47.81 L75.72 47.60 L75.07 47.38 L74.42 47.16 L73.76 46.94 L73.21 46.72 L72.61 46.50 L72.06 46.29 L71.57 46.07 L71.08 45.85 L70.59 45.63 L70.16 45.41 L69.66 45.20 L69.28 44.98 L68.84 44.76 L68.46 44.54 L68.08 44.32 L67.69 44.10 L67.37 43.88 L66.99 43.66 L66.66 43.44 L66.33 43.23 L66.00 43.01 L65.73 42.79 L65.40 42.57 L65.13 42.35 L64.80 42.13 L64.53 41.92 L64.25 41.70 L64.04 41.48 L63.76 41.26 L63.49 41.04 L63.27 40.82 L63.00 40.61 L62.78 40.39 L62.56 40.17 L62.35 39.95 L62.13 39.73 L61.91 39.51 L61.69 39.29 L61.47 39.07 L61.25 38.85 L61.09 38.64 L60.87 38.42 L60.71 38.20 L60.54 37.98 L60.32 37.76 L60.16 37.54 L59.99 37.33 L59.83 37.11 L59.67 36.89 L59.50 36.67 L59.34 36.45 L59.17 36.24 L59.01 36.02 L58.90 35.80 L58.74 35.58 L58.57 35.36 L58.47 35.14 L58.30 34.93 L58.19 34.72 L58.03 34.50 L57.92 34.28 L57.81 34.07 L57.65 33.85 L57.54 33.64 L57.43 33.43 L57.32 33.22 L57.21 33.03 L57.10 32.82 L56.94 32.62 L56.83 32.41 L56.72 32.21 L56.61 32.01 L56.55 31.83 L56.45 31.63 L56.34 31.44 L56.23 31.25 L56.12 31.06 L56.01 30.88 L55.95 30.70 L55.84 30.52 L55.73 30.34 L55.62 30.16 L55.57 29.98 L55.46 29.81 L55.41 29.64 L55.30 29.47 L55.19 29.30 L55.13 29.13 L55.02 28.97 L54.97 28.80 L54.86 28.64 L54.80 28.47 L54.70 28.32 L54.64 28.16 L54.53 28.00 L54.48 27.84 L54.42 27.68 L54.31 27.52 L54.26 27.37 L54.21 27.21 L54.10 27.05 L54.04 26.90 L53.99 26.74 L53.88 26.58 L53.83 26.43 L53.77 26.27 L53.72 26.11 L53.61 25.95 L53.55 25.79 L53.50 25.64 L53.44 25.48 L53.39 25.32 L53.28 25.16 L53.22 25.01 L53.17 24.85 L53.11 24.69 L53.06 24.53 L53.01 24.38 L52.95 24.22 L52.90 24.07 L52.79 23.91 L52.73 23.75 L52.68 23.59 L52.62 23.43 L52.57 23.27 L52.51 23.12 L52.46 22.97 L52.40 22.81 L52.35 22.65 L52.29 22.49 L52.24 22.33 L52.18 22.18 L52.13 22.02 L52.08 21.86 L52.08 21.70 L52.02 21.55 L51.97 21.39 L51.91 21.23 L51.86 21.07 L51.80 20.92 L51.75 20.76 L51.75 20.60 L51.69 20.44 L51.64 20.29 L51.58 20.13 L51.53 19.98 L51.47 19.82 L51.47 19.66 L51.42 19.50 L51.36 19.34 L51.31 19.18 L51.31 19.03 L51.26 18.88 L51.20 18.72 L51.15 18.56 L51.15 18.40 L51.09 18.24 L51.04 18.09 L51.04 17.93 L50.98 17.77 L50.93 17.62 L50.93 17.46 L50.87 17.30 L50.82 17.14 L50.82 16.98 L50.76 16.82 L50.76 16.67 L50.71 16.51 L50.65 16.36 L50.65 16.20 L50.60 16.04 L50.60 15.88 L50.54 15.73 L50.54 15.57 L50.49 15.41 L50.49 15.25 L50.43 15.10 L50.43 14.94 L50.38 14.79 L50.38 14.63 L50.33 14.47 L50.33 14.31 L50.27 14.15 L50.27 13.99 L50.27 13.84 L50.22 13.68 L50.22 13.53 L50.16 13.37 L50.16 13.21 L50.16 13.05\"/><path fill=\"#000\" d=\"M-4.00 78.45 L0.00 75.86 L4.00 73.40 L8.00 71.09 L12.00 68.91 L16.00 66.88 L20.00 64.99 L24.00 63.24 L28.00 61.63 L32.00 60.17 L36.00 58.84 L40.00 57.66 L44.00 56.62 L48.00 55.71 L52.00 54.95 L56.52 54.26 L61.31 53.58 L65.75 52.96 L69.84 52.41 L73.59 51.92 L76.99 51.51 L80.04 51.15 L82.75 50.86 L85.11 50.64 L87.12 50.48 L88.79 50.39 L90.11 50.37 L91.08 50.41 L91.71 50.52 L91.99 50.69 L88.02 51.18 L89.80 54.01 L90.76 54.43 L90.47 54.45 L89.57 54.51 L88.23 54.65 L86.49 54.87 L84.39 55.18 L81.92 55.57 L79.10 56.05 L75.93 56.61 L72.42 57.26 L68.55 57.99 L64.34 58.81 L59.79 59.72 L54.93 60.70 L51.19 61.58 L47.45 62.58 L43.72 63.72 L40.00 64.99 L36.29 66.39 L32.58 67.92 L28.80 69.38 L25.01 70.97 L21.20 72.70 L17.40 74.57 L13.58 76.57 L9.76 78.72 L5.92 81.00 L2.08 83.43\"/><path fill=\"#000\" transform=\"matrix(-1 0 0 -1 100 100)\" d=\"M-4.00 78.45 L0.00 75.86 L4.00 73.40 L8.00 71.09 L12.00 68.91 L16.00 66.88 L20.00 64.99 L24.00 63.24 L28.00 61.63 L32.00 60.17 L36.00 58.84 L40.00 57.66 L44.00 56.62 L48.00 55.71 L52.00 54.95 L56.52 54.26 L61.31 53.58 L65.75 52.96 L69.84 52.41 L73.59 51.92 L76.99 51.51 L80.04 51.15 L82.75 50.86 L85.11 50.64 L87.12 50.48 L88.79 50.39 L90.11 50.37 L91.08 50.41 L91.71 50.52 L91.99 50.69 L88.02 51.18 L89.80 54.01 L90.76 54.43 L90.47 54.45 L89.57 54.51 L88.23 54.65 L86.49 54.87 L84.39 55.18 L81.92 55.57 L79.10 56.05 L75.93 56.61 L72.42 57.26 L68.55 57.99 L64.34 58.81 L59.79 59.72 L54.93 60.70 L51.19 61.58 L47.45 62.58 L43.72 63.72 L40.00 64.99 L36.29 66.39 L32.58 67.92 L28.80 69.38 L25.01 70.97 L21.20 72.70 L17.40 74.57 L13.58 76.57 L9.76 78.72 L5.92 81.00 L2.08 83.43\"/></mask><clipPath id=\"cemt\"><path transform=\"matrix(-1 0 0 -1 100 100)\" d=\"M0.12 84.72 L4.01 82.20 L7.84 79.84 L11.67 77.63 L15.49 75.55 L19.30 73.61 L23.11 71.82 L26.90 70.16 L30.69 68.63 L34.43 67.14 L38.14 65.67 L41.86 64.34 L45.58 63.14 L49.32 62.06 L53.06 61.12 L57.38 60.20 L62.11 59.26 L66.49 58.39 L70.53 57.62 L74.22 56.92 L77.56 56.32 L80.56 55.80 L83.20 55.36 L85.48 55.01 L87.41 54.75 L88.95 54.57 L90.08 54.48 L90.71 54.44 L90.53 54.36 L88.45 52.55 L110 51.18 L110 110 L-10 110Z\"/></clipPath><clipPath id=\"cemb\"><path d=\"M0.12 84.72 L4.01 82.20 L7.84 79.84 L11.67 77.63 L15.49 75.55 L19.30 73.61 L23.11 71.82 L26.90 70.16 L30.69 68.63 L34.43 67.14 L38.14 65.67 L41.86 64.34 L45.58 63.14 L49.32 62.06 L53.06 61.12 L57.38 60.20 L62.11 59.26 L66.49 58.39 L70.53 57.62 L74.22 56.92 L77.56 56.32 L80.56 55.80 L83.20 55.36 L85.48 55.01 L87.41 54.75 L88.95 54.57 L90.08 54.48 L90.71 54.44 L90.53 54.36 L88.45 52.55 L110 51.18 L110 110 L-10 110Z\"/></clipPath></defs><g mask=\"url(#kem)\"><rect width=\"100\" height=\"100\" fill=\"#a79cf7\"/><rect width=\"100\" height=\"100\" fill=\"#cfc7ff\" clip-path=\"url(#cemt)\"/><rect width=\"100\" height=\"100\" fill=\"#7566d8\" clip-path=\"url(#cemb)\"/></g></svg>"
4
hooks/theme.ts 188 lines
1// The look of the mod's accent: violet (the brand) or Claude orange. Every violet in the mod is one hex of the same
2// family, so the theme turns them in one place instead of keeping two sets of colours: any #rrggbb in a violet hue is
3// moved to the orange hue, neutrals and the other signal colours (green, yellow, red, blue) are left alone.
4
5export type ThemeName = 'violet' | 'orange' | 'rose'
6export const THEMES: readonly ThemeName[] = ['violet', 'orange', 'rose']
7// The hue each theme turns the violets to. Rose is a cherry-blossom pink; its sparkles are drawn as falling petals (see petals).
8const HUES: Record<Exclude<ThemeName, 'violet'>, number> = { orange: 15, rose: 340 }
9
10let current: ThemeName = 'violet'
11// Light appearance: the panels are drawn for a dark app; on a light one every colour is turned (lightHex) so the surfaces
12// come out pale and the text and accents deep. It sits on top of the theme's hue, so every theme has a light form.
13let light = false
14export const setLight = (on: boolean) => {
15  if (on !== light) lmemo.clear()
16  light = on
17}
18export const isLight = () => light
19export const setTheme = (name: ThemeName) => {
20  // The turned colours are remembered per hex, so a new theme starts with none: orange's would show in rose.
21  if (name !== current) memo.clear()
22  current = name
23}
24export const getTheme = () => current
25
26/** The brand violet, the colour the rest of the family is turned from. */
27export const VIOLET = '#a79cf7'
28
29// Claude's orange (#d97757) sits at hue 15, saturation 63%, lightness 60%. The brand violet is lighter and stronger, so
30// the saturation and the lightness of what turns come down to meet it.
31const hexRe = /#[0-9a-fA-F]{6}\b/g
32const memo = new Map<string, string>()
33const lmemo = new Map<string, string>()
34
35function toHsl(r: number, g: number, b: number): [number, number, number, number] {
36  const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min
37  const l = (max + min) / 2
38  const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1))
39  let h = 0
40  if (d !== 0) {
41    h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4
42    h = (h * 60 + 360) % 360
43  }
44  return [h, s, l, d]
45}
46
47function fromHsl(h: number, s: number, l: number): string {
48  const c = (1 - Math.abs(2 * l - 1)) * s
49  const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
50  const m = l - c / 2
51  const [r, g, b] = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x] : h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x]
52  const byte = (v: number) => Math.max(0, Math.min(255, Math.round((v + m) * 255))).toString(16).padStart(2, '0')
53  return `#${byte(r)}${byte(g)}${byte(b)}`
54}
55
56/** One hex in the current theme: violets turn orange, everything else stays. */
57export function tintHex(hex: string): string {
58  if (current === 'violet') return hex
59  const key = hex.toLowerCase()
60  const hit = memo.get(key)
61  if (hit) return hit
62  const n = parseInt(key.slice(1), 16)
63  const [h, s, l, chroma] = toHsl((n >> 16) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255)
64  // Violet hues with real colour in them; the greys that lean blue (hardly any chroma or saturation) are not in the family.
65  const out = h >= 235 && h <= 300 && chroma >= 0.03 && s >= 0.15 ? fromHsl(HUES[current], Math.min(1, s * (current === 'rose' ? 0.9 : 0.75)), l > 0.5 ? Math.max(0.5, l - (current === 'rose' ? 0.08 : 0.19)) : l) : hex
66  memo.set(key, out)
67  return out
68}
69
70// A cherry-blossom petal, and the fall it makes: each sparkle of the art becomes a petal at the same place, falling slowly
71// and turning as it goes. The drawing's own CSS animates sparkles in place; the petals bring their own.
72const PETAL = 'M0 -2.2C1.7 -1.7 1.9 0.9 0 2.2C-1.9 0.9 -1.7 -1.7 0 -2.2Z'
73const PETAL_CSS = '.pt{fill:#ffb3cf;opacity:0;animation-name:fall;animation-timing-function:linear;animation-iteration-count:infinite}@keyframes fall{0%{opacity:0;transform:translate(0,-4px) rotate(0deg)}12%{opacity:.6}85%{opacity:.5}100%{opacity:0;transform:translate(-16px,34px) rotate(280deg)}}'
74function petals(text: string): string {
75  if (!text.includes('class="sp"')) return text
76  return text
77    .replace(/<path class="sp" style="transform-origin:([\d.]+)px [\d.]+px;animation-duration:([\d.]+)s;animation-delay:([\d.]+)s" d="[^"]*"\/>/g, (_m, x: string, dur: string, delay: string) =>
78      `<g transform="translate(${x} 0)"><path class="pt" style="animation-duration:${(Number(dur) * 2.2).toFixed(1)}s;animation-delay:${delay}s" d="${PETAL}"/></g>`)
79    .replace('</style>', `${PETAL_CSS}</style>`)
80}
81
82/**
83 * One hex turned for a light app. A surface that was dark comes out pale. Text that was light comes out deep, as light as
84 * it can be while still reading on the pale surfaces (4.6:1), so violet stays violet and green stays green; text that was
85 * already dark is only darkened if it would not read. Drawn art has no role, so it follows its own lightness. An
86 * eight-digit hex keeps its alpha.
87 */
88const luminance = (hex: string) => {
89  const n = parseInt(hex.slice(1, 7), 16)
90  const f = (v: number) => (v /= 255) <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)
91  return 0.2126 * f(n >> 16) + 0.7152 * f((n >> 8) & 255) + 0.0722 * f(n & 255)
92}
93// The palest and the deepest of the turned surfaces: text has to read on both.
94const PALE_REF = luminance('#e3e0ed')
95export function lightHex(hex: string, role: 'text' | 'surface' | 'art' = 'art'): string {
96  const key = hex.toLowerCase()
97  const memoKey = role + key
98  const hit = lmemo.get(memoKey)
99  if (hit) return hit
100  const n = parseInt(key.slice(1, 7), 16)
101  const [h, s, l] = toHsl((n >> 16) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255)
102  const asText = role === 'text' || (role === 'art' && l >= 0.5)
103  let out: string
104  if (asText) {
105    const sat = Math.min(s, 0.72)
106    // Greys keep their order (main text darker than dim); strong colours start a little lighter so they stay colours.
107    let at = l >= 0.5 ? Math.max(0.12, Math.min(0.5, 1 - l + (s >= 0.35 ? 0.2 : 0))) : l
108    out = fromHsl(h, sat, at)
109    while (at > 0.12 && (PALE_REF + 0.05) / (luminance(out) + 0.05) < 4.6) {
110      at -= 0.01
111      out = fromHsl(h, sat, at)
112    }
113  } else {
114    // Near white like the app's own light panels: the darkest surfaces (the panel itself) come out lightest, and the
115    // lighter ones (hover, edges, header) a step darker, as they were a step lighter on dark.
116    // A tinted surface (an alert band, the brand panel) keeps more of its colour, so cold still reads blue and swamp green.
117    const top = s >= 0.2 ? 0.955 : 0.99
118    out = l >= 0.5 ? key.slice(0, 7) : fromHsl(h, Math.min(s, s >= 0.2 ? 0.6 : 0.35), Math.max(0.8, Math.min(top, top - (l - 0.07) * 0.9)))
119  }
120  const result = key.length === 9 ? out + key.slice(7) : out
121  lmemo.set(memoKey, result)
122  return result
123}
124const lightRe = /#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?(?![0-9a-fA-F])/g
125
126/** Every hex in a string (an SVG, a style) in the current theme. */
127export function tint(text: string): string {
128  if (current === 'violet' && !light) return text
129  const turned = current === 'violet' ? text : text.replace(hexRe, tintHex)
130  // Petals only where the art was violet and has turned pink: the green, yellow, blue and red cards keep their sparkles.
131  const drawn = current === 'rose' && turned !== text ? petals(turned) : turned
132  if (!light) return drawn
133  // A shape marked data-keep (a switch's knob) keeps its colour: it sits on a colour of its own, not on the panel.
134  return drawn.replace(/(<[^>]*>)|([^<]+)/g, (part: string, tag?: string) =>
135    tag && tag.includes('data-keep') ? part : part.replace(lightRe, hex => lightHex(hex)))
136}
137
138/** The accent as text colour. */
139export const accent = () => tintHex(VIOLET)
140
141type Component = (props: Record<string, unknown>) => unknown
142const wrapped = new WeakMap<object, Component>()
143
144/** The Svg element that draws its source in the theme. In violet it is the app's own, untouched. */
145export function themedSvg<T>(Svg: T): T {
146  if ((current === 'violet' && !light) || typeof Svg !== 'function') return Svg
147  let wrap = wrapped.get(Svg as object)
148  if (!wrap) {
149    const inner = Svg as unknown as Component
150    wrap = props => inner(typeof props.source === 'string' ? { ...props, source: tint(props.source) } : props)
151    wrapped.set(Svg as object, wrap)
152  }
153  return wrap as unknown as T
154}
155
156const PAINT = ['color', 'backgroundColor', 'borderColor', 'borderTopColor', 'borderBottomColor', 'borderLeftColor', 'borderRightColor']
157const paint = (v: unknown, role: 'text' | 'surface') => (typeof v === 'string' && /^#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?$/.test(v) ? lightHex(v, role) : v)
158/** A props object with its hex colours (and those of its hover) turned for a light app. */
159export function lightProps(props: Record<string, unknown>): Record<string, unknown> {
160  let out = props
161  for (const k of PAINT) {
162    const turned = paint(props[k], k === 'color' ? 'text' : 'surface')
163    if (turned !== props[k]) out = { ...out, [k]: turned }
164  }
165  const hover = props.hover
166  if (hover && typeof hover === 'object') out = { ...out, hover: lightProps(hover as Record<string, unknown>) }
167  return out
168}
169const lightWrapped = new WeakMap<object, Component>()
170const lit = (Part: unknown): unknown => {
171  if (typeof Part !== 'function') return Part
172  let wrap = lightWrapped.get(Part as object)
173  if (!wrap) {
174    const inner = Part as unknown as Component
175    wrap = props => inner(props && typeof props === 'object' ? lightProps(props) : props)
176    lightWrapped.set(Part as object, wrap)
177  }
178  return wrap
179}
180
181/** The parts `$.ui.resolve` hands over, with the Svg in the theme and, in a light app, the Box, Text and Button turned light. Absent stays absent. */
182export function themedEls<T extends object>(els: T): T {
183  const out: Record<string, unknown> = { ...els }
184  if ('Svg' in els) out.Svg = themedSvg((els as { Svg: unknown }).Svg)
185  if (light) for (const part of ['Box', 'Text', 'Button']) if (part in els) out[part] = lit((els as Record<string, unknown>)[part])
186  return out as T
187}
188
hooks/progress.tsx 616 lines
1import { atom, read, update } from 'claude-code'
2import type { On } from 'claude-code'
3import { tintHex } from './theme'
4
5import type { Progress, ProgressStep } from '../types'
6
7// The progress bar: a band above the prompt that follows a bigger task step by step (docs/specs/2026-10-06-progress-bar.md).
8// Its own file, so the branches that edit register.tsx meet it in a few lines only. The engine follows $ only into
9// functions of the file it is in, so what needs $ in a hook register.tsx already holds (session.start, prompt.submit,
10// turn.complete, the band) is a short section there; the rules and the drawing are here.
11
12/** A list shorter than this is no bigger task: no bar. */
13export const MIN_STEPS = 3
14/** Past this many steps the bar is one continuous fill instead of a segment per step. */
15const SEGMENTS_MAX = 12
16
17// The brand purple, as in register.tsx, and the two signal sets: yellow when Claude asks, green when it is done.
18/** A phase's look in the current theme: the violet ones turn orange, the signal greens and yellows stay. */
19export const looks = (phase: Progress['phase']) => {
20  const l = LOOKS[phase]
21  return { color: tintHex(l.color), bg: tintHex(l.bg), edge: tintHex(l.edge) }
22}
23export const LOOKS: Record<Progress['phase'], { color: string; bg: string; edge: string }> = {
24  planning: { color: '#a79cf7', bg: '#15121f', edge: '#4a3f80' },
25  working: { color: '#a79cf7', bg: '#15121f', edge: '#4a3f80' },
26  paused: { color: '#7d76a8', bg: '#15121f', edge: '#3a3360' },
27  asking: { color: '#e0a33a', bg: '#1c1608', edge: '#6b5218' },
28  done: { color: '#6fcf8e', bg: '#0e1a12', edge: '#2f6b45' },
29}
30// Every segment holds the same long run of one character and is clipped to its share: equal content, equal width.
31const RUN = 160
32
33const progressState = atom({ plugin: 'effortless', key: 'progress' } as const, null)
34// The list the person closed with ✕, as register.tsx sets it; read here only.
35const progressHidden = atom({ plugin: 'effortless', key: 'progressHidden' } as const, null)
36
37export type Cue = 'question' | 'done'
38
39type Todo = { content: string; status: ProgressStep['status']; activeForm?: string }
40
41/** The steps of a TodoWrite list, the whole list each time. */
42export function stepsFromTodos(todos: readonly Todo[]): ProgressStep[] {
43  return todos.map((t, i) => ({ id: `todo-${i}`, label: t.content, doing: t.activeForm || t.content, status: t.status }))
44}
45
46/** The steps with a task TaskCreate made added at the end, pending. */
47export function withTaskCreated(steps: readonly ProgressStep[], id: string, task: { subject: string; activeForm?: string }): ProgressStep[] {
48  return [...steps.filter(s => s.id !== id), { id, label: task.subject, doing: task.activeForm || task.subject, status: 'pending' }]
49}
50
51/** The steps after a TaskUpdate: a new status, subject or running form; deleted drops the step. */
52export function withTaskUpdated(
53  steps: readonly ProgressStep[],
54  change: { taskId: string; status?: ProgressStep['status'] | 'deleted'; subject?: string; activeForm?: string },
55): ProgressStep[] {
56  if (change.status === 'deleted') return steps.filter(s => s.id !== change.taskId)
57  return steps.map(s =>
58    s.id !== change.taskId
59      ? s
60      : {
61          ...s,
62          ...(change.status ? { status: change.status } : {}),
63          ...(change.subject ? { label: change.subject } : {}),
64          ...(change.activeForm ? { doing: change.activeForm } : {}),
65        },
66  )
67}
68
69/** How far along the task is, 0 to 1: completed steps, and half of each one in progress. */
70export function progressShare(steps: readonly ProgressStep[]): number {
71  if (!steps.length) return 0
72  const done = steps.filter(s => s.status === 'completed').length
73  const doing = steps.filter(s => s.status === 'in_progress').length
74  return Math.min(1, (done + doing / 2) / steps.length)
75}
76
77/** The step to name: the one in progress, else the next pending one. */
78export function currentStep(steps: readonly ProgressStep[]): ProgressStep | undefined {
79  return steps.find(s => s.status === 'in_progress') ?? steps.find(s => s.status === 'pending')
80}
81
82/** Which step the task is at, counted from 1: the steps completed plus one, at most all of them. */
83export function stepNumber(steps: readonly ProgressStep[]): number {
84  return Math.min(steps.length, steps.filter(s => s.status === 'completed').length + 1)
85}
86
87/** The list as one string: closing the bar hides it for this list until another one comes. */
88export function stepsKey(steps: readonly ProgressStep[]): string {
89  return steps.map(s => s.label).join('\n')
90}
91
92/**
93 * Where the task stands when a main-conversation turn ends: done when every step is completed, asking when the answer
94 * ends on a question, paused otherwise; null when there is nothing to follow (no list came, or too short a one).
95 */
96export function phaseAtTurnEnd(
97  p: Progress,
98  turn: { reason: string; answer: string },
99  endsOnQuestion: (context: string) => boolean,
100): Progress['phase'] | null {
101  if (p.steps.length < MIN_STEPS) return null
102  if (p.steps.every(s => s.status === 'completed')) return 'done'
103  if (turn.reason === 'answer' && endsOnQuestion(`assistant: ${turn.answer}`)) return 'asking'
104  return 'paused'
105}
106
107/** The bar after a turn ends, and the sound to play: a chime when it turns done or asking. */
108export function atTurnEnd(
109  p: Progress | null,
110  turn: { reason: string; answer: string },
111  endsOnQuestion: (context: string) => boolean,
112): { next: Progress | null; cue?: Cue } {
113  if (!p) return { next: null }
114  const phase = phaseAtTurnEnd(p, turn, endsOnQuestion)
115  if (phase === null) return { next: null }
116  if (phase === p.phase) return { next: p }
117  return { next: { ...p, phase }, ...(phase === 'done' ? { cue: 'done' } : phase === 'asking' ? { cue: 'question' } : {}) }
118}
119
120/** The bar after the person sends a prompt: a finished task is over; an asking or paused one carries on. */
121export function afterPrompt(p: Progress | null, prompt: { text: string; origin: { kind: string } }): Progress | null {
122  const byPerson = prompt.origin.kind === 'composer' || prompt.origin.kind === 'bridge' || prompt.origin.kind === 'sdk'
123  if (!p || !byPerson || prompt.text.trim().startsWith('/')) return p
124  if (p.phase === 'done') return null
125  return p.phase === 'asking' || p.phase === 'paused' ? { ...p, phase: 'working' } : p
126}
127
128/**
129 * How Windows plays a sound: `$.audio.play` plays nothing in a Windows terminal, so PowerShell's SoundPlayer plays the
130 * file. Null on other systems, where `$.audio.play` does it.
131 */
132export function soundArgv(root: string, file: string): string[] | null {
133  if (!/^[A-Za-z]:[\\/]/.test(root)) return null
134  const path = `${root.replace(/[\\/]+$/, '')}\\${file}`.replace(/\//g, '\\').replace(/'/g, "''")
135  return ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', `(New-Object Media.SoundPlayer '${path}').PlaySync()`]
136}
137
138// Sounds wait here for the session's timer (register.tsx plays them): a hook's own $ is refused once the hook has
139// returned, and a sound should not hold the turn up while it plays.
140let cues: Cue[] = []
141
142/** Queues a sound, unless sounds are switched off. */
143export function queueCue(hidden: readonly string[], sound: Cue | undefined) {
144  if (sound && !hidden.includes('sounds') && !hidden.includes('progress')) cues.push(sound)
145}
146
147/** The queued sounds, taken off the queue. */
148export function takeCues(): Cue[] {
149  const due = cues
150  cues = []
151  return due
152}
153
154/** /effortless progress [ask|done|clear]: a demo task of five steps in the state asked for, to see the bar. */
155export function demoProgress(arg: string): { progress: Progress | null; cue?: Cue; text: string } {
156  const demo = (completed: number, doing: boolean): ProgressStep[] =>
157    [
158      ['Read the code', 'Reading the code'],
159      ['Write the tests', 'Writing the tests'],
160      ['Build the bar', 'Building the bar'],
161      ['Run the tests', 'Running the tests'],
162      ['Write the handoff', 'Writing the handoff'],
163    ].map(([label, running], i) => ({
164      id: `demo-${i}`,
165      label,
166      doing: running,
167      status: i < completed ? 'completed' : i === completed && doing ? 'in_progress' : 'pending',
168    }))
169  if (arg === 'clear') return { progress: null, text: 'The progress bar is cleared.' }
170  if (arg === 'plan')
171    return { progress: { phase: 'planning', steps: [] }, text: 'The progress bar is planning (a test): Claude is in plan mode and no steps exist yet.' }
172  if (arg === 'ask')
173    return {
174      progress: { phase: 'asking', steps: demo(2, true) },
175      cue: 'question',
176      text: 'The progress bar is yellow: Claude is asking something (a test). /effortless progress clear removes it.',
177    }
178  if (arg === 'done')
179    return {
180      progress: { phase: 'done', steps: demo(5, false) },
181      cue: 'done',
182      text: 'The progress bar is green: every step is done (a test). /effortless progress clear removes it.',
183    }
184  return {
185    progress: { phase: 'working', steps: demo(2, true) },
186    text: 'A demo progress bar is showing, step 3 of 5 (a test). Try /effortless progress plan, progress ask, progress done, progress clear.',
187  }
188}
189
190/** The line in the system prompt that asks Claude to keep a step list for multi-step work. */
191export const PROGRESS_PROMPT =
192  'When a request takes three or more distinct steps, write them as a task list with the task tools (TaskCreate and ' +
193  'TaskUpdate, or TodoWrite) before you start, and keep the status of each step current as you go. Skip the list for quick ' +
194  'answers and one-step changes.'
195
196/**
197 * The hooks register.tsx does not have yet: the step list from the todo and task tools, Claude asking, and plan mode. `hidden` gives the parts switched off.
198 */
199export function registerProgress(on: On, hidden: () => readonly string[]) {
200  const off = () => hidden().includes('progress')
201
202  // The bar follows the task tools, and Claude writes a list only when it decides to: this line asks it to for real
203  // multi-step work, so the bar has steps to show. Static text, so the prompt cache stays warm. It rides in the first
204  // message's context, not the system prompt: prompt.compose is missing on the stable CLI (2.1.285) and a module that
205  // names it does not load there at all.
206  on('prompt.context', async ($, e, next) => {
207    const result = await next(e)
208    if (off()) return result
209    return { ...result, blocks: [...result.blocks, { name: 'effortless-progress', text: PROGRESS_PROMPT }] }
210  })
211
212  on('tool.call', async ($, e, next) => {
213    if (off() || e.agentId !== undefined) return next(e)
214    if (e.tool === 'AskUserQuestion') {
215      const p = await read($, progressState)
216      const shown = Boolean(p && (p.phase === 'working' || p.phase === 'paused') && p.steps.length >= MIN_STEPS)
217      if (shown) {
218        await update($, progressState, cur => (cur ? { ...cur, phase: 'asking' } : cur))
219        // A chime only with a bar to see: a list the person closed asks quietly.
220        if (p && (await read($, progressHidden)) !== stepsKey(p.steps)) queueCue(hidden(), 'question')
221      }
222      const result = await next(e)
223      if (shown) await update($, progressState, cur => (cur?.phase === 'asking' ? { ...cur, phase: 'working' } : cur))
224      return result
225    }
226    if (e.tool === 'EnterPlanMode') {
227      // Claude is planning for real: the bar says so until a step list comes. A guess from the effort pick showed it
228      // for turns that never made a list, a handoff among them.
229      const result = await next(e)
230      if (!('deny' in result && result.deny) && !result.isError) await update($, progressState, p => p ?? { phase: 'planning', steps: [] })
231      return result
232    }
233    if (e.tool !== 'TodoWrite' && e.tool !== 'TaskCreate' && e.tool !== 'TaskUpdate') return next(e)
234    const result = await next(e)
235    if (('deny' in result && result.deny) || result.isError) return result
236    if (e.tool === 'TodoWrite') {
237      const steps = stepsFromTodos(e.todos)
238      await update($, progressState, () => ({ phase: 'working', steps }))
239    } else if (e.tool === 'TaskCreate') {
240      const id = (result.result as { task?: { id?: string } } | undefined)?.task?.id
241      if (id) await update($, progressState, p => ({ phase: 'working', steps: withTaskCreated(p && p.phase !== 'done' ? p.steps : [], id, e) }))
242    } else {
243      await update($, progressState, p => (p ? { phase: p.phase === 'planning' ? 'working' : p.phase, steps: withTaskUpdated(p.steps, e) } : p))
244    }
245    return result
246  })
247}
248
249/** What drawing the bar takes besides the state: the surface's elements, the rows there is room for, the ✕. */
250export type ProgressDraw = {
251  // The surface's element table, as $.ui.resolve(e) gives it.
252  // eslint-disable-next-line @typescript-eslint/no-explicit-any
253  Box: any
254  // eslint-disable-next-line @typescript-eslint/no-explicit-any
255  Text: any
256  // eslint-disable-next-line @typescript-eslint/no-explicit-any
257  Button: any
258  /** The surface's Svg, on the surfaces that have one (not the terminal): the track is drawn as one still image. */
259  // eslint-disable-next-line @typescript-eslint/no-explicit-any
260  Svg?: any
261  maxRows: number
262  onClose: () => unknown
263}
264
265/**
266 * Whether the bar shows in this place: 'active' (planning, working, asking) sits above the alert bands, 'resting'
267 * (done, paused) below them. A list too short, or one the person closed, shows nowhere.
268 */
269export function progressShows(p: Progress | null, hiddenKey: string | null, when: 'active' | 'resting'): p is Progress {
270  if (!p) return false
271  // A finished task comes before the alerts too: it is news, and it lasts only until the next message.
272  const active = p.phase === 'planning' || p.phase === 'working' || p.phase === 'asking' || p.phase === 'done'
273  if ((when === 'active') !== active) return false
274  if (p.phase === 'planning') return true
275  return p.steps.length >= MIN_STEPS && hiddenKey !== stepsKey(p.steps)
276}
277
278/** The thinking dots' size in pixels. */
279export const THINK_W = 34
280
281/**
282 * Planning, before any step exists: three dots pulsing one after another, like thinking. No bar: nothing is measured
283 * yet, so nothing pretends to fill. Fixed size, so its interactive frame fits it.
284 */
285export function thinkingSvg(phase: Progress['phase'], w = THINK_W, h = PILL_H): string {
286  const look = looks(phase)
287  const r = h / 2
288  const dots = [0, 1, 2]
289    .map(i => `<circle class="dot" style="animation-delay:${(i * 0.18).toFixed(2)}s" cx="${6 + i * 11}" cy="${r}" r="3" fill="${look.color}"/>`)
290    .join('')
291  return (
292    `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">` +
293    '<style>:root{color-scheme:light dark}html,body{margin:0;overflow:hidden}svg{background:transparent;display:block}' +
294    '.dot{transform-box:fill-box;transform-origin:center;opacity:.3;animation:dot 1.1s ease-in-out infinite}' +
295    '@keyframes dot{30%{opacity:1;transform:translateY(-2.5px) scale(1.15)}60%{opacity:.3;transform:none}}</style>' +
296    dots +
297    '</svg>'
298  )
299}
300
301/** The thinking pill's height in pixels. */
302export const PILL_H = 16
303
304/**
305 * The track as one image that scales to the band's width, so it fits any window: a pill per step. Done steps are
306 * filled, pending steps a faint outline, and the step in progress is alive: soft light flows through it, a shine
307 * passes over it now and then and its glow breathes. A small flag at the end turns into a ticked circle when the task
308 * is done. Past SEGMENTS_MAX one continuous pill, filled to the share done. The image is not interactive (an
309 * interactive one needs a fixed size); its CSS animation still runs where the surface animates images, and it reads
310 * the same standing still.
311 */
312export function progressTrackSvg(p: Progress): string {
313  const look = looks(p.phase)
314  const id = p.phase
315  const W = 1000
316  const H = 24
317  const end = 30
318  const y = 4
319  const h = 16
320  const r = h / 2
321  const n = (v: number) => v.toFixed(1)
322  const finished = p.phase === 'done'
323  const pending = (x: number, w: number) =>
324    `<rect x="${n(x + 0.6)}" y="${y + 0.6}" width="${n(Math.max(0, w - 1.2))}" height="${h - 1.2}" rx="${r - 0.6}" fill="#ffffff" fill-opacity=".04" stroke="${look.color}" stroke-opacity=".3" stroke-width="1.2"/>`
325  const filled = (x: number, w: number) => `<rect x="${n(x)}" y="${y}" width="${n(Math.max(0, w))}" height="${h}" rx="${r}" fill="url(#fill-${id})"/>`
326  // The live pill: a breathing halo, a body of light flowing to the right, a shine that passes over it, a bright rim.
327  const live = (x: number, w: number, share = 1) => {
328    const lit = Math.max(r * 2, w * share)
329    const flow = 160
330    return (
331      `<rect class="halo" x="${n(x)}" y="${y}" width="${n(lit)}" height="${h}" rx="${r}" fill="${look.color}" filter="url(#halo-${id})"/>` +
332      (share < 1 ? `<rect x="${n(x)}" y="${y}" width="${n(w)}" height="${h}" rx="${r}" fill="${look.color}" fill-opacity=".1"/>` : '') +
333      `<clipPath id="cut-${id}"><rect x="${n(x)}" y="${y}" width="${n(lit)}" height="${h}" rx="${r}"/></clipPath>` +
334      `<g clip-path="url(#cut-${id})">` +
335      `<rect x="${n(x)}" y="${y}" width="${n(lit)}" height="${h}" fill="${look.color}" fill-opacity=".78"/>` +
336      `<g class="flow"><rect x="${n(x - flow)}" y="${y}" width="${n(lit + flow * 2)}" height="${h}" fill="url(#flow-${id})"/></g>` +
337      `<rect x="${n(x)}" y="${y}" width="${n(lit)}" height="${h / 2}" fill="#ffffff" fill-opacity=".1"/>` +
338      `<g class="shine"><rect x="${n(x - 60)}" y="${y - 4}" width="44" height="${h + 8}" transform="skewX(-25)" fill="url(#shine-${id})"/></g>` +
339      '</g>' +
340      `<rect x="${n(x + 0.6)}" y="${y + 0.6}" width="${n(lit - 1.2)}" height="${h - 1.2}" rx="${r - 0.6}" fill="none" stroke="#ffffff" stroke-opacity=".32" stroke-width="1.2"/>` +
341      `<style>.shine{animation:shine-${id} 3.2s ease-in-out infinite}@keyframes shine-${id}{0%,35%{transform:translateX(0)}100%{transform:translateX(${n(lit + 140)}px)}}</style>`
342    )
343  }
344  const total = p.steps.length
345  const room = W - end
346  const parts: string[] = []
347  if (total > 0 && total <= SEGMENTS_MAX) {
348    const gap = 10
349    const seg = (room - gap * (total - 1)) / total
350    p.steps.forEach((step, i) => {
351      const x = i * (seg + gap)
352      if (finished || step.status === 'completed') parts.push(filled(x, seg))
353      else if (step.status === 'in_progress' && p.phase !== 'paused') parts.push(live(x, seg))
354      else parts.push(pending(x, seg))
355    })
356  } else {
357    parts.push(finished ? filled(0, room) : live(0, room, progressShare(p.steps)))
358  }
359  const fx = W - 12
360  const flag = finished
361    ? `<circle cx="${fx}" cy="12" r="9" fill="${look.color}"/><path d="M${fx - 4.4} 12.2L${fx - 1.4} 15.2L${fx + 4.2} 8.8" fill="none" stroke="${look.bg}" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>`
362    : `<path d="M${fx - 6} 21V3.5" stroke="${look.color}" stroke-opacity=".75" stroke-width="1.6" stroke-linecap="round"/><path d="M${fx - 5.2} 4L${fx + 7} 7.8L${fx - 5.2} 11.6Z" fill="${look.color}" fill-opacity=".75"/>`
363  return (
364    `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">` +
365    '<style>svg{background:transparent;display:block;overflow:visible}' +
366    '.flow{animation:flow 2.6s linear infinite}@keyframes flow{from{transform:translateX(0)}to{transform:translateX(160px)}}' +
367    '.halo{opacity:.35;animation:halo 2.4s ease-in-out infinite}@keyframes halo{50%{opacity:.75}}</style>' +
368    `<defs><linearGradient id="fill-${id}" x1="0" x2="1"><stop offset="0" stop-color="${look.color}" stop-opacity=".62"/><stop offset="1" stop-color="${look.color}"/></linearGradient>` +
369    // A repeating band of light every 160 units, so moving it by one period loops without a seam.
370    `<linearGradient id="flow-${id}" gradientUnits="userSpaceOnUse" x1="0" x2="160" spreadMethod="repeat"><stop offset="0" stop-color="#ffffff" stop-opacity="0"/>` +
371    `<stop offset=".5" stop-color="#ffffff" stop-opacity=".26"/><stop offset="1" stop-color="#ffffff" stop-opacity="0"/></linearGradient>` +
372    `<linearGradient id="shine-${id}" x1="0" x2="1"><stop offset="0" stop-color="#ffffff" stop-opacity="0"/><stop offset=".5" stop-color="#ffffff" stop-opacity=".8"/><stop offset="1" stop-color="#ffffff" stop-opacity="0"/></linearGradient>` +
373    `<filter id="halo-${id}" x="-10%" y="-80%" width="120%" height="260%"><feGaussianBlur stdDeviation="3.5"/></filter></defs>` +
374    parts.join('') + flag + '</svg>'
375  )
376}
377
378/** The background art's size in pixels: wider than any band, cut by it on the right. */
379export const ART_W = 1600
380export const ART_H = 64
381
382/**
383 * The band's whole background, in its colour, in layers: a faint dot grid, soft aurora clouds drifting and breathing,
384 * two flowing lines of light, a slow streak passing across, twinkling sparkles, and one layer by state. Planning:
385 * points of a constellation lighting up and linking. Working: particles streaming right. Asking: ripples spreading and
386 * question marks rising. Done: confetti falling and checkmarks rising. Fixed size, anchored left and cut by the band,
387 * so its interactive frame always fits; one source per state.
388 */
389export function progressArtSvg(phase: Progress['phase']): string {
390  const c = looks(phase).color
391  // A fixed pseudo-random spread, so the same state always draws the same art.
392  const spread = (count: number, salt: number) =>
393    Array.from({ length: count }, (_, i) => {
394      const a = Math.sin((i + 1) * 12.9898 + salt * 78.233) * 43758.5453
395      const b = Math.sin((i + 1) * 39.3467 + salt * 11.135) * 24634.6345
396      return [(i + (a - Math.floor(a))) * (ART_W / count), 8 + (b - Math.floor(b)) * (ART_H - 16), a - Math.floor(a)] as const
397    })
398  const f = (v: number) => v.toFixed(1)
399  const sparks = spread(26, 1)
400    .map(([x, y, k]) => {
401      const s = 2 + k * 2.6
402      return (
403        `<path class="sp" style="transform-origin:${f(x)}px ${f(y)}px;animation-delay:${(k * 4.2).toFixed(2)}s" ` +
404        `d="M${f(x)} ${f(y - s)}L${f(x + s * 0.28)} ${f(y - s * 0.28)}L${f(x + s)} ${f(y)}L${f(x + s * 0.28)} ${f(y + s * 0.28)}L${f(x)} ${f(y + s)}L${f(x - s * 0.28)} ${f(y + s * 0.28)}L${f(x - s)} ${f(y)}L${f(x - s * 0.28)} ${f(y - s * 0.28)}Z" fill="#fff"/>`
405      )
406    })
407    .join('')
408  const clouds = spread(6, 5)
409    .map(([x, , k], i) =>
410      `<ellipse class="cl" style="animation-delay:-${(k * 9).toFixed(2)}s;animation-duration:${(9 + k * 6).toFixed(2)}s" cx="${f(x)}" cy="${i % 2 ? 50 : 14}" rx="${f(120 + k * 90)}" ry="26" fill="${c}"/>`,
411    )
412    .join('')
413  // Two sine lines across the band; their dashes travel, so light runs along them.
414  const wave = (amp: number, period: number, phase0: number, cls: string, op: number) => {
415    let d = ''
416    for (let x = 0; x <= ART_W; x += 20) d += `${x ? 'L' : 'M'}${x} ${f(ART_H / 2 + amp * Math.sin((x / period) * Math.PI * 2 + phase0))}`
417    return `<path class="${cls}" d="${d}" fill="none" stroke="${c}" stroke-opacity="${op}" stroke-width="1.2" stroke-linecap="round"/>`
418  }
419  const rising = (count: number, glyph: (x: number) => string) =>
420    spread(count, 7)
421      .map(([x, , k]) => `<g class="up" style="animation-delay:${(k * 3).toFixed(2)}s;animation-duration:${(3 + k * 1.5).toFixed(2)}s">${glyph(x)}</g>`)
422      .join('')
423  let extra = ''
424  if (phase === 'done') {
425    const hues = [c, '#ffffff', '#f2d16b', '#a79cf7']
426    extra =
427      spread(34, 11)
428        .map(([x, , k], i) =>
429          `<rect class="cf" style="animation-delay:-${(k * 4).toFixed(2)}s;animation-duration:${(2.6 + k * 2).toFixed(2)}s" x="${f(x)}" y="-8" width="${(3 + k * 3).toFixed(1)}" height="${(2 + k * 2).toFixed(1)}" rx=".6" fill="${hues[i % hues.length]}"/>`,
430        )
431        .join('') +
432      rising(14, x => `<path d="M${f(x - 5)} 56L${f(x - 1.5)} 59.5L${f(x + 5.5)} 52" fill="none" stroke="${c}" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>`)
433  } else if (phase === 'asking') {
434    extra =
435      spread(5, 9)
436        .map(([x, y, k]) => `<circle class="rp" style="transform-origin:${f(x)}px ${f(y)}px;animation-delay:${(k * 3).toFixed(2)}s" cx="${f(x)}" cy="${f(y)}" r="22" fill="none" stroke="${c}" stroke-width="1.2"/>`)
437        .join('') +
438      rising(10, x => `<text x="${f(x)}" y="60" font-size="13" font-family="sans-serif" font-weight="700" fill="${c}">?</text>`)
439  } else if (phase === 'planning') {
440    const pts = spread(16, 4)
441    extra =
442      pts
443        .slice(1)
444        .map(([x, y], i) => {
445          const [px, py] = pts[i]
446          return `<line class="lk" style="animation-delay:${(i * 0.35).toFixed(2)}s" x1="${f(px)}" y1="${f(py)}" x2="${f(x)}" y2="${f(y)}" stroke="${c}" stroke-width="1" stroke-dasharray="140" stroke-dashoffset="140"/>`
447        })
448        .join('') +
449      pts.map(([x, y], i) => `<circle class="nd" style="animation-delay:${(i * 0.35).toFixed(2)}s" cx="${f(x)}" cy="${f(y)}" r="2.2" fill="${c}"/>`).join('')
450  } else {
451    extra = spread(30, 3)
452      .map(([x, y, k]) => `<circle class="pt" style="animation-delay:-${(k * 3).toFixed(2)}s;animation-duration:${(1.8 + k * 2.2).toFixed(2)}s" cx="${f(x)}" cy="${f(y)}" r="${(0.8 + k * 1.4).toFixed(1)}" fill="${c}"/>`)
453      .join('') +
454      spread(9, 6)
455        .map(([x, y, k]) => `<rect class="ln" style="animation-delay:${(k * 2.4).toFixed(2)}s" x="${f(x)}" y="${f(y)}" width="${f(40 + k * 50)}" height="1.2" rx=".6" fill="${c}"/>`)
456        .join('')
457  }
458  return (
459    `<svg xmlns="http://www.w3.org/2000/svg" width="${ART_W}" height="${ART_H}" viewBox="0 0 ${ART_W} ${ART_H}">` +
460    '<style>:root{color-scheme:light dark}html,body{margin:0;overflow:hidden}svg{background:transparent;display:block}' +
461    '.sp{opacity:0;transform:scale(0);animation:tw 4.2s ease-in-out infinite}' +
462    '@keyframes tw{0%,70%,100%{opacity:0;transform:scale(0) rotate(0)}82%{opacity:.85;transform:scale(1) rotate(30deg)}92%{opacity:0;transform:scale(.2) rotate(60deg)}}' +
463    '.cl{opacity:.1;animation-name:cl;animation-timing-function:ease-in-out;animation-iteration-count:infinite;animation-direction:alternate}' +
464    '@keyframes cl{from{opacity:.06;transform:translateX(-60px)}to{opacity:.2;transform:translateX(60px)}}' +
465    '.w1{stroke-dasharray:90 260;animation:w 7s linear infinite}.w2{stroke-dasharray:50 330;animation:w 11s linear infinite reverse}' +
466    '@keyframes w{to{stroke-dashoffset:-1400}}' +
467    '.up{opacity:0;animation-name:up;animation-timing-function:ease-out;animation-iteration-count:infinite}' +
468    '@keyframes up{0%{opacity:0;transform:translateY(0)}20%{opacity:.6}100%{opacity:0;transform:translateY(-52px)}}' +
469    '.ln{opacity:0;animation:ln 2.4s ease-in infinite}@keyframes ln{0%{opacity:0;transform:translateX(-60px)}30%{opacity:.35}100%{opacity:0;transform:translateX(220px)}}' +
470    '.pt{opacity:0;animation-name:pt;animation-timing-function:linear;animation-iteration-count:infinite}' +
471    '@keyframes pt{0%{opacity:0;transform:translateX(0)}15%{opacity:.6}100%{opacity:0;transform:translateX(160px)}}' +
472    '.rp{opacity:0;transform:scale(.2);animation:rp 3s ease-out infinite}@keyframes rp{0%{opacity:.6;transform:scale(.2)}100%{opacity:0;transform:scale(1.6)}}' +
473    '.cf{animation-name:cf;animation-timing-function:linear;animation-iteration-count:infinite}' +
474    '@keyframes cf{0%{opacity:0;transform:translateY(0) rotate(0)}10%{opacity:.85}100%{opacity:0;transform:translateY(80px) rotate(540deg)}}' +
475    '.cf{transform-box:fill-box;transform-origin:center}' +
476    '.lk{opacity:.4;animation:lk 5.6s ease-in-out infinite}@keyframes lk{0%{stroke-dashoffset:140;opacity:0}30%{opacity:.45}60%{stroke-dashoffset:0;opacity:.45}100%{stroke-dashoffset:0;opacity:0}}' +
477    '.nd{transform-box:fill-box;transform-origin:center;opacity:.2;animation:nd 5.6s ease-in-out infinite}@keyframes nd{0%,100%{opacity:.15;transform:scale(.7)}25%{opacity:1;transform:scale(1.5)}50%{opacity:.5;transform:scale(1)}}' +
478    `.sweep{animation:sweep 6s linear infinite}@keyframes sweep{from{transform:translateX(-400px)}to{transform:translateX(${ART_W + 100}px)}}</style>` +
479    `<defs><linearGradient id="wash" x1="0" x2="1"><stop offset="0" stop-color="${c}" stop-opacity=".06"/><stop offset=".6" stop-color="${c}" stop-opacity=".1"/><stop offset="1" stop-color="${c}" stop-opacity=".2"/></linearGradient>` +
480    '<linearGradient id="beam" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#fff" stop-opacity=".08"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>' +
481    `<pattern id="dots" width="14" height="14" patternUnits="userSpaceOnUse"><circle cx="7" cy="7" r=".8" fill="${c}" fill-opacity=".16"/></pattern>` +
482    '<filter id="soft" x="-50%" y="-100%" width="200%" height="300%"><feGaussianBlur stdDeviation="16"/></filter></defs>' +
483    `<rect width="${ART_W}" height="${ART_H}" fill="url(#wash)"/>` +
484    `<rect width="${ART_W}" height="${ART_H}" fill="url(#dots)"/>` +
485    `<g filter="url(#soft)">${clouds}</g>` +
486    wave(9, 420, 0, 'w1', 0.5) + wave(6, 300, 2, 'w2', 0.35) +
487    `<g class="sweep"><rect x="0" y="-20" width="300" height="${ART_H + 40}" transform="skewX(-20)" fill="url(#beam)"/></g>` +
488    sparks + extra + '</svg>'
489  )
490}
491
492/** The bar's first words: where the task is. */
493export function progressTitle(p: Progress): string {
494  const total = p.steps.length
495  const at = stepNumber(p.steps)
496  if (p.phase === 'planning') return 'Planning a bigger task'
497  if (p.phase === 'done') return `Done · all ${total} steps`
498  if (p.phase === 'asking') return `Waiting for your answer · step ${at} of ${total}`
499  if (p.phase === 'paused') return `Paused at step ${at} of ${total}`
500  return `Step ${at} of ${total}`
501}
502
503/**
504 * The band: the brand surface (or yellow, or green), the title and the step on the first row, the track below it.
505 * The track is a still Svg where the surface has one, else a row of characters: an interactive Svg sits in a frame of
506 * a fixed default size (about 300 by 150) unless given both sizes, and the track has no fixed width. The motion is in
507 * the art behind the band, which has a fixed size.
508 */
509export function drawProgress(p: Progress, d: ProgressDraw) {
510  const { Box, Text, Button } = d
511  const look = looks(p.phase)
512  const total = p.steps.length
513  const step = currentStep(p.steps)
514  const title = progressTitle(p)
515  const words = p.phase === 'done' || p.phase === 'planning' ? '' : ((p.phase === 'paused' ? step?.label : step?.doing) ?? '')
516  const name = (
517    <Box key="progress-name" position="relative" flexShrink={0}>
518      <Text color={look.color} bold>
519        ✦ effortless
520      </Text>
521    </Box>
522  )
523  const closeButton = <Button key="progress-close" plain role="dismiss" label="✕" onPress={d.onClose} />
524  // Short of room, or nothing to count yet: one row, the bar as ten blocks.
525  if (d.maxRows < 4 || p.phase === 'planning') {
526    const blocks = Math.round(progressShare(p.steps) * 10)
527    return (
528      <Box key="progress-bar" position="relative" flexDirection="row" gap={1} alignItems="center" paddingX={1} overflow="hidden"
529        backgroundColor={look.bg} borderStyle="round" borderColor={look.edge}>
530        {d.Svg ? (
531          <Box key="progress-art" position="absolute" top={-1} left={0} bottom={-1}>
532            <d.Svg key={`art-${p.phase}`} source={progressArtSvg(p.phase)} alt="" width={ART_W} height={ART_H} />
533          </Box>
534        ) : null}
535        {name}
536        {p.phase === 'planning' ? (
537          d.Svg ? (
538            <Box key="progress-thinking" position="relative" flexShrink={0}>
539              <d.Svg source={thinkingSvg(p.phase)} alt="thinking" width={THINK_W} height={PILL_H} />
540            </Box>
541          ) : null
542        ) : (
543          <Box key="progress-blocks" position="relative" flexShrink={0} flexDirection="row">
544            <Text color={look.color}>{'▰'.repeat(blocks)}</Text>
545            <Text dimColor>{'▱'.repeat(10 - blocks)}</Text>
546          </Box>
547        )}
548        <Box key="progress-words" position="relative" flexShrink={1} minWidth={0}>
549          <Text wrap="truncate">{words ? `${title} · ${words}` : title}</Text>
550        </Box>
551        <Box flexGrow={1} />
552        {closeButton}
553      </Box>
554    )
555  }
556  const fill = (key: string, lit: boolean) => (
557    <Box key={key} flexGrow={1} flexShrink={1} minWidth={0} height={1} overflow="hidden">
558      {lit ? <Text color={look.color}>{'━'.repeat(RUN)}</Text> : <Text dimColor>{'─'.repeat(RUN)}</Text>}
559    </Box>
560  )
561  // One segment per step: completed full, in progress half, pending empty. Past SEGMENTS_MAX one continuous fill.
562  const track =
563    total <= SEGMENTS_MAX
564      ? p.steps.map((s, i) =>
565          s.status === 'in_progress' && p.phase !== 'done' ? (
566            <Box key={`seg-${i}`} flexGrow={1} flexShrink={1} minWidth={0} height={1} overflow="hidden" flexDirection="row">
567              {fill(`seg-${i}-a`, true)}
568              {fill(`seg-${i}-b`, false)}
569            </Box>
570          ) : (
571            fill(`seg-${i}`, p.phase === 'done' || s.status === 'completed')
572          ),
573        )
574      : [
575          <Box key="seg-done" width={`${Math.round(progressShare(p.steps) * 100)}%`} height={1} overflow="hidden">
576            <Text color={look.color}>{'━'.repeat(RUN * 2)}</Text>
577          </Box>,
578          fill('seg-rest', false),
579        ]
580  return (
581    <Box key="progress-bar" position="relative" flexDirection="column" paddingX={1} overflow="hidden"
582      backgroundColor={look.bg} borderStyle="round" borderColor={look.edge}>
583      {d.Svg ? (
584        <Box key="progress-art" position="absolute" top={-1} left={0} bottom={-1}>
585          <d.Svg key={`art-${p.phase}`} source={progressArtSvg(p.phase)} alt="" width={ART_W} height={ART_H} />
586        </Box>
587      ) : null}
588      <Box key="progress-head" position="relative" flexDirection="row" gap={1} alignItems="center">
589        {name}
590        <Box key="progress-words" flexShrink={1} minWidth={0} flexDirection="row" gap={1}>
591          <Text color={look.color} wrap="truncate">
592            {title}
593          </Text>
594          {words ? <Text wrap="truncate">{words}</Text> : null}
595        </Box>
596        <Box flexGrow={1} />
597        {closeButton}
598      </Box>
599      {d.Svg ? (
600        <Box key="progress-track" position="relative" flexDirection="row">
601          <d.Svg key={`track-${p.phase}`} source={progressTrackSvg(p)} alt={title} />
602        </Box>
603      ) : (
604        <Box key="progress-track" width="100%" height={1} flexDirection="row" gap={total <= SEGMENTS_MAX ? 1 : 0} alignItems="center">
605          {track}
606          <Box key="progress-flag" flexShrink={0}>
607            <Text color={p.phase === 'done' ? look.color : undefined} dimColor={p.phase !== 'done'}>
608              ⚑
609            </Text>
610          </Box>
611        </Box>
612      )}
613    </Box>
614  )
615}
616
types/index.d.ts 222 lines
1export type ModelKey = 'haiku' | 'sonnet' | 'opus' | 'fable'
2export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
3
4/** What the prompts Auto steered cost, measured: tokens by kind, and per effort the prompts and their weighted cost. */
5export type Spent = {
6  prompts: number
7  requests: number
8  input: number
9  write: number
10  read: number
11  out: number
12  byEffort: Partial<Record<Effort, { prompts: number; cost: number }>>
13  /** How often each judge decided, the time it took in all (ms), and its tokens. */
14  judge: { jev: number; haiku: number; ms: number; tokens: number }
15  /** Prompts that ran on a cheaper model than the chat's, and how many of those the person then redid. */
16  moved: number
17  redone: number
18  /** Subagents the judge picked an effort for: how many per effort, their requests, and what those cost (weighted). */
19  helpers: { runs: Partial<Record<Effort, number>>; requests: number; cost: number }
20}
21
22/** What the next turn runs with, and who decided it. */
23export type Pick = {
24  model: ModelKey
25  effort: Effort
26  /** A few words on why, shown dim in the band. */
27  why: string
28  /** named: the person said the level in the prompt ("max effort"). */
29  by: 'jev' | 'haiku' | 'manual' | 'named'
30  /** How sure the judge was of the effort, 0-1, when it said. */
31  sure?: number
32  /** Haiku only: why a handoff would suit now (a few words), when it said it would. */
33  handoff?: string
34}
35
36/** What follows a handoff: clear and carry on, clear and wait, or keep the chat and copy it. */
37export type HandoffAfter = 'continue' | 'confirm' | 'copy'
38
39/** A choice in the handoff bar: quick (a fork) or full (the person's skill), then what follows. */
40export type HandoffChoice = { kind: 'quick' | 'full'; after: HandoffAfter }
41
42/** The settings panel's unsaved changes, by field, as the settings store them (strings). */
43export type SettingsDraft = {
44  bias?: string
45  floor?: string
46  ceiling?: string
47  judge?: string
48  handoffAfter?: string
49  handoffSkill?: string
50  customUrl?: string
51  customModel?: string
52  /** Comma-separated parts switched off: handoff, cold, swamp, hot, down. */
53  hide?: string
54  key?: string
55  swampAt?: string
56  /** default (dashboard band) or minimal (footer buttons). */
57  layout?: string
58  /** haiku (Haiku 5.5 writes the compaction's summary) or session (the chat's own model). */
59  compactWith?: string
60  /** advised (the slot is Compact until Haiku advises a handoff) or always (the slot is the Handoff button). */
61  handoffButton?: string
62  /** on: a prompt the judge calls simple runs on a cheaper model than the chat's; off: always the chat's model. */
63  modelAuto?: string
64  /** on: each subagent gets an effort picked by the judge before it starts; off: it runs at the chat's effort. */
65  helpers?: string
66  /** violet (the brand), orange (Claude's) or rose (cherry blossom). */
67  theme?: string
68  appearance?: string
69}
70
71/** One step of the task the progress bar follows: a todo or a task. */
72export type ProgressStep = {
73  id: string
74  /** What the step is ("Run the tests"). */
75  label: string
76  /** The step while it runs ("Running the tests"). */
77  doing: string
78  status: 'pending' | 'in_progress' | 'completed'
79}
80
81/** The progress bar's state: where the task is, and its steps. */
82export type Progress = {
83  phase: 'planning' | 'working' | 'asking' | 'paused' | 'done'
84  steps: ProgressStep[]
85}
86
87/** Where one subagent stands, as the agent panel shows it. */
88export type AgentState = 'picking' | 'running' | 'waiting' | 'done' | 'failed'
89
90/** One subagent of this chat, for the agent panel. */
91export type AgentRec = {
92  id: string
93  /** The agent type (Explore, general-purpose, a plugin's agent). */
94  type: string
95  /** The Agent call's short description of the task. */
96  task: string
97  state: AgentState
98  /** The id of the agent that spawned it; absent when the main chat did. */
99  parentId?: string
100  /** When it was spawned and when it ended (clock ms). */
101  startedAt: number
102  endedAt?: number
103  /** Its own task list, when it keeps one: the progress the card shows. */
104  steps?: ProgressStep[]
105  /** What it does now: a tool and its argument, short. */
106  now?: string
107  /** When its current tool call started (clock ms); absent between calls. */
108  toolSince?: number
109  /** Since when it waits on that tool (clock ms). */
110  waitingSince?: number
111  model?: string
112  effort?: string
113  /** Why effortless picked that, a few words. */
114  why?: string
115  /** Weighted tokens so far. */
116  cost?: number
117  /** The file its current tool call is in, relative to the project root; absent between calls. */
118  file?: string
119}
120
121/** A file this chat or its agents read or changed, for the agent panel's map. */
122export type FileTouch = {
123  /** Relative to the project root, with forward slashes. */
124  path: string
125  edited: boolean
126  /** Who touched it: agent ids, 'main' for the main chat. */
127  by: string[]
128  lastAt: number
129  /** The project files it imports, relative to the root, without extension. */
130  imports: string[]
131}
132
133declare module 'claude-code' {
134  interface PluginState {
135    effortless: {
136      /** Auto on effort: every prompt is judged and its effort applied. */
137      isAuto: boolean
138      /** The last change Auto made to the effort, shown for a moment as "Low → High". */
139      switched: { from: Effort; to: Effort } | null
140      /** What the prompts Auto steered cost this session, measured (see Spent). */
141      saved: Spent
142      /** The session runs a model where an effort change rewrites the prompt cache: Auto waits. */
143      paused: boolean
144      /** Whole minutes the prompt cache stays warm: null before the first response, 0 once cold. */
145      cacheLeft: number | null
146      /** What the cache countdown knows, kept here because a reload starts the module's own variables over. */
147      cacheMemo: { expires: number; ttl: '5m' | '1h'; last?: number } | null
148      /** A compaction started from the footer's Compact button is running. */
149      isCompacting: boolean
150      /** The compact bar is open, asking for an optional note. */
151      compactAsk: boolean
152      /** The cold band was closed until the cache goes cold again. */
153      isColdHidden: boolean
154      /** Why the picked judge is failing, or null. */
155      judgeDown: string | null
156      judgeTest: { ok: boolean | null; text: string } | null
157      /** The reason the judge-down band was closed for. */
158      judgeDownHidden: string | null
159      /** The effortless settings panel is open above the prompt. */
160      settingsOpen: boolean
161      settingsCard: 'effort' | 'judge' | 'handoff' | 'show' | null
162      /** Changes made in the settings panel and not saved yet. */
163      settingsDraft: SettingsDraft
164      /** The user and plugin skills, read when the settings panel opens. */
165      installedSkills: string[]
166      /** The fullest usage window once past 80%, or null. */
167      hot: { kind: string; percent: number; resetsAt: string | null } | null
168      /** Percent at which the running-hot band was closed. */
169      hotHidden: number | null
170      /** Save mode until this time (ms), or null. */
171      saveUntil: number | null
172      /** Context tokens read per request once the chat is swamped, else null. */
173      swamped: number | null
174      /** Tokens at which the swamp band was closed; it returns once the context grows well past it. */
175      swampHiddenAt: number | null
176      /** Why Haiku thinks a handoff would suit now, shown with Handoff lit; null when it has no advice. */
177      handoffAdvice: string | null
178      /** The first-run setup is not done yet. */
179      setupPending: boolean
180      /** Where a handoff is: null idle, writing, or clearing and resending. */
181      handoffStage: 'writing' | 'clearing' | null
182      /** The handoff bar above the prompt, open with the choice shown in it, or null. */
183      handoffPick: HandoffChoice | null
184      /** The newest reply's text: the reply card hangs under its last block. */
185      lastAnswer: string
186      /** What the newest reply cost: tokens weighted by price over the main thread's requests, and its time. */
187      lastTurn: { cost: number; ms: number } | null
188      /** The handoff card under the newest reply, from the start of a handoff until the reply after it lands. */
189      handoffCard: { kind: 'writing' | 'done' | 'copied'; full: boolean; at: number; seen: boolean } | null
190      /** A new version above the prompt: offered, updating, updated or failed, or null. */
191      updateCard: { stage: 'offer' | 'updating' | 'done' | 'failed'; version: string; note: string; at: number; detail?: string } | null
192      /** The setup guide's step, or null when it is closed. */
193      setupStep: 'pick' | 'jev' | 'lean' | 'handoff' | 'done' | null
194      /** Choices made in the setup guide, saved together at Done or the cross. */
195      setupDraft: SettingsDraft
196      /** Auto on model: the judge may suggest another model (never switched without a yes). Off by default. */
197      isAutoModel: boolean
198      pick: Pick | null
199      isJudging: boolean
200      suggestion: ModelKey | null
201      /** The effort the person last set outside the mod (the app's control, their /effort): what the app shows. */
202      appEffort: string | null
203      /** The model the session runs now, as the engine last reported it (start, a switch, a request). */
204      model: ModelKey | null
205      /** The task the progress bar follows, or null when there is none. */
206      progress: Progress | null
207      /** The step list the progress bar was closed for; a new list shows it again. */
208      progressHidden: string | null
209      /** This chat's subagents, oldest first, for the agent panel. */
210      agents: AgentRec[]
211      /** The agent card opened in the panel, or 'done' for the finished ones, or null. */
212      agentsOpen: string | null
213      /** The files this chat and its agents touched, newest last, for the panel's map. */
214      agentFiles: FileTouch[]
215      /** The main chat's own task list, for the panel's total progress; null when it keeps none. */
216      agentSteps: ProgressStep[] | null
217      /** The module (folder) opened in the panel's list, or null. */
218      agentsModule: string | null
219    }
220  }
221}
222