SLOPSHOPPER

effortless

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

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

Draws

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

<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%">

Powered by Haiku 5.5

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

The judgereads each prompt and picks the effort and model, in about a second, on your own Claude login
Cheaper model when it cana 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
Compactionevery 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.

Install

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

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

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

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

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

Or in a terminal, in one line:

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

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

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

What the bar shows

On the barMeans
High · Opusthe effort 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:00time until the prompt cache goes cold, after which the next message pays full price to re-read the chat
Haiku: a refactor…who judged and why
Autoswitches the judge on and off. Changing effort in the app yourself also turns Auto off: you always win
Compactcompacts the chat. It turns into a glowing Handoff when Haiku says a fresh chat would pay off, with Compact as quiet text beside it. Set Settings → Handoff → Handoff button to Always to keep Handoff on the bar
⚙settings

Prefer it quiet? Settings → Customize has a Minimal look with no bar.

When a chat gets heavy

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

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

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

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

Settings

Haiku, and Jev if you add it

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

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

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

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

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

Keys and trust

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

What it saves, honestly

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.

  • Against a high default (high, xhigh) Auto saves a lot: a median xhigh prompt cost about three times a medium one.
  • Against a medium default it mostly saves a few percent, and gives hard jobs high on their own.
  • Keeping chats short and compacting before the cache goes cold often saves more than effort does. That is what the countdown is for.

How often the judge is right

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

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

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

What this does and does not show:

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

Models

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

Split view

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

In the terminal

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

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

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

Commands

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

Open source

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

Develop

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

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

License

MIT

Source 7 files
hooks/register.tsx 5006 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelForkResult, Register, RenderInput } from 'claude-code'
3
4import type { AgentRec, Effort, HandoffAfter, HandoffChoice, ModelKey, Pick, SettingsDraft, Spent } from '../types'
5import { agentsPane, demoAgents, demoFiles, demoSteps, importsOf, relPath, toolLine, touchOf, withTouch, withWaits } from './agents'
6import { ART_COLUMNS, ART_FRAME_MS, ART_MIN_WIDTH, ART_ROWS, type ArtKind, artFrame, MOVING } from './art'
7import { MARK_SVG } from './brand-mark'
8import { accent, 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 lines
1import type { AgentRec, AgentState, FileTouch, Progress, ProgressStep } from '../types'
2import { MARK_SVG } from './brand-mark'
3import { accent, tintHex } from './theme'
4import { ART_H, ART_W, looks, PILL_H, progressArtSvg, progressShare, progressTrackSvg, THINK_W, thinkingSvg } from './progress'
5
6// The agent panel (docs/agent-panel/): a pane on the right with this chat and the subagents it sends off. Its own file,
7// like progress.tsx: the rules and the drawing live here, and what needs $ (the hooks, the state) is a short section in
8// register.tsx, since the engine follows $ only into functions of the file that holds the hooks.
9//
10// Built from the bar's own parts, not new ones: the dashboard's card (mark, bold word, ring), the progress band's looks
11// and art per phase for each agent (planning, working, asking, done), its live track for an agent's steps, the done
12// card's green, the settings bar's big tilted mark, cut off by the corner.
13
14/** The pane's id, for $.ui.open and its ui.render hook. */
15export const AGENTS_PANE = 'effortless-agents'
16
17/** The progress band's phase each agent state is drawn in: its colours and its art. */
18const PHASE: Record<Exclude<AgentState, 'failed'>, Progress['phase']> = {
19  picking: 'planning',
20  running: 'working',
21  waiting: 'asking',
22  done: 'done',
23}
24/** The one state the progress band has no look for: a red card, as the alert bands' red. */
25const FAILED = { color: '#e5534b', bg: '#1a0f0f', edge: '#6a2a26' }
26const lookOf = (s: AgentState) => (s === 'failed' ? FAILED : looks(PHASE[s]))
27
28/** How the whole chat stands, worst news first: someone waits on a tool, someone works, all done, or nothing ran. */
29export type Mood = 'waiting' | 'working' | 'done' | 'quiet'
30export function moodOf(agents: readonly AgentRec[]): Mood {
31  if (agents.some(a => a.state === 'waiting')) return 'waiting'
32  if (agents.some(a => a.state === 'running' || a.state === 'picking')) return 'working'
33  return agents.length ? 'done' : 'quiet'
34}
35
36/** The header's word for the mood, as the bar names its effort: one bold word. */
37export function moodWord(agents: readonly AgentRec[]): string {
38  const live = agents.filter(a => a.state === 'running' || a.state === 'picking').length
39  const waiting = agents.filter(a => a.state === 'waiting').length
40  if (waiting) return `${waiting} waiting`
41  if (live) return `${live} working`
42  return agents.length ? 'All done' : 'Quiet'
43}
44
45/** The share of the chat's agent work done, 0-100: finished agents count whole, the rest by their own steps. */
46export function overallPercent(agents: readonly AgentRec[]): number {
47  if (!agents.length) return 0
48  const share = agents.map(a => (a.state === 'done' || a.state === 'failed' ? 1 : a.steps?.length ? progressShare(a.steps) : 0))
49  return Math.round((share.reduce((s, v) => s + v, 0) / agents.length) * 100)
50}
51
52/** Minutes and seconds, as the cache clock shows them. */
53export function clockText(ms: number): string {
54  const s = Math.max(0, Math.floor(ms / 1000))
55  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
56}
57
58/** How long one tool call runs before its agent counts as waiting on it (a test run, a build, a long fetch). */
59export const WAIT_MS = 8000
60
61/** The agents that have sat in one tool call past WAIT_MS turned to waiting; null when none changed. */
62export function withWaits(agents: readonly AgentRec[], nowMs: number): AgentRec[] | null {
63  let changed = false
64  const next = agents.map(a => {
65    if (a.state !== 'running' || a.toolSince === undefined || nowMs - a.toolSince < WAIT_MS) return a
66    changed = true
67    return { ...a, state: 'waiting' as const, waitingSince: a.toolSince }
68  })
69  return changed ? next : null
70}
71
72/** What an agent does now, in a few words: the tool and the one argument that says what it is about. */
73export function toolLine(tool: string, input: Record<string, unknown>): string {
74  const str = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
75  const base = (path: string) => path.split(/[\\/]/).filter(Boolean).pop() ?? path
76  const arg =
77    tool === 'Bash' ? str('command')
78    : tool === 'Read' || tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit' ? base(str('file_path') || str('notebook_path'))
79    : tool === 'Grep' || tool === 'Glob' ? str('pattern')
80    : tool === 'WebFetch' ? str('url').replace(/^https?:\/\//, '')
81    : tool === 'WebSearch' ? str('query')
82    : tool === 'Agent' ? str('description')
83    : ''
84  const name = tool.startsWith('mcp__') ? tool.split('__').pop() ?? tool : tool
85  const one = arg.split('\n')[0].trim()
86  return one ? `${name} ${one.length > 60 ? `${one.slice(0, 59)}…` : one}` : name
87}
88
89/** The agents worth a card of their own: everything not finished. Finished ones fold into one green card. */
90export function splitAgents(agents: readonly AgentRec[]): { live: AgentRec[]; done: AgentRec[] } {
91  return { live: agents.filter(a => a.state !== 'done'), done: agents.filter(a => a.state === 'done') }
92}
93
94/** The mark, big, tilted and faint, as the settings bar shows it: cut off by the pane's bottom-right corner. */
95const CORNER_MARK = MARK_SVG.replace('<g mask=', '<g opacity=".16" transform="rotate(9 50 50)" mask=')
96
97/**
98 * The pane's background, the bar's art made tall: the brand wash rising from the bottom-right corner in the mood's
99 * colour, the bar's slanted light lines and its sparkles, under the band's grain. One source per mood, so a redraw in
100 * the same mood is the same image.
101 */
102export function paneWashSvg(mood: Mood, w = 400, h = 900): string {
103  const c = mood === 'waiting' ? looks('asking').color : mood === 'done' ? looks('done').color : '#8f7ff0'
104  const light = mood === 'waiting' ? '#f2c97a' : mood === 'done' ? '#b4f0c8' : '#b3a6ff'
105  const strength = mood === 'quiet' ? 0.45 : mood === 'waiting' ? 0.6 : 1
106  const f = (v: number) => v.toFixed(1)
107  // The bar's slanted lines, as BRAND_SVG draws them, stretched to the pane's height.
108  const lines = [0, 1, 2, 3, 4, 5]
109    .map(i => {
110      const x = w * 0.18 + i * (w * 0.15)
111      return `<line x1="${f(x)}" y1="${h + 10}" x2="${f(x + h * 0.36)}" y2="-10" stroke="#fff" stroke-opacity="${i % 2 ? 0.05 : 0.04}" stroke-width="${[3, 1.2, 4, 1.5, 3, 1.2][i]}"/>`
112    })
113    .join('')
114  // The bar's sparkles: they pop, turn and go, each on its own beat; more of them while agents work.
115  const count = mood === 'quiet' ? 8 : mood === 'done' ? 12 : 18
116  const sparks = Array.from({ length: count }, (_, i) => {
117    const a = Math.sin((i + 1) * 12.9898) * 43758.5453
118    const b = Math.sin((i + 1) * 78.233) * 24634.6345
119    const x = w * 0.15 + (a - Math.floor(a)) * w * 0.85
120    const y = h * 0.3 + (b - Math.floor(b)) * h * 0.7
121    const s = 1.6 + ((a * 7) % 1) * 1.8
122    const d = `M${f(x)} ${f(y - s)}L${f(x + s * 0.21)} ${f(y - s * 0.21)}L${f(x + s)} ${f(y)}L${f(x + s * 0.21)} ${f(y + s * 0.21)}L${f(x)} ${f(y + s)}L${f(x - s * 0.21)} ${f(y + s * 0.21)}L${f(x - s)} ${f(y)}L${f(x - s * 0.21)} ${f(y - s * 0.21)}Z`
123    return `<path class="sp" style="transform-origin:${f(x)}px ${f(y)}px;animation-duration:${(3.6 + (i % 5) * 0.45).toFixed(2)}s;animation-delay:${((i * 0.83) % 4.4).toFixed(2)}s" d="${d}"/>`
124  }).join('')
125  return (
126    `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMaxYMax slice">` +
127    '<style>:root{color-scheme:light dark}html,body{margin:0}svg{background:transparent;display:block}' +
128    '.sp{fill:#fff;opacity:0;transform:scale(0);animation-name:gl;animation-timing-function:ease-in-out;animation-iteration-count:infinite}' +
129    '@keyframes gl{0%,72%,100%{opacity:0;transform:scale(0) rotate(0deg)}82%{opacity:.9;transform:scale(1) rotate(30deg)}92%{opacity:0;transform:scale(.2) rotate(60deg)}}' +
130    '.br{animation:br 6s ease-in-out infinite}@keyframes br{0%,100%{opacity:.85}50%{opacity:1}}</style>' +
131    `<defs><radialGradient id="bg" cx="${w}" cy="${h}" r="${f(h * 0.95)}" gradientUnits="userSpaceOnUse">` +
132    `<stop offset="0" stop-color="${light}" stop-opacity="${(0.42 * strength).toFixed(2)}"/>` +
133    `<stop offset=".35" stop-color="${c}" stop-opacity="${(0.24 * strength).toFixed(2)}"/>` +
134    `<stop offset=".75" stop-color="${c}" stop-opacity="${(0.06 * strength).toFixed(2)}"/>` +
135    `<stop offset="1" stop-color="${c}" stop-opacity="0"/></radialGradient>` +
136    '<linearGradient id="fade" x1="0" y1="0" x2="1" y2="1"><stop offset=".2" stop-color="#fff" stop-opacity="0"/><stop offset=".7" stop-color="#fff"/></linearGradient>' +
137    `<mask id="m"><rect width="${w}" height="${h}" fill="url(#fade)"/></mask>` +
138    '<pattern id="grain" width="2" height="2" patternUnits="userSpaceOnUse"><rect width=".6" height=".6" fill="#fff" fill-opacity=".07"/></pattern></defs>' +
139    `<rect class="br" width="${w}" height="${h}" fill="url(#bg)"/>` +
140    `<g mask="url(#m)">${lines}${sparks}<rect width="${w}" height="${h}" fill="url(#grain)"/></g>` +
141    '</svg>'
142  )
143}
144
145// --- The map: the parts of the code this chat touched, and how they import each other ---------------------------------
146
147/** The file a tool call reads or changes, and whether it changes it; null for a call that touches no one file. */
148export function touchOf(tool: string, input: Record<string, unknown>): { path: string; edited: boolean } | null {
149  const path = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : ''
150  if (!path) return null
151  if (tool === 'Read') return { path, edited: false }
152  if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit' || tool === 'NotebookEdit') return { path, edited: true }
153  return null
154}
155
156/** A path relative to the project root, with forward slashes; null when it lies outside the root. */
157export function relPath(path: string, root: string): string | null {
158  const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
159  const p = norm(path)
160  const r = norm(root)
161  if (!/^([a-zA-Z]:)?\//.test(p)) return p.replace(/^\.\//, '')
162  if (p.toLowerCase() === r.toLowerCase()) return null
163  return p.toLowerCase().startsWith(`${r.toLowerCase()}/`) ? p.slice(r.length + 1) : null
164}
165
166/** A path without its extension (and without a trailing /index): what an import names. */
167export function stemOf(path: string): string {
168  return path.replace(/\.(d\.ts|tsx?|jsx?|mjs|cjs|py)$/, '').replace(/\/index$/, '')
169}
170
171/** Joins a relative import onto the importing file's folder. */
172function joinRel(from: string, spec: string): string {
173  const parts = from.split('/').slice(0, -1)
174  for (const seg of spec.split('/')) {
175    if (seg === '..') parts.pop()
176    else if (seg !== '.' && seg !== '') parts.push(seg)
177  }
178  return parts.join('/')
179}
180
181/** The project files a source file imports, as stems relative to the root: relative JS/TS imports, Python modules. */
182export function importsOf(path: string, text: string): string[] {
183  const out = new Set<string>()
184  if (/\.(tsx?|jsx?|mjs|cjs)$/.test(path)) {
185    const re = /(?:from\s*|import\s*\(?\s*|require\(\s*)['"](\.{1,2}\/[^'"]+)['"]/g
186    for (const m of text.matchAll(re)) out.add(stemOf(joinRel(path, m[1])))
187  } else if (/\.py$/.test(path)) {
188    for (const m of text.matchAll(/^\s*from\s+(\.*)([\w.]*)\s+import/gm)) {
189      const dots = m[1].length
190      const mod = m[2].replace(/\./g, '/')
191      if (dots) out.add(joinRel(path, `${'../'.repeat(dots - 1)}./${mod}`))
192      else if (mod) out.add(mod)
193    }
194    for (const m of text.matchAll(/^\s*import\s+([\w.]+)/gm)) out.add(m[1].replace(/\./g, '/'))
195  }
196  out.delete(stemOf(path))
197  return [...out]
198}
199
200/** The module a file belongs to: its folder, at most two levels deep; files at the root are "project". */
201export function moduleOf(path: string): string {
202  const dirs = path.split('/').slice(0, -1)
203  return dirs.length ? dirs.slice(0, 2).join('/') : 'project'
204}
205
206export type Module = { key: string; files: FileTouch[]; edited: boolean; live: AgentRec[] }
207
208/** The modules the chat touched, most touched first, with the agents working in each right now. */
209export function modulesOf(files: readonly FileTouch[], agents: readonly AgentRec[]): Module[] {
210  const map = new Map<string, Module>()
211  for (const f of files) {
212    const key = moduleOf(f.path)
213    const m = map.get(key) ?? { key, files: [], edited: false, live: [] }
214    m.files.push(f)
215    m.edited ||= f.edited
216    map.set(key, m)
217  }
218  for (const a of agents) if (a.file && (a.state === 'running' || a.state === 'waiting')) map.get(moduleOf(a.file))?.live.push(a)
219  return [...map.values()].sort((x, y) => y.files.length - x.files.length || x.key.localeCompare(y.key))
220}
221
222/** Which modules import which, as "from>to" pairs, among the files the chat touched. */
223export function moduleLinks(files: readonly FileTouch[]): string[] {
224  const byStem = new Map(files.map(f => [stemOf(f.path), f.path]))
225  const links = new Set<string>()
226  for (const f of files)
227    for (const stem of f.imports) {
228      const to = byStem.get(stem)
229      if (to && moduleOf(to) !== moduleOf(f.path)) links.add(`${moduleOf(f.path)}>${moduleOf(to)}`)
230    }
231  return [...links]
232}
233
234/** The file list with one more touch: who, read or changed, and its imports once they are known. */
235export function withTouch(files: readonly FileTouch[], path: string, edited: boolean, by: string, nowMs: number, imports?: string[]): FileTouch[] {
236  const old = files.find(f => f.path === path)
237  const next: FileTouch = {
238    path,
239    edited: edited || Boolean(old?.edited),
240    by: old ? (old.by.includes(by) ? old.by : [...old.by, by]) : [by],
241    lastAt: nowMs,
242    imports: imports ?? old?.imports ?? [],
243  }
244  return [...files.filter(f => f.path !== path), next].slice(-80)
245}
246
247const MAP_W = 400
248const MAP_H = 190
249/**
250 * The map: each module a node on a ring, sized by the files touched in it; a line wherever one imports another.
251 * Changed modules are solid, read ones an outline; where an agent works now the node glows violet and light runs along
252 * its links. In the bar's look: its dot grid, its violet, its breathing glow.
253 */
254export function mapSvg(modules: readonly Module[], links: readonly string[]): string {
255  const f = (v: number) => v.toFixed(1)
256  const shown = modules.slice(0, 10)
257  const cx = MAP_W / 2
258  const cy = MAP_H / 2
259  const pos = new Map<string, { x: number; y: number; r: number }>()
260  shown.forEach((m, i) => {
261    const n = shown.length
262    const a = -Math.PI / 2 + (i / Math.max(1, n)) * Math.PI * 2
263    const ring = n === 1 ? 0 : 1
264    pos.set(m.key, { x: cx + Math.cos(a) * 150 * ring, y: cy + Math.sin(a) * 62 * ring, r: 4 + Math.min(7, Math.sqrt(m.files.length) * 2.6) })
265  })
266  const live = new Set(shown.filter(m => m.live.length).map(m => m.key))
267  const edges = links
268    .map(l => l.split('>'))
269    .filter(([a, b]) => pos.has(a) && pos.has(b))
270    .map(([a, b]) => {
271      const p = pos.get(a)!
272      const q = pos.get(b)!
273      const mx = (p.x + q.x) / 2 + (q.y - p.y) * 0.15
274      const my = (p.y + q.y) / 2 - (q.x - p.x) * 0.15
275      const hot = live.has(a) || live.has(b)
276      return `<path d="M${f(p.x)} ${f(p.y)} Q${f(mx)} ${f(my)} ${f(q.x)} ${f(q.y)}" fill="none" stroke="${hot ? '#a79cf7' : '#4a3f80'}" stroke-opacity="${hot ? 0.9 : 0.7}" stroke-width="1.2"${hot ? ' stroke-dasharray="3 5" class="fl"' : ''}/>`
277    })
278    .join('')
279  const nodes = shown
280    .map(m => {
281      const p = pos.get(m.key)!
282      const isLive = live.has(m.key)
283      const name = m.key.split('/').pop() ?? m.key
284      const halo = isLive ? `<circle class="br" cx="${f(p.x)}" cy="${f(p.y)}" r="${f(p.r + 9)}" fill="url(#halo)"/>` : ''
285      const body = m.edited
286        ? `<circle cx="${f(p.x)}" cy="${f(p.y)}" r="${f(p.r)}" fill="${isLive ? '#a79cf7' : '#d4d4d8'}"/>`
287        : `<circle cx="${f(p.x)}" cy="${f(p.y)}" r="${f(p.r)}" fill="#15121f" stroke="${isLive ? '#a79cf7' : '#8b8b93'}" stroke-width="1.4"/>`
288      const label = `<text x="${f(p.x)}" y="${f(p.y + p.r + 13)}" text-anchor="middle" class="${isLive ? 'tl' : 't'}">${name}</text>`
289      return halo + body + label
290    })
291    .join('')
292  const dots: string[] = []
293  for (let y = 6; y < MAP_H; y += 12) for (let x = 6; x < MAP_W; x += 12) dots.push(`<circle cx="${x}" cy="${y}" r=".6"/>`)
294  return (
295    `<svg xmlns="http://www.w3.org/2000/svg" width="${MAP_W}" height="${MAP_H}" viewBox="0 0 ${MAP_W} ${MAP_H}">` +
296    '<style>:root{color-scheme:light dark}html,body{margin:0}svg{background:transparent;display:block}' +
297    ".t{font:11px system-ui,'Segoe UI',sans-serif;fill:#8b8b93}.tl{font:600 11px system-ui,'Segoe UI',sans-serif;fill:#ececf0}" +
298    '.fl{animation:fl 1s linear infinite}@keyframes fl{to{stroke-dashoffset:-8}}' +
299    '.br{animation:br 2.4s ease-in-out infinite}@keyframes br{0%,100%{opacity:.5}50%{opacity:1}}</style>' +
300    '<defs><radialGradient id="halo"><stop offset="0" stop-color="#a79cf7" stop-opacity=".55"/><stop offset="1" stop-color="#a79cf7" stop-opacity="0"/></radialGradient></defs>' +
301    `<g fill="#ffffff" fill-opacity=".06">${dots.join('')}</g>${edges}${nodes}</svg>`
302  )
303}
304
305/** The map card: the picture, then a row per module that opens to its files. */
306function mapCard(d: AgentsDraw, modules: readonly Module[], links: readonly string[]) {
307  const { Box, Text, Button, Svg } = d
308  return (
309    <Box key="agents-map" position="relative" flexDirection="column" paddingX={1} backgroundColor={CARD_BG} borderStyle="round" borderColor={CARD_EDGE}>
310      <Box flexDirection="row">
311        <Text color={TEXT} bold>Map</Text>
312        <Box flexGrow={1} />
313        <Text dimColor>{`${modules.length} part${modules.length === 1 ? '' : 's'} touched`}</Text>
314      </Box>
315      {Svg ? <Svg source={mapSvg(modules, links)} alt={`map of ${modules.length} parts of the code`} width={MAP_W} height={MAP_H} /> : null}
316      {modules.map(m => {
317        const isOpen = d.module === m.key
318        const lead = m.live.length ? m.live.map(a => a.type).join(', ') : m.edited ? 'changed' : 'read'
319        return (
320          <Box key={`mod-${m.key}`} position="relative" flexDirection="column">
321            <Box flexDirection="row">
322              <Text color={m.live.length ? accent() : DIM}>{isOpen ? '▾ ' : '▸ '}</Text>
323              <Text color={TEXT} bold={m.live.length > 0}>{m.key}</Text>
324              <Text dimColor>{`  ${m.files.length}`}</Text>
325              <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden" />
326              <Text color={m.live.length ? accent() : DIM}>{lead}</Text>
327            </Box>
328            {isOpen
329              ? m.files.map(file => {
330                  const worker = m.live.find(a => a.file === file.path)
331                  return (
332                    <Box key={`file-${file.path}`} flexDirection="row" paddingLeft={2}>
333                      <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
334                        <Text color={worker ? '#ececf0' : TEXT} wrap="truncate">{file.path.split('/').pop()}</Text>
335                      </Box>
336                      <Text color={worker ? accent() : DIM}>{worker ? `  ${worker.type}` : file.edited ? '  changed' : '  read'}</Text>
337                    </Box>
338                  )
339                })
340              : null}
341            <Box position="absolute" top={0} left={0} right={0} height={1}>
342              <Button key={`mod-${m.key}-press`} plain label={' '.repeat(60)} hover={{ backgroundColor: '#00000000' }} onPress={() => d.onModule(m.key)} />
343            </Box>
344          </Box>
345        )
346      })}
347    </Box>
348  )
349}
350
351/** What drawing the pane takes besides the agents: the elements, the bar's parts from register.tsx, the presses. */
352export type AgentsDraw = {
353  // The surface's element table, as $.ui.resolve(e) gives it.
354  // eslint-disable-next-line @typescript-eslint/no-explicit-any
355  Box: any
356  // eslint-disable-next-line @typescript-eslint/no-explicit-any
357  Text: any
358  // eslint-disable-next-line @typescript-eslint/no-explicit-any
359  Button: any
360  // eslint-disable-next-line @typescript-eslint/no-explicit-any
361  Svg?: any
362  /** Rows of the pane's body, so the background fills it. */
363  rows: number
364  nowMs: number
365  /** The chat's own title for the header. */
366  title: string
367  /** The opened card: an agent's id, 'done' for the finished ones, or null. */
368  open: string | null
369  /** The bar's parts: its context ring, its card art (DASH_SVG) and its size. */
370  ringSvg: (percent: number, color: string) => string
371  cardArt: { source: string; width: number; height: number }
372  onOpen: (id: string) => unknown
373  /** The files touched, the main chat's own steps, the module opened in the list. */
374  files: readonly FileTouch[]
375  steps: readonly ProgressStep[] | null
376  module: string | null
377  onModule: (key: string) => unknown
378}
379
380const TEXT = '#d4d4d8'
381/** The pane's ground: the brand card's deep violet, as the bar's brand band (BRAND_BG). */
382const PANE_BG = '#15121f'
383const DIM = '#8b8b93'
384const CARD_BG = '#141416'
385const CARD_EDGE = '#2a2a2f'
386
387/** The header: the dashboard's card, saying how the chat's agents stand. Mark, one bold word, the ring and its share. */
388function header(d: AgentsDraw, agents: readonly AgentRec[]) {
389  const { Box, Text, Svg } = d
390  const mood = moodOf(agents)
391  const color = mood === 'waiting' ? looks('asking').color : mood === 'done' ? looks('done').color : TEXT
392  const pct = d.steps && d.steps.length > 1 ? Math.round(progressShare(d.steps) * 100) : overallPercent(agents)
393  return (
394    <Box key="agents-head" position="relative" flexDirection="column" paddingX={1} overflow="hidden"
395      backgroundColor={CARD_BG} borderStyle="round" borderColor={CARD_EDGE}>
396      {Svg ? (
397        <Box key="agents-head-art" position="absolute" top={-1} right={0} bottom={-1}>
398          <Svg source={d.cardArt.source} alt="effortless" width={d.cardArt.width} height={d.cardArt.height} />
399        </Box>
400      ) : null}
401      <Box position="relative" flexDirection="row" alignItems="center" minWidth={0}>
402        <Box position="absolute" top={0} left={0} />
403        {Svg ? (
404          <Box flexShrink={0} marginRight={1} alignItems="center">
405            <Svg source={MARK_SVG} alt="effortless" width={18} height={18} />
406          </Box>
407        ) : <Text color={accent()} bold>✦ </Text>}
408        <Text color={color} bold>{moodWord(agents)}</Text>
409        {Svg && agents.length ? (
410          <Box flexShrink={0} marginLeft={2} flexDirection="row" gap={1} alignItems="center">
411            <Svg source={d.ringSvg(pct, color)} alt={`${pct}% done`} width={16} height={16} />
412            <Text color={TEXT}>{`${pct}%`}</Text>
413          </Box>
414        ) : null}
415        <Box flexGrow={1} flexShrink={1} minWidth={0} marginLeft={2} overflow="hidden">
416          <Text dimColor wrap="truncate">{agents.length ? `${agents.length} agent${agents.length > 1 ? 's' : ''}` : d.title}</Text>
417        </Box>
418      </Box>
419      {Svg && d.steps && d.steps.length > 1 ? (
420        <Box position="relative" flexDirection="column">
421          <Box position="absolute" top={0} left={0} />
422          <Svg source={progressTrackSvg({ phase: mood === 'done' ? 'done' : 'working', steps: [...d.steps] })}
423            alt={`${d.steps.filter(s => s.status === 'completed').length} of ${d.steps.length} steps`} width={1000} height={24} />
424        </Box>
425      ) : null}
426    </Box>
427  )
428}
429
430/** The line under an agent's name: its live track when it keeps a list, else what it does now, else its time. */
431function agentLine(d: AgentsDraw, a: AgentRec) {
432  const { Box, Text, Svg } = d
433  const look = lookOf(a.state)
434  if (a.state === 'picking')
435    return (
436      <Box flexDirection="row" alignItems="center" gap={1}>
437        {Svg ? <Svg source={thinkingSvg('planning')} alt="picking" width={THINK_W} height={PILL_H} /> : null}
438        <Text color={look.color}>picking a model</Text>
439      </Box>
440    )
441  if (a.state === 'waiting')
442    return <Text color={look.color} wrap="truncate">{`waiting on ${a.now ?? 'a tool'}`}</Text>
443  if (a.state === 'failed') return <Text color={look.color}>{`failed after ${clockText((a.endedAt ?? d.nowMs) - a.startedAt)}`}</Text>
444  if (Svg && a.steps && a.steps.length > 1)
445    return <Svg source={progressTrackSvg({ phase: 'working', steps: a.steps })} alt={`${a.steps.filter(s => s.status === 'completed').length} of ${a.steps.length}`} width={1000} height={24} />
446  return <Text dimColor wrap="truncate">{a.now ?? 'starting'}</Text>
447}
448
449/** One agent: a small progress band in its state's look and art. Its name in the look's colour, the task dim, its time. */
450function agentCard(d: AgentsDraw, a: AgentRec) {
451  const { Box, Text, Button, Svg } = d
452  const look = lookOf(a.state)
453  const phase = a.state === 'failed' ? null : PHASE[a.state]
454  const isOpen = d.open === a.id
455  return (
456    <Box key={`agent-${a.id}`} position="relative" flexDirection="column" paddingX={1} overflow="hidden"
457      backgroundColor={look.bg} borderStyle="round" borderColor={look.edge}>
458      {Svg && phase ? (
459        <Box key={`agent-${a.id}-art`} position="absolute" top={0} left={0} right={0} bottom={0}>
460          <Svg source={cardArtSvg(phase)} alt={`${a.type} ${a.state}`} width={ART_W} height={CARD_ART_H} />
461        </Box>
462      ) : null}
463      <Box position="relative" flexDirection="column">
464        <Box position="absolute" top={0} left={0} />
465        <Box flexDirection="row">
466          <Text color={look.color} bold>{a.parentId ? `└ ${a.type}` : a.type}</Text>
467          <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
468            <Text color={TEXT} wrap="truncate">{`  ${a.task}`}</Text>
469          </Box>
470          {a.endedAt ? <Text color={DIM}>{`  ${clockText(a.endedAt - a.startedAt)}`}</Text> : null}
471        </Box>
472        {agentLine(d, a)}
473        {isOpen ? (
474          <Box flexDirection="column" marginTop={1}>
475            {a.model ? (
476              <Box flexDirection="row">
477                <Text color={look.color} bold>{a.effort ? `${a.model} ${a.effort}` : a.model}</Text>
478                {a.why ? <Text dimColor wrap="truncate">{`  ${a.why}`}</Text> : null}
479              </Box>
480            ) : null}
481            {a.now && a.state !== 'waiting' ? <Text dimColor wrap="truncate">{a.now}</Text> : null}
482          </Box>
483        ) : null}
484      </Box>
485      {/* The whole card takes the press: a blank button in a layer over it, as the settings cards do. */}
486      <Box position="absolute" top={0} left={0} right={0} bottom={0}>
487        <Button key={`agent-${a.id}-press`} plain label={' '.repeat(60)} hover={{ backgroundColor: '#00000000' }} onPress={() => d.onOpen(a.id)} />
488      </Box>
489    </Box>
490  )
491}
492
493/** A card's height in px, for its art: two lines and the borders, with room to spare. */
494const CARD_ART_H = ART_H
495/** The progress band's art for a phase, filling a card: sliced to the card's height rather than drawn as a strip. */
496const cardArtCache = new Map<string, string>()
497export function cardArtSvg(phase: Progress['phase']): string {
498  let svg = cardArtCache.get(phase)
499  if (!svg) {
500    svg = progressArtSvg(phase).replace(
501      `width="${ART_W}" height="${ART_H}" viewBox="0 0 ${ART_W} ${ART_H}">`,
502      `width="${ART_W}" height="${CARD_ART_H}" viewBox="0 0 ${ART_W} ${ART_H}" preserveAspectRatio="xMinYMin slice">`,
503    )
504    cardArtCache.set(phase, svg)
505  }
506  return svg
507}
508
509/** The finished agents, folded into one green card: the done band's look. Opens to list them. */
510function doneCard(d: AgentsDraw, done: readonly AgentRec[]) {
511  const { Box, Text, Button, Svg } = d
512  const look = looks('done')
513  const isOpen = d.open === 'done'
514  return (
515    <Box key="agents-done" position="relative" flexDirection="column" paddingX={1} overflow="hidden"
516      backgroundColor={look.bg} borderStyle="round" borderColor={look.edge}>
517      {Svg ? (
518        <Box position="absolute" top={0} left={0} right={0} bottom={0}>
519          <Svg source={cardArtSvg('done')} alt="finished agents" width={ART_W} height={CARD_ART_H} />
520        </Box>
521      ) : null}
522      <Box position="relative" flexDirection="column">
523        <Box position="absolute" top={0} left={0} />
524        <Box flexDirection="row">
525          <Text color={look.color} bold>{`✓ ${done.length} done`}</Text>
526          <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
527            <Text dimColor wrap="truncate">  reported back</Text>
528          </Box>
529        </Box>
530        {isOpen
531          ? done.map(a => (
532              <Box key={`done-${a.id}`} flexDirection="row">
533                <Text color={TEXT}>{a.parentId ? `└ ${a.type}` : a.type}</Text>
534                <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
535                  <Text dimColor wrap="truncate">{`  ${a.task}`}</Text>
536                </Box>
537                <Text color={DIM}>{`  ${clockText((a.endedAt ?? d.nowMs) - a.startedAt)}`}</Text>
538              </Box>
539            ))
540          : null}
541      </Box>
542      <Box position="absolute" top={0} left={0} right={0} bottom={0}>
543        <Button key="agents-done-press" plain label={' '.repeat(60)} hover={{ backgroundColor: '#00000000' }} onPress={() => d.onOpen('done')} />
544      </Box>
545    </Box>
546  )
547}
548
549/**
550 * The whole pane: the mood's wash and the corner mark behind, the header, a card per agent still going, the finished
551 * ones folded into one. Nothing else unless pressed open.
552 */
553export function agentsPane(d: AgentsDraw, agents: readonly AgentRec[]) {
554  const { Box, Text, Svg } = d
555  const { live, done } = splitAgents(agents)
556  const mood = moodOf(agents)
557  // One row of the desktop pane is about 19 px; the background is drawn a little taller than the body.
558  const tall = Math.max(240, (d.rows + 2) * 19)
559  return (
560    <Box key="agents" position="relative" flexDirection="column" height={d.rows} overflow="hidden" backgroundColor={tintHex(PANE_BG)}>
561      {Svg ? (
562        <Box key="agents-wash" position="absolute" top={0} left={0} right={0} bottom={0}>
563          <Svg source={paneWashSvg(mood, 400, tall)} alt={`agents ${mood}`} width={400} height={tall} />
564        </Box>
565      ) : null}
566      {Svg ? (
567        <Box key="agents-mark" position="absolute" right={-7} bottom={-4}>
568          <Svg source={CORNER_MARK} alt="effortless" width={230} height={230} />
569        </Box>
570      ) : null}
571      <Box position="relative" flexDirection="column" gap={1}>
572        <Box position="absolute" top={0} left={0} />
573        {header(d, agents)}
574        {d.files.length ? mapCard(d, modulesOf(d.files, agents), moduleLinks(d.files)) : null}
575        {live.map(a => agentCard(d, a))}
576        {done.length ? doneCard(d, done) : null}
577        {!agents.length ? <Text dimColor>  Agents show here when Claude sends some off.</Text> : null}
578      </Box>
579    </Box>
580  )
581}
582
583/** Sample agents for `/effortless agents demo`: one of each state, so the pane can be seen without a real run. */
584export function demoAgents(nowMs: number): AgentRec[] {
585  const step = (label: string, status: 'completed' | 'in_progress' | 'pending', i: number) => ({ id: `s${i}`, label, doing: label, status })
586  return [
587    { id: 'demo-1', type: 'Explore', task: 'find the pane and agent hooks', state: 'running', startedAt: nowMs - 103_000, model: 'Haiku', effort: 'Low', why: 'read-only search, wide but shallow', file: 'hooks/agents.tsx',
588      now: 'Grep "agentId" in hooks/', steps: [step('List hooks', 'completed', 0), step('Read types', 'completed', 1), step('Find spawn', 'in_progress', 2), step('Report', 'pending', 3)] },
589    { id: 'demo-2', type: 'code-reviewer', task: 'review the agent-panel branch', state: 'waiting', file: 'tests/effortless.test.ts', startedAt: nowMs - 251_000, waitingSince: nowMs - 38_000, now: 'Bash npm test', model: 'Opus', effort: 'High', why: 'a review before merge: misses cost more' },
590    { id: 'demo-3', type: 'Plan', task: 'outline the release notes', state: 'picking', startedAt: nowMs - 2_000 },
591    { id: 'demo-4', type: 'general-purpose', task: 'add state for the panel', state: 'done', startedAt: nowMs - 400_000, endedAt: nowMs - 208_000, model: 'Sonnet', effort: 'Medium' },
592    { id: 'demo-5', parentId: 'demo-4', type: 'Explore', task: 'read the tests', state: 'done', startedAt: nowMs - 380_000, endedAt: nowMs - 340_000, model: 'Haiku', effort: 'Low' },
593  ]
594}
595
596/** The files the sample agents touched, for `/effortless agents demo`: three parts of the code and how they import. */
597export function demoFiles(nowMs: number): FileTouch[] {
598  const f = (path: string, edited: boolean, imports: string[]): FileTouch => ({ path, edited, by: ['main'], lastAt: nowMs, imports })
599  return [
600    f('hooks/register.tsx', true, ['hooks/agents', 'hooks/progress', 'hooks/art', 'hooks/brand-mark', 'types']),
601    f('hooks/agents.tsx', true, ['hooks/progress', 'hooks/brand-mark', 'types']),
602    f('hooks/progress.tsx', false, ['types']),
603    f('types/index.d.ts', true, []),
604    f('tests/effortless.test.ts', false, ['hooks/register', 'hooks/progress']),
605    f('docs/agent-panel/PLAN.md', false, []),
606    f('tools/render-band/render.mjs', false, []),
607  ]
608}
609
610/** The main chat's own list for the demo: what the whole job is, step by step. */
611export function demoSteps(): ProgressStep[] {
612  const step = (label: string, status: ProgressStep['status'], i: number): ProgressStep => ({ id: `m${i}`, label, doing: label, status })
613  return [
614    step('Find the hooks', 'completed', 0),
615    step('Add the state', 'completed', 1),
616    step('Draw the pane', 'in_progress', 2),
617    step('Review the branch', 'pending', 3),
618    step('Release', 'pending', 4),
619  ]
620}
621
hooks/art.ts 119 lines
1import { getTheme, tintHex } from './theme'
2
3// Art for the terminal's bands: a small Raster of `▀` cells, each two pixels tall (foreground on top, background
4// below). Pure functions of the kind and a frame count, so the art can be tested and blitted from a timer.
5
6export type ArtKind = 'cold' | 'swamp' | 'hot' | 'down' | 'brand' | 'calm' | 'compacting' | 'done'
7
8/** The size every band's art is drawn at: 16 columns by 2 rows, 16 by 4 pixels. */
9export const ART_COLUMNS = 16
10export const ART_ROWS = 2
11/** Below this many columns the band leaves its art out, so the words keep their room. */
12export const ART_MIN_WIDTH = 90
13/** One frame every this many milliseconds while a moving band shows. */
14export const ART_FRAME_MS = 100
15
16/** The kinds that move; the rest are drawn once. */
17export const MOVING: ReadonlySet<ArtKind> = new Set(['cold', 'swamp', 'hot', 'down', 'compacting'])
18
19type Rgb = readonly [number, number, number]
20
21const hex = (h: string): Rgb => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)]
22const mix = (a: Rgb, b: Rgb, k: number): Rgb => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k]
23const pack = (c: Rgb) => ((Math.round(c[0]) << 16) | (Math.round(c[1]) << 8) | Math.round(c[2])) >>> 0
24// A fixed pseudo-random number per seed, so particles keep their lanes from frame to frame.
25const rand = (seed: number) => {
26  const s = Math.sin(seed * 12.9898) * 43758.5453
27  return s - Math.floor(s)
28}
29
30// Each kind's ground (the band's background) and its light colour.
31const PALETTE: Record<ArtKind, { ground: Rgb; deep: Rgb; light: Rgb; glow: Rgb }> = {
32  cold: { ground: hex('#0e1820'), deep: hex('#2f5c80'), light: hex('#dff1ff'), glow: hex('#7cc4ff') },
33  swamp: { ground: hex('#111710'), deep: hex('#22351c'), light: hex('#cfe8b8'), glow: hex('#a7c98f') },
34  hot: { ground: hex('#1a110c'), deep: hex('#b8461b'), light: hex('#ffb06a'), glow: hex('#f08a3c') },
35  down: { ground: hex('#12141b'), deep: hex('#39415a'), light: hex('#b4b8c4'), glow: hex('#6b7288') },
36  brand: { ground: hex('#15121f'), deep: hex('#4a3f80'), light: hex('#e4dfff'), glow: hex('#a79cf7') },
37  // The dashboard's: the brand's sparkles in grey, since it shows all the time.
38  calm: { ground: hex('#141416'), deep: hex('#2a2a2f'), light: hex('#d4d4d8'), glow: hex('#6e6e76') },
39  compacting: { ground: hex('#15121f'), deep: hex('#4a3f80'), light: hex('#e4dfff'), glow: hex('#a79cf7') },
40  done: { ground: hex('#0f1c15'), deep: hex('#2f7a4c'), light: hex('#d8f5e3'), glow: hex('#5ec48a') },
41}
42
43// The violet kinds in the current theme; the rest are not touched by it. Cached per theme so a pixel does not recolour.
44const themed = new Map<string, (typeof PALETTE)[ArtKind]>()
45function paletteOf(kind: ArtKind) {
46  const base = PALETTE[kind]
47  if (getTheme() === 'violet' || (kind !== 'brand' && kind !== 'compacting')) return base
48  const key = `${getTheme()}-${kind}`
49  let p = themed.get(key)
50  if (!p) {
51    const turn = (c: Rgb): Rgb => hex(tintHex(`#${c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`))
52    p = { ground: turn(base.ground), deep: turn(base.deep), light: turn(base.light), glow: turn(base.glow) }
53    themed.set(key, p)
54  }
55  return p
56}
57
58// The done tick, as pixels on the 16 by 4 grid (x, y).
59const TICK: ReadonlyArray<readonly [number, number]> = [[9, 2], [10, 3], [11, 2], [12, 1], [13, 0]]
60
61/** The colour of one pixel of `kind` at frame `t`: x from 0 to columns - 1, y from 0 (top) to rows * 2 - 1. */
62export function artPixel(kind: ArtKind, t: number, x: number, y: number, cols = ART_COLUMNS, rows = ART_ROWS): number {
63  const p = PALETTE[kind]
64  const h = rows * 2
65  // The scene, then faded in from the left over six columns like the desktop art's mask.
66  let c: Rgb = mix(p.ground, p.deep, (y / (h - 1)) * 0.45)
67  if (kind === 'cold') {
68    // Frost along the bottom; flakes falling, each in its own lane, drifting a little.
69    if (y === h - 1) c = mix(c, p.glow, 0.35 + 0.15 * rand(x))
70    for (let i = 0; i < 5; i++) {
71      const lane = 4 + Math.floor(rand(i + 1) * (cols - 4))
72      const fy = (t / (5 + i) + rand(i + 9) * h) % (h + 1)
73      const fx = lane + Math.round(Math.sin(t / 7 + i))
74      if (x === fx && y === Math.floor(fy)) c = p.light
75    }
76  } else if (kind === 'swamp') {
77    // Bubbles rising, each lane on its own pace; they pale as they near the top.
78    for (let i = 0; i < 4; i++) {
79      const lane = 5 + Math.floor(rand(i + 3) * (cols - 5))
80      const by = h - 1 - ((t / (6 + i * 2) + rand(i + 5) * h) % (h + 1))
81      if (x === lane && y === Math.round(by)) c = mix(p.glow, p.light, 1 - by / h)
82    }
83  } else if (kind === 'hot') {
84    // A glowing bottom row that flickers; embers rising and dimming.
85    if (y === h - 1) c = mix(p.deep, p.glow, 0.5 + 0.5 * Math.abs(Math.sin(t / 3 + x)))
86    for (let i = 0; i < 5; i++) {
87      const lane = 4 + Math.floor(rand(i + 7) * (cols - 4))
88      const ey = h - 1 - ((t / (3 + i) + rand(i + 2) * h) % h)
89      if (x === lane && y === Math.round(ey) && y < h - 1) c = mix(p.light, p.glow, 1 - ey / h)
90    }
91  } else if (kind === 'down') {
92    // Still flecks under a slow pulse: the judge is not answering.
93    const pulse = 0.5 + 0.5 * Math.sin(t / 8)
94    if (rand(x * 7 + y * 13) > 0.82) c = mix(c, p.light, 0.25 + 0.35 * pulse)
95  } else if (kind === 'brand' || kind === 'calm' || kind === 'compacting') {
96    // Sparkles in fixed places: still for brand, twinkling while compacting.
97    if (rand(x * 5 + y * 11 + 3) > 0.84) {
98      const twinkle = kind === 'compacting' ? 0.5 + 0.5 * Math.sin(t / 2 + x * 1.7 + y) : 0.8
99      c = mix(p.glow, p.light, twinkle)
100    }
101  } else if (kind === 'done') {
102    if (TICK.some(([tx, ty]) => tx === x && ty === y)) c = p.light
103  }
104  return pack(mix(p.ground, c, Math.min(1, x / 6)))
105}
106
107/** One frame of `kind`'s art as Raster cells: base64 of `[codePoint, top, bottom]` per cell, row-major. */
108export function artFrame(kind: ArtKind, t: number, cols = ART_COLUMNS, rows = ART_ROWS): string {
109  const words = new Uint32Array(cols * rows * 3)
110  for (let row = 0; row < rows; row++)
111    for (let x = 0; x < cols; x++) {
112      const i = (row * cols + x) * 3
113      words[i] = 0x2580 // ▀: the foreground paints the top pixel, the background the bottom one
114      words[i + 1] = artPixel(kind, t, x, row * 2, cols, rows)
115      words[i + 2] = artPixel(kind, t, x, row * 2 + 1, cols, rows)
116    }
117  return new Uint8Array(words.buffer).toBase64()
118}
119
hooks/brand-mark.ts 4 lines
1// The effortless mark (the four-point star), from brand/mark.svg. Generated: node -e in the commit that added it;
2// regenerate when the mark changes.
3export const MARK_SVG = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 100 100\"><defs><mask id=\"kem\"><path fill=\"#fff\" d=\"M49.84 12.97 L49.84 13.13 L49.84 13.29 L49.78 13.45 L49.78 13.60 L49.78 13.76 L49.73 13.91 L49.73 14.07 L49.67 14.23 L49.67 14.39 L49.67 14.55 L49.62 14.71 L49.62 14.86 L49.57 15.02 L49.57 15.17 L49.51 15.33 L49.51 15.49 L49.46 15.65 L49.46 15.81 L49.40 15.96 L49.35 16.12 L49.35 16.28 L49.29 16.43 L49.29 16.59 L49.24 16.75 L49.24 16.90 L49.18 17.06 L49.13 17.22 L49.13 17.38 L49.07 17.54 L49.02 17.69 L49.02 17.85 L48.96 18.01 L48.91 18.17 L48.91 18.32 L48.85 18.48 L48.80 18.64 L48.74 18.80 L48.74 18.95 L48.69 19.11 L48.64 19.26 L48.64 19.42 L48.58 19.58 L48.53 19.74 L48.47 19.90 L48.42 20.05 L48.42 20.21 L48.36 20.37 L48.31 20.52 L48.25 20.68 L48.20 20.84 L48.14 20.99 L48.14 21.15 L48.09 21.31 L48.03 21.47 L47.98 21.63 L47.92 21.78 L47.87 21.94 L47.82 22.10 L47.76 22.26 L47.71 22.41 L47.65 22.57 L47.60 22.73 L47.54 22.89 L47.49 23.04 L47.43 23.20 L47.38 23.35 L47.32 23.51 L47.27 23.67 L47.21 23.83 L47.16 23.99 L47.10 24.15 L47.05 24.30 L46.99 24.46 L46.94 24.61 L46.89 24.77 L46.78 24.93 L46.72 25.09 L46.67 25.24 L46.61 25.40 L46.56 25.56 L46.45 25.72 L46.39 25.87 L46.34 26.03 L46.28 26.19 L46.23 26.35 L46.12 26.50 L46.07 26.66 L46.01 26.82 L45.96 26.98 L45.85 27.13 L45.79 27.29 L45.69 27.44 L45.63 27.60 L45.58 27.76 L45.47 27.92 L45.41 28.08 L45.30 28.24 L45.25 28.39 L45.14 28.56 L45.09 28.72 L44.98 28.88 L44.92 29.05 L44.81 29.22 L44.76 29.39 L44.65 29.56 L44.59 29.73 L44.48 29.89 L44.38 30.07 L44.32 30.25 L44.21 30.43 L44.10 30.61 L44.05 30.79 L43.94 30.97 L43.83 31.16 L43.72 31.35 L43.61 31.54 L43.50 31.73 L43.39 31.93 L43.34 32.11 L43.23 32.31 L43.12 32.51 L42.95 32.72 L42.84 32.92 L42.73 33.12 L42.63 33.33 L42.52 33.54 L42.41 33.75 L42.30 33.96 L42.13 34.18 L42.02 34.39 L41.86 34.61 L41.75 34.82 L41.64 35.03 L41.48 35.25 L41.32 35.47 L41.21 35.69 L41.04 35.91 L40.88 36.13 L40.77 36.34 L40.61 36.56 L40.44 36.78 L40.28 37.00 L40.11 37.22 L39.95 37.44 L39.73 37.65 L39.57 37.87 L39.40 38.09 L39.19 38.31 L39.02 38.53 L38.80 38.75 L38.64 38.96 L38.42 39.18 L38.20 39.40 L37.98 39.62 L37.76 39.84 L37.55 40.06 L37.33 40.28 L37.11 40.50 L36.84 40.72 L36.62 40.93 L36.35 41.15 L36.13 41.37 L35.85 41.59 L35.58 41.81 L35.31 42.02 L35.03 42.24 L34.76 42.46 L34.43 42.68 L34.16 42.90 L33.83 43.12 L33.50 43.33 L33.18 43.55 L32.85 43.77 L32.47 43.99 L32.09 44.21 L31.76 44.43 L31.32 44.65 L30.94 44.87 L30.50 45.09 L30.06 45.30 L29.63 45.52 L29.19 45.74 L28.70 45.96 L28.15 46.18 L27.66 46.40 L27.12 46.61 L26.51 46.83 L25.91 47.05 L25.26 47.27 L24.60 47.49 L23.89 47.71 L23.13 47.92 L22.36 48.14 L21.49 48.36 L20.61 48.58 L19.63 48.80 L18.59 49.02 L17.39 49.23 L16.14 49.45 L14.66 49.67 L12.91 49.89 L7.78 50.00 L11.88 50.00 L13.84 50.22 L15.43 50.44 L16.84 50.66 L18.05 50.88 L19.14 51.09 L20.18 51.31 L21.11 51.53 L21.93 51.75 L22.75 51.97 L23.51 52.19 L24.28 52.40 L24.93 52.62 L25.58 52.84 L26.24 53.06 L26.79 53.28 L27.39 53.50 L27.94 53.71 L28.43 53.93 L28.92 54.15 L29.41 54.37 L29.84 54.59 L30.34 54.80 L30.72 55.02 L31.16 55.24 L31.54 55.46 L31.92 55.68 L32.31 55.90 L32.63 56.12 L33.01 56.34 L33.34 56.56 L33.67 56.77 L34.00 56.99 L34.27 57.21 L34.60 57.43 L34.87 57.65 L35.20 57.87 L35.47 58.08 L35.75 58.30 L35.96 58.52 L36.24 58.74 L36.51 58.96 L36.73 59.18 L37.00 59.39 L37.22 59.61 L37.44 59.83 L37.65 60.05 L37.87 60.27 L38.09 60.49 L38.31 60.71 L38.53 60.93 L38.75 61.15 L38.91 61.36 L39.13 61.58 L39.29 61.80 L39.46 62.02 L39.68 62.24 L39.84 62.46 L40.01 62.67 L40.17 62.89 L40.33 63.11 L40.50 63.33 L40.66 63.55 L40.83 63.76 L40.99 63.98 L41.10 64.20 L41.26 64.42 L41.43 64.64 L41.53 64.86 L41.70 65.07 L41.81 65.28 L41.97 65.50 L42.08 65.72 L42.19 65.93 L42.35 66.15 L42.46 66.36 L42.57 66.57 L42.68 66.78 L42.79 66.97 L42.90 67.18 L43.06 67.38 L43.17 67.59 L43.28 67.79 L43.39 67.99 L43.45 68.17 L43.55 68.37 L43.66 68.56 L43.77 68.75 L43.88 68.94 L43.99 69.12 L44.05 69.30 L44.16 69.48 L44.27 69.66 L44.38 69.84 L44.43 70.02 L44.54 70.19 L44.59 70.36 L44.70 70.53 L44.81 70.70 L44.87 70.87 L44.98 71.03 L45.03 71.20 L45.14 71.36 L45.20 71.53 L45.30 71.68 L45.36 71.84 L45.47 72.00 L45.52 72.16 L45.58 72.32 L45.69 72.48 L45.74 72.63 L45.79 72.79 L45.90 72.95 L45.96 73.10 L46.01 73.26 L46.12 73.42 L46.17 73.57 L46.23 73.73 L46.28 73.89 L46.39 74.05 L46.45 74.21 L46.50 74.36 L46.56 74.52 L46.61 74.68 L46.72 74.84 L46.78 74.99 L46.83 75.15 L46.89 75.31 L46.94 75.47 L46.99 75.62 L47.05 75.78 L47.10 75.93 L47.21 76.09 L47.27 76.25 L47.32 76.41 L47.38 76.57 L47.43 76.73 L47.49 76.88 L47.54 77.03 L47.60 77.19 L47.65 77.35 L47.71 77.51 L47.76 77.67 L47.82 77.82 L47.87 77.98 L47.92 78.14 L47.92 78.30 L47.98 78.45 L48.03 78.61 L48.09 78.77 L48.14 78.93 L48.20 79.08 L48.25 79.24 L48.25 79.40 L48.31 79.56 L48.36 79.71 L48.42 79.87 L48.47 80.02 L48.53 80.18 L48.53 80.34 L48.58 80.50 L48.64 80.66 L48.69 80.82 L48.69 80.97 L48.74 81.12 L48.80 81.28 L48.85 81.44 L48.85 81.60 L48.91 81.76 L48.96 81.91 L48.96 82.07 L49.02 82.23 L49.07 82.38 L49.07 82.54 L49.13 82.70 L49.18 82.86 L49.18 83.02 L49.24 83.18 L49.24 83.33 L49.29 83.49 L49.35 83.64 L49.35 83.80 L49.40 83.96 L49.40 84.12 L49.46 84.27 L49.46 84.43 L49.51 84.59 L49.51 84.75 L49.57 84.90 L49.57 85.06 L49.62 85.21 L49.62 85.37 L49.67 85.53 L49.67 85.69 L49.73 85.85 L49.73 86.01 L49.73 86.16 L49.78 86.32 L49.78 86.47 L49.84 86.63 L49.84 86.79 L49.84 86.95 L50.16 87.03 L50.16 86.87 L50.16 86.71 L50.22 86.55 L50.22 86.40 L50.22 86.24 L50.27 86.09 L50.27 85.93 L50.33 85.77 L50.33 85.61 L50.33 85.45 L50.38 85.29 L50.38 85.14 L50.43 84.98 L50.43 84.83 L50.49 84.67 L50.49 84.51 L50.54 84.35 L50.54 84.19 L50.60 84.04 L50.65 83.88 L50.65 83.72 L50.71 83.57 L50.71 83.41 L50.76 83.25 L50.76 83.10 L50.82 82.94 L50.87 82.78 L50.87 82.62 L50.93 82.46 L50.98 82.31 L50.98 82.15 L51.04 81.99 L51.09 81.83 L51.09 81.68 L51.15 81.52 L51.20 81.36 L51.26 81.20 L51.26 81.05 L51.31 80.89 L51.36 80.74 L51.36 80.58 L51.42 80.42 L51.47 80.26 L51.53 80.10 L51.58 79.95 L51.58 79.79 L51.64 79.63 L51.69 79.48 L51.75 79.32 L51.80 79.16 L51.86 79.01 L51.86 78.85 L51.91 78.69 L51.97 78.53 L52.02 78.37 L52.08 78.22 L52.13 78.06 L52.18 77.90 L52.24 77.74 L52.29 77.59 L52.35 77.43 L52.40 77.27 L52.46 77.11 L52.51 76.96 L52.57 76.80 L52.62 76.65 L52.68 76.49 L52.73 76.33 L52.79 76.17 L52.84 76.01 L52.90 75.85 L52.95 75.70 L53.01 75.54 L53.06 75.39 L53.11 75.23 L53.22 75.07 L53.28 74.91 L53.33 74.76 L53.39 74.60 L53.44 74.44 L53.55 74.28 L53.61 74.13 L53.66 73.97 L53.72 73.81 L53.77 73.65 L53.88 73.50 L53.93 73.34 L53.99 73.18 L54.04 73.02 L54.15 72.87 L54.21 72.71 L54.31 72.56 L54.37 72.40 L54.42 72.24 L54.53 72.08 L54.59 71.92 L54.70 71.76 L54.75 71.61 L54.86 71.44 L54.91 71.28 L55.02 71.12 L55.08 70.95 L55.19 70.78 L55.24 70.61 L55.35 70.44 L55.41 70.27 L55.52 70.11 L55.62 69.93 L55.68 69.75 L55.79 69.57 L55.90 69.39 L55.95 69.21 L56.06 69.03 L56.17 68.84 L56.28 68.65 L56.39 68.46 L56.50 68.27 L56.61 68.07 L56.66 67.89 L56.77 67.69 L56.88 67.49 L57.05 67.28 L57.16 67.08 L57.27 66.88 L57.37 66.67 L57.48 66.46 L57.59 66.25 L57.70 66.04 L57.87 65.82 L57.98 65.61 L58.14 65.39 L58.25 65.18 L58.36 64.97 L58.52 64.75 L58.68 64.53 L58.79 64.31 L58.96 64.09 L59.12 63.87 L59.23 63.66 L59.39 63.44 L59.56 63.22 L59.72 63.00 L59.89 62.78 L60.05 62.56 L60.27 62.35 L60.43 62.13 L60.60 61.91 L60.81 61.69 L60.98 61.47 L61.20 61.25 L61.36 61.04 L61.58 60.82 L61.80 60.60 L62.02 60.38 L62.24 60.16 L62.45 59.94 L62.67 59.72 L62.89 59.50 L63.16 59.28 L63.38 59.07 L63.65 58.85 L63.87 58.63 L64.15 58.41 L64.42 58.19 L64.69 57.98 L64.97 57.76 L65.24 57.54 L65.57 57.32 L65.84 57.10 L66.17 56.88 L66.50 56.67 L66.82 56.45 L67.15 56.23 L67.53 56.01 L67.91 55.79 L68.24 55.57 L68.68 55.35 L69.06 55.13 L69.50 54.91 L69.94 54.70 L70.37 54.48 L70.81 54.26 L71.30 54.04 L71.85 53.82 L72.34 53.60 L72.88 53.39 L73.49 53.17 L74.09 52.95 L74.74 52.73 L75.40 52.51 L76.11 52.29 L76.87 52.08 L77.64 51.86 L78.51 51.64 L79.39 51.42 L80.37 51.20 L81.41 50.98 L82.61 50.77 L83.86 50.55 L85.34 50.33 L87.09 50.11 L92.22 50.00 L88.12 50.00 L86.16 49.78 L84.57 49.56 L83.16 49.34 L81.95 49.12 L80.86 48.91 L79.82 48.69 L78.89 48.47 L78.07 48.25 L77.25 48.03 L76.49 47.81 L75.72 47.60 L75.07 47.38 L74.42 47.16 L73.76 46.94 L73.21 46.72 L72.61 46.50 L72.06 46.29 L71.57 46.07 L71.08 45.85 L70.59 45.63 L70.16 45.41 L69.66 45.20 L69.28 44.98 L68.84 44.76 L68.46 44.54 L68.08 44.32 L67.69 44.10 L67.37 43.88 L66.99 43.66 L66.66 43.44 L66.33 43.23 L66.00 43.01 L65.73 42.79 L65.40 42.57 L65.13 42.35 L64.80 42.13 L64.53 41.92 L64.25 41.70 L64.04 41.48 L63.76 41.26 L63.49 41.04 L63.27 40.82 L63.00 40.61 L62.78 40.39 L62.56 40.17 L62.35 39.95 L62.13 39.73 L61.91 39.51 L61.69 39.29 L61.47 39.07 L61.25 38.85 L61.09 38.64 L60.87 38.42 L60.71 38.20 L60.54 37.98 L60.32 37.76 L60.16 37.54 L59.99 37.33 L59.83 37.11 L59.67 36.89 L59.50 36.67 L59.34 36.45 L59.17 36.24 L59.01 36.02 L58.90 35.80 L58.74 35.58 L58.57 35.36 L58.47 35.14 L58.30 34.93 L58.19 34.72 L58.03 34.50 L57.92 34.28 L57.81 34.07 L57.65 33.85 L57.54 33.64 L57.43 33.43 L57.32 33.22 L57.21 33.03 L57.10 32.82 L56.94 32.62 L56.83 32.41 L56.72 32.21 L56.61 32.01 L56.55 31.83 L56.45 31.63 L56.34 31.44 L56.23 31.25 L56.12 31.06 L56.01 30.88 L55.95 30.70 L55.84 30.52 L55.73 30.34 L55.62 30.16 L55.57 29.98 L55.46 29.81 L55.41 29.64 L55.30 29.47 L55.19 29.30 L55.13 29.13 L55.02 28.97 L54.97 28.80 L54.86 28.64 L54.80 28.47 L54.70 28.32 L54.64 28.16 L54.53 28.00 L54.48 27.84 L54.42 27.68 L54.31 27.52 L54.26 27.37 L54.21 27.21 L54.10 27.05 L54.04 26.90 L53.99 26.74 L53.88 26.58 L53.83 26.43 L53.77 26.27 L53.72 26.11 L53.61 25.95 L53.55 25.79 L53.50 25.64 L53.44 25.48 L53.39 25.32 L53.28 25.16 L53.22 25.01 L53.17 24.85 L53.11 24.69 L53.06 24.53 L53.01 24.38 L52.95 24.22 L52.90 24.07 L52.79 23.91 L52.73 23.75 L52.68 23.59 L52.62 23.43 L52.57 23.27 L52.51 23.12 L52.46 22.97 L52.40 22.81 L52.35 22.65 L52.29 22.49 L52.24 22.33 L52.18 22.18 L52.13 22.02 L52.08 21.86 L52.08 21.70 L52.02 21.55 L51.97 21.39 L51.91 21.23 L51.86 21.07 L51.80 20.92 L51.75 20.76 L51.75 20.60 L51.69 20.44 L51.64 20.29 L51.58 20.13 L51.53 19.98 L51.47 19.82 L51.47 19.66 L51.42 19.50 L51.36 19.34 L51.31 19.18 L51.31 19.03 L51.26 18.88 L51.20 18.72 L51.15 18.56 L51.15 18.40 L51.09 18.24 L51.04 18.09 L51.04 17.93 L50.98 17.77 L50.93 17.62 L50.93 17.46 L50.87 17.30 L50.82 17.14 L50.82 16.98 L50.76 16.82 L50.76 16.67 L50.71 16.51 L50.65 16.36 L50.65 16.20 L50.60 16.04 L50.60 15.88 L50.54 15.73 L50.54 15.57 L50.49 15.41 L50.49 15.25 L50.43 15.10 L50.43 14.94 L50.38 14.79 L50.38 14.63 L50.33 14.47 L50.33 14.31 L50.27 14.15 L50.27 13.99 L50.27 13.84 L50.22 13.68 L50.22 13.53 L50.16 13.37 L50.16 13.21 L50.16 13.05\"/><path fill=\"#000\" d=\"M-4.00 78.45 L0.00 75.86 L4.00 73.40 L8.00 71.09 L12.00 68.91 L16.00 66.88 L20.00 64.99 L24.00 63.24 L28.00 61.63 L32.00 60.17 L36.00 58.84 L40.00 57.66 L44.00 56.62 L48.00 55.71 L52.00 54.95 L56.52 54.26 L61.31 53.58 L65.75 52.96 L69.84 52.41 L73.59 51.92 L76.99 51.51 L80.04 51.15 L82.75 50.86 L85.11 50.64 L87.12 50.48 L88.79 50.39 L90.11 50.37 L91.08 50.41 L91.71 50.52 L91.99 50.69 L88.02 51.18 L89.80 54.01 L90.76 54.43 L90.47 54.45 L89.57 54.51 L88.23 54.65 L86.49 54.87 L84.39 55.18 L81.92 55.57 L79.10 56.05 L75.93 56.61 L72.42 57.26 L68.55 57.99 L64.34 58.81 L59.79 59.72 L54.93 60.70 L51.19 61.58 L47.45 62.58 L43.72 63.72 L40.00 64.99 L36.29 66.39 L32.58 67.92 L28.80 69.38 L25.01 70.97 L21.20 72.70 L17.40 74.57 L13.58 76.57 L9.76 78.72 L5.92 81.00 L2.08 83.43\"/><path fill=\"#000\" transform=\"matrix(-1 0 0 -1 100 100)\" d=\"M-4.00 78.45 L0.00 75.86 L4.00 73.40 L8.00 71.09 L12.00 68.91 L16.00 66.88 L20.00 64.99 L24.00 63.24 L28.00 61.63 L32.00 60.17 L36.00 58.84 L40.00 57.66 L44.00 56.62 L48.00 55.71 L52.00 54.95 L56.52 54.26 L61.31 53.58 L65.75 52.96 L69.84 52.41 L73.59 51.92 L76.99 51.51 L80.04 51.15 L82.75 50.86 L85.11 50.64 L87.12 50.48 L88.79 50.39 L90.11 50.37 L91.08 50.41 L91.71 50.52 L91.99 50.69 L88.02 51.18 L89.80 54.01 L90.76 54.43 L90.47 54.45 L89.57 54.51 L88.23 54.65 L86.49 54.87 L84.39 55.18 L81.92 55.57 L79.10 56.05 L75.93 56.61 L72.42 57.26 L68.55 57.99 L64.34 58.81 L59.79 59.72 L54.93 60.70 L51.19 61.58 L47.45 62.58 L43.72 63.72 L40.00 64.99 L36.29 66.39 L32.58 67.92 L28.80 69.38 L25.01 70.97 L21.20 72.70 L17.40 74.57 L13.58 76.57 L9.76 78.72 L5.92 81.00 L2.08 83.43\"/></mask><clipPath id=\"cemt\"><path transform=\"matrix(-1 0 0 -1 100 100)\" d=\"M0.12 84.72 L4.01 82.20 L7.84 79.84 L11.67 77.63 L15.49 75.55 L19.30 73.61 L23.11 71.82 L26.90 70.16 L30.69 68.63 L34.43 67.14 L38.14 65.67 L41.86 64.34 L45.58 63.14 L49.32 62.06 L53.06 61.12 L57.38 60.20 L62.11 59.26 L66.49 58.39 L70.53 57.62 L74.22 56.92 L77.56 56.32 L80.56 55.80 L83.20 55.36 L85.48 55.01 L87.41 54.75 L88.95 54.57 L90.08 54.48 L90.71 54.44 L90.53 54.36 L88.45 52.55 L110 51.18 L110 110 L-10 110Z\"/></clipPath><clipPath id=\"cemb\"><path d=\"M0.12 84.72 L4.01 82.20 L7.84 79.84 L11.67 77.63 L15.49 75.55 L19.30 73.61 L23.11 71.82 L26.90 70.16 L30.69 68.63 L34.43 67.14 L38.14 65.67 L41.86 64.34 L45.58 63.14 L49.32 62.06 L53.06 61.12 L57.38 60.20 L62.11 59.26 L66.49 58.39 L70.53 57.62 L74.22 56.92 L77.56 56.32 L80.56 55.80 L83.20 55.36 L85.48 55.01 L87.41 54.75 L88.95 54.57 L90.08 54.48 L90.71 54.44 L90.53 54.36 L88.45 52.55 L110 51.18 L110 110 L-10 110Z\"/></clipPath></defs><g mask=\"url(#kem)\"><rect width=\"100\" height=\"100\" fill=\"#a79cf7\"/><rect width=\"100\" height=\"100\" fill=\"#cfc7ff\" clip-path=\"url(#cemt)\"/><rect width=\"100\" height=\"100\" fill=\"#7566d8\" clip-path=\"url(#cemb)\"/></g></svg>"
4
hooks/theme.ts 85 lines
1// The look of the mod's accent: violet (the brand) or Claude orange. Every violet in the mod is one hex of the same
2// family, so the theme turns them in one place instead of keeping two sets of colours: any #rrggbb in a violet hue is
3// moved to the orange hue, neutrals and the other signal colours (green, yellow, red, blue) are left alone.
4
5export type ThemeName = 'violet' | 'orange'
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}
85
hooks/progress.tsx 616 lines
1import { atom, read, update } from 'claude-code'
2import type { On } from 'claude-code'
3import { tintHex } from './theme'
4
5import type { Progress, ProgressStep } from '../types'
6
7// The progress bar: a band above the prompt that follows a bigger task step by step (docs/specs/2026-10-06-progress-bar.md).
8// Its own file, so the branches that edit register.tsx meet it in a few lines only. The engine follows $ only into
9// functions of the file it is in, so what needs $ in a hook register.tsx already holds (session.start, prompt.submit,
10// turn.complete, the band) is a short section there; the rules and the drawing are here.
11
12/** A list shorter than this is no bigger task: no bar. */
13export const MIN_STEPS = 3
14/** Past this many steps the bar is one continuous fill instead of a segment per step. */
15const SEGMENTS_MAX = 12
16
17// The brand purple, as in register.tsx, and the two signal sets: yellow when Claude asks, green when it is done.
18/** A phase's look in the current theme: the violet ones turn orange, the signal greens and yellows stay. */
19export const looks = (phase: Progress['phase']) => {
20  const l = LOOKS[phase]
21  return { color: tintHex(l.color), bg: tintHex(l.bg), edge: tintHex(l.edge) }
22}
23export const LOOKS: Record<Progress['phase'], { color: string; bg: string; edge: string }> = {
24  planning: { color: '#a79cf7', bg: '#15121f', edge: '#4a3f80' },
25  working: { color: '#a79cf7', bg: '#15121f', edge: '#4a3f80' },
26  paused: { color: '#7d76a8', bg: '#15121f', edge: '#3a3360' },
27  asking: { color: '#e0a33a', bg: '#1c1608', edge: '#6b5218' },
28  done: { color: '#6fcf8e', bg: '#0e1a12', edge: '#2f6b45' },
29}
30// Every segment holds the same long run of one character and is clipped to its share: equal content, equal width.
31const RUN = 160
32
33const progressState = atom({ plugin: 'effortless', key: 'progress' } as const, null)
34// The list the person closed with ✕, as register.tsx sets it; read here only.
35const progressHidden = atom({ plugin: 'effortless', key: 'progressHidden' } as const, null)
36
37export type Cue = 'question' | 'done'
38
39type Todo = { content: string; status: ProgressStep['status']; activeForm?: string }
40
41/** The steps of a TodoWrite list, the whole list each time. */
42export function stepsFromTodos(todos: readonly Todo[]): ProgressStep[] {
43  return todos.map((t, i) => ({ id: `todo-${i}`, label: t.content, doing: t.activeForm || t.content, status: t.status }))
44}
45
46/** The steps with a task TaskCreate made added at the end, pending. */
47export function withTaskCreated(steps: readonly ProgressStep[], id: string, task: { subject: string; activeForm?: string }): ProgressStep[] {
48  return [...steps.filter(s => s.id !== id), { id, label: task.subject, doing: task.activeForm || task.subject, status: 'pending' }]
49}
50
51/** The steps after a TaskUpdate: a new status, subject or running form; deleted drops the step. */
52export function withTaskUpdated(
53  steps: readonly ProgressStep[],
54  change: { taskId: string; status?: ProgressStep['status'] | 'deleted'; subject?: string; activeForm?: string },
55): ProgressStep[] {
56  if (change.status === 'deleted') return steps.filter(s => s.id !== change.taskId)
57  return steps.map(s =>
58    s.id !== change.taskId
59      ? s
60      : {
61          ...s,
62          ...(change.status ? { status: change.status } : {}),
63          ...(change.subject ? { label: change.subject } : {}),
64          ...(change.activeForm ? { doing: change.activeForm } : {}),
65        },
66  )
67}
68
69/** How far along the task is, 0 to 1: completed steps, and half of each one in progress. */
70export function progressShare(steps: readonly ProgressStep[]): number {
71  if (!steps.length) return 0
72  const done = steps.filter(s => s.status === 'completed').length
73  const doing = steps.filter(s => s.status === 'in_progress').length
74  return Math.min(1, (done + doing / 2) / steps.length)
75}
76
77/** The step to name: the one in progress, else the next pending one. */
78export function currentStep(steps: readonly ProgressStep[]): ProgressStep | undefined {
79  return steps.find(s => s.status === 'in_progress') ?? steps.find(s => s.status === 'pending')
80}
81
82/** Which step the task is at, counted from 1: the steps completed plus one, at most all of them. */
83export function stepNumber(steps: readonly ProgressStep[]): number {
84  return Math.min(steps.length, steps.filter(s => s.status === 'completed').length + 1)
85}
86
87/** The list as one string: closing the bar hides it for this list until another one comes. */
88export function stepsKey(steps: readonly ProgressStep[]): string {
89  return steps.map(s => s.label).join('\n')
90}
91
92/**
93 * Where the task stands when a main-conversation turn ends: done when every step is completed, asking when the answer
94 * ends on a question, paused otherwise; null when there is nothing to follow (no list came, or too short a one).
95 */
96export function phaseAtTurnEnd(
97  p: Progress,
98  turn: { reason: string; answer: string },
99  endsOnQuestion: (context: string) => boolean,
100): Progress['phase'] | null {
101  if (p.steps.length < MIN_STEPS) return null
102  if (p.steps.every(s => s.status === 'completed')) return 'done'
103  if (turn.reason === 'answer' && endsOnQuestion(`assistant: ${turn.answer}`)) return 'asking'
104  return 'paused'
105}
106
107/** The bar after a turn ends, and the sound to play: a chime when it turns done or asking. */
108export function atTurnEnd(
109  p: Progress | null,
110  turn: { reason: string; answer: string },
111  endsOnQuestion: (context: string) => boolean,
112): { next: Progress | null; cue?: Cue } {
113  if (!p) return { next: null }
114  const phase = phaseAtTurnEnd(p, turn, endsOnQuestion)
115  if (phase === null) return { next: null }
116  if (phase === p.phase) return { next: p }
117  return { next: { ...p, phase }, ...(phase === 'done' ? { cue: 'done' } : phase === 'asking' ? { cue: 'question' } : {}) }
118}
119
120/** The bar after the person sends a prompt: a finished task is over; an asking or paused one carries on. */
121export function afterPrompt(p: Progress | null, prompt: { text: string; origin: { kind: string } }): Progress | null {
122  const byPerson = prompt.origin.kind === 'composer' || prompt.origin.kind === 'bridge' || prompt.origin.kind === 'sdk'
123  if (!p || !byPerson || prompt.text.trim().startsWith('/')) return p
124  if (p.phase === 'done') return null
125  return p.phase === 'asking' || p.phase === 'paused' ? { ...p, phase: 'working' } : p
126}
127
128/**
129 * How Windows plays a sound: `$.audio.play` plays nothing in a Windows terminal, so PowerShell's SoundPlayer plays the
130 * file. Null on other systems, where `$.audio.play` does it.
131 */
132export function soundArgv(root: string, file: string): string[] | null {
133  if (!/^[A-Za-z]:[\\/]/.test(root)) return null
134  const path = `${root.replace(/[\\/]+$/, '')}\\${file}`.replace(/\//g, '\\').replace(/'/g, "''")
135  return ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', `(New-Object Media.SoundPlayer '${path}').PlaySync()`]
136}
137
138// Sounds wait here for the session's timer (register.tsx plays them): a hook's own $ is refused once the hook has
139// returned, and a sound should not hold the turn up while it plays.
140let cues: Cue[] = []
141
142/** Queues a sound, unless sounds are switched off. */
143export function queueCue(hidden: readonly string[], sound: Cue | undefined) {
144  if (sound && !hidden.includes('sounds') && !hidden.includes('progress')) cues.push(sound)
145}
146
147/** The queued sounds, taken off the queue. */
148export function takeCues(): Cue[] {
149  const due = cues
150  cues = []
151  return due
152}
153
154/** /effortless progress [ask|done|clear]: a demo task of five steps in the state asked for, to see the bar. */
155export function demoProgress(arg: string): { progress: Progress | null; cue?: Cue; text: string } {
156  const demo = (completed: number, doing: boolean): ProgressStep[] =>
157    [
158      ['Read the code', 'Reading the code'],
159      ['Write the tests', 'Writing the tests'],
160      ['Build the bar', 'Building the bar'],
161      ['Run the tests', 'Running the tests'],
162      ['Write the handoff', 'Writing the handoff'],
163    ].map(([label, running], i) => ({
164      id: `demo-${i}`,
165      label,
166      doing: running,
167      status: i < completed ? 'completed' : i === completed && doing ? 'in_progress' : 'pending',
168    }))
169  if (arg === 'clear') return { progress: null, text: 'The progress bar is cleared.' }
170  if (arg === 'plan')
171    return { progress: { phase: 'planning', steps: [] }, text: 'The progress bar is planning (a test): Claude is in plan mode and no steps exist yet.' }
172  if (arg === 'ask')
173    return {
174      progress: { phase: 'asking', steps: demo(2, true) },
175      cue: 'question',
176      text: 'The progress bar is yellow: Claude is asking something (a test). /effortless progress clear removes it.',
177    }
178  if (arg === 'done')
179    return {
180      progress: { phase: 'done', steps: demo(5, false) },
181      cue: 'done',
182      text: 'The progress bar is green: every step is done (a test). /effortless progress clear removes it.',
183    }
184  return {
185    progress: { phase: 'working', steps: demo(2, true) },
186    text: 'A demo progress bar is showing, step 3 of 5 (a test). Try /effortless progress plan, progress ask, progress done, progress clear.',
187  }
188}
189
190/** The line in the system prompt that asks Claude to keep a step list for multi-step work. */
191export const PROGRESS_PROMPT =
192  'When a request takes three or more distinct steps, write them as a task list with the task tools (TaskCreate and ' +
193  'TaskUpdate, or TodoWrite) before you start, and keep the status of each step current as you go. Skip the list for quick ' +
194  'answers and one-step changes.'
195
196/**
197 * The hooks register.tsx does not have yet: the step list from the todo and task tools, Claude asking, and plan mode. `hidden` gives the parts switched off.
198 */
199export function registerProgress(on: On, hidden: () => readonly string[]) {
200  const off = () => hidden().includes('progress')
201
202  // The bar follows the task tools, and Claude writes a list only when it decides to: this line asks it to for real
203  // multi-step work, so the bar has steps to show. Static text, so the prompt cache stays warm. It rides in the first
204  // message's context, not the system prompt: prompt.compose is missing on the stable CLI (2.1.285) and a module that
205  // names it does not load there at all.
206  on('prompt.context', async ($, e, next) => {
207    const result = await next(e)
208    if (off()) return result
209    return { ...result, blocks: [...result.blocks, { name: 'effortless-progress', text: PROGRESS_PROMPT }] }
210  })
211
212  on('tool.call', async ($, e, next) => {
213    if (off() || e.agentId !== undefined) return next(e)
214    if (e.tool === 'AskUserQuestion') {
215      const p = await read($, progressState)
216      const shown = Boolean(p && (p.phase === 'working' || p.phase === 'paused') && p.steps.length >= MIN_STEPS)
217      if (shown) {
218        await update($, progressState, cur => (cur ? { ...cur, phase: 'asking' } : cur))
219        // A chime only with a bar to see: a list the person closed asks quietly.
220        if (p && (await read($, progressHidden)) !== stepsKey(p.steps)) queueCue(hidden(), 'question')
221      }
222      const result = await next(e)
223      if (shown) await update($, progressState, cur => (cur?.phase === 'asking' ? { ...cur, phase: 'working' } : cur))
224      return result
225    }
226    if (e.tool === 'EnterPlanMode') {
227      // Claude is planning for real: the bar says so until a step list comes. A guess from the effort pick showed it
228      // for turns that never made a list, a handoff among them.
229      const result = await next(e)
230      if (!('deny' in result && result.deny) && !result.isError) await update($, progressState, p => p ?? { phase: 'planning', steps: [] })
231      return result
232    }
233    if (e.tool !== 'TodoWrite' && e.tool !== 'TaskCreate' && e.tool !== 'TaskUpdate') return next(e)
234    const result = await next(e)
235    if (('deny' in result && result.deny) || result.isError) return result
236    if (e.tool === 'TodoWrite') {
237      const steps = stepsFromTodos(e.todos)
238      await update($, progressState, () => ({ phase: 'working', steps }))
239    } else if (e.tool === 'TaskCreate') {
240      const id = (result.result as { task?: { id?: string } } | undefined)?.task?.id
241      if (id) await update($, progressState, p => ({ phase: 'working', steps: withTaskCreated(p && p.phase !== 'done' ? p.steps : [], id, e) }))
242    } else {
243      await update($, progressState, p => (p ? { phase: p.phase === 'planning' ? 'working' : p.phase, steps: withTaskUpdated(p.steps, e) } : p))
244    }
245    return result
246  })
247}
248
249/** What drawing the bar takes besides the state: the surface's elements, the rows there is room for, the ✕. */
250export type ProgressDraw = {
251  // The surface's element table, as $.ui.resolve(e) gives it.
252  // eslint-disable-next-line @typescript-eslint/no-explicit-any
253  Box: any
254  // eslint-disable-next-line @typescript-eslint/no-explicit-any
255  Text: any
256  // eslint-disable-next-line @typescript-eslint/no-explicit-any
257  Button: any
258  /** The surface's Svg, on the surfaces that have one (not the terminal): the track is drawn as one still image. */
259  // eslint-disable-next-line @typescript-eslint/no-explicit-any
260  Svg?: any
261  maxRows: number
262  onClose: () => unknown
263}
264
265/**
266 * Whether the bar shows in this place: 'active' (planning, working, asking) sits above the alert bands, 'resting'
267 * (done, paused) below them. A list too short, or one the person closed, shows nowhere.
268 */
269export function progressShows(p: Progress | null, hiddenKey: string | null, when: 'active' | 'resting'): p is Progress {
270  if (!p) return false
271  // A finished task comes before the alerts too: it is news, and it lasts only until the next message.
272  const active = p.phase === 'planning' || p.phase === 'working' || p.phase === 'asking' || p.phase === 'done'
273  if ((when === 'active') !== active) return false
274  if (p.phase === 'planning') return true
275  return p.steps.length >= MIN_STEPS && hiddenKey !== stepsKey(p.steps)
276}
277
278/** The thinking dots' size in pixels. */
279export const THINK_W = 34
280
281/**
282 * Planning, before any step exists: three dots pulsing one after another, like thinking. No bar: nothing is measured
283 * yet, so nothing pretends to fill. Fixed size, so its interactive frame fits it.
284 */
285export function thinkingSvg(phase: Progress['phase'], w = THINK_W, h = PILL_H): string {
286  const look = looks(phase)
287  const r = h / 2
288  const dots = [0, 1, 2]
289    .map(i => `<circle class="dot" style="animation-delay:${(i * 0.18).toFixed(2)}s" cx="${6 + i * 11}" cy="${r}" r="3" fill="${look.color}"/>`)
290    .join('')
291  return (
292    `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">` +
293    '<style>:root{color-scheme:light dark}html,body{margin:0;overflow:hidden}svg{background:transparent;display:block}' +
294    '.dot{transform-box:fill-box;transform-origin:center;opacity:.3;animation:dot 1.1s ease-in-out infinite}' +
295    '@keyframes dot{30%{opacity:1;transform:translateY(-2.5px) scale(1.15)}60%{opacity:.3;transform:none}}</style>' +
296    dots +
297    '</svg>'
298  )
299}
300
301/** The thinking pill's height in pixels. */
302export const PILL_H = 16
303
304/**
305 * The track as one image that scales to the band's width, so it fits any window: a pill per step. Done steps are
306 * filled, pending steps a faint outline, and the step in progress is alive: soft light flows through it, a shine
307 * passes over it now and then and its glow breathes. A small flag at the end turns into a ticked circle when the task
308 * is done. Past SEGMENTS_MAX one continuous pill, filled to the share done. The image is not interactive (an
309 * interactive one needs a fixed size); its CSS animation still runs where the surface animates images, and it reads
310 * the same standing still.
311 */
312export function progressTrackSvg(p: Progress): string {
313  const look = looks(p.phase)
314  const id = p.phase
315  const W = 1000
316  const H = 24
317  const end = 30
318  const y = 4
319  const h = 16
320  const r = h / 2
321  const n = (v: number) => v.toFixed(1)
322  const finished = p.phase === 'done'
323  const pending = (x: number, w: number) =>
324    `<rect x="${n(x + 0.6)}" y="${y + 0.6}" width="${n(Math.max(0, w - 1.2))}" height="${h - 1.2}" rx="${r - 0.6}" fill="#ffffff" fill-opacity=".04" stroke="${look.color}" stroke-opacity=".3" stroke-width="1.2"/>`
325  const filled = (x: number, w: number) => `<rect x="${n(x)}" y="${y}" width="${n(Math.max(0, w))}" height="${h}" rx="${r}" fill="url(#fill-${id})"/>`
326  // The live pill: a breathing halo, a body of light flowing to the right, a shine that passes over it, a bright rim.
327  const live = (x: number, w: number, share = 1) => {
328    const lit = Math.max(r * 2, w * share)
329    const flow = 160
330    return (
331      `<rect class="halo" x="${n(x)}" y="${y}" width="${n(lit)}" height="${h}" rx="${r}" fill="${look.color}" filter="url(#halo-${id})"/>` +
332      (share < 1 ? `<rect x="${n(x)}" y="${y}" width="${n(w)}" height="${h}" rx="${r}" fill="${look.color}" fill-opacity=".1"/>` : '') +
333      `<clipPath id="cut-${id}"><rect x="${n(x)}" y="${y}" width="${n(lit)}" height="${h}" rx="${r}"/></clipPath>` +
334      `<g clip-path="url(#cut-${id})">` +
335      `<rect x="${n(x)}" y="${y}" width="${n(lit)}" height="${h}" fill="${look.color}" fill-opacity=".78"/>` +
336      `<g class="flow"><rect x="${n(x - flow)}" y="${y}" width="${n(lit + flow * 2)}" height="${h}" fill="url(#flow-${id})"/></g>` +
337      `<rect x="${n(x)}" y="${y}" width="${n(lit)}" height="${h / 2}" fill="#ffffff" fill-opacity=".1"/>` +
338      `<g class="shine"><rect x="${n(x - 60)}" y="${y - 4}" width="44" height="${h + 8}" transform="skewX(-25)" fill="url(#shine-${id})"/></g>` +
339      '</g>' +
340      `<rect x="${n(x + 0.6)}" y="${y + 0.6}" width="${n(lit - 1.2)}" height="${h - 1.2}" rx="${r - 0.6}" fill="none" stroke="#ffffff" stroke-opacity=".32" stroke-width="1.2"/>` +
341      `<style>.shine{animation:shine-${id} 3.2s ease-in-out infinite}@keyframes shine-${id}{0%,35%{transform:translateX(0)}100%{transform:translateX(${n(lit + 140)}px)}}</style>`
342    )
343  }
344  const total = p.steps.length
345  const room = W - end
346  const parts: string[] = []
347  if (total > 0 && total <= SEGMENTS_MAX) {
348    const gap = 10
349    const seg = (room - gap * (total - 1)) / total
350    p.steps.forEach((step, i) => {
351      const x = i * (seg + gap)
352      if (finished || step.status === 'completed') parts.push(filled(x, seg))
353      else if (step.status === 'in_progress' && p.phase !== 'paused') parts.push(live(x, seg))
354      else parts.push(pending(x, seg))
355    })
356  } else {
357    parts.push(finished ? filled(0, room) : live(0, room, progressShare(p.steps)))
358  }
359  const fx = W - 12
360  const flag = finished
361    ? `<circle cx="${fx}" cy="12" r="9" fill="${look.color}"/><path d="M${fx - 4.4} 12.2L${fx - 1.4} 15.2L${fx + 4.2} 8.8" fill="none" stroke="${look.bg}" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>`
362    : `<path d="M${fx - 6} 21V3.5" stroke="${look.color}" stroke-opacity=".75" stroke-width="1.6" stroke-linecap="round"/><path d="M${fx - 5.2} 4L${fx + 7} 7.8L${fx - 5.2} 11.6Z" fill="${look.color}" fill-opacity=".75"/>`
363  return (
364    `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">` +
365    '<style>svg{background:transparent;display:block;overflow:visible}' +
366    '.flow{animation:flow 2.6s linear infinite}@keyframes flow{from{transform:translateX(0)}to{transform:translateX(160px)}}' +
367    '.halo{opacity:.35;animation:halo 2.4s ease-in-out infinite}@keyframes halo{50%{opacity:.75}}</style>' +
368    `<defs><linearGradient id="fill-${id}" x1="0" x2="1"><stop offset="0" stop-color="${look.color}" stop-opacity=".62"/><stop offset="1" stop-color="${look.color}"/></linearGradient>` +
369    // A repeating band of light every 160 units, so moving it by one period loops without a seam.
370    `<linearGradient id="flow-${id}" gradientUnits="userSpaceOnUse" x1="0" x2="160" spreadMethod="repeat"><stop offset="0" stop-color="#ffffff" stop-opacity="0"/>` +
371    `<stop offset=".5" stop-color="#ffffff" stop-opacity=".26"/><stop offset="1" stop-color="#ffffff" stop-opacity="0"/></linearGradient>` +
372    `<linearGradient id="shine-${id}" x1="0" x2="1"><stop offset="0" stop-color="#ffffff" stop-opacity="0"/><stop offset=".5" stop-color="#ffffff" stop-opacity=".8"/><stop offset="1" stop-color="#ffffff" stop-opacity="0"/></linearGradient>` +
373    `<filter id="halo-${id}" x="-10%" y="-80%" width="120%" height="260%"><feGaussianBlur stdDeviation="3.5"/></filter></defs>` +
374    parts.join('') + flag + '</svg>'
375  )
376}
377
378/** The background art's size in pixels: wider than any band, cut by it on the right. */
379export const ART_W = 1600
380export const ART_H = 64
381
382/**
383 * The band's whole background, in its colour, in layers: a faint dot grid, soft aurora clouds drifting and breathing,
384 * two flowing lines of light, a slow streak passing across, twinkling sparkles, and one layer by state. Planning:
385 * points of a constellation lighting up and linking. Working: particles streaming right. Asking: ripples spreading and
386 * question marks rising. Done: confetti falling and checkmarks rising. Fixed size, anchored left and cut by the band,
387 * so its interactive frame always fits; one source per state.
388 */
389export function progressArtSvg(phase: Progress['phase']): string {
390  const c = looks(phase).color
391  // A fixed pseudo-random spread, so the same state always draws the same art.
392  const spread = (count: number, salt: number) =>
393    Array.from({ length: count }, (_, i) => {
394      const a = Math.sin((i + 1) * 12.9898 + salt * 78.233) * 43758.5453
395      const b = Math.sin((i + 1) * 39.3467 + salt * 11.135) * 24634.6345
396      return [(i + (a - Math.floor(a))) * (ART_W / count), 8 + (b - Math.floor(b)) * (ART_H - 16), a - Math.floor(a)] as const
397    })
398  const f = (v: number) => v.toFixed(1)
399  const sparks = spread(26, 1)
400    .map(([x, y, k]) => {
401      const s = 2 + k * 2.6
402      return (
403        `<path class="sp" style="transform-origin:${f(x)}px ${f(y)}px;animation-delay:${(k * 4.2).toFixed(2)}s" ` +
404        `d="M${f(x)} ${f(y - s)}L${f(x + s * 0.28)} ${f(y - s * 0.28)}L${f(x + s)} ${f(y)}L${f(x + s * 0.28)} ${f(y + s * 0.28)}L${f(x)} ${f(y + s)}L${f(x - s * 0.28)} ${f(y + s * 0.28)}L${f(x - s)} ${f(y)}L${f(x - s * 0.28)} ${f(y - s * 0.28)}Z" fill="#fff"/>`
405      )
406    })
407    .join('')
408  const clouds = spread(6, 5)
409    .map(([x, , k], i) =>
410      `<ellipse class="cl" style="animation-delay:-${(k * 9).toFixed(2)}s;animation-duration:${(9 + k * 6).toFixed(2)}s" cx="${f(x)}" cy="${i % 2 ? 50 : 14}" rx="${f(120 + k * 90)}" ry="26" fill="${c}"/>`,
411    )
412    .join('')
413  // Two sine lines across the band; their dashes travel, so light runs along them.
414  const wave = (amp: number, period: number, phase0: number, cls: string, op: number) => {
415    let d = ''
416    for (let x = 0; x <= ART_W; x += 20) d += `${x ? 'L' : 'M'}${x} ${f(ART_H / 2 + amp * Math.sin((x / period) * Math.PI * 2 + phase0))}`
417    return `<path class="${cls}" d="${d}" fill="none" stroke="${c}" stroke-opacity="${op}" stroke-width="1.2" stroke-linecap="round"/>`
418  }
419  const rising = (count: number, glyph: (x: number) => string) =>
420    spread(count, 7)
421      .map(([x, , k]) => `<g class="up" style="animation-delay:${(k * 3).toFixed(2)}s;animation-duration:${(3 + k * 1.5).toFixed(2)}s">${glyph(x)}</g>`)
422      .join('')
423  let extra = ''
424  if (phase === 'done') {
425    const hues = [c, '#ffffff', '#f2d16b', '#a79cf7']
426    extra =
427      spread(34, 11)
428        .map(([x, , k], i) =>
429          `<rect class="cf" style="animation-delay:-${(k * 4).toFixed(2)}s;animation-duration:${(2.6 + k * 2).toFixed(2)}s" x="${f(x)}" y="-8" width="${(3 + k * 3).toFixed(1)}" height="${(2 + k * 2).toFixed(1)}" rx=".6" fill="${hues[i % hues.length]}"/>`,
430        )
431        .join('') +
432      rising(14, x => `<path d="M${f(x - 5)} 56L${f(x - 1.5)} 59.5L${f(x + 5.5)} 52" fill="none" stroke="${c}" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>`)
433  } else if (phase === 'asking') {
434    extra =
435      spread(5, 9)
436        .map(([x, y, k]) => `<circle class="rp" style="transform-origin:${f(x)}px ${f(y)}px;animation-delay:${(k * 3).toFixed(2)}s" cx="${f(x)}" cy="${f(y)}" r="22" fill="none" stroke="${c}" stroke-width="1.2"/>`)
437        .join('') +
438      rising(10, x => `<text x="${f(x)}" y="60" font-size="13" font-family="sans-serif" font-weight="700" fill="${c}">?</text>`)
439  } else if (phase === 'planning') {
440    const pts = spread(16, 4)
441    extra =
442      pts
443        .slice(1)
444        .map(([x, y], i) => {
445          const [px, py] = pts[i]
446          return `<line class="lk" style="animation-delay:${(i * 0.35).toFixed(2)}s" x1="${f(px)}" y1="${f(py)}" x2="${f(x)}" y2="${f(y)}" stroke="${c}" stroke-width="1" stroke-dasharray="140" stroke-dashoffset="140"/>`
447        })
448        .join('') +
449      pts.map(([x, y], i) => `<circle class="nd" style="animation-delay:${(i * 0.35).toFixed(2)}s" cx="${f(x)}" cy="${f(y)}" r="2.2" fill="${c}"/>`).join('')
450  } else {
451    extra = spread(30, 3)
452      .map(([x, y, k]) => `<circle class="pt" style="animation-delay:-${(k * 3).toFixed(2)}s;animation-duration:${(1.8 + k * 2.2).toFixed(2)}s" cx="${f(x)}" cy="${f(y)}" r="${(0.8 + k * 1.4).toFixed(1)}" fill="${c}"/>`)
453      .join('') +
454      spread(9, 6)
455        .map(([x, y, k]) => `<rect class="ln" style="animation-delay:${(k * 2.4).toFixed(2)}s" x="${f(x)}" y="${f(y)}" width="${f(40 + k * 50)}" height="1.2" rx=".6" fill="${c}"/>`)
456        .join('')
457  }
458  return (
459    `<svg xmlns="http://www.w3.org/2000/svg" width="${ART_W}" height="${ART_H}" viewBox="0 0 ${ART_W} ${ART_H}">` +
460    '<style>:root{color-scheme:light dark}html,body{margin:0;overflow:hidden}svg{background:transparent;display:block}' +
461    '.sp{opacity:0;transform:scale(0);animation:tw 4.2s ease-in-out infinite}' +
462    '@keyframes tw{0%,70%,100%{opacity:0;transform:scale(0) rotate(0)}82%{opacity:.85;transform:scale(1) rotate(30deg)}92%{opacity:0;transform:scale(.2) rotate(60deg)}}' +
463    '.cl{opacity:.1;animation-name:cl;animation-timing-function:ease-in-out;animation-iteration-count:infinite;animation-direction:alternate}' +
464    '@keyframes cl{from{opacity:.06;transform:translateX(-60px)}to{opacity:.2;transform:translateX(60px)}}' +
465    '.w1{stroke-dasharray:90 260;animation:w 7s linear infinite}.w2{stroke-dasharray:50 330;animation:w 11s linear infinite reverse}' +
466    '@keyframes w{to{stroke-dashoffset:-1400}}' +
467    '.up{opacity:0;animation-name:up;animation-timing-function:ease-out;animation-iteration-count:infinite}' +
468    '@keyframes up{0%{opacity:0;transform:translateY(0)}20%{opacity:.6}100%{opacity:0;transform:translateY(-52px)}}' +
469    '.ln{opacity:0;animation:ln 2.4s ease-in infinite}@keyframes ln{0%{opacity:0;transform:translateX(-60px)}30%{opacity:.35}100%{opacity:0;transform:translateX(220px)}}' +
470    '.pt{opacity:0;animation-name:pt;animation-timing-function:linear;animation-iteration-count:infinite}' +
471    '@keyframes pt{0%{opacity:0;transform:translateX(0)}15%{opacity:.6}100%{opacity:0;transform:translateX(160px)}}' +
472    '.rp{opacity:0;transform:scale(.2);animation:rp 3s ease-out infinite}@keyframes rp{0%{opacity:.6;transform:scale(.2)}100%{opacity:0;transform:scale(1.6)}}' +
473    '.cf{animation-name:cf;animation-timing-function:linear;animation-iteration-count:infinite}' +
474    '@keyframes cf{0%{opacity:0;transform:translateY(0) rotate(0)}10%{opacity:.85}100%{opacity:0;transform:translateY(80px) rotate(540deg)}}' +
475    '.cf{transform-box:fill-box;transform-origin:center}' +
476    '.lk{opacity:.4;animation:lk 5.6s ease-in-out infinite}@keyframes lk{0%{stroke-dashoffset:140;opacity:0}30%{opacity:.45}60%{stroke-dashoffset:0;opacity:.45}100%{stroke-dashoffset:0;opacity:0}}' +
477    '.nd{transform-box:fill-box;transform-origin:center;opacity:.2;animation:nd 5.6s ease-in-out infinite}@keyframes nd{0%,100%{opacity:.15;transform:scale(.7)}25%{opacity:1;transform:scale(1.5)}50%{opacity:.5;transform:scale(1)}}' +
478    `.sweep{animation:sweep 6s linear infinite}@keyframes sweep{from{transform:translateX(-400px)}to{transform:translateX(${ART_W + 100}px)}}</style>` +
479    `<defs><linearGradient id="wash" x1="0" x2="1"><stop offset="0" stop-color="${c}" stop-opacity=".06"/><stop offset=".6" stop-color="${c}" stop-opacity=".1"/><stop offset="1" stop-color="${c}" stop-opacity=".2"/></linearGradient>` +
480    '<linearGradient id="beam" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset=".5" stop-color="#fff" stop-opacity=".08"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>' +
481    `<pattern id="dots" width="14" height="14" patternUnits="userSpaceOnUse"><circle cx="7" cy="7" r=".8" fill="${c}" fill-opacity=".16"/></pattern>` +
482    '<filter id="soft" x="-50%" y="-100%" width="200%" height="300%"><feGaussianBlur stdDeviation="16"/></filter></defs>' +
483    `<rect width="${ART_W}" height="${ART_H}" fill="url(#wash)"/>` +
484    `<rect width="${ART_W}" height="${ART_H}" fill="url(#dots)"/>` +
485    `<g filter="url(#soft)">${clouds}</g>` +
486    wave(9, 420, 0, 'w1', 0.5) + wave(6, 300, 2, 'w2', 0.35) +
487    `<g class="sweep"><rect x="0" y="-20" width="300" height="${ART_H + 40}" transform="skewX(-20)" fill="url(#beam)"/></g>` +
488    sparks + extra + '</svg>'
489  )
490}
491
492/** The bar's first words: where the task is. */
493export function progressTitle(p: Progress): string {
494  const total = p.steps.length
495  const at = stepNumber(p.steps)
496  if (p.phase === 'planning') return 'Planning a bigger task'
497  if (p.phase === 'done') return `Done · all ${total} steps`
498  if (p.phase === 'asking') return `Waiting for your answer · step ${at} of ${total}`
499  if (p.phase === 'paused') return `Paused at step ${at} of ${total}`
500  return `Step ${at} of ${total}`
501}
502
503/**
504 * The band: the brand surface (or yellow, or green), the title and the step on the first row, the track below it.
505 * The track is a still Svg where the surface has one, else a row of characters: an interactive Svg sits in a frame of
506 * a fixed default size (about 300 by 150) unless given both sizes, and the track has no fixed width. The motion is in
507 * the art behind the band, which has a fixed size.
508 */
509export function drawProgress(p: Progress, d: ProgressDraw) {
510  const { Box, Text, Button } = d
511  const look = looks(p.phase)
512  const total = p.steps.length
513  const step = currentStep(p.steps)
514  const title = progressTitle(p)
515  const words = p.phase === 'done' || p.phase === 'planning' ? '' : ((p.phase === 'paused' ? step?.label : step?.doing) ?? '')
516  const name = (
517    <Box key="progress-name" position="relative" flexShrink={0}>
518      <Text color={look.color} bold>
519        ✦ effortless
520      </Text>
521    </Box>
522  )
523  const closeButton = <Button key="progress-close" plain role="dismiss" label="✕" onPress={d.onClose} />
524  // Short of room, or nothing to count yet: one row, the bar as ten blocks.
525  if (d.maxRows < 4 || p.phase === 'planning') {
526    const blocks = Math.round(progressShare(p.steps) * 10)
527    return (
528      <Box key="progress-bar" position="relative" flexDirection="row" gap={1} alignItems="center" paddingX={1} overflow="hidden"
529        backgroundColor={look.bg} borderStyle="round" borderColor={look.edge}>
530        {d.Svg ? (
531          <Box key="progress-art" position="absolute" top={-1} left={0} bottom={-1}>
532            <d.Svg key={`art-${p.phase}`} source={progressArtSvg(p.phase)} alt="" width={ART_W} height={ART_H} />
533          </Box>
534        ) : null}
535        {name}
536        {p.phase === 'planning' ? (
537          d.Svg ? (
538            <Box key="progress-thinking" position="relative" flexShrink={0}>
539              <d.Svg source={thinkingSvg(p.phase)} alt="thinking" width={THINK_W} height={PILL_H} />
540            </Box>
541          ) : null
542        ) : (
543          <Box key="progress-blocks" position="relative" flexShrink={0} flexDirection="row">
544            <Text color={look.color}>{'▰'.repeat(blocks)}</Text>
545            <Text dimColor>{'▱'.repeat(10 - blocks)}</Text>
546          </Box>
547        )}
548        <Box key="progress-words" position="relative" flexShrink={1} minWidth={0}>
549          <Text wrap="truncate">{words ? `${title} · ${words}` : title}</Text>
550        </Box>
551        <Box flexGrow={1} />
552        {closeButton}
553      </Box>
554    )
555  }
556  const fill = (key: string, lit: boolean) => (
557    <Box key={key} flexGrow={1} flexShrink={1} minWidth={0} height={1} overflow="hidden">
558      {lit ? <Text color={look.color}>{'━'.repeat(RUN)}</Text> : <Text dimColor>{'─'.repeat(RUN)}</Text>}
559    </Box>
560  )
561  // One segment per step: completed full, in progress half, pending empty. Past SEGMENTS_MAX one continuous fill.
562  const track =
563    total <= SEGMENTS_MAX
564      ? p.steps.map((s, i) =>
565          s.status === 'in_progress' && p.phase !== 'done' ? (
566            <Box key={`seg-${i}`} flexGrow={1} flexShrink={1} minWidth={0} height={1} overflow="hidden" flexDirection="row">
567              {fill(`seg-${i}-a`, true)}
568              {fill(`seg-${i}-b`, false)}
569            </Box>
570          ) : (
571            fill(`seg-${i}`, p.phase === 'done' || s.status === 'completed')
572          ),
573        )
574      : [
575          <Box key="seg-done" width={`${Math.round(progressShare(p.steps) * 100)}%`} height={1} overflow="hidden">
576            <Text color={look.color}>{'━'.repeat(RUN * 2)}</Text>
577          </Box>,
578          fill('seg-rest', false),
579        ]
580  return (
581    <Box key="progress-bar" position="relative" flexDirection="column" paddingX={1} overflow="hidden"
582      backgroundColor={look.bg} borderStyle="round" borderColor={look.edge}>
583      {d.Svg ? (
584        <Box key="progress-art" position="absolute" top={-1} left={0} bottom={-1}>
585          <d.Svg key={`art-${p.phase}`} source={progressArtSvg(p.phase)} alt="" width={ART_W} height={ART_H} />
586        </Box>
587      ) : null}
588      <Box key="progress-head" position="relative" flexDirection="row" gap={1} alignItems="center">
589        {name}
590        <Box key="progress-words" flexShrink={1} minWidth={0} flexDirection="row" gap={1}>
591          <Text color={look.color} wrap="truncate">
592            {title}
593          </Text>
594          {words ? <Text wrap="truncate">{words}</Text> : null}
595        </Box>
596        <Box flexGrow={1} />
597        {closeButton}
598      </Box>
599      {d.Svg ? (
600        <Box key="progress-track" position="relative" flexDirection="row">
601          <d.Svg key={`track-${p.phase}`} source={progressTrackSvg(p)} alt={title} />
602        </Box>
603      ) : (
604        <Box key="progress-track" width="100%" height={1} flexDirection="row" gap={total <= SEGMENTS_MAX ? 1 : 0} alignItems="center">
605          {track}
606          <Box key="progress-flag" flexShrink={0}>
607            <Text color={p.phase === 'done' ? look.color : undefined} dimColor={p.phase !== 'done'}>
608              ⚑
609            </Text>
610          </Box>
611        </Box>
612      )}
613    </Box>
614  )
615}
616
types/index.d.ts 213 lines
1export type ModelKey = 'haiku' | 'sonnet' | 'opus' | 'fable'
2export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
3
4/** What the prompts Auto steered cost, measured: tokens by kind, and per effort the prompts and their weighted cost. */
5export type Spent = {
6  prompts: number
7  requests: number
8  input: number
9  write: number
10  read: number
11  out: number
12  byEffort: Partial<Record<Effort, { prompts: number; cost: number }>>
13  /** How often each judge decided, the time it took in all (ms), and its tokens. */
14  judge: { jev: number; haiku: number; ms: number; tokens: number }
15}
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