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

<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>
<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%">
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 judge | reads each prompt and picks the effort, in about a second, on your own Claude login |
| Cheaper model when it can | experimental, 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) |
| Compaction | every 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.
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.
| On the bar | Means |
|---|---|
| High · Opus | the 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:00 | time 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 |
| Auto | switches 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 |
| Compact | compacts 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 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.
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.
| Setting | What it does |
|---|---|
auto (default) | Haiku, and Jev too when a TypeSafe key is set in the settings or TYPESAFE_API_KEY |
haiku | Haiku only, a key is never used |
jev | Haiku 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.
/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.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.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.5 | Correct | Cost per task | Requests | Time |
|---|---|---|---|---|
| medium (recommended), no effortless | 12 of 12 | $0.190 | 7.3 | 24 s |
| high, no effortless | 12 of 12 | $0.209 | 8.0 | 27 s |
| xhigh, no effortless | 12 of 12 | $0.257 | 9.9 | 44 s |
| effortless on | 12 of 12 | $0.149 | 5.8 | 20 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.
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.
/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:
| Judge | Right, all 73 | Right, 20 held out | Too low | Too high | Median time |
|---|---|---|---|---|---|
| Always medium | 44% | 40% | 17 | 24 | - |
| Always high | 51% | 50% | 0 | 36 | - |
| Haiku | 92-93% | 95% | 1 | 4-5 | 0.75 s |
| Jev | 99% | 100% | 0 | 1 | 0.24 s |
What this does and does not show:
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.
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.
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.
| Command | Does |
|---|---|
/effortless settings | opens the settings panel |
/effortless setup | runs the setup again |
/effortless auto | Auto on or off |
/effortless handoff / handoff full | hands off without the bar, with your last choice of what follows |
/effortless stats | what the prompts Auto steered cost this session, per effort, and what the judge took |
/effortless cold, swamp, hot, down | shows that bar now, to try it |
/effortless bench | scores each judge you have on the labelled prompts in bench/judge-cases.json |
/effortless debug | what the mod sees, and on its last line the model and effort the last request ran on |
/effortless update | checks for a new version now |
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.
~/.claude/textless/scenes./textless get <id>./textless.Website, scenes and Marketplace: heycubit.github.io/effortless/textless. The code is in textless/.
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.
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.
hooks/register.tsx 5401 lines1import { 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 undefinedhooks/agents.tsx 621 lines1import 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}
621hooks/art.ts 119 lines1import { 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}
119hooks/brand-mark.ts 4 lines1// 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>"
4hooks/theme.ts 188 lines1// 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}
188hooks/progress.tsx 616 lines1import { 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}
616types/index.d.ts 222 lines1export 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