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

<img src="docs/readme/banner.png" alt="effortless, a Claude Code mod: one bar above the prompt that picks the model and the reasoning effort for every prompt" width="100%">
<a href="https://heycubit.github.io/effortless/"><b>Website</b></a> · <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 model and the reasoning effort for every prompt. Easy questions run on Haiku or Sonnet at a low effort; hard jobs get High on your own model, and you never touch the model or Effort control. In one long chat on Opus about half the replies ran on Sonnet or Haiku, roughly half the cost by our estimate. 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 and model, in about a second, on your own Claude login |
| Cheaper model when it can | a prompt the judge calls simple runs on Haiku 5.5 (or Sonnet), never above your chat's model. Only that prompt moves: your chat stays on its model, and that model's cache stays warm for the next hard prompt. The bar shows the model beside the effort: Low · Haiku, High · Opus |
| Compaction | every compaction, /compact and the automatic one included, is summarized by Haiku 5.5. If Haiku fails, Claude Code compacts as usual |
Each can be switched off: Settings → Judge → Model, and 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 and the model Auto picked for this prompt. Deciding while the judge thinks; a switch flashes violet and fades to white. A cheaper model after it (Low · Haiku) means only this prompt runs there |
| ◔ 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: 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.
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.Measured over 80k requests of real Claude Code use, about 76% of the cost is the context being read back from the cache on every tool call, 16% cache writes and only 8% output. Effort mostly changes how many tool calls a prompt makes.
/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 update | checks for a new version now |
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 5006 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, 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 === 'orange'
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}
45let BRAND_BG = '#15121f'
46let BRAND_EDGE = '#4a3f80'
47// The settings panel's header bar: a shade lighter than the panel, so it reads as a title bar.
48let BRAND_HEAD = '#221c3a'
49// The box behind the level while it is hovered: the grey of the app's own pills.
50let HOVER_BOX = '#2b2b2f'
51const EFFORT_LABELS: Record<Effort, string> = { low: 'Low', medium: 'Medium', high: 'High', xhigh: 'XHigh', max: 'Max' }
52
53// Auto is two switches. `isAuto` is effort (the name stays: it is what the store and state hold);
54// `isAutoModel` lets the judge suggest another model and starts off.
55const isAuto = atom({ plugin: 'effortless', key: 'isAuto' } as const, true)
56const JEV_TIMEOUT_MS = 3000
57const JEV_URL = 'https://api.typesafe.ai/v1/systemone'
58const 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 } }
59const SWITCHED_MS = 2500
60// The prompt cache lives this long after the last request read or wrote it. A response may say which lifetime its
61// cache writes got (usage.cache_creation: ephemeral_1h / ephemeral_5m); until one does, 1 hour is assumed: 99% of
62// the cache writes in 80k requests of real Claude Code use (289k of 292k) were 1 hour.
63const CACHE_TTL = { '5m': 5 * 60_000, '1h': 60 * 60_000 } as const
64const CACHE_TICK_MS = 15_000
65// Under this much time left the countdown turns amber: send now, or pay to write the whole context again.
66// Shown minutes at or under these turn the countdown yellow, then red: grey while there is time, yellow at 20, red at 5.
67const CACHE_YELLOW_MIN = 20
68const CACHE_RED_MIN = 5
69const ICE = '#7cc4ff'
70const ICE_BG = '#0e1820'
71const ICE_EDGE = '#2f5c80'
72const BOG = '#a7c98f'
73const BOG_BG = '#111710'
74const BOG_EDGE = '#3e5a33'
75const EMBER = '#f08a3c'
76const EMBER_BG = '#1a110c'
77const EMBER_EDGE = '#6a3418'
78const SLATE = '#b4b8c4'
79const SLATE_BG = '#12141b'
80const SLATE_EDGE = '#39415a'
81const YELLOW = '#e0a33a'
82const RED = '#e5534b'
83const isAutoModel = atom({ plugin: 'effortless', key: 'isAutoModel' } as const, false)
84const pick = atom({ plugin: 'effortless', key: 'pick' } as const, null)
85const isJudging = atom({ plugin: 'effortless', key: 'isJudging' } as const, false)
86const suggestion = atom({ plugin: 'effortless', key: 'suggestion' } as const, null)
87const appEffort = atom({ plugin: 'effortless', key: 'appEffort' } as const, null)
88const model = atom({ plugin: 'effortless', key: 'model' } as const, null)
89// The last change Auto made to the effort, shown for a moment as "Low → High" and then cleared.
90const switched = atom({ plugin: 'effortless', key: 'switched' } as const, null)
91// What the prompts Auto steered cost this session, measured, and what judging them took.
92const saved = atom({ plugin: 'effortless', key: 'saved' } as const, EMPTY_SPENT)
93// True while the session runs a model where Auto must not change effort (see cacheSafe).
94const paused = atom({ plugin: 'effortless', key: 'paused' } as const, false)
95// How long the main conversation's prompt cache stays warm, in whole minutes left: null before the first response,
96// 0 once it has gone cold. Updated only when the minute changes, so the footer redraws once a minute at most.
97const cacheLeft = atom({ plugin: 'effortless', key: 'cacheLeft' } as const, null)
98const cacheMemo = atom({ plugin: 'effortless', key: 'cacheMemo' } as const, null)
99const isCompacting = atom({ plugin: 'effortless', key: 'isCompacting' } as const, false)
100// The compact bar: open after Compact is pressed, with a field for what the summary should keep.
101const compactAsk = atom({ plugin: 'effortless', key: 'compactAsk' } as const, false)
102// What is typed in that field. Kept here, not in state: a write per key would redraw the bar under the cursor.
103let compactNote = ''
104// When the compact bar opened: its first half second fades the swamped band's colours out (see compactFadeSvg).
105let compactOpenedAt = 0
106const COMPACT_FADE_MS = 450
107/** The swamped band's colours over the compact bar, fading to nothing: the band seems to turn from green to violet. */
108function compactFadeSvg(elapsedMs: number): string {
109 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)
110}
111// The person closed the cold band; it comes back the next time the cache goes cold.
112// The test pane of /effortless try pane.
113// Where a handoff is: null idle, writing (the handoff turn runs), clearing (clear and resend).
114const handoffStage = atom({ plugin: 'effortless', key: 'handoffStage' } as const, null)
115// The handoff bar above the prompt, open with the choice shown in it, or null.
116const handoffPick = atom({ plugin: 'effortless', key: 'handoffPick' } as const, null)
117// The context is swamped: tokens read per request, or null below the line. Drives the swamp band.
118const swamped = atom({ plugin: 'effortless', key: 'swamped' } as const, null)
119// Why Haiku thinks a handoff would suit now, or null. It lights Handoff and says why; there is no percent threshold.
120const handoffAdvice = atom({ plugin: 'effortless', key: 'handoffAdvice' } as const, null)
121// The swamp band was closed at this many tokens; it comes back once the context has grown well past it.
122// The handoff card above the prompt: shown from the start of a handoff, and once it lands ('done' in the
123// cleared chat, 'copied'). It goes with the next message, its ✕, or HANDOFF_CARD_MS after it was set.
124type HandoffCard = { kind: 'writing' | 'done' | 'copied' | 'compacting' | 'compacted'; full: boolean; at: number; seen: boolean }
125// Kinds still under way: they stay until they land, with moving art. Compacting shares the card with the handoff.
126const cardRunning = (kind: HandoffCard['kind']) => kind === 'writing' || kind === 'compacting'
127// Kinds that landed well: the card turns green with a checkmark.
128const cardLanded = (kind: HandoffCard['kind']) => kind === 'done' || kind === 'compacted'
129const handoffCard = atom({ plugin: 'effortless', key: 'handoffCard' } as const, null)
130const updateCard = atom({ plugin: 'effortless', key: 'updateCard' } as const, null)
131const HANDOFF_CARD_MS = 2 * 60_000
132// The handoff card's art while it is written: sparkles carried from left to right.
133const 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>`
134
135async function setHandoffCard($: EngineInterface, kind: HandoffCard['kind'], full: boolean, seen = false) {
136 const at = await $.clock.now()
137 await update($, handoffAdvice, () => null)
138 await update($, handoffCard, () => ({ kind, full, at, seen }))
139 // Gone after a while even with no reply; a timer that dies with its request leaves the next reply to clear it.
140 if (!cardRunning(kind)) {
141 try {
142 $.clock.after(HANDOFF_CARD_MS, () => void update($, handoffCard, card => (card && card.at === at ? null : card)))
143 } catch {}
144 }
145}
146
147// The newest reply's text, so the warning card goes under its last block and nowhere else.
148const lastAnswer = atom({ plugin: 'effortless', key: 'lastAnswer' } as const, '')
149const lastTurn = atom({ plugin: 'effortless', key: 'lastTurn' } as const, null)
150// The weighted tokens of the main thread's requests since the prompt; kept in lastTurn when the turn ends.
151let turnCost = 0
152const swampHiddenAt = atom({ plugin: 'effortless', key: 'swampHiddenAt' } as const, null)
153// The first-run setup is not done: the footer offers "Setup".
154const setupPending = atom({ plugin: 'effortless', key: 'setupPending' } as const, false)
155// Why the judge the person picked is failing (Haiku stands in), or null when it works.
156// The effortless settings panel is open above the prompt.
157const settingsOpen = atom({ plugin: 'effortless', key: 'settingsOpen' } as const, false)
158// What was changed in the panel and not saved yet, by field; Save applies it all, the cross drops it.
159// The user and plugin skills, read when the settings panel opens.
160const installedSkills = atom({ plugin: 'effortless', key: 'installedSkills' } as const, [])
161const settingsDraft = atom({ plugin: 'effortless', key: 'settingsDraft' } as const, {})
162const judgeDown = atom({ plugin: 'effortless', key: 'judgeDown' } as const, null)
163// Which part of the settings panel is open: null for the cards.
164type SettingsCard = 'effort' | 'judge' | 'handoff' | 'show'
165const settingsCard = atom({ plugin: 'effortless', key: 'settingsCard' } as const, null)
166/** The settings panel's parts: a card each on the overview, and what the part is for, said once it is open. */
167const CARDS: readonly { id: SettingsCard; title: string; about: string }[] = [
168 { id: 'effort', title: 'Effort', about: 'How hard Claude thinks. The slider tips close calls; Min and Max are hard limits.' },
169 { id: 'handoff', title: 'Handoff', about: 'The skill that writes a full handoff, and at what share of context to suggest compacting or handing off.' },
170 { id: 'show', title: 'Customize', about: 'How effortless looks and which parts it shows. Uninstall removes it.' },
171 { id: 'judge', title: 'Judge', about: 'Haiku judges prompts and handoffs. Add Jev for quicker effort calls. Test checks it answers.' },
172]
173const BIAS_WORDS = ['Cheapest', 'Cheaper', 'Balanced', 'Smarter', 'Smartest'] as const
174// The settings panel's judge test: running, or what it found. Null before a test and once the panel closes.
175const judgeTest = atom({ plugin: 'effortless', key: 'judgeTest' } as const, null)
176// The judge-down band was closed for this reason; a new reason shows it again.
177const judgeDownHidden = atom({ plugin: 'effortless', key: 'judgeDownHidden' } as const, null)
178// A usage limit is close: which window, how much is used, when it resets. Null below the line.
179const hot = atom({ plugin: 'effortless', key: 'hot' } as const, null)
180// The running-hot band was closed at this many percent; it returns ten points later or in a new window.
181const hotHidden = atom({ plugin: 'effortless', key: 'hotHidden' } as const, null)
182// The agent panel: this chat's subagents, and the card opened in it (hooks/agents.tsx).
183const agentsState = atom({ plugin: 'effortless', key: 'agents' } as const, [])
184const agentsOpen = atom({ plugin: 'effortless', key: 'agentsOpen' } as const, null)
185// The panel's map: the files touched, the main chat's own steps, the module opened in its list.
186const agentFiles = atom({ plugin: 'effortless', key: 'agentFiles' } as const, [])
187const agentSteps = atom({ plugin: 'effortless', key: 'agentSteps' } as const, null)
188const agentsModule = atom({ plugin: 'effortless', key: 'agentsModule' } as const, null)
189// Save mode: Auto picks at most medium until this time (ms), when the limit resets.
190const saveUntil = atom({ plugin: 'effortless', key: 'saveUntil' } as const, null)
191const isColdHidden = atom({ plugin: 'effortless', key: 'isColdHidden' } as const, false)
192// The setup guide above the prompt: which step it shows, or null when it is closed.
193const setupStep = atom({ plugin: 'effortless', key: 'setupStep' } as const, null)
194// What was picked in the setup guide and not saved yet: it is saved in one go at Done or ✕. Each saved setting reloads
195// the plugin, and the app says so in the chat, so a save per click filled the chat with notices.
196const setupDraft = atom({ plugin: 'effortless', key: 'setupDraft' } as const, {})
197
198// The band above the prompt when the cache has gone cold: an icy gradient with snowflakes drifting down.
199const 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>`
200// The frost is drawn larger than the band and cut by it: wide enough for the right side, tall enough for any band.
201const FROST_WIDTH = 440
202const FROST_HEIGHT = 64
203// TypeSafe's mark (from typesafe.ai), drawn beside the Jev button: a Button holds text only.
204const 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>`
205// Claude's mark (from claude.ai) in white, drawn beside the Haiku button.
206const 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>`
207// The band when the context is swamped: murky green, bubbles rising, slow ripples.
208const 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>`
209// The band when a usage limit is close: embers, sparks rising.
210const 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>`
211// The band when the judge the person picked is failing: signal lost, a blip sweeping along a flat line.
212const 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>`
213// The right of the setup guide, pure decoration (the name is on the left): a purple gradient with a soft glow,
214// 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
215// motion is CSS inside it.
216const 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>`
217
218// The handoff card once it has landed: the brand's art in green, with a big dark checkmark on the right.
219// The dashboard sits there all the time, so it is grey: purple is kept for the moments that ask for attention. Its
220// art is the brand's, in grey and at 40% of its strength; only the ✦ keeps the accent.
221const DASH_SVG = BRAND_SVG.replace(/stop-opacity="([0-9.]+)"/g, (_m, v: string) => `stop-opacity="${(Number(v) * 0.4).toFixed(3)}"`)
222 .replace(/#(7c6cf0|8f7ff0|b3a6ff|c9bdff|9a86ff)/g, '#9a9aa2')
223let DASH_BG = '#141416'
224let DASH_EDGE = '#2a2a2f'
225/** The settings title's word in the website hero's style: crisp, going soft and grainy at its bottom right (a radial
226 * mask fades the crisp copy out there and a copy through the site's #grainy filter takes over). The app gives the image
227 * the band's font. */
228const 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>`
229/** The mark as the settings bar shows it: big, tilted and faint, cut off by the bar. */
230const SETTINGS_MARK = MARK_SVG.replace('<g mask=', '<g opacity=".2" transform="rotate(9 50 50)" mask=')
231// The dashboard's mark (see dash-mark): its size, and its look with Auto off.
232const DASH_MARK_SIZE = 18
233const DASH_MARK_OFF = MARK_SVG.replace('<g mask=', '<g opacity=".35" mask=')
234/** How long a band's entrance runs: it is drawn only this long after the band appears, since every redraw of the band
235 * (a choice, the minute tick) would replay it. */
236const INTRO_MS = 1300
237// The band last drawn above the prompt, and when it appeared (see introShows).
238let introKind = ''
239let introAt = 0
240/** Whether the band of `kind` is still in its entrance: true for INTRO_MS after it replaced another band. */
241/** An animated Svg drawn `elapsedMs` into its animations. Every redraw rebuilds the band and starts its images' CSS
242 * animations over; with this, a redraw mid-animation picks it up where it was instead of jumping back. */
243export function inPhase(svg: string, elapsedMs: number): string {
244 return svg.replace('</svg>', `<style>svg *{animation-delay:${(-Math.max(0, elapsedMs) / 1000).toFixed(3)}s !important}</style></svg>`)
245}
246// How far the entrance on screen is, for introLayer.
247let introElapsed = 0
248// Set when a band replaces another, taken by the first draw of the new one.
249let introFresh = false
250async function introShows($: EngineInterface, kind: string): Promise<boolean> {
251 const now = await $.clock.now()
252 if (kind !== introKind) {
253 introKind = kind
254 introAt = now
255 introFresh = true
256 $.clock.after(INTRO_MS + 100, () => $.ui.invalidate('ui.render'))
257 }
258 introElapsed = now - introAt
259 return introElapsed < INTRO_MS
260}
261/** The entrance layer (INTRO_SVG) in a band's own colours, or nothing once the entrance is over. */
262// The mark beside a card's title, sized to one line of text.
263const TITLE_MARK_SIZE = 16
264
265/** A card's title with the effortless mark where the ✦ stood; the ✦ stays where the surface draws no images. */
266function markTitle(els: { Box: unknown; Text: unknown; Svg?: unknown }, key: string, color: string, title: string, mark = MARK_SVG) {
267 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 }
268 if (!S) return <T key={key} color={color} bold wrap="truncate">{title}</T>
269 return (
270 <B key={key} flexDirection="row" alignItems="center" flexShrink={1} minWidth={0}>
271 <B flexShrink={0} marginRight={1} alignItems="center">
272 <S source={mark} alt="effortless" width={TITLE_MARK_SIZE} height={TITLE_MARK_SIZE} />
273 </B>
274 <T color={color} bold wrap="truncate">{title.replace(/^✦ /, '')}</T>
275 </B>
276 )
277}
278
279function introLayer(els: { Box: unknown; Svg?: unknown }, key: string, show: boolean, wash = '#8b6cff', light = '#b9a7ff') {
280 if (!els.Svg || !show) return null
281 const { Box: B, Svg: S } = els as unknown as { Box: (p: Record<string, unknown>) => unknown; Svg: (p: Record<string, unknown>) => unknown }
282 return (
283 <B key={`${key}-intro`} position="absolute" top={-1} left={0} right={0} bottom={-1}>
284 <S source={inPhase(INTRO_SVG.replace('#8b6cff', wash).replace('#b9a7ff', light), introElapsed)} alt="" width={1600} height={240} />
285 </B>
286 )
287}
288/** The settings panel's top bar: a shade above the band. */
289let DASH_HEAD = '#18181b'
290const DASH_TEXT = '#d4d4d8'
291/** The dashboard's quieter figures: the cache countdown while it has time left. */
292const DASH_DIM = '#8b8b93'
293// A settings card under the pointer: a shade up from the band, its edge a shade up from that.
294let CARD_HOVER = '#1c1c20'
295let CARD_HOVER_EDGE = '#3b3b42'
296const DONE_SVG = BRAND_SVG
297 .replace(/#7c6cf0/g, '#2fae62').replace(/#8f7ff0/g, '#3cc472').replace(/#b3a6ff/g, '#7fe0a4')
298 .replace(/#c9bdff/g, '#b4f0c8').replace(/#9a86ff/g, '#4fd486')
299 .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>')
300const DONE_ACCENT = '#7fe0a4'
301const DONE_BG = '#0f1c15'
302// The Updated card: near black, a dim green edge; the green is in its title, the check and the sparkles.
303const UPDATED_BG = '#08090a'
304const UPDATED_EDGE = '#1f4a33'
305// The brand art of the offer card, mirrored to the left and turned green: the landed card keeps its texture and sparkles
306// behind the mark and title, while the links on the right stay on near black.
307const UPDATED_GLOW_SVG = BRAND_SVG
308 .replace('preserveAspectRatio="xMaxYMid slice"', 'preserveAspectRatio="xMinYMid slice"')
309 .replace('</defs>', '</defs><g transform="translate(360 0) scale(-1 1)">').replace('</svg>', '</g></svg>')
310 .replace(/#7c6cf0/g, '#2f9a5e').replace(/#8f7ff0/g, '#3fae6e').replace(/#b3a6ff/g, '#7fe0a4').replace(/#c9bdff/g, '#a8ecc2').replace(/#9a86ff/g, '#4cbf7c')
311 .replace(/stop-opacity="([0-9.]+)"/g, (_m, v: string) => `stop-opacity="${(Number(v) * 0.6).toFixed(3)}"`)
312// The Share icon: an arrow leaving a tray, white like the GitHub mark beside it.
313const 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>`
314// A thin vertical line between the links on the Updated card.
315const 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>`
316// Hover on the near-black card: a light veil (the dark one used elsewhere is invisible here).
317const UPDATED_HOVER = '#ffffff1f'
318const DONE_EDGE = '#2f7a4c'
319// The mark in the done green, for the titles of cards that have landed.
320const DONE_MARK_SVG = MARK_SVG.replace(/#7566d8/g, '#2f9a5e').replace(/#a79cf7/g, '#7fe0a4').replace(/#cfc7ff/g, '#c8f4d8')
321
322const 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.
323
324Models, cheapest first:
325- 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.
326- sonnet: normal coding, edits across a few files, explanations, writing.
327- opus: hard debugging, architecture, large refactors, careful reviews.
328- fable: the hardest long-horizon or research-level work.
329
330Effort: low for quick answers, medium for normal work, high for hard problems, xhigh or max only for very hard ones.
331
332Effort 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.
333
334Judge 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.
335
336If 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.
337
338A 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.
339
340If the person asks for deep thought ("think hard", "ultrathink", "be thorough"), pick at least high; if they ask for a quick answer, pick low.
341
342If the message is a short follow-up to ongoing work ("yes", "go", "ok", "continue", or the same in any language), keep the current pair.
343
344Reply 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"}`
345
346/** Reads `{ model, effort, why }` out of a reply, or nothing when it doesn't hold one. */
347export function parseVerdict(text: string): { model: ModelKey; effort: Effort; why: string; sure?: number; handoff?: string } | undefined {
348 const match = text.match(/\{[\s\S]*\}/)
349 if (!match) return undefined
350 let raw: unknown
351 try {
352 raw = JSON.parse(match[0])
353 } catch {
354 return undefined
355 }
356 if (typeof raw !== 'object' || raw === null) return undefined
357 const { model, effort, why, sure, handoff } = raw as Record<string, unknown>
358 const key = typeof model === 'string' ? model.toLowerCase() : ''
359 const found = MODELS.find(m => key === m.key || key === m.id || key.includes(m.key))
360 if (!found || !EFFORTS.includes(effort as Effort)) return undefined
361 const how = typeof sure === 'number' && sure >= 0 && sure <= 1 ? { sure } : {}
362 // Only a reason counts: "handoff":"" or false is no advice.
363 const advice = typeof handoff === 'string' && handoff.trim() ? { handoff: handoff.trim().slice(0, 70) } : {}
364 return { model: found.key, effort: effort as Effort, why: typeof why === 'string' ? why.slice(0, 60) : '', ...how, ...advice }
365}
366
367/**
368 * What the judge reads besides the new message: the person's previous message and the assistant's last reply. The
369 * reply is kept long and from its end, where a question with options ("A, B or C?") sits: "B" alone looks like a
370 * small job, the reply says what B sets off.
371 */
372async function recentContext($: EngineInterface): Promise<string> {
373 return contextFrom(await $.session.messages())
374}
375
376/** The context text from the conversation so far: the last user message, short, and the assistant's last reply's end. */
377export function contextFrom(messages: readonly { role: string; text: string }[]): string {
378 const lastUser = [...messages].reverse().find(m => m.role === 'user')
379 const lastAssistant = lastAssistantText(messages)
380 return [lastUser ? `user: ${lastUser.text.slice(0, 400)}` : '', lastAssistant ? `assistant: ${lastAssistant.slice(-2000)}` : '']
381 .filter(Boolean)
382 .join('\n')
383}
384
385/** The assistant's last reply, or nothing. */
386function lastAssistantText(messages: readonly { role: string; text: string }[]): string {
387 return [...messages].reverse().find(m => m.role === 'assistant' && m.text.trim())?.text ?? ''
388}
389
390
391// Jev answers typed questions with probabilities; these are the options it picks between.
392const JEV_EFFORTS: Record<Effort, string> = {
393 low: 'quick answer, no tools, no planning',
394 medium: 'normal work: a few tool calls or edits',
395 high: 'hard or large multi-step job: many tool calls, costly mistakes',
396 xhigh: 'very hard: a long investigation',
397 max: 'the hardest research-level work',
398}
399const JEV_MODELS: Record<ModelKey, string> = {
400 haiku: 'trivial questions, lookups, renames, chit-chat',
401 sonnet: 'normal coding, edits across a few files, explanations, writing',
402 opus: 'hard debugging, architecture, large refactors, careful reviews',
403 fable: 'the hardest long-horizon or research-level work',
404}
405const JEV_TASK =
406 'Choose the reasoning effort (and model) an agentic assistant should use for the next user message. It reads files, ' +
407 'runs tools and edits things, not only code. Pick the cheapest that still does the job well. Judge the scope and the ' +
408 'amount of work, not whether it is code: "go through my whole drive and clean it up" is a big tool-heavy job. ' +
409 'Effort is relative to current_model: a stronger model needs less for the same job. Opus at medium does about what ' +
410 'Sonnet does at high, so for one task pick one step lower on Opus than on Sonnet. ' +
411 'A short follow-up ("yes", "go", "ok", in any language) keeps the current effort. When the message answers a ' +
412 "question in the assistant's last reply (picks an option), judge only the work that answer starts, not its length and " +
413 '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; ' +
414 '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 ' +
415 'question about how to do something that needs no tools is low. "think hard", "ultrathink" or "be thorough" means ' +
416 'at least high; "quick question" means low.'
417
418/** The skill or custom command a typed "/name args" runs, or nothing for the app's own commands and effortless's. */
419async function typedSkill($: EngineInterface, text: string): Promise<{ name: string; description: string } | undefined> {
420 const name = text.trim().slice(1).split(/\s+/)[0]
421 if (!name) return undefined
422 const found = (await $.command.list().catch(() => [])).find(c => c.name === name)
423 if (!found || !['user', 'plugin', 'mcp'].includes(found.source) || found.plugin === 'effortless') return undefined
424 return { name, description: found.description ?? '' }
425}
426
427/** A short follow-up such as "go", "ok", "yes", "continue": two words and a dozen characters at most. */
428export function isFollowUp(text: string): boolean {
429 const t = text.trim()
430 return t.length > 0 && t.length <= 12 && t.split(/\s+/).length <= 2
431}
432
433/**
434 * Whether the assistant's last reply ended on a question ("Should I archive it?", "A, B or C?"). A short message
435 * after one answers it, and the answer can start a small or a big job, so it goes to the judge instead of keeping
436 * the effort.
437 */
438export function endsOnQuestion(context: string): boolean {
439 const reply = context.split('\nassistant: ').pop() ?? ''
440 return context.includes('assistant: ') && reply.slice(-400).includes('?')
441}
442
443/** The message as the judge reads it: images and files it carries are named, since the judge sees only text. */
444export function withAttachments(text: string, attachments?: readonly { type: string }[]): string {
445 if (!attachments?.length) return text
446 const counts = new Map<string, number>()
447 for (const a of attachments) counts.set(a.type, (counts.get(a.type) ?? 0) + 1)
448 const said = [...counts].map(([type, n]) => `${n} ${type}${n > 1 ? 's' : ''}`).join(', ')
449 return `${text}
450
451[The message comes with ${said} to look at.]`
452}
453
454/** The message as the judge reads it when it was typed while a task was still running. */
455export function withMidTurn(text: string, effort: Effort): string {
456 return `${text}
457
458[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.]`
459}
460// A lower verdict for a message typed mid-turn, applied when that turn ends (see prompt.submit).
461let heldPick: Pick | null = null
462
463/** A short follow-up that keeps the current effort: "go" between two steps, not an answer to a question. */
464export function keepsEffort(message: string, context: string): boolean {
465 return isFollowUp(message) && !endsOnQuestion(context)
466}
467
468/** The TypeSafe key in a ~/.config/jev/.env file's text, the same file the jev-* skills read. */
469export function parseJevKey(text: string): string | undefined {
470 const value = text.match(/^\s*TYPESAFE_API_KEY\s*=\s*(.*?)\s*$/m)?.[1].replace(/^['"]|['"]$/g, '')
471 return value || undefined
472}
473
474/**
475 * Jev's answer as a verdict. Below even odds on the effort it is unsure, and an unsure call keeps the
476 * current effort: that is what a "go" between two steps of work should do.
477 */
478const UNSURE = 'unsure, keeping'
479export function parseJevAnswer(text: string, current: Pick | null): { model: ModelKey; effort: Effort; why: string; sure?: number } | undefined {
480 let json: Record<string, unknown>
481 try {
482 json = JSON.parse(text)
483 } catch {
484 return undefined
485 }
486 const answers = (json.answers ?? (json.result as Record<string, unknown> | undefined)?.answers) as
487 | Record<string, { choice?: string; confidence?: number; probabilities?: Record<string, number> }>
488 | undefined
489 const effort = answers?.effort?.choice as Effort | undefined
490 if (!effort || !EFFORTS.includes(effort)) return undefined
491 const model = MODELS.find(m => m.key === answers?.model?.choice)?.key ?? current?.model ?? 'sonnet'
492 const sure = answers?.effort?.confidence ?? answers?.effort?.probabilities?.[effort]
493 if (current && typeof sure === 'number' && sure < 0.5) return { model, effort: current.effort, why: UNSURE, sure }
494 if (typeof sure !== 'number') return { model, effort, why: '' }
495 return { model, effort, why: `${Math.round(sure * 100)}% sure`, sure }
496}
497
498let askJevFile: EnvAsk | undefined
499// A key was saved from the settings panel into ~/.config/jev/.env: that file wins over an older key in the settings.
500let keyFromFile = false
501/**
502 * The TypeSafe key: from the settings, else TYPESAFE_API_KEY. Only when the person picked the jev judge outright is
503 * ~/.config/jev/.env read as well: a mod should not open a file holding a secret it was not asked to use.
504 */
505async function jevKey($: EngineInterface): Promise<string | undefined> {
506 if (config.typesafeKey && !keyFromFile) return config.typesafeKey
507 const fromEnv = await envJevKey($)
508 if (fromEnv) return fromEnv
509 if (config.judge !== 'jev' && !keyFromFile) return undefined
510 askJevFile =
511 askJevFile ??
512 (async () => {
513 const home = (await envUserProfile($)) ?? (await envHome($))
514 if (!home) return undefined
515 const text = await $.fs.read(`${home}/.config/jev/.env`).catch(() => '')
516 return parseJevKey(typeof text === 'string' ? text : '')
517 })()
518 return askJevFile
519}
520
521type Judged = { verdict?: Pick; tokens: number }
522
523/** Why a judge the person picked could not answer, in words: what a status code means for them. */
524export function judgeFailure(name: string, status: number | 'timeout'): string {
525 if (status === 'timeout') return `${name} did not answer in time`
526 if (status === 401 || status === 403) return `${name} rejected the key (${status})`
527 if (status === 402) return `${name} is out of credits (${status})`
528 if (status === 429) return `${name} is rate limited (${status})`
529 return `${name} failed (${status})`
530}
531const warned = new Set<string>()
532/** Tells the person once per session and reason that their judge failed and Haiku stands in. */
533function warnJudge($: EngineInterface, reason: string) {
534 // A test from the settings panel takes the reason for its own line, with no toast and no judge-down band.
535 if (judgeTesting) {
536 judgeTesting.reason = reason
537 return
538 }
539 void proof($, `judge fallback: ${reason}`)
540 void update($, judgeDown, () => reason).then(() => $.ui.invalidate('ui.render'))
541 if (warned.has(reason)) return
542 warned.add(reason)
543 $.ui.toast(`effortless: ${reason}. Haiku judges for now.`)
544}
545
546/** Which judge the person picked in the plugin's settings, and what it needs. */
547export type JudgeConfig = {
548 judge: 'auto' | 'haiku' | 'jev'
549 typesafeKey: string
550 /** A skill or slash command that writes the handoff instead of the built-in prompt, e.g. "session-handoff". */
551 handoffSkill: string
552 /** What follows the handoff: clear and carry on, clear and wait, or keep the chat and copy the handoff. The handoff
553 * bar opens on the choice made last; this is the first one. */
554 handoffAfter: HandoffAfter
555 /** The effort slider, -2 (cheaper) to 2 (smarter): tips the judge's close calls that way. */
556 bias: number
557 /** Auto never goes below this effort. */
558 floor: Effort
559 /** Auto never goes above this effort. */
560 ceiling: Effort
561 /** What the person switched off: the footer's handoff button and any of the alert bands. */
562 hide: Hideable[]
563 /** The swamp band shows once the context fills this share of the window, in percent. */
564 swampAt: number
565 /** default: the dashboard band above the prompt; minimal: the footer's small buttons and no band. */
566 layout: 'default' | 'minimal'
567 /** Who writes a compaction's summary: Haiku 5.5 (the default, a fraction of the price) or the chat's own model. */
568 compactWith: 'haiku' | 'session'
569 /** advised: the footer slot is Compact until Haiku advises a handoff. always: it is always the Handoff button. */
570 handoffButton: 'advised' | 'always'
571 /** A prompt the judge calls simple runs on a cheaper model than the chat's (never a dearer one). */
572 modelAuto: 'on' | 'off'
573 /** The accent colour of everything effortless draws: the brand violet or Claude orange. */
574 theme: ThemeName
575}
576
577/** The swamp thresholds the settings offer, in percent of the context window. */
578export const SWAMP_STEPS = [10, 20, 30, 40, 50, 60, 70, 80] as const
579
580/** The parts of effortless a person can switch off: the footer's (setup) and the progress bar's (setup, settings).
581 * The alerts (cold, swamp, running hot, judge down) and the line under replies always show; each alert has its own ✕.
582 * An older hide list naming them is read without them. */
583export const HIDEABLE = ['handoff', 'timer', 'reason', 'progress', 'sounds'] as const
584export type Hideable = (typeof HIDEABLE)[number]
585// What the app passed to register, so settings kept in the store can be laid over it at session start.
586let pluginOptions: Record<string, unknown> = {}
587let config: JudgeConfig = {
588 judge: 'auto',
589 typesafeKey: '',
590 handoffSkill: '',
591 handoffAfter: 'continue',
592 bias: 0,
593 floor: 'low',
594 ceiling: 'max',
595 hide: ['reason'],
596 swampAt: 80,
597 layout: 'default',
598 compactWith: 'haiku',
599 handoffButton: 'advised',
600 modelAuto: 'on',
601 theme: 'violet',
602}
603
604/** The settings as the engine hands them over (defaults filled in), cleaned to the shape the judge reads. */
605export function readConfig(options: unknown): JudgeConfig {
606 const o = (options ?? {}) as Record<string, unknown>
607 const str = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
608 const picked = str(o.judge)
609 return {
610 judge: picked === 'haiku' || picked === 'jev' ? picked : 'auto',
611 typesafeKey: str(o.typesafeKey),
612 handoffSkill: str(o.handoffSkill).replace(/^\//, ''),
613 handoffAfter: (['confirm', 'copy'] as const).find(a => a === str(o.handoffAfter)) ?? 'continue',
614 bias: Math.max(-2, Math.min(2, Math.round(Number(str(o.effortBias)) || 0))),
615 floor: EFFORTS.includes(str(o.effortFloor) as Effort) ? (str(o.effortFloor) as Effort) : 'low',
616 ceiling: EFFORTS.includes(str(o.effortCeiling) as Effort) ? (str(o.effortCeiling) as Effort) : 'max',
617 swampAt: SWAMP_STEPS.includes(Number(str(o.swampAt)) as (typeof SWAMP_STEPS)[number]) ? Number(str(o.swampAt)) : 80,
618 layout: str(o.layout) === 'minimal' ? 'minimal' : 'default',
619 compactWith: str(o.compactWith) === 'session' ? 'session' : 'haiku',
620 handoffButton: str(o.handoffButton) === 'always' ? 'always' : 'advised',
621 modelAuto: str(o.modelAuto) === 'off' ? 'off' : 'on',
622 theme: THEMES.find(t => t === str(o.theme)) ?? 'violet',
623 // The judge's line is off until switched on; an empty string saved from the panel means everything shows.
624 hide: (o.hide === undefined ? 'reason' : str(o.hide))
625 .split(',')
626 .map(part => part.trim())
627 .filter((part): part is Hideable => (HIDEABLE as readonly string[]).includes(part)),
628 }
629}
630
631/** The text the judge reads: the current pair, the last turns and the next message. */
632function judgeQuestion(prompt: string, current: Pick | null, context: string): string {
633 return [
634 current ? `Current: ${current.model} / ${current.effort}` : 'Current: none',
635 context ? `Recent conversation:\n${context}` : '',
636 `Next message:\n${prompt.slice(0, 2000)}`,
637 ]
638 .filter(Boolean)
639 .join('\n\n')
640}
641
642/**
643 * Haiku always judges; Jev is the optional quick one. Jev is asked first when a TypeSafe key is found (unless the
644 * 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
645 * Haiku, which needs nothing but the session's own login. Never throws.
646 */
647async function judge($: EngineInterface, prompt: string, current: Pick | null): Promise<Judged> {
648 const context = await recentContext($).catch(() => '')
649 const key = config.judge !== 'haiku' ? await jevKey($).catch(() => undefined) : undefined
650 if (key) {
651 const jev = await askJev($, key, prompt, current, context)
652 if (jev) return jev.verdict?.why === UNSURE ? haikuAfter($, jev, prompt, current, context) : jev
653 }
654 return askHaiku($, prompt, current, context)
655}
656
657/** Jev was unsure: Haiku makes the call, and both judges' tokens count. */
658async function haikuAfter($: EngineInterface, jev: Judged, prompt: string, current: Pick | null, context: string): Promise<Judged> {
659 const haiku = await askHaiku($, prompt, current, context)
660 return haiku.verdict ? { verdict: haiku.verdict, tokens: haiku.tokens + jev.tokens } : jev
661}
662
663/** Jev on TypeSafe: an answer, or nothing when it fails, is unsure of its own format or takes too long. */
664async function askJev($: EngineInterface, key: string, prompt: string, current: Pick | null, context: string): Promise<Judged | undefined> {
665 try {
666 // A stalled endpoint must not hold the prompt: after JEV_TIMEOUT_MS the judge falls through to Haiku.
667 const res = await Promise.race([
668 $.http.fetch((await envJevUrl($)) ?? JEV_URL, {
669 method: 'POST',
670 headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
671 body: JSON.stringify({
672 model: 'jev-latest',
673 state: {
674 task: JEV_TASK,
675 current_model: current?.model ?? null,
676 current_effort: current?.effort ?? null,
677 recent_conversation: context,
678 next_message: prompt.slice(0, 4000),
679 },
680 questions: {
681 effort: { type: 'choice', instructions: 'Which effort fits the next message?', criteria: JEV_EFFORTS },
682 model: { type: 'choice', instructions: 'Which model fits the next message?', criteria: JEV_MODELS },
683 },
684 }),
685 }),
686 $.clock.sleep(JEV_TIMEOUT_MS).then(() => {
687 throw new Error('jev timeout')
688 }),
689 ])
690 if (!res.ok) warnJudge($, judgeFailure('Jev', res.status))
691 const verdict = res.ok ? parseJevAnswer(res.text, current) : undefined
692 if (verdict) {
693 let used = 0
694 try {
695 const usage = (JSON.parse(res.text) as { usage?: { input_tokens?: number; output_tokens?: number } }).usage
696 used = (usage?.input_tokens ?? 0) + (usage?.output_tokens ?? 0)
697 } catch {
698 // No usage in the reply: counted as 0.
699 }
700 if (await read($, judgeDown)) await update($, judgeDown, () => null)
701 return { verdict: { ...verdict, by: 'jev' }, tokens: used }
702 }
703 } catch (error) {
704 // Jev down or slow: fall through to Haiku, and say so.
705 warnJudge($, String(error).includes('timeout') ? judgeFailure('Jev', 'timeout') : 'Jev could not be reached')
706 }
707 return undefined
708}
709
710// Set while the settings panel tests a judge: warnJudge leaves its reason here instead of warning.
711let judgeTesting: { reason?: string } | null = null
712/** The prompt a judge test sends: small and plain, so any working judge answers it. */
713const JUDGE_TEST_PROMPT = 'rename one variable in a single file'
714
715/** Asks the judge picked in the panel (its unsaved key, URL and model included) one sample prompt, and says in a line
716 * whether it answered, with what and how fast, or why not. Never falls back to Haiku: the point is the judge itself. */
717async function testJudge($: EngineInterface, judgeKind: string, draftKey: string): Promise<{ ok: boolean; text: string }> {
718 const start = await $.clock.now()
719 const took = async () => `${(((await $.clock.now()) - start) / 1000).toFixed(1)} s`
720 const said = (name: string, j: Judged | undefined) =>
721 j?.verdict?.why === UNSURE ? `${name} answered, unsure on the sample` : `${name} answered: ${j?.verdict?.effort}`
722 judgeTesting = {}
723 try {
724 if (judgeKind === 'jev' || judgeKind === 'auto') {
725 const key = parseJevKey(draftKey) ?? (draftKey.trim() || undefined) ?? (await jevKey($).catch(() => undefined)) ?? (await typesafeKeyAnywhere($).catch(() => undefined))
726 if (!key && judgeKind === 'jev') return { ok: false, text: 'No TypeSafe key found' }
727 if (key) {
728 const j = await askJev($, key, JUDGE_TEST_PROMPT, null, '')
729 if (j?.verdict) return { ok: true, text: `${said('Jev', j)} in ${await took()}` }
730 return { ok: false, text: judgeTesting.reason ?? 'Jev gave no usable answer' }
731 }
732 }
733 const h = await askHaiku($, JUDGE_TEST_PROMPT, null, '')
734 return h.verdict ? { ok: true, text: `${said('Haiku', h)} in ${await took()}` } : { ok: false, text: 'Haiku gave no answer' }
735 } catch (error) {
736 return { ok: false, text: String(error).slice(0, 80) }
737 } finally {
738 judgeTesting = null
739 }
740}
741
742/** Haiku through the session's own login: the judge that needs no key, and the fallback for the others. */
743async function askHaiku($: EngineInterface, prompt: string, current: Pick | null, context: string): Promise<Judged> {
744 const used = lastContext && lastContext.window ? `Context used: ${Math.round(lastContext.percent)}% of the window` : ''
745 const asked = [judgeQuestion(prompt, current, context), used].filter(Boolean).join('\n\n')
746 const ask = (model: string) => $.model.complete({ model, system: JUDGE_SYSTEM, prompt: asked, maxTokens: 160, effort: 'low', timeoutMs: 6000 })
747 // Haiku 5.5 by name: Claude Code builds that predate it map the plain alias to Haiku 4.5, at ten times the price.
748 // Where 5.5 does not answer, the alias stands in.
749 let r = await ask(COMPACT_MODEL)
750 if (!r.isAnswered) r = await ask('haiku')
751 const verdict = r.isAnswered ? parseVerdict(r.text) : undefined
752 const tokens = r.isAnswered && r.usage ? r.usage.input_tokens + r.usage.output_tokens : 0
753 return { verdict: verdict && { ...verdict, by: 'haiku' }, tokens }
754}
755
756// Whether a fresh chat would now serve better than carrying on: Haiku's call, whatever judge picks the effort. It runs
757// from HANDOFF_CHECK_FROM percent of context, every HANDOFF_CHECK_EVERY messages, so it costs a cent now and then. It
758// reads more than the effort judge: what the chat was for, the trail of topics, the last turns, the context and the cache.
759const HANDOFF_CHECK_FROM = 30
760const HANDOFF_CHECK_EVERY = 2
761const 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.
762
763Say 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.
764
765Reply with JSON only: {"handoff":"reason of at most 8 words, in the person's language"} for yes, or {"handoff":null} for no.`
766
767/** What the handoff check reads: the chat's purpose, a trail of the person's messages, the last reply's end. */
768export function handoffEvidence(messages: readonly { role: string; text: string }[], next: string, percent: number, cold: boolean): string {
769 const asked = messages.filter(m => m.role === 'user' && m.text.trim())
770 const first = asked[0]?.text.slice(0, 500) ?? ''
771 const trail = asked.slice(-8, -1).map(m => `- ${m.text.slice(0, 160).replace(/\s+/g, ' ')}`)
772 const reply = lastAssistantText(messages).slice(-700)
773 return [
774 first ? `What the chat was for (first message):\n${first}` : '',
775 trail.length ? `Trail of earlier messages:\n${trail.join('\n')}` : '',
776 reply ? `The assistant's last reply (its end):\n${reply}` : '',
777 `Next message:\n${next.slice(0, 600)}`,
778 `Context used: ${Math.round(percent)}% of the window; ${asked.length} messages so far; the cache is ${cold ? 'cold' : 'warm'}.`,
779 ]
780 .filter(Boolean)
781 .join('\n\n')
782}
783
784/** Reads Haiku's answer: a reason, or null for no (anything unreadable is no). */
785export function parseHandoffAnswer(text: string): string | null {
786 const match = text.match(/\{[\s\S]*\}/)
787 if (!match) return null
788 try {
789 const { handoff } = JSON.parse(match[0]) as { handoff?: unknown }
790 return typeof handoff === 'string' && handoff.trim() ? handoff.trim().slice(0, 70) : null
791 } catch {
792 return null
793 }
794}
795
796let promptsSinceHandoffCheck = 0
797/** Called with each message the person sends. Sets or clears the advice; never throws, never holds the message. */
798async function checkHandoff($: EngineInterface, next: string) {
799 const percent = lastContext?.percent ?? 0
800 if (percent < HANDOFF_CHECK_FROM) {
801 promptsSinceHandoffCheck = 0
802 if ((await read($, handoffAdvice)) !== null) {
803 await update($, handoffAdvice, () => null)
804 $.ui.invalidate('ui.render')
805 }
806 return
807 }
808 promptsSinceHandoffCheck++
809 if (promptsSinceHandoffCheck < HANDOFF_CHECK_EVERY) return
810 promptsSinceHandoffCheck = 0
811 try {
812 const evidence = handoffEvidence(await $.session.messages(), next, percent, (await cacheMinutes($)) === 0 && cacheExpires > 0)
813 const ask = (model: string) => $.model.complete({ model, system: HANDOFF_SYSTEM, prompt: evidence, maxTokens: 60, effort: 'low', timeoutMs: 8000 })
814 let r = await ask(COMPACT_MODEL)
815 if (!r.isAnswered) r = await ask('haiku')
816 if (!r.isAnswered) return
817 const reason = parseHandoffAnswer(r.text)
818 void proof($, `handoff check at ${Math.round(percent)}%: ${reason ?? 'no'}`)
819 if (reason !== (await read($, handoffAdvice))) {
820 await update($, handoffAdvice, () => reason)
821 $.ui.invalidate('ui.render')
822 }
823 } catch {
824 // A failed check changes nothing.
825 }
826}
827
828// The judge benchmark (/effortless bench): labelled prompts in bench/judge-cases.json, each run through the same
829// pipeline a real prompt takes (a short follow-up keeps the current effort, anything else goes to a judge).
830export type BenchCase = { id: string; kind: string; holdout?: boolean; current: Pick; context?: string; message: string; ok: Effort[] }
831export type BenchAnswer = { id: string; judge: string; effort?: Effort; by?: string; why?: string; ms: number; tokens: number }
832
833/** Where an answer lands against the labels: right, too low (risks quality), too high (wastes), or no answer. */
834export function benchGrade(c: BenchCase, effort: Effort | undefined): 'hit' | 'under' | 'over' | 'none' {
835 if (!effort) return 'none'
836 if (c.ok.includes(effort)) return 'hit'
837 const rank = EFFORTS.indexOf(effort)
838 return rank < Math.min(...c.ok.map(e => EFFORTS.indexOf(e))) ? 'under' : 'over'
839}
840
841/** The benchmark's table: per judge the share it got right, how it missed, and how fast it was; then per kind. */
842export function benchReport(cases: BenchCase[], answers: BenchAnswer[]): string {
843 const byId = new Map(cases.map(c => [c.id, c]))
844 const judges = [...new Set(answers.map(a => a.judge))]
845 const kinds = [...new Set(cases.map(c => c.kind))]
846 const pct = (n: number, of: number) => (of ? `${Math.round((n / of) * 100)}%` : '-')
847 const median = (xs: number[]) => {
848 const s = [...xs].sort((a, b) => a - b)
849 return s.length ? s[Math.floor(s.length / 2)] : 0
850 }
851 const lines = ['| Judge | Right | Too low | Too high | No answer | Median ms |', '| --- | --- | --- | --- | --- | --- |']
852 for (const j of judges) {
853 const mine = answers.filter(a => a.judge === j)
854 const g = mine.map(a => benchGrade(byId.get(a.id)!, a.effort))
855 const n = (k: string) => g.filter(x => x === k).length
856 const asked = mine.filter(a => a.ms > 0).map(a => a.ms)
857 lines.push(`| ${j} | ${pct(n('hit'), g.length)} | ${n('under')} | ${n('over')} | ${n('none')} | ${asked.length ? median(asked) : '-'} |`)
858 }
859 // The held-out cases were never tuned against: the honest score. The rest shaped the judge prompts.
860 const sets = [
861 ['tuned on', cases.filter(c => !c.holdout)],
862 ['held out', cases.filter(c => c.holdout)],
863 ] as const
864 lines.push('', `| Set (cases) | ${judges.join(' | ')} |`, `| --- | ${judges.map(() => '---').join(' | ')} |`)
865 for (const [name, set] of sets) {
866 if (!set.length) continue
867 const ids = set.map(c => c.id)
868 const cells = judges.map(j => {
869 const mine = answers.filter(a => a.judge === j && ids.includes(a.id))
870 return pct(mine.filter(a => benchGrade(byId.get(a.id)!, a.effort) === 'hit').length, mine.length)
871 })
872 lines.push(`| ${name} (${set.length}) | ${cells.join(' | ')} |`)
873 }
874 lines.push('', `| Kind (cases) | ${judges.join(' | ')} |`, `| --- | ${judges.map(() => '---').join(' | ')} |`)
875 for (const k of kinds) {
876 const ids = cases.filter(c => c.kind === k).map(c => c.id)
877 const cells = judges.map(j => {
878 const mine = answers.filter(a => a.judge === j && ids.includes(a.id))
879 return pct(mine.filter(a => benchGrade(byId.get(a.id)!, a.effort) === 'hit').length, mine.length)
880 })
881 lines.push(`| ${k} (${ids.length}) | ${cells.join(' | ')} |`)
882 }
883 const misses = answers
884 .filter(a => !a.judge.startsWith('always') && benchGrade(byId.get(a.id)!, a.effort) !== 'hit')
885 .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)}"`)
886 return [...lines, '', 'Misses:', ...(misses.length ? misses : ['- none'])].join('\n')
887}
888
889/** Runs every case through each available judge, a few at a time, and returns the answers. */
890async function runBench($: EngineInterface, cases: BenchCase[]): Promise<BenchAnswer[]> {
891 const key = await typesafeKeyAnywhere($).catch(() => undefined)
892 const judges: [string, (c: BenchCase) => Promise<Judged | undefined>][] = [
893 ['haiku', c => askHaiku($, c.message, c.current, c.context ?? '')],
894 ]
895 if (key)
896 judges.push([
897 'jev',
898 async c => {
899 const jev = await askJev($, key, c.message, c.current, c.context ?? '')
900 if (!jev) return askHaiku($, c.message, c.current, c.context ?? '')
901 return jev.verdict?.why === UNSURE ? haikuAfter($, jev, c.message, c.current, c.context ?? '') : jev
902 },
903 ])
904 const answers: BenchAnswer[] = []
905 // Baselines: what a fixed effort would score on the same labels.
906 for (const fixed of ['medium', 'high'] as Effort[])
907 for (const c of cases) answers.push({ id: c.id, judge: `always ${fixed}`, effort: fixed, ms: 0, tokens: 0 })
908 const jobs = judges.flatMap(([name, ask]) => cases.map(c => ({ name, ask, c })))
909 let next = 0
910 const worker = async () => {
911 while (next < jobs.length) {
912 const { name, ask, c } = jobs[next++]
913 // The mod never asks a judge about a short follow-up: it keeps the current effort.
914 if (keepsEffort(c.message, c.context ?? '')) {
915 answers.push({ id: c.id, judge: name, effort: c.current.effort, ms: 0, tokens: 0 })
916 continue
917 }
918 const started = await $.clock.now()
919 const got = await ask(c).catch(() => undefined)
920 answers.push({ id: c.id, judge: name, effort: got?.verdict?.effort, by: got?.verdict?.by, why: got?.verdict?.why, ms: (await $.clock.now()) - started, tokens: got?.tokens ?? 0 })
921 }
922 }
923 await Promise.all([worker(), worker(), worker(), worker()])
924 return answers
925}
926
927function keyOf(id: string): ModelKey | undefined {
928 return MODELS.find(m => id.includes(m.key))?.key
929}
930
931/** Records the model the session now runs; the pick follows it, so the band never shows a stale one. */
932async function modelIs($: EngineInterface, id: string) {
933 const pause = !cacheSafe(id) && keyOf(id) !== 'haiku'
934 if (pause !== (await read($, paused))) await update($, paused, () => pause)
935 const key = keyOf(id)
936 if (!key || key === (await read($, model))) return
937 await update($, model, () => key)
938 // The engine's effort can differ per model, so a change across a switch is not the person's doing;
939 // and a model turned down earlier may be suggested again.
940 engineEffort = undefined
941 declined = null
942 const current = await read($, pick)
943 if (current && current.model !== key) await choose($, { ...current, model: key })
944 if ((await read($, suggestion)) === key) await update($, suggestion, () => null)
945}
946
947async function sessionModel($: EngineInterface): Promise<ModelKey> {
948 const id = await $.session.model()
949 return MODELS.find(m => id.includes(m.key))?.key ?? 'sonnet'
950}
951
952
953// Proof log: every verdict, /effort and request effort, written to EFFORTLESS_LOG, or to
954// %TEMP%/effortless-proof.log while the mod is loaded from a dev-mods folder. Off otherwise.
955const proofLines: string[] = []
956// An environment variable does not change while the session runs, so each one is asked for once. The
957// engine wants the name spelled out in every $.env.get call, hence one small getter per variable.
958type EnvAsk = Promise<string | undefined> | undefined
959let askLog: EnvAsk
960let askTemp: EnvAsk
961let askTmpdir: EnvAsk
962let askModelUi: EnvAsk
963let askJevUrl: EnvAsk
964let askJevKey: EnvAsk
965let askUserProfile: EnvAsk
966let askHome: EnvAsk
967const envLog = ($: EngineInterface) => (askLog = askLog ?? $.env.get('EFFORTLESS_LOG'))
968const envTemp = ($: EngineInterface) => (askTemp = askTemp ?? $.env.get('TEMP'))
969const envTmpdir = ($: EngineInterface) => (askTmpdir = askTmpdir ?? $.env.get('TMPDIR'))
970const envModelUi = ($: EngineInterface) => (askModelUi = askModelUi ?? $.env.get('EFFORTLESS_MODEL_UI'))
971const envJevUrl = ($: EngineInterface) => (askJevUrl = askJevUrl ?? $.env.get('JEV_URL'))
972const envJevKey = ($: EngineInterface) => (askJevKey = askJevKey ?? $.env.get('TYPESAFE_API_KEY'))
973const envUserProfile = ($: EngineInterface) => (askUserProfile = askUserProfile ?? $.env.get('USERPROFILE'))
974const envHome = ($: EngineInterface) => (askHome = askHome ?? $.env.get('HOME'))
975
976async function proofPath($: EngineInterface): Promise<string | undefined> {
977 const named = await envLog($)
978 if (named) return named
979 if (!$.plugin.root.replace(/\\/g, '/').includes('/dev-mods/')) return undefined
980 const tmp = (await envTemp($)) ?? (await envTmpdir($)) ?? '/tmp'
981 return `${tmp}/effortless-proof.log`
982}
983async function proof($: EngineInterface, line: string) {
984 const path = await proofPath($).catch(() => undefined)
985 if (!path) return
986 proofLines.push(`${new Date().toISOString()} ${line}`)
987 await $.fs.write(path, proofLines.slice(-200).join('\n') + '\n').catch(() => undefined)
988}
989
990// The effort the engine itself last asked for on the main loop (the app's setting; this mod only
991// rewrites the request, never the setting). It changing means the person set it (the app's Effort
992// control, their own /effort): that wins and Auto goes off.
993let engineEffort: string | undefined
994// The slash command this mod last typed into the prompt box.
995let lastTyped = ''
996// The toast about pressing Enter is shown once; on every click it is only noise.
997let toldAboutEnter = false
998let lastFooter = ''
999// A model the person turned down; not suggested again until they pick something else.
1000let declined: ModelKey | null = null
1001
1002/**
1003 * Types a slash command into the prompt box for the person to send with Enter. A command the person
1004 * sends is read by the app itself, so its own Model and Effort controls follow; one the mod runs
1005 * inside the engine is not. Never overwrites a draft: returns false when the box is not empty.
1006 */
1007async function typeCommand($: EngineInterface, text: string): Promise<boolean> {
1008 try {
1009 const draft = (await $.prompt.read()).text.trim()
1010 // A draft of your own is never overwritten; the mod's own earlier command is replaced.
1011 if (draft !== '' && draft !== lastTyped) return false
1012 const filled = await $.prompt.fill({ text })
1013 if (filled.isFilled) lastTyped = text
1014 if (filled.isFilled && !toldAboutEnter) {
1015 toldAboutEnter = true
1016 $.ui.toast(`Press Enter to send ${text} so the app's own control follows`)
1017 }
1018 return filled.isFilled
1019 } catch {
1020 return false
1021 }
1022}
1023
1024/** A model change reloads the context, so it only happens when the person says yes. */
1025async function switchModel($: EngineInterface, model: ModelKey) {
1026 try {
1027 await $.command.run({ command: 'model', args: model })
1028 declined = null
1029 await update($, suggestion, () => null)
1030 const current = await read($, pick)
1031 if (current) await choose($, { ...current, model })
1032 } catch {
1033 $.ui.toast('Could not switch the model right now')
1034 }
1035}
1036
1037/**
1038 * The tally in its current shape. A hot reload keeps the state an older version wrote ({ requests, actual,
1039 * baseline } before 0.2.0), so every read goes through this.
1040 */
1041export function asSpent(t: unknown): Spent {
1042 const v = (t ?? {}) as Partial<Spent>
1043 const num = (x: unknown) => (typeof x === 'number' && Number.isFinite(x) ? x : 0)
1044 const judged = (v.judge ?? {}) as Partial<Spent['judge']>
1045 return {
1046 prompts: num(v.prompts),
1047 requests: num(v.requests),
1048 input: num(v.input),
1049 write: num(v.write),
1050 read: num(v.read),
1051 out: num(v.out),
1052 byEffort: v.byEffort && typeof v.byEffort === 'object' ? v.byEffort : {},
1053 judge: { jev: num(judged.jev), haiku: num(judged.haiku), ms: num(judged.ms), tokens: num(judged.tokens) },
1054 }
1055}
1056
1057/** A request's tokens weighted by price, in input-token terms (see WEIGHT). */
1058export function weighted(usage: { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number | null; cache_creation_input_tokens?: number | null }): number {
1059 return (
1060 (usage.input_tokens ?? 0) * WEIGHT.input +
1061 (usage.cache_creation_input_tokens ?? 0) * WEIGHT.write +
1062 (usage.cache_read_input_tokens ?? 0) * WEIGHT.read +
1063 (usage.output_tokens ?? 0) * WEIGHT.out
1064 )
1065}
1066
1067/** Adds one request Auto steered: every kind of token it used, under the effort it ran at. */
1068async function tally(
1069 $: EngineInterface,
1070 effort: Effort,
1071 usage: { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number | null; cache_creation_input_tokens?: number | null },
1072) {
1073 const cached = usage.cache_read_input_tokens ?? 0
1074 const write = usage.cache_creation_input_tokens ?? 0
1075 const cost = usage.input_tokens * WEIGHT.input + write * WEIGHT.write + cached * WEIGHT.read + usage.output_tokens * WEIGHT.out
1076 await update($, saved, old => {
1077 const t = asSpent(old)
1078 const bucket = t.byEffort[effort] ?? { prompts: 0, cost: 0 }
1079 return {
1080 ...t,
1081 requests: t.requests + 1,
1082 input: t.input + usage.input_tokens,
1083 write: t.write + write,
1084 read: t.read + cached,
1085 out: t.out + usage.output_tokens,
1086 byEffort: { ...t.byEffort, [effort]: { ...bucket, cost: bucket.cost + cost } },
1087 }
1088 })
1089}
1090
1091/** Counts one prompt Auto judged: under its effort, and what the judge took. */
1092async function countPrompt($: EngineInterface, effort: Effort | undefined, by: Pick['by'] | undefined, ms: number, judgeTokens: number) {
1093 await update($, saved, old => {
1094 const t = asSpent(old)
1095 const judged = {
1096 jev: t.judge.jev + (by === 'jev' ? 1 : 0),
1097 haiku: t.judge.haiku + (by === 'haiku' ? 1 : 0),
1098 ms: t.judge.ms + ms,
1099 tokens: t.judge.tokens + judgeTokens,
1100 }
1101 if (!effort) return { ...t, judge: judged }
1102 const bucket = t.byEffort[effort] ?? { prompts: 0, cost: 0 }
1103 return { ...t, judge: judged, prompts: t.prompts + 1, byEffort: { ...t.byEffort, [effort]: { ...bucket, prompts: bucket.prompts + 1 } } }
1104 })
1105}
1106
1107/** The cache lifetime a response's writes got, when it says; nothing when it wrote nothing or does not say. */
1108export function cacheTtlOf(usage: unknown): keyof typeof CACHE_TTL | undefined {
1109 const c = (usage as { cache_creation?: { ephemeral_1h_input_tokens?: number; ephemeral_5m_input_tokens?: number } } | null)
1110 ?.cache_creation
1111 if ((c?.ephemeral_1h_input_tokens ?? 0) > 0) return '1h'
1112 if ((c?.ephemeral_5m_input_tokens ?? 0) > 0) return '5m'
1113 return undefined
1114}
1115
1116/** Most of the prompt came from the cache: it was still warm. */
1117export function mostlyCached(usage: unknown): boolean {
1118 const u = (usage ?? {}) as { input_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number }
1119 const read_ = u.cache_read_input_tokens ?? 0
1120 const all = read_ + (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)
1121 return all > 0 && read_ / all > 0.5
1122}
1123
1124/** Save mode on or off: Auto stays at medium or below until the limit resets. The hot band's button and
1125 * /effortless save. Says what it did. */
1126async function toggleSave($: EngineInterface): Promise<string> {
1127 const saving = (await read($, saveUntil)) !== null
1128 const heat = await read($, hot)
1129 const until = heat?.resetsAt ? new Date(heat.resetsAt).getTime() : (await $.clock.now()) + 5 * 3600_000
1130 await update($, saveUntil, () => (saving ? null : until))
1131 return saving ? 'save mode off' : 'save mode on, Auto stays at medium or below until the limit resets'
1132}
1133
1134/** Below this a cold cache costs too little to warn about: the next message rereads the chat at full price once. */
1135const COLD_MIN_TOKENS = 60_000
1136
1137/** The context's tokens when the cold band is worth showing: the cache is cold, the band not closed, and the chat big
1138 * enough that rereading it matters. Null otherwise, and while the size is not known yet. */
1139async function coldWorth($: EngineInterface): Promise<number | null> {
1140 if ((await read($, cacheLeft)) !== 0 || config.hide.includes('cold') || (await read($, isColdHidden))) return null
1141 const tokens = lastContext?.tokens
1142 if (coldForced) return tokens ?? 0
1143 return tokens !== undefined && tokens >= COLD_MIN_TOKENS ? tokens : null
1144}
1145
1146type TurnWarning = { kind: 'cold' | 'hot'; title: string; line: string; color: string; bg: string; edge: string; art: string }
1147
1148/** What the cold and hot bands would warn about now, as the card under the newest reply says it: the same order and the same
1149 * hiding (a part switched off, a band closed with ✕), with a command in place of the band's buttons. */
1150async function turnWarning($: EngineInterface): Promise<TurnWarning | null> {
1151 const coldTokens = await coldWorth($)
1152 if (coldTokens !== null) {
1153 return { kind: 'cold', title: 'Chat went cold', line: `Next message rereads ${kTokens(coldTokens)} tokens at full price. Hand off or /compact first.`, color: ICE, bg: ICE_BG, edge: ICE_EDGE, art: FROST_SVG }
1154 }
1155 const heat = await read($, hot)
1156 const heatHidden = await read($, hotHidden)
1157 if (heat && !config.hide.includes('hot') && (heatHidden === null || heat.percent >= heatHidden + HOT_REGROW)) {
1158 const window = heat.kind === 'five_hour' ? '5h' : 'weekly'
1159 return {
1160 kind: 'hot',
1161 title: `Running hot · ${Math.round(heat.percent)}% of your ${window} limit`,
1162 line: 'Save mode keeps Auto at medium or below: /effortless save.',
1163 color: EMBER, bg: EMBER_BG, edge: EMBER_EDGE, art: EMBER_SVG,
1164 }
1165 }
1166 // Swamped is said by the band above the prompt only: a card under every reply was noise in a long chat.
1167 return null
1168}
1169
1170/** The countdown's colour for whole minutes left (rounded up, as cacheMinutes gives): none (grey), yellow or red. */
1171export function cacheColor(minutesLeft: number): string | undefined {
1172 const shown = minutesLeft - 1
1173 if (minutesLeft <= 0) return ICE
1174 if (shown > CACHE_YELLOW_MIN) return undefined
1175 return shown <= CACHE_RED_MIN ? RED : YELLOW
1176}
1177
1178/** The footer's words for the time left: "58m", "<1m", or "Cold". */
1179export function cacheLabel(minutesLeft: number): string {
1180 if (minutesLeft <= 0) return '❄ Cold'
1181 if (minutesLeft === 1) return '<1m'
1182 return `${minutesLeft - 1}m`
1183}
1184
1185/** The dashboard's countdown to the second: "58:41", or "Cold" once it ran out. */
1186export function cacheClock(msLeft: number): string {
1187 if (msLeft <= 0) return '❄ Cold'
1188 const s = Math.ceil(msLeft / 1000)
1189 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
1190}
1191
1192/** How many seconds the countdown image carries: the band is redrawn once a minute, with some slack. */
1193const CLOCK_FRAMES = 75
1194/** The dashboard's countdown as an image that ticks by itself: one frame per second, each shown for its second by a
1195 * CSS animation. The band is redrawn only once a minute: every redraw rebuilds the band, which restarts every image's
1196 * animation (the Handoff glow restarted each second while the band was redrawn for the seconds). The app sets the
1197 * image's font and colour to the band's, so the digits match the text around them. */
1198export function cacheClockSvg(msLeft: number, color: string): string {
1199 const start = Math.max(0, Math.ceil(msLeft / 1000))
1200 const frames: string[] = []hooks/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 85 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'
6export const THEMES: readonly ThemeName[] = ['violet', 'orange']
7
8let current: ThemeName = 'violet'
9export const setTheme = (name: ThemeName) => {
10 current = name
11}
12export const getTheme = () => current
13
14/** The brand violet, the colour the rest of the family is turned from. */
15export const VIOLET = '#a79cf7'
16
17// Claude's orange (#d97757) sits at hue 15, saturation 63%, lightness 60%. The brand violet is lighter and stronger, so
18// the saturation and the lightness of what turns come down to meet it.
19const ORANGE_HUE = 15
20const hexRe = /#[0-9a-fA-F]{6}\b/g
21const memo = new Map<string, string>()
22
23function toHsl(r: number, g: number, b: number): [number, number, number, number] {
24 const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min
25 const l = (max + min) / 2
26 const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1))
27 let h = 0
28 if (d !== 0) {
29 h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4
30 h = (h * 60 + 360) % 360
31 }
32 return [h, s, l, d]
33}
34
35function fromHsl(h: number, s: number, l: number): string {
36 const c = (1 - Math.abs(2 * l - 1)) * s
37 const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
38 const m = l - c / 2
39 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]
40 const byte = (v: number) => Math.max(0, Math.min(255, Math.round((v + m) * 255))).toString(16).padStart(2, '0')
41 return `#${byte(r)}${byte(g)}${byte(b)}`
42}
43
44/** One hex in the current theme: violets turn orange, everything else stays. */
45export function tintHex(hex: string): string {
46 if (current === 'violet') return hex
47 const key = hex.toLowerCase()
48 const hit = memo.get(key)
49 if (hit) return hit
50 const n = parseInt(key.slice(1), 16)
51 const [h, s, l, chroma] = toHsl((n >> 16) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255)
52 // Violet hues with real colour in them; the greys that lean blue (hardly any chroma or saturation) are not in the family.
53 const out = h >= 235 && h <= 300 && chroma >= 0.03 && s >= 0.15 ? fromHsl(ORANGE_HUE, Math.min(1, s * 0.75), l > 0.5 ? Math.max(0.5, l - 0.19) : l) : hex
54 memo.set(key, out)
55 return out
56}
57
58/** Every hex in a string (an SVG, a style) in the current theme. */
59export function tint(text: string): string {
60 return current === 'violet' ? text : text.replace(hexRe, tintHex)
61}
62
63/** The accent as text colour. */
64export const accent = () => tintHex(VIOLET)
65
66type Component = (props: Record<string, unknown>) => unknown
67const wrapped = new WeakMap<object, Component>()
68
69/** The Svg element that draws its source in the theme. In violet it is the app's own, untouched. */
70export function themedSvg<T>(Svg: T): T {
71 if (current === 'violet' || typeof Svg !== 'function') return Svg
72 let wrap = wrapped.get(Svg as object)
73 if (!wrap) {
74 const inner = Svg as unknown as Component
75 wrap = props => inner(typeof props.source === 'string' ? { ...props, source: tint(props.source) } : props)
76 wrapped.set(Svg as object, wrap)
77 }
78 return wrap as unknown as T
79}
80
81/** The parts `$.ui.resolve` hands over, with the Svg in the theme. Absent stays absent. */
82export function themedEls<T extends object>(els: T): T {
83 return 'Svg' in els ? { ...els, Svg: themedSvg((els as { Svg: unknown }).Svg) } : els
84}
85hooks/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 213 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}
16
17/** What the next turn runs with, and who decided it. */
18export type Pick = {
19 model: ModelKey
20 effort: Effort
21 /** A few words on why, shown dim in the band. */
22 why: string
23 by: 'jev' | 'haiku' | 'manual'
24 /** How sure the judge was of the effort, 0-1, when it said. */
25 sure?: number
26 /** Haiku only: why a handoff would suit now (a few words), when it said it would. */
27 handoff?: string
28}
29
30/** What follows a handoff: clear and carry on, clear and wait, or keep the chat and copy it. */
31export type HandoffAfter = 'continue' | 'confirm' | 'copy'
32
33/** A choice in the handoff bar: quick (a fork) or full (the person's skill), then what follows. */
34export type HandoffChoice = { kind: 'quick' | 'full'; after: HandoffAfter }
35
36/** The settings panel's unsaved changes, by field, as the settings store them (strings). */
37export type SettingsDraft = {
38 bias?: string
39 floor?: string
40 ceiling?: string
41 judge?: string
42 handoffAfter?: string
43 handoffSkill?: string
44 customUrl?: string
45 customModel?: string
46 /** Comma-separated parts switched off: handoff, cold, swamp, hot, down. */
47 hide?: string
48 key?: string
49 swampAt?: string
50 /** default (dashboard band) or minimal (footer buttons). */
51 layout?: string
52 /** haiku (Haiku 5.5 writes the compaction's summary) or session (the chat's own model). */
53 compactWith?: string
54 /** advised (the slot is Compact until Haiku advises a handoff) or always (the slot is the Handoff button). */
55 handoffButton?: string
56 /** on: a prompt the judge calls simple runs on a cheaper model than the chat's; off: always the chat's model. */
57 modelAuto?: string
58 /** violet (the brand) or orange (Claude's). */
59 theme?: string
60}
61
62/** One step of the task the progress bar follows: a todo or a task. */
63export type ProgressStep = {
64 id: string
65 /** What the step is ("Run the tests"). */
66 label: string
67 /** The step while it runs ("Running the tests"). */
68 doing: string
69 status: 'pending' | 'in_progress' | 'completed'
70}
71
72/** The progress bar's state: where the task is, and its steps. */
73export type Progress = {
74 phase: 'planning' | 'working' | 'asking' | 'paused' | 'done'
75 steps: ProgressStep[]
76}
77
78/** Where one subagent stands, as the agent panel shows it. */
79export type AgentState = 'picking' | 'running' | 'waiting' | 'done' | 'failed'
80
81/** One subagent of this chat, for the agent panel. */
82export type AgentRec = {
83 id: string
84 /** The agent type (Explore, general-purpose, a plugin's agent). */
85 type: string
86 /** The Agent call's short description of the task. */
87 task: string
88 state: AgentState
89 /** The id of the agent that spawned it; absent when the main chat did. */
90 parentId?: string
91 /** When it was spawned and when it ended (clock ms). */
92 startedAt: number
93 endedAt?: number
94 /** Its own task list, when it keeps one: the progress the card shows. */
95 steps?: ProgressStep[]
96 /** What it does now: a tool and its argument, short. */
97 now?: string
98 /** When its current tool call started (clock ms); absent between calls. */
99 toolSince?: number
100 /** Since when it waits on that tool (clock ms). */
101 waitingSince?: number
102 model?: string
103 effort?: string
104 /** Why effortless picked that, a few words. */
105 why?: string
106 /** Weighted tokens so far. */
107 cost?: number
108 /** The file its current tool call is in, relative to the project root; absent between calls. */
109 file?: string
110}
111
112/** A file this chat or its agents read or changed, for the agent panel's map. */
113export type FileTouch = {
114 /** Relative to the project root, with forward slashes. */
115 path: string
116 edited: boolean
117 /** Who touched it: agent ids, 'main' for the main chat. */
118 by: string[]
119 lastAt: number
120 /** The project files it imports, relative to the root, without extension. */
121 imports: string[]
122}
123
124declare module 'claude-code' {
125 interface PluginState {
126 effortless: {
127 /** Auto on effort: every prompt is judged and its effort applied. */
128 isAuto: boolean
129 /** The last change Auto made to the effort, shown for a moment as "Low → High". */
130 switched: { from: Effort; to: Effort } | null
131 /** What the prompts Auto steered cost this session, measured (see Spent). */
132 saved: Spent
133 /** The session runs a model where an effort change rewrites the prompt cache: Auto waits. */
134 paused: boolean
135 /** Whole minutes the prompt cache stays warm: null before the first response, 0 once cold. */
136 cacheLeft: number | null
137 /** What the cache countdown knows, kept here because a reload starts the module's own variables over. */
138 cacheMemo: { expires: number; ttl: '5m' | '1h'; last?: number } | null
139 /** A compaction started from the footer's Compact button is running. */
140 isCompacting: boolean
141 /** The compact bar is open, asking for an optional note. */
142 compactAsk: boolean
143 /** The cold band was closed until the cache goes cold again. */
144 isColdHidden: boolean
145 /** Why the picked judge is failing, or null. */
146 judgeDown: string | null
147 judgeTest: { ok: boolean | null; text: string } | null
148 /** The reason the judge-down band was closed for. */
149 judgeDownHidden: string | null
150 /** The effortless settings panel is open above the prompt. */
151 settingsOpen: boolean
152 settingsCard: 'effort' | 'judge' | 'handoff' | 'show' | null
153 /** Changes made in the settings panel and not saved yet. */
154 settingsDraft: SettingsDraft
155 /** The user and plugin skills, read when the settings panel opens. */
156 installedSkills: string[]
157 /** The fullest usage window once past 80%, or null. */
158 hot: { kind: string; percent: number; resetsAt: string | null } | null
159 /** Percent at which the running-hot band was closed. */
160 hotHidden: number | null
161 /** Save mode until this time (ms), or null. */
162 saveUntil: number | null
163 /** Context tokens read per request once the chat is swamped, else null. */
164 swamped: number | null
165 /** Tokens at which the swamp band was closed; it returns once the context grows well past it. */
166 swampHiddenAt: number | null
167 /** Why Haiku thinks a handoff would suit now, shown with Handoff lit; null when it has no advice. */
168 handoffAdvice: string | null
169 /** The first-run setup is not done yet. */
170 setupPending: boolean
171 /** Where a handoff is: null idle, writing, or clearing and resending. */
172 handoffStage: 'writing' | 'clearing' | null
173 /** The handoff bar above the prompt, open with the choice shown in it, or null. */
174 handoffPick: HandoffChoice | null
175 /** The newest reply's text: the reply card hangs under its last block. */
176 lastAnswer: string
177 /** What the newest reply cost: tokens weighted by price over the main thread's requests, and its time. */
178 lastTurn: { cost: number; ms: number } | null
179 /** The handoff card under the newest reply, from the start of a handoff until the reply after it lands. */
180 handoffCard: { kind: 'writing' | 'done' | 'copied'; full: boolean; at: number; seen: boolean } | null
181 /** A new version above the prompt: offered, updating, updated or failed, or null. */
182 updateCard: { stage: 'offer' | 'updating' | 'done' | 'failed'; version: string; note: string; at: number; detail?: string } | null
183 /** The setup guide's step, or null when it is closed. */
184 setupStep: 'pick' | 'jev' | 'lean' | 'handoff' | 'done' | null
185 /** Choices made in the setup guide, saved together at Done or the cross. */
186 setupDraft: SettingsDraft
187 /** Auto on model: the judge may suggest another model (never switched without a yes). Off by default. */
188 isAutoModel: boolean
189 pick: Pick | null
190 isJudging: boolean
191 suggestion: ModelKey | null
192 /** The effort the person last set outside the mod (the app's control, their /effort): what the app shows. */
193 appEffort: string | null
194 /** The model the session runs now, as the engine last reported it (start, a switch, a request). */
195 model: ModelKey | null
196 /** The task the progress bar follows, or null when there is none. */
197 progress: Progress | null
198 /** The step list the progress bar was closed for; a new list shows it again. */
199 progressHidden: string | null
200 /** This chat's subagents, oldest first, for the agent panel. */
201 agents: AgentRec[]
202 /** The agent card opened in the panel, or 'done' for the finished ones, or null. */
203 agentsOpen: string | null
204 /** The files this chat and its agents touched, newest last, for the panel's map. */
205 agentFiles: FileTouch[]
206 /** The main chat's own task list, for the panel's total progress; null when it keeps none. */
207 agentSteps: ProgressStep[] | null
208 /** The module (folder) opened in the panel's list, or null. */
209 agentsModule: string | null
210 }
211 }
212}
213