Look away while Claude works: a GNOME notification, a sound, a toast and a band when it needs you or finishes something long, plus an /attention inbox.

A Claude Code plugin, an add-on of the loopkeeper set (its command stays /attention), that tells you when Claude needs you or has finished something long, so you can look away while it works. It is built for Linux with GNOME, and it keeps an inbox of those moments. You stop checking back every few minutes for a permission prompt that has been sitting there all along.
Use it when
Install: /plugin install lk-attention@loopkeeper, after /plugin marketplace add connectwithub/loopkeeper; it needs Linux with GNOME (more under Install and Linux notes). Requires Claude Code 2.1.294 or later.
Data handling: it keeps the settings you change with /attention in its own plugin store, and the inbox (its last 50 events) in session state only, so a new session starts empty. It reads ~/.claude/attention.json and <project>/.claude/attention.json and writes no file. It runs notify-send (only when Claude Code's own notification call is missing) and paplay or pw-play for the sound, all on your machine; it makes no model calls and sends nothing over the network. Notifications carry Bash commands and questions clipped to one line, and your desktop's notification history keeps them. The bundled assets/ding.wav is a short tone generated for this plugin, not a recording, covered by its MIT license (see Assets). It collects no personal data; the saved settings stay until you delete them. Details under Footprint.
Commands: /attention and its options are listed under Commands.
It raises an event when:
| Kind | When | | :- | :- | | long-turn-done | A main-loop turn ends normally after at least longTurnMs (20 s) | | permission-waiting | A main-loop tool call is waiting for your OK: a permission dialog is shown (classic.PermissionRequest). With permissionSignal: "grace", a tool.check verdict of ask still unanswered after permissionGraceMs (4 s) also counts. Raised once per tool call, cleared when the call settles or the turn ends. Subagent asks are ignored unless includeSubagents is on. | | question-waiting | Claude opens an AskUserQuestion dialog (first draw per tool call). Cleared when you answer or the turn ends. | | subagent-done | A subagent, foreground or background, finishes after running at least subagentMs (60 s). The title names it by its Agent call's description. | | failed | A main-loop turn ends on an API error. An interrupt (Esc) is kept in the inbox as history but does not notify, because you were the one who pressed it. |
Each event goes out through three channels:
$.ui.toast(title), always.$.ui.notify({ title, body }) on Claude Code 2.1.295 and later; on older builds notify-send -a "Claude Code" -i dialog-information <title> <body>, which GNOME shows as a banner and keeps in its notification list.soundFile, else /usr/share/sounds/freedesktop/stereo/complete.oga, played with paplay and then pw-play. When neither file plays, the bundled assets/ding.wav (a 0.3 s chime) goes through $.audio.play, then paplay and pw-play.While unseen events exist, a line above the prompt says what is waiting:
attention ● 2 waiting permission (Bash), question
The chip is drawn in the theme's accent orange, and red when a failure is among them. The kinds sit on the right: in the default text colour while an ask waits or a turn failed, in the quiet colour otherwise. When you submit a prompt yourself, everything is marked seen. A task notification or a message from another session doesn't count as you coming back.
Needs Claude Code 2.1.294 or later. Notifications are built for Linux with GNOME (see Linux notes).
From the loopkeeper marketplace, inside a Claude Code session:
/plugin marketplace add connectwithub/loopkeeper
/plugin install lk-attention@loopkeeper
Turn it off or on again with /plugin disable lk-attention@loopkeeper and /plugin enable lk-attention@loopkeeper. To try it from a clone without installing it: claude --plugin-dir /path/to/loopkeeper/plugins/lk-attention. Check and test it from its folder with claude plugin validate --strict . and claude plugin test ..
Then run /attention test in a session to check that the banner and the sound reach you.
| Command | What it does | | :- | :- | | /attention | Opens the inbox pane (see Pane), with Mark all seen (m) and Close (c) | | /attention text | Prints the settings and the inbox | | /attention on / off | Turns the whole mod on or off | | /attention sound on / off | Turns the sound on or off | | /attention native on / off | Turns the desktop notification on or off | | /attention band on / off | Shows or hides the line above the prompt | | /attention subagents on / off | Includes or ignores permission asks from subagents (default off) | | /attention signal dialog / grace | What raises a permission wait: only a shown dialog (default), or an ask left unanswered past the grace | | /attention grace <ms> | In grace mode, sets how long an ask must stay unanswered before it notifies: 4000, 4s | | /attention long <ms> | Sets how long a turn must run before it notifies: 20000, 20s, 2m | | /attention test | Sends a test event through every channel, skipping the rate limit and quiet period, and reports which method worked for each |
Settings changed with these commands are kept in the mod's $.store, so they carry over to every session.
/attention opens the inbox pane, drawn with the kit (hooks/kit.ts, a verbatim copy of the loopkeeper core's kit v3): bordered, unfilled cards on every surface, with ● text badges in the terminal and fully rounded Svg pills in the Desktop app. At the top, a hero card (kicker ATTENTION) shows the newest unseen event: its title, then its kind, its age and its one-line body, colored by kind. With nothing unseen it says Nothing waiting in green. Below it, tiles count Unseen (orange, red with an unseen failure), Asks (permission and question waits, orange while one is unseen), Done (long turns and subagents, green) and, once there is one, Failed (red). A row shows the switches (an on/off badge in green or red, then native, sound, band and the long-turn threshold in the quiet colour). The INBOX · n section lists every event, newest first: a ● in the kind's colour while unseen or a quiet ○ once seen, a kind badge (asks in the accent orange, failures red, finished work green, a test in the info colour), the title (bold while unseen, quiet once seen), the body in the quiet colour, and the clock time and age on the right. The Mark all seen and Close buttons sit at the bottom. The band above the prompt is the one kit line described above.
Two optional JSON files, merged in this order (later wins, field by field):
~/.claude/attention.json<project>/.claude/attention.json/attention{
"longTurnMs": 20000,
"subagentMs": 60000,
"sound": true,
"native": true,
"soundFile": "",
"quietAfterPromptMs": 0,
"permissionSignal": "dialog",
"permissionGraceMs": 4000,
"includeSubagents": false
}
| Field | Default | Meaning | | :- | :- | :- | | longTurnMs | 20000 | A main-loop turn at least this long notifies when it ends | | subagentMs | 60000 | A subagent that ran at least this long notifies when it ends | | sound | true | Play a sound with each notification | | native | true | Send a desktop notification | | soundFile | "" | A file to play instead of the freedesktop sound, such as /usr/share/sounds/freedesktop/stereo/bell.oga | | permissionSignal | "dialog" | "dialog": only a shown permission dialog notifies. "grace": an unanswered ask notifies after the grace. | | permissionGraceMs | 4000 | In grace mode, how long a tool.check ask must stay unanswered before it notifies | | includeSubagents | false | Also notify for permission asks from subagents | | quietAfterPromptMs | 0 | For this long after you submit a prompt, events only toast: no banner, no sound. Use it if you sit and watch short turns. |
The files also accept enabled and band (booleans). Fields of the wrong type are ignored.
Native notifications and sounds are rate-limited to one per kind every 10 seconds. Toasts and the inbox are never limited.
A tool.check verdict of ask doesn't mean you will be asked. It means "let the permission mode decide". In auto permission mode the classifier answers most asks itself, often while a long command is already running. So by default (permissionSignal: "dialog") a permission wait is raised only by classic.PermissionRequest, which fires when a dialog is really on screen. That event has no tool_use_id, so it is matched to the newest call in flight with the same tool, and that call settling clears the wait. A bare ask raises and schedules nothing in this mode. Subagent asks are ignored unless includeSubagents is on, because auto mode decides them and you can't approve them.
permissionSignal: "grace" is the 0.1.2 behaviour, for builds where classic.PermissionRequest never fires. The tool.check hook learns the verdict by asking the permission check first ($.tool.check, which runs nothing and opens no dialog), then passes the call on unchanged. An ask is held for permissionGraceMs (4 s), and dropped if its call runs (the terminal's ToolProgress pill) or settles, or the turn ends or is interrupted. Only an ask still open after that notifies. Once a dialog event has fired in the session, it alone decides for the main loop. classic.PreToolUse can't be the "approved" signal, because it fires before tool.check.
notify-send comes with libnotify-bin on Ubuntu and Debian, and libnotify on Fedora and Arch. GNOME shows its banners and keeps them in the message tray. In Do Not Disturb mode GNOME keeps them in the tray without a banner.paplay comes with pulseaudio-utils, and works on PipeWire through pipewire-pulse. pw-play comes with PipeWire itself. Either one is enough.sound-theme-freedesktop) supplies complete.oga. Without it, the bundled assets/ding.wav plays.$.audio.play names only the macOS player in its typings, so on Linux the mod runs paplay or pw-play itself first./attention test prints which method worked for each channel, such as native via notify-send; sound via paplay /usr/share/sounds/freedesktop/stereo/complete.oga.turn.complete (main loop, not interrupted, over a threshold) and the permission test on tool.check, where the verdict after next(e) is ask only when a real dialog will show.$.audio.play({ asset }), with a player of our own as the fallback.update() callback, so a repeat (a second tool.check, a re-drawn dialog) can never raise it twice.paplay/pw-play for Linux sound, and a map from tool_use_id to the loop it runs in, because tool.check carries no agentId while tool.call does.$.state atoms, config in $.store plus JSON files, a one-line band that keeps what other plugins draw under it, and the test layout.What claude plugin validate --strict . reports for this plugin (Claude Code 2.1.294), and what that means:
| Area | What the plugin has |
|---|---|
| Hooks module | One: ./register.ts, in hooks/hooks.json. |
| Hook events | session.start, classic.SessionStart (clear, resume, fork), prompt.submit, agent.spawn, tool.call, tool.check, classic.PermissionRequest, turn.complete, command.run (attention), ui.render (the AskUserQuestion dialog, ToolProgress, the band, the attention pane). |
| Refusals | None. Its gating hooks only watch and pass every event on. |
Calls ($.…) | audio.play; clock (after, now); command.register; env.get; fs (exists, read); process.run; session.cwd; state (get, set); store (get, set); tool.check (grace mode only); ui (close, notify, open, resolve, toast). |
| State | 6 atoms under lk-attention. |
| Store | The settings saved by /attention. |
| Env | Reads HOME; writes nothing. |
What it runs:
notify-send (when $.ui.notify is missing) and paplay or pw-play for the sound. Linux only.~/.claude/attention.json and <project>/.claude/attention.json; checks that the sound files exist; writes nothing.The directory's validator listed these items for this plugin. Each bullet says what the code does and where (hooks/register.ts unless named).
MOD_RUNS_PROCESS, MOD_PROCESS_COMMAND_COMPUTED). Three programs, all to deliver a notice, through $.process.run:notify-send -a 'Claude Code' -i dialog-information <title> <body> in sendNative() (line 197; the argv comes from notifyArgv() in hooks/attention.ts), only when $.ui.notify is missing, with a 5-second timeout;paplay <file>, then pw-play <file> when paplay is missing, in playFile() (line 208; argv from soundArgvs()), with a 10-second timeout. The file is the user's soundFile, else /usr/share/sounds/freedesktop/stereo/complete.oga, else the bundled assets/ding.wav when $.audio.play cannot play it.The program names and flags are fixed text; only the title, the body and the sound file's path vary.
MOD_LOCAL_DATA_LEAVES, MOD_SESSION_DATA_LEAVES). Nothing leaves the machine: notify-send shows a desktop notification on the user's own screen and the players play a local file. A notice's title and body are built in the code and clipped to 100 and 160 characters (raise(), line 298): which tool waits for an OK and the command or path it would act on, the first line of a question Claude asks, or the first line of a finished turn's or subagent's answer. The prompt.submit hook (line 572) reads only the prompt's origin, to note that the user is back; it does not read the prompt's text and passes the prompt on unchanged. The files it reads are its own config files; for the sound files it only checks that they exist.MOD_HOOKS_PERMISSION_CHECK). The tool.check hook (line 628) decides nothing: it always returns next(e) unchanged, so the user's own rules and answers decide every call. Only with permissionSignal: "grace" (the default is "dialog") does it first ask Claude Code what the user's rules would answer, with $.tool.check({ tool, input }), which runs no tool and opens no dialog; when that answer is ask, it holds a "needs your OK" notice for the grace period and raises it only if the call is still waiting. classic.PermissionRequest (line 648) only notes that a permission dialog is on screen, so the notice is raised at once, and returns next(e) unchanged.assets/ding.wav (policy hold BINARIES_NOT_INSPECTED). A sound file, not a program: a tone generated for this plugin, 0.3 s of mono 16-bit PCM at 22,050 Hz, an 880 Hz sine with a quieter one at 1,320 Hz (see Assets). The code only hands it to $.audio.play({ asset: 'assets/ding.wav' }) or, as the file argument, to paplay / pw-play; nothing executes it.NAME_CONFUSABLE). lk-attention is the attention add-on of the loopkeeper set: each add-on carries the set's lk- prefix (lk-agent-map, lk-handoff-guard, lk-token-budget, lk-where-am-i, lk-pr-watch, lk-secret-guard) and ships from the same marketplace and author as the loopkeeper core. It is not the directory's attention connector and has no tie to it.plugin.json (warnings UNKNOWN_KEY). documentationUrl and supportUrl are listing fields the directory reads. types points at types/index.d.ts, the $.state contract that claude plugin validate checks. Claude Code ignores all three at load time.ICON_MISSING). Added at .claude-plugin/icon.png (512 × 512); its source is docs/assets/icons/lk-attention.svg in the repo.MOD_CALL_NAMED_IN_TEXT). agent.spawn, command.run, prompt.submit, tool.call, tool.check and classic.PreToolUse are named in the header comment (lines 4–18), which lists the hooks and says why classic.PreToolUse is not used.MOD_UNKNOWN_CALL, line 191). $.ui.notify({ title, body }) is Claude Code's own desktop-notification call, newer than the 2.1.294 typings the plugin is checked against (the comment above it notes 2.1.295); no other plugin provides it. On a build without it the call throws and the plugin uses notify-send instead.MOD_REGISTERS_SURFACES). It registers one slash command, /attention, at session start (line 550). It registers no tools or agents.MOD_HOOKS_POLICY_EVENT). classic.SessionStart with source clear, resume or fork (line 563) loads the config again, because those resets clear $.state; classic.PermissionRequest (line 648) is described above. Both return next(e) unchanged and alter nothing that passes through them. The third mention is classic.PreToolUse in the header comment (line 12).MOD_HOOKS_OPERATION). prompt.submit (line 572) is described above. command.run (line 670) has the matcher { command: 'attention' }, so it runs only for /attention. agent.spawn (line 588) and tool.call (line 603) return what next(e) gave back, unchanged; they only note a subagent's label and which loop a call runs in. None of them makes the call it hooks.$.ui.notify arrives in Claude Code 2.1.295. The validator refuses reading a mods API method as a value, so the mod detects it by calling it and falling back to notify-send when the call throws. The 2.1.294 test kit can't stub ui.notify, so only the notify-send path is tested.ToolProgress pill. On the desktop app, an approved call that runs past the grace can still notify once, so dialog mode is the default.ui.render hook can't write state. It comes a moment after the dialog appears.quietAfterPromptMs.agent.spawn, or from the Agent call's description. A subagent whose spawn happened before a reload of the mod is named by the start of its id.$.state: it keeps its last 50 events through a mod reload but not into a new session..claude-plugin/plugin.json manifest
hooks/hooks.json points at register.ts
hooks/register.ts the hooks: events, delivery, band, pane, commands
hooks/attention.ts pure helpers: config merge, band text, argv builders
hooks/kit.ts the UI kit v3, a verbatim copy of the loopkeeper core's kit (don't edit here)
types/index.d.ts Config, AttentionEvent and the $.state declarations
assets/ding.wav 0.3 s chime, 13 KB, see Assets below
tests/attention.test.ts helper unit tests
tests/register.test.ts event, delivery, band, pane and command tests
tests/pane.test.ts the pane and the band on both surfaces
tests/kit.test.ts the core's kit suite, without its render snapshots
assets/ding.wav is a tone generated for this plugin, not a recording: 0.3 s of mono 16-bit audio at 22,050 Hz, an 880 Hz sine with a quieter sine a fifth above (1,320 Hz). It is covered by the plugin's MIT license.
hooks/register.ts 815 lines1// attention: tell the user when Claude needs them or finished something long,
2// and keep an inbox of those moments.
3//
4// turn.complete long main-loop turn done, failed turn (error or interrupt), subagent done
5// Permission waits follow config.permissionSignal. "dialog" (default): only
6// classic.PermissionRequest raises one. "grace": also
7// tool.check permission ask: the verdict after next(e) is 'ask'. Held for a grace
8// period (auto mode approves most asks within it); raised only if still
9// unanswered. Subagent asks are ignored unless includeSubagents.
10// classic.PermissionRequest the dialog is really shown: raises the held ask at once
11// ui.render ToolProgress the tool is running (so it was approved): drops the held ask.
12// classic.PreToolUse can't serve: it fires before tool.check.
13// tool.call remembers which loop a call runs in; clears its wait once it settles
14// agent.spawn the subagent's label and start time
15// ui.render AskUserQuestion: question wait (first render per tool call id);
16// AbovePrompt: the one-line band; Pane: the /attention inbox
17// prompt.submit the user is back: marks the inbox seen and starts the quiet period
18// command.run /attention and its subcommands
19//
20// Delivery (toast, native, sound) runs from $.clock.after(0) so no hook waits on
21// notify-send or a player. Helpers that take $ are top-level functions so static
22// analysis can read them.
23
24import { atom, read, update } from 'claude-code'
25import type { EngineInterface, On } from 'claude-code'
26
27import type { AttentionEvent, Config, Kind } from '../types'
28import {
29 BUNDLED_SOUND,
30 DEFAULTS,
31 HELP,
32 INBOX_MAX,
33 RATE_MS,
34 SYSTEM_SOUND,
35 bandOf,
36 clockTime,
37 describeInput,
38 firstLine,
39 firstQuestion,
40 fmtDuration,
41 inboxText,
42 mergeConfig,
43 notifyArgv,
44 oneLine,
45 parseMs,
46 sanitize,
47 shortKind,
48 soundArgvs,
49} from './attention'
50import { TONES, makeKit } from './kit'
51import type { Elements, Tone } from './kit'
52
53type Api = EngineInterface
54
55const PANE = 'attention'
56const SETTINGS_KEY = 'settings'
57const CONFIG_FILE = '.claude/attention.json'
58const HUMAN_ORIGINS = ['composer', 'bridge', 'sdk']
59
60const inboxAtom = atom({ plugin: 'lk-attention', key: 'inbox' } as const, [] as AttentionEvent[])
61const configAtom = atom({ plugin: 'lk-attention', key: 'config' } as const, DEFAULTS)
62const claimedAtom = atom({ plugin: 'lk-attention', key: 'claimed' } as const, [] as string[])
63const lastNativeAtom = atom({ plugin: 'lk-attention', key: 'lastNative' } as const, {} as Record<string, number>)
64const lastPromptAtom = atom({ plugin: 'lk-attention', key: 'lastPromptAt' } as const, null as number | null)
65const cwdAtom = atom({ plugin: 'lk-attention', key: 'cwd' } as const, '')
66
67/** tool_use_id -> the loop and tool of a call in flight (tool.check carries no agentId). */
68const calls = new Map<string, { tool: string; agentId?: string; description?: string }>()
69/** agentId -> label and start time, from agent.spawn. */
70const agents = new Map<string, { label: string; startedAt: number }>()
71/** AskUserQuestion render ids already handed to a timer, so re-renders schedule nothing. */
72const queued = new Set<string>()
73/** Main-loop asks held for the grace period, by tool_use_id. */
74const pending = new Map<string, { tool: string; input: unknown; agentId?: string; timer: { cancel(): void } | null }>()
75/** Set once classic.PermissionRequest has fired: from then on it alone raises main-loop waits. */
76let sawPermissionRequest = false
77let seq = 0
78
79type Raise = {
80 kind: Kind
81 title: string
82 body: string
83 agentId?: string
84 toolUseId?: string
85 tool?: string
86 /** Toast and inbox only: no native notification or sound (the user is at the keyboard). */
87 quiet?: boolean
88 /** Raise it already seen (history only, no band). */
89 seen?: boolean
90}
91
92// ---------------------------------------------------------------- pane helpers (pure)
93
94const ASK_KINDS: readonly Kind[] = ['permission-waiting', 'question-waiting']
95const DONE_KINDS: readonly Kind[] = ['long-turn-done', 'subagent-done']
96
97/** The quiet color of the kit (secondary text, seen events), a theme key. */
98const QUIET = TONES.muted.fg
99
100/** The kit tone of a kind: asks accent (the Claude orange), failures danger, finished work ok, the rest info. */
101function toneOfKind(kind: Kind): Tone {
102 if (ASK_KINDS.includes(kind)) return 'accent'
103 if (kind === 'failed') return 'danger'
104 if (DONE_KINDS.includes(kind)) return 'ok'
105 return 'info'
106}
107
108/** Inbox counts by kind for the pane's tiles. */
109function countsOf(inbox: readonly AttentionEvent[]): { asks: number; done: number; failed: number } {
110 const n = (kinds: readonly Kind[]) => inbox.filter(ev => kinds.includes(ev.kind)).length
111 return { asks: n(ASK_KINDS), done: n(DONE_KINDS), failed: n(['failed']) }
112}
113
114async function now($: Api): Promise<number> {
115 try {
116 const t = await $.clock.now()
117 return typeof t === 'number' && Number.isFinite(t) ? t : Date.now()
118 } catch {
119 return Date.now()
120 }
121}
122
123/** Claims a key once: true for the first caller, false ever after (one update, so no double raise). */
124async function claim($: Api, key: string): Promise<boolean> {
125 let first = false
126 await update($, claimedAtom, (list: string[]) => {
127 if (list.includes(key)) return list
128 first = true
129 return [...list, key].slice(-500)
130 })
131 return first
132}
133
134async function readJson($: Api, path: string): Promise<unknown> {
135 try {
136 if (!(await $.fs.exists(path))) return null
137 return JSON.parse(String(await $.fs.read(path)))
138 } catch {
139 return null
140 }
141}
142
143async function readSaved($: Api): Promise<unknown> {
144 try {
145 return await $.store.get(SETTINGS_KEY)
146 } catch {
147 return null
148 }
149}
150
151/** Merges defaults, ~/.claude/attention.json, <cwd>/.claude/attention.json and /attention settings. */
152async function loadConfig($: Api): Promise<Config> {
153 let home = ''
154 try {
155 home = String((await $.env.get('HOME')) ?? '')
156 } catch {
157 home = ''
158 }
159 const cwd = await read($, cwdAtom)
160 const user = home ? await readJson($, home.replace(/\/+$/, '') + '/' + CONFIG_FILE) : null
161 const project = cwd ? await readJson($, cwd.replace(/\/+$/, '') + '/' + CONFIG_FILE) : null
162 const cfg = mergeConfig(user, project, await readSaved($))
163 await update($, configAtom, () => cfg)
164 return cfg
165}
166
167async function saveSetting($: Api, patch: Partial<Config>): Promise<Config> {
168 await $.store.set(SETTINGS_KEY, { ...sanitize(await readSaved($)), ...patch })
169 return loadConfig($)
170}
171
172async function onSessionStart($: Api, cwd: string): Promise<void> {
173 if (!cwd) {
174 try {
175 cwd = String((await $.session.cwd()) ?? '')
176 } catch {
177 cwd = ''
178 }
179 }
180 if (cwd) await update($, cwdAtom, () => cwd)
181 await loadConfig($)
182}
183
184/** Sends the desktop notification: $.ui.notify when this build has it, else notify-send. */
185async function sendNative($: Api, title: string, body: string): Promise<string> {
186 // $.ui.notify arrived in Claude Code 2.1.295. The validator refuses reading a $ method
187 // as a value, so feature detection is the call itself: on an older build it throws
188 // (not a function) and notify-send takes over.
189 try {
190 // @ts-ignore not in the 2.1.294 typings
191 await $.ui.notify({ title, body })
192 return 'native via $.ui.notify'
193 } catch {
194 // Fall through to notify-send.
195 }
196 try {
197 const r = await $.process.run(notifyArgv(title, body), { timeoutMs: 5000 })
198 return r.exitCode === 0 ? 'native via notify-send' : `native failed: notify-send exited ${r.exitCode}`
199 } catch {
200 return 'native failed: notify-send not found'
201 }
202}
203
204/** Runs paplay, then pw-play, on a file; the player that worked, or null. */
205async function playFile($: Api, file: string): Promise<string | null> {
206 for (const argv of soundArgvs(file)) {
207 try {
208 const r = await $.process.run(argv, { timeoutMs: 10_000 })
209 if (r.exitCode === 0) return argv[0]
210 } catch {
211 // Not installed: try the next player.
212 }
213 }
214 return null
215}
216
217/** Plays the configured file, else the freedesktop sound, else the bundled ding. */
218async function playSound($: Api, cfg: Config): Promise<string> {
219 let file = cfg.soundFile
220 if (!file) {
221 try {
222 if (await $.fs.exists(SYSTEM_SOUND)) file = SYSTEM_SOUND
223 } catch {
224 file = ''
225 }
226 }
227 if (file) {
228 const player = await playFile($, file)
229 if (player) return `sound via ${player} ${file}`
230 }
231 try {
232 await $.audio.play({ asset: BUNDLED_SOUND })
233 return `sound via $.audio.play ${BUNDLED_SOUND}`
234 } catch {
235 // No $.audio, or the engine has no player here: run one ourselves.
236 }
237 let root = ''
238 try {
239 root = String($.plugin.root ?? '')
240 } catch {
241 root = ''
242 }
243 if (root) {
244 const bundled = root.replace(/\/+$/, '') + '/' + BUNDLED_SOUND
245 const player = await playFile($, bundled)
246 if (player) return `sound via ${player} ${bundled}`
247 }
248 return 'sound failed: no player worked (install pulseaudio-utils or pipewire)'
249}
250
251/** Toast always; native and sound unless quiet, rate-limited or switched off. */
252async function deliver($: Api, ev: AttentionEvent, opts: { force: boolean; quiet: boolean }): Promise<string[]> {
253 const cfg = await read($, configAtom)
254 const report: string[] = []
255 try {
256 await $.ui.toast(ev.title)
257 report.push('toast')
258 } catch {
259 report.push('toast failed')
260 }
261 if (!opts.force) {
262 if (opts.quiet) return report
263 const last = await read($, lastPromptAtom)
264 if (cfg.quietAfterPromptMs > 0 && last !== null && ev.at - last < cfg.quietAfterPromptMs) {
265 report.push('quiet after your prompt')
266 return report
267 }
268 }
269 if (!cfg.native && !cfg.sound) return report
270 if (!opts.force) {
271 let allowed = false
272 await update($, lastNativeAtom, (m: Record<string, number>) => {
273 const prev = m[ev.kind]
274 if (typeof prev === 'number' && ev.at - prev < RATE_MS) return m
275 allowed = true
276 return { ...m, [ev.kind]: ev.at }
277 })
278 if (!allowed) {
279 report.push('rate-limited')
280 return report
281 }
282 }
283 if (cfg.native) report.push(await sendNative($, ev.title, ev.body))
284 if (cfg.sound) report.push(await playSound($, cfg))
285 return report
286}
287
288/** Adds an event to the inbox and schedules its delivery. Null when the mod is off. */
289async function raise($: Api, r: Raise): Promise<AttentionEvent | null> {
290 const cfg = await read($, configAtom)
291 if (!cfg.enabled) return null
292 const at = await now($)
293 seq += 1
294 const ev: AttentionEvent = {
295 id: `${r.kind}:${at}:${seq}`,
296 at,
297 kind: r.kind,
298 title: oneLine(r.title, 100),
299 body: oneLine(r.body, 160),
300 seen: r.seen === true,
301 }
302 if (r.agentId) ev.agentId = r.agentId
303 if (r.toolUseId) ev.toolUseId = r.toolUseId
304 if (r.tool) ev.tool = r.tool
305 await update($, inboxAtom, (list: AttentionEvent[]) => [ev, ...list].slice(0, INBOX_MAX))
306 const quiet = r.quiet === true
307 try {
308 $.clock.after(0, () => {
309 deliver($, ev, { force: false, quiet }).catch(() => undefined)
310 })
311 } catch {
312 // No clock: the inbox and band still have it.
313 }
314 return ev
315}
316
317/** Marks waits resolved and seen: one tool call's, or every open one when id is null. */
318async function resolveWaits($: Api, toolUseId: string | null): Promise<void> {
319 await update($, inboxAtom, (list: AttentionEvent[]) => {
320 let changed = false
321 const out = list.map(ev => {
322 const isWait = ev.kind === 'permission-waiting' || ev.kind === 'question-waiting'
323 if (!isWait || ev.resolved) return ev
324 if (toolUseId !== null && ev.toolUseId !== toolUseId) return ev
325 changed = true
326 return { ...ev, resolved: true, seen: true }
327 })
328 return changed ? out : list
329 })
330}
331
332async function markAllSeen($: Api): Promise<void> {
333 await update($, inboxAtom, (list: AttentionEvent[]) =>
334 list.some(ev => !ev.seen) ? list.map(ev => (ev.seen ? ev : { ...ev, seen: true })) : list,
335 )
336}
337
338function agentLabel(agentId: string): string {
339 const a = agents.get(agentId)
340 return a && a.label ? a.label : agentId.slice(0, 8)
341}
342
343async function onTurnComplete($: Api, e: Record<string, unknown>): Promise<void> {
344 const cfg = await read($, configAtom)
345 if (!cfg.enabled) return
346 const agentId = typeof e.agentId === 'string' && e.agentId ? e.agentId : ''
347 const duration = typeof e.durationMs === 'number' && Number.isFinite(e.durationMs) ? e.durationMs : 0
348 const reason = typeof e.reason === 'string' ? e.reason : ''
349 const aborted = e.isAborted === true || reason === 'aborted'
350 const answer = firstLine(e.answer)
351 const turnId = typeof e.turnId === 'string' ? e.turnId : ''
352
353 if (!agentId) {
354 // The turn is over: nothing is waiting on the user any more.
355 cancelPending(null)
356 await resolveWaits($, null)
357 if (reason === 'error') {
358 await raise($, { kind: 'failed', title: 'Claude stopped on an error', body: answer || 'An API error ended the turn.' })
359 } else if (aborted) {
360 // The user interrupted it, so they are here: history only.
361 await raise($, { kind: 'failed', title: `Turn interrupted after ${fmtDuration(duration)}`, body: answer, quiet: true, seen: true })
362 } else if (duration >= cfg.longTurnMs) {
363 await raise($, { kind: 'long-turn-done', title: `Claude finished (${fmtDuration(duration)})`, body: answer || 'The turn is done.' })
364 }
365 return
366 }
367
368 if (aborted || reason === 'error' || reason === 'refusal') return
369 const started = agents.get(agentId)?.startedAt
370 const ran = Math.max(duration, typeof started === 'number' ? (await now($)) - started : 0)
371 if (ran < cfg.subagentMs) return
372 if (!(await claim($, `a:${agentId}:${turnId}`))) return
373 await raise($, {
374 kind: 'subagent-done',
375 title: `Subagent done: ${agentLabel(agentId)} (${fmtDuration(ran)})`,
376 body: answer || 'The subagent returned.',
377 agentId,
378 })
379}
380
381function cancelPending(toolUseId: string | null): void {
382 for (const [id, p] of [...pending]) {
383 if (toolUseId !== null && id !== toolUseId) continue
384 try {
385 p.timer?.cancel()
386 } catch {
387 // Already fired.
388 }
389 pending.delete(id)
390 }
391}
392
393/** Holds an 'ask' for the grace period; raises it only if nothing answered it by then. */
394async function holdAsk($: Api, tool: string, toolUseId: string, input: unknown, agentId: string | undefined): Promise<void> {
395 const cfg = await read($, configAtom)
396 // In dialog mode an 'ask' only means "the mode decides": auto mode's classifier
397 // answers most itself, so nothing is raised or scheduled.
398 if (cfg.permissionSignal !== 'grace') return
399 if (agentId && !cfg.includeSubagents) return
400 if (pending.has(toolUseId)) return
401 const entry: { tool: string; input: unknown; agentId?: string; timer: { cancel(): void } | null } = { tool, input, agentId, timer: null }
402 pending.set(toolUseId, entry)
403 try {
404 entry.timer = $.clock.after(cfg.permissionGraceMs, () => {
405 if (pending.get(toolUseId) !== entry) return
406 pending.delete(toolUseId)
407 // Once the dialog event has been seen it is authoritative for the main loop.
408 if (sawPermissionRequest && !agentId) return
409 onPermissionAsk($, tool, toolUseId, input, agentId).catch(() => undefined)
410 })
411 } catch {
412 pending.delete(toolUseId)
413 }
414}
415
416/** classic.PermissionRequest: a dialog is on screen now. Matches the held ask by tool and input. */
417async function onDialogShown($: Api, tool: string, input: unknown, agentId: string): Promise<void> {
418 const cfg = await read($, configAtom)
419 if (!cfg.enabled || (agentId && !cfg.includeSubagents)) return
420 const same = (a: unknown, b: unknown) => {
421 try {
422 return JSON.stringify(a) === JSON.stringify(b)
423 } catch {
424 return false
425 }
426 }
427 const held = [...pending].filter(([, p]) => p.tool === tool && (p.agentId ?? '') === agentId)
428 const match = held.find(([, p]) => same(p.input, input)) ?? held[held.length - 1]
429 if (match) {
430 cancelPending(match[0])
431 await onPermissionAsk($, tool, match[0], input, agentId)
432 return
433 }
434 // No held ask (dialog mode): take the newest call in flight for this tool and loop,
435 // so its tool.call settling clears the wait.
436 const inFlight = [...calls].filter(([, c]) => c.tool === tool && (c.agentId ?? '') === agentId)
437 if (inFlight.length > 0) {
438 await onPermissionAsk($, tool, inFlight[inFlight.length - 1][0], input, agentId)
439 return
440 }
441 // Nothing to match: raise with a key of our own (cleared when the turn ends).
442 const at = await now($)
443 await onPermissionAsk($, tool, `pr:${tool}:${at}`, input, agentId)
444}
445
446async function onPermissionAsk($: Api, tool: string, toolUseId: string, input: unknown, agentHint?: string): Promise<void> {
447 if (!(await claim($, `p:${toolUseId}`))) return
448 const agentId = calls.get(toolUseId)?.agentId || agentHint || undefined
449 const who = agentId ? `Subagent ${agentLabel(agentId)}` : 'Claude'
450 await raise($, {
451 kind: 'permission-waiting',
452 title: `${who} needs your OK: ${tool}`,
453 body: describeInput(tool, input),
454 toolUseId,
455 tool,
456 agentId,
457 })
458}
459
460async function onQuestion($: Api, requestId: string, questions: unknown): Promise<void> {
461 if (!(await claim($, `q:${requestId}`))) return
462 const call = calls.get(requestId)
463 const agentId = call?.agentId
464 await raise($, {
465 kind: 'question-waiting',
466 title: agentId ? `Subagent ${agentLabel(agentId)} is asking you something` : 'Claude is asking you something',
467 body: firstQuestion(questions) || 'A question is waiting for your answer.',
468 toolUseId: requestId,
469 agentId,
470 })
471}
472
473async function runCommand($: Api, args: string): Promise<{ text?: string }> {
474 const words = args.trim().split(/\s+/).filter(Boolean)
475 const sub = (words[0] ?? '').toLowerCase()
476 const arg = (words[1] ?? '').toLowerCase()
477 const onOff = (v: string) => (v === 'on' ? true : v === 'off' ? false : null)
478
479 if (sub === '') {
480 await $.ui.open({ id: PANE, title: 'Attention', focus: true, closeOnEscape: true })
481 return {}
482 }
483 if (sub === 'text') return { text: inboxText(await read($, inboxAtom), await read($, configAtom)) }
484 if (sub === 'on' || sub === 'off') {
485 const cfg = await saveSetting($, { enabled: sub === 'on' })
486 return { text: cfg.enabled ? 'attention on.' : 'attention off: nothing is raised until /attention on.' }
487 }
488 if (sub === 'sound' || sub === 'native' || sub === 'band') {
489 const v = onOff(arg)
490 if (v === null) return { text: `Use /attention ${sub} on or /attention ${sub} off.` }
491 const cfg = await saveSetting($, { [sub]: v } as Partial<Config>)
492 return { text: `${sub[0].toUpperCase()}${sub.slice(1)} ${cfg[sub] ? 'on' : 'off'}.` }
493 }
494 if (sub === 'subagents') {
495 const v = onOff(arg)
496 if (v === null) return { text: 'Use /attention subagents on or /attention subagents off.' }
497 const cfg = await saveSetting($, { includeSubagents: v })
498 return { text: cfg.includeSubagents ? 'Permission waits from subagents notify too.' : 'Permission waits from subagents are ignored.' }
499 }
500 if (sub === 'signal') {
501 if (arg !== 'dialog' && arg !== 'grace') return { text: 'Use /attention signal dialog or /attention signal grace.' }
502 const cfg = await saveSetting($, { permissionSignal: arg })
503 return {
504 text:
505 cfg.permissionSignal === 'dialog'
506 ? 'Permission waits notify only when a dialog is shown.'
507 : `Permission waits notify when an ask stays unanswered for ${fmtDuration(cfg.permissionGraceMs)}.`,
508 }
509 }
510 if (sub === 'grace') {
511 const ms = parseMs(words.slice(1).join(' '))
512 if (ms === null) return { text: 'Use /attention grace <ms>, such as 4000 or 4s.' }
513 const cfg = await saveSetting($, { permissionGraceMs: ms })
514 return { text: `A permission ask notifies after ${fmtDuration(cfg.permissionGraceMs)} unanswered.` }
515 }
516 if (sub === 'long') {
517 const ms = parseMs(words.slice(1).join(' '))
518 if (ms === null) return { text: 'Use /attention long <ms>, such as 20000, 20s or 2m.' }
519 const cfg = await saveSetting($, { longTurnMs: ms })
520 return { text: `Turns of ${fmtDuration(cfg.longTurnMs)} or more notify when they finish.` }
521 }
522 if (sub === 'test') {
523 const cfg = await read($, configAtom)
524 const at = await now($)
525 seq += 1
526 const ev: AttentionEvent = {
527 id: `test:${at}:${seq}`,
528 at,
529 kind: 'test',
530 title: 'attention test',
531 body: 'If you can see and hear this, notifications work.',
532 seen: false,
533 }
534 await update($, inboxAtom, (list: AttentionEvent[]) => [ev, ...list].slice(0, INBOX_MAX))
535 const report = await deliver($, ev, { force: true, quiet: false })
536 const off = [!cfg.native ? 'native is off' : '', !cfg.sound ? 'sound is off' : ''].filter(Boolean)
537 return { text: `Test sent: ${report.join('; ')}${off.length ? ` (${off.join(', ')})` : ''}.` }
538 }
539 return { text: HELP }
540}
541
542export function register(on: On) {
543 on('session.start', async ($, e, next) => {
544 try {
545 await onSessionStart($, typeof e.cwd === 'string' ? e.cwd : '')
546 } catch {
547 // Defaults stay in place.
548 }
549 try {
550 await $.command.register({
551 name: 'attention',
552 description: 'Attention inbox: open the pane, or text, on, off, sound, native, band, subagents, signal, grace, long, test',
553 argumentHint: '[text | on | off | sound on|off | native on|off | band on|off | subagents on|off | signal dialog|grace | grace <ms> | long <ms> | test]',
554 immediate: true,
555 })
556 } catch {
557 // The name is taken: events still raise and the band still draws.
558 }
559 return next(e)
560 })
561
562 // /clear, /resume and /branch reset $.state without a session.start: load config again.
563 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
564 try {
565 await onSessionStart($, typeof e.cwd === 'string' ? e.cwd : '')
566 } catch {
567 // Defaults stay in place.
568 }
569 return next(e)
570 })
571
572 on('prompt.submit', async ($, e, next) => {
573 try {
574 const origin = e.origin as { kind?: unknown } | undefined
575 const kind = origin && typeof origin.kind === 'string' ? origin.kind : ''
576 // Task notifications and peers' messages arrive here too; only the user's own prompt counts.
577 if (!kind || HUMAN_ORIGINS.includes(kind)) {
578 const t = await now($)
579 await update($, lastPromptAtom, () => t)
580 await markAllSeen($)
581 }
582 } catch {
583 // Observing only.
584 }
585 return next(e)
586 })
587
588 on('agent.spawn', async ($, e, next) => {
589 const result = await next(e)
590 try {
591 const id = result && typeof result.agentId === 'string' ? result.agentId : ''
592 if (id) {
593 const fromCall = typeof e.tool_use_id === 'string' ? calls.get(e.tool_use_id)?.description : undefined
594 const label = oneLine(e.description || fromCall || e.name || e.subagentType || '', 40)
595 agents.set(id, { label, startedAt: await now($) })
596 }
597 } catch {
598 // The label falls back to the agent id.
599 }
600 return result
601 })
602
603 on('tool.call', async ($, e, next) => {
604 const id = typeof e.tool_use_id === 'string' ? e.tool_use_id : ''
605 if (id) {
606 const rec = e as unknown as Record<string, unknown>
607 calls.set(id, {
608 tool: typeof e.tool === 'string' ? e.tool : '',
609 agentId: typeof e.agentId === 'string' ? e.agentId : undefined,
610 description: typeof rec.description === 'string' ? rec.description : undefined,
611 })
612 }
613 try {
614 return await next(e)
615 } finally {
616 if (id) {
617 calls.delete(id)
618 cancelPending(id)
619 try {
620 await resolveWaits($, id)
621 } catch {
622 // The wait clears at the end of the turn instead.
623 }
624 }
625 }
626 })
627
628 on('tool.check', async ($, e, next) => {
629 try {
630 const id = typeof e.tool_use_id === 'string' ? e.tool_use_id : ''
631 const tool = typeof e.tool === 'string' ? e.tool : ''
632 // A query (no tool_use_id) shows no dialog; AskUserQuestion raises its own question event.
633 // Only grace mode needs the verdict, so the pre-query runs only then.
634 if (id && tool !== 'AskUserQuestion') {
635 const cfg = await read($, configAtom)
636 if (cfg.enabled && cfg.permissionSignal === 'grace') {
637 const q = await $.tool.check({ tool: e.tool, input: e.input })
638 if (q && q.decision === 'ask') await holdAsk($, tool, id, e.input, calls.get(id)?.agentId)
639 }
640 }
641 } catch {
642 // Observing only.
643 }
644 return next(e)
645 })
646
647 // Fires only when a permission dialog is really shown (not for asks auto mode decides).
648 on('classic.PermissionRequest', async ($, e, next) => {
649 try {
650 sawPermissionRequest = true
651 const tool = typeof e.tool_name === 'string' ? e.tool_name : ''
652 const agentId = typeof e.agent_id === 'string' ? e.agent_id : ''
653 if (tool && tool !== 'AskUserQuestion') await onDialogShown($, tool, e.tool_input, agentId)
654 } catch {
655 // Observing only.
656 }
657 return next(e)
658 })
659
660 on('turn.complete', async ($, e, next) => {
661 const result = await next(e)
662 try {
663 await onTurnComplete($, e as unknown as Record<string, unknown>)
664 } catch {
665 // Observing only.
666 }
667 return result
668 })
669
670 on('command.run', { command: 'attention' }, async ($, e) => {
671 try {
672 return await runCommand($, typeof e.args === 'string' ? e.args : '')
673 } catch (err) {
674 return { text: 'attention: ' + (err instanceof Error ? err.message : String(err)) }
675 }
676 })
677
678 // Drawing is pure (no state writes while ui.render dispatches), so the raise runs
679 // from a zero-delay timer; claim() inside it keeps it to the first render per id.
680 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
681 try {
682 const id = typeof e.requestId === 'string' ? e.requestId : ''
683 if (id && !queued.has(id) && (await read($, configAtom)).enabled) {
684 if (queued.size > 500) queued.clear()
685 queued.add(id)
686 const props = (e.props ?? {}) as { questions?: unknown }
687 const questions = props.questions
688 $.clock.after(0, () => {
689 onQuestion($, id, questions).catch(() => undefined)
690 })
691 }
692 } catch {
693 // Observing only: the dialog draws as usual.
694 }
695 return next(e)
696 })
697
698 // The run-in-background pill is drawn under a foreground tool that is already running,
699 // so its call was approved: drop the held ask before the grace fires. Only module
700 // memory and the timer change here (no state write), so drawing stays pure.
701 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
702 try {
703 const props = (e.props ?? {}) as { tool_use_id?: unknown }
704 const id = typeof props.tool_use_id === 'string' ? props.tool_use_id : typeof e.requestId === 'string' ? e.requestId : ''
705 if (id && pending.has(id)) cancelPending(id)
706 } catch {
707 // Observing only.
708 }
709 return next(e)
710 })
711
712 // The band, one kit line: a bold label, the state chip in the tone bandOf encodes (accent, or
713 // danger after a failure) and the waiting kinds on the right side: in the default text color when
714 // an ask waits or a turn failed, else quiet (kit.line draws its right side quiet).
715 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
716 const props = (e.props ?? {}) as { hasSurvey?: boolean }
717 const cfg = await read($, configAtom)
718 if (props.hasSurvey === true || !cfg.band || !cfg.enabled) return next(e)
719 const band = bandOf(await read($, inboxAtom))
720 if (!band) return next(e)
721 const ui = $.ui.resolve(e)
722 const { Box } = ui
723 const kit = makeKit(ui as unknown as Elements, e.surface)
724 const chips = [{ text: band.state, tone: band.tone }]
725 const drawn = (
726 band.urgent && band.kinds
727 ? Box({
728 flexDirection: 'row',
729 justifyContent: 'space-between',
730 columnGap: 2,
731 children: [kit.line('attention', chips) as ReturnType<typeof Box>, kit.text(band.kinds, { wrap: 'truncate' }) as ReturnType<typeof Box>],
732 })
733 : kit.line('attention', chips, band.kinds)
734 ) as ReturnType<typeof Box>
735 const line = Box({ key: 'attention-band', flexDirection: 'column', children: [drawn] })
736 const theirs = await next(e)
737 if (!theirs) return line
738 return Box({ flexDirection: 'column', children: [line, theirs] })
739 })
740
741 // The inbox pane, drawn with the kit: a hero for the newest unseen event, tiles, the inbox rows, buttons.
742 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
743 const inbox = await read($, inboxAtom)
744 const cfg = await read($, configAtom)
745 const at = await now($)
746 const kit = makeKit($.ui.resolve(e) as unknown as Elements, e.surface)
747 const ago = (ev: AttentionEvent) => `${fmtDuration(at - ev.at)} ago`
748 const onOff = (v: boolean) => (v ? 'on' : 'off')
749 const unseen = inbox.filter(ev => !ev.seen)
750 // The inbox is newest first, so the first unseen event is the newest.
751 const newest = unseen[0]
752 const counts = countsOf(inbox)
753 const unseenFailed = unseen.some(ev => ev.kind === 'failed')
754 const unseenAsk = unseen.some(ev => ASK_KINDS.includes(ev.kind))
755
756 const hero = newest
757 ? kit.hero({
758 kicker: 'attention',
759 title: newest.title,
760 sub: [shortKind(newest.kind), ago(newest), newest.body].filter(Boolean).join(' · '),
761 tone: toneOfKind(newest.kind),
762 })
763 : kit.hero({
764 kicker: 'attention',
765 title: 'Nothing waiting',
766 sub: inbox.length ? `${inbox.length} events in the inbox, all seen` : 'The inbox is empty.',
767 tone: 'ok',
768 })
769
770 const tiles: { label: string; value: string; tone?: Tone }[] = [
771 { label: 'Unseen', value: String(unseen.length), tone: unseen.length ? (unseenFailed ? 'danger' : 'accent') : 'muted' },
772 { label: 'Asks', value: String(counts.asks), tone: unseenAsk ? 'accent' : 'muted' },
773 { label: 'Done', value: String(counts.done), tone: counts.done ? 'ok' : 'muted' },
774 ]
775 if (counts.failed) tiles.push({ label: 'Failed', value: String(counts.failed), tone: 'danger' })
776
777 const settings = kit.row({
778 left: [
779 kit.pill(cfg.enabled ? 'on' : 'off', cfg.enabled ? 'ok' : 'danger'),
780 kit.text(
781 `native ${onOff(cfg.native)} · sound ${onOff(cfg.sound)} · band ${onOff(cfg.band)} · long turn ${fmtDuration(cfg.longTurnMs)}`,
782 { color: QUIET, wrap: 'truncate' },
783 ),
784 ],
785 })
786
787 const rows: unknown[] = inbox.length
788 ? inbox.map(ev => {
789 const tone = toneOfKind(ev.kind)
790 return kit.row({
791 key: ev.id,
792 left: [
793 kit.text(ev.seen ? '○' : '●', { color: ev.seen ? QUIET : TONES[tone].fg }),
794 kit.pill(shortKind(ev.kind), tone),
795 kit.text(ev.title, ev.seen ? { color: QUIET, wrap: 'truncate' } : { bold: true, wrap: 'truncate' }),
796 ev.body ? kit.text(ev.body, { color: QUIET, wrap: 'truncate' }) : null,
797 ],
798 right: [clockTime(ev.at), ago(ev)],
799 })
800 })
801 : [kit.text('Nothing yet. /attention test checks notify-send and the sound.', { color: QUIET })]
802
803 return kit.page([
804 hero,
805 kit.tiles(tiles),
806 settings,
807 kit.stack([kit.header(`Inbox · ${inbox.length}`, unseen.length ? `${unseen.length} unseen · newest first` : 'newest first'), ...rows]),
808 kit.buttons([
809 { key: 'seen', label: 'Mark all seen', hotkey: 'm', plain: true, onPress: () => markAllSeen($) },
810 { key: 'close', label: 'Close', hotkey: 'c', plain: true, onPress: () => $.ui.close({ id: PANE }) },
811 ]),
812 ]) as never
813 })
814}
815hooks/attention.ts 202 lines1// Pure helpers for the attention mod: config merging, text, band and argv builders.
2// Nothing here touches $, so tests call these directly.
3
4import type { AttentionEvent, Config, Kind } from '../types'
5
6export const INBOX_MAX = 50
7export const RATE_MS = 10_000
8export const SYSTEM_SOUND = '/usr/share/sounds/freedesktop/stereo/complete.oga'
9export const BUNDLED_SOUND = 'assets/ding.wav'
10export const PLAYERS = ['paplay', 'pw-play'] as const
11
12export const DEFAULTS: Config = {
13 enabled: true,
14 sound: true,
15 native: true,
16 band: true,
17 longTurnMs: 20_000,
18 subagentMs: 60_000,
19 quietAfterPromptMs: 0,
20 soundFile: '',
21 includeSubagents: false,
22 permissionGraceMs: 4000,
23 permissionSignal: 'dialog',
24}
25
26export const HELP = [
27 'attention: tells you when Claude needs you or finished something long.',
28 ' /attention open the inbox pane',
29 ' /attention text print the inbox',
30 ' /attention on|off everything on or off',
31 ' /attention sound on|off the sound',
32 ' /attention native on|off the desktop notification',
33 ' /attention band on|off the line above the prompt',
34 ' /attention long <ms> how long a turn must run to notify (20000, 20s, 2m)',
35 ' /attention subagents on|off permission waits from subagents too',
36 ' /attention signal dialog|grace what raises a permission wait (default dialog)',
37 ' /attention grace <ms> how long an ask must stay unanswered before it notifies (4000, 4s)',
38 ' /attention test send a test event through every channel',
39].join('\n')
40
41function isRecord(v: unknown): v is Record<string, unknown> {
42 return typeof v === 'object' && v !== null && !Array.isArray(v)
43}
44
45/** Keeps only well-formed Config fields from an untrusted object. */
46export function sanitize(raw: unknown): Partial<Config> {
47 if (!isRecord(raw)) return {}
48 const out: Partial<Config> = {}
49 for (const k of ['enabled', 'sound', 'native', 'band', 'includeSubagents'] as const) {
50 if (typeof raw[k] === 'boolean') out[k] = raw[k] as boolean
51 }
52 for (const k of ['longTurnMs', 'subagentMs', 'quietAfterPromptMs', 'permissionGraceMs'] as const) {
53 const n = raw[k]
54 if (typeof n === 'number' && Number.isFinite(n) && n >= 0) out[k] = Math.round(n)
55 }
56 if (raw.permissionSignal === 'dialog' || raw.permissionSignal === 'grace') out.permissionSignal = raw.permissionSignal
57 if (typeof raw.soundFile === 'string') out.soundFile = raw.soundFile.trim()
58 return out
59}
60
61/** Defaults, then each layer in order; later layers win field by field. */
62export function mergeConfig(...layers: unknown[]): Config {
63 let cfg: Config = { ...DEFAULTS }
64 for (const layer of layers) cfg = { ...cfg, ...sanitize(layer) }
65 return cfg
66}
67
68/** "20000", "20s", "1.5m" or "500ms" to milliseconds; null when it is not one. */
69export function parseMs(text: string): number | null {
70 const m = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m)?\s*$/i.exec(text)
71 if (!m) return null
72 const n = Number(m[1])
73 const unit = (m[2] ?? 'ms').toLowerCase()
74 const ms = unit === 's' ? n * 1000 : unit === 'm' ? n * 60_000 : n
75 return Number.isFinite(ms) ? Math.round(ms) : null
76}
77
78/** 42s, 2m 5s, 1h 3m. */
79export function fmtDuration(ms: number): string {
80 const s = Math.max(0, Math.round(ms / 1000))
81 if (s < 60) return `${s}s`
82 const m = Math.floor(s / 60)
83 if (m < 60) return s % 60 ? `${m}m ${s % 60}s` : `${m}m`
84 return m % 60 ? `${Math.floor(m / 60)}h ${m % 60}m` : `${Math.floor(m / 60)}h`
85}
86
87/** One line, whitespace collapsed, clipped with an ellipsis. */
88export function oneLine(text: unknown, max = 120): string {
89 const s = typeof text === 'string' ? text.replace(/\s+/g, ' ').trim() : ''
90 return s.length > max ? s.slice(0, max - 1).trimEnd() + '…' : s
91}
92
93/** The first non-empty line of a markdown answer, markup stripped. */
94export function firstLine(answer: unknown, max = 120): string {
95 if (typeof answer !== 'string') return ''
96 const line = answer
97 .split('\n')
98 .map(l => l.replace(/[#*`>_|]/g, '').trim())
99 .find(Boolean)
100 return oneLine(line ?? '', max)
101}
102
103/** What a permission wait is about, from the tool's input. */
104export function describeInput(tool: string, input: unknown): string {
105 if (!isRecord(input)) return tool
106 for (const k of ['command', 'file_path', 'notebook_path', 'url', 'pattern', 'description']) {
107 if (typeof input[k] === 'string' && input[k]) return oneLine(input[k], 100)
108 }
109 return tool
110}
111
112/** The first question an AskUserQuestion dialog shows. */
113export function firstQuestion(questions: unknown): string {
114 if (!Array.isArray(questions) || questions.length === 0) return ''
115 const q = questions[0]
116 if (isRecord(q)) return oneLine(q.question ?? q.header ?? '', 120)
117 return oneLine(q, 120)
118}
119
120/** The notify-send command used when $.ui.notify is not there (Claude Code before 2.1.295). */
121export function notifyArgv(title: string, body: string): string[] {
122 return ['notify-send', '-a', 'Claude Code', '-i', 'dialog-information', oneLine(title, 80) || 'Claude Code', oneLine(body, 200)]
123}
124
125/** The player commands to try for a sound file, in order. */
126export function soundArgvs(file: string): string[][] {
127 return PLAYERS.map(p => [p, file])
128}
129
130const SHORT: Record<Kind, string> = {
131 'permission-waiting': 'permission',
132 'question-waiting': 'question',
133 failed: 'failed',
134 'long-turn-done': 'done',
135 'subagent-done': 'subagent',
136 test: 'test',
137}
138
139const ORDER: Kind[] = ['permission-waiting', 'question-waiting', 'failed', 'long-turn-done', 'subagent-done', 'test']
140
141export function shortKind(kind: Kind): string {
142 return SHORT[kind] ?? kind
143}
144
145/** The band for the unseen events, or null when nothing is unseen: the whole text, the state chip
146 * ("3 waiting", "1 new"), the kinds for the right side, the kit tone of the chip (danger after a
147 * failure, else accent, the Claude orange) and whether it is urgent: an ask waiting or a failure
148 * draws the kinds in the default text color, anything else quiet. */
149export function bandOf(
150 inbox: readonly AttentionEvent[],
151): { text: string; state: string; kinds: string; tone: 'accent' | 'danger'; urgent: boolean } | null {
152 const unseen = inbox.filter(ev => !ev.seen)
153 if (unseen.length === 0) return null
154 const parts: string[] = []
155 for (const kind of ORDER) {
156 const evs = unseen.filter(ev => ev.kind === kind)
157 if (evs.length === 0) continue
158 const name = SHORT[kind]
159 if (kind === 'permission-waiting') {
160 const tools = [...new Set(evs.map(ev => ev.tool || '?'))]
161 parts.push(`${name} (${tools.join(', ')})`)
162 } else {
163 parts.push(evs.length > 1 ? `${name} x${evs.length}` : name)
164 }
165 }
166 const waiting = unseen.some(ev => ev.kind === 'permission-waiting' || ev.kind === 'question-waiting')
167 const state = `${unseen.length} ${waiting ? 'waiting' : 'new'}`
168 const kinds = parts.join(', ')
169 const failed = unseen.some(ev => ev.kind === 'failed')
170 return { text: `attention ● ${state}: ${kinds}`, state, kinds, tone: failed ? 'danger' : 'accent', urgent: waiting || failed }
171}
172
173/** HH:MM:SS in local time. */
174export function clockTime(at: number): string {
175 try {
176 return new Date(at).toTimeString().slice(0, 8)
177 } catch {
178 return '--:--:--'
179 }
180}
181
182/** One inbox row as plain text. */
183export function rowText(ev: AttentionEvent): string {
184 const mark = ev.seen ? '○' : '●'
185 const body = ev.body ? ` · ${ev.body}` : ''
186 return `${mark} ${clockTime(ev.at)} ${shortKind(ev.kind).padEnd(10)} ${ev.title}${body}`
187}
188
189/** /attention text: settings, then the inbox newest first. */
190export function inboxText(inbox: readonly AttentionEvent[], cfg: Config): string {
191 const unseen = inbox.filter(ev => !ev.seen).length
192 const head =
193 `attention ${cfg.enabled ? 'on' : 'off'} · native ${cfg.native ? 'on' : 'off'} · sound ${cfg.sound ? 'on' : 'off'}` +
194 ` · band ${cfg.band ? 'on' : 'off'} · long turn ${fmtDuration(cfg.longTurnMs)} · subagent ${fmtDuration(cfg.subagentMs)}` +
195 ` · signal ${cfg.permissionSignal}` +
196 (cfg.permissionSignal === 'grace' ? ` · grace ${fmtDuration(cfg.permissionGraceMs)}` : '') +
197 ` · subagent asks ${cfg.includeSubagents ? 'on' : 'off'}` +
198 (cfg.quietAfterPromptMs > 0 ? ` · quiet ${fmtDuration(cfg.quietAfterPromptMs)} after a prompt` : '')
199 if (inbox.length === 0) return `${head}\nInbox empty.`
200 return [`${head}`, `Inbox: ${inbox.length} events, ${unseen} unseen (newest first)`, ...inbox.map(rowText)].join('\n')
201}
202hooks/kit.ts 955 lines1// kit.ts: the shared UI kit (kit v3).
2//
3// The module has no imports and touches no mods API: every helper takes the elements a hook got
4// from `$.ui.resolve(e)` and the surface (`e.surface`) and returns an element tree.
5//
6// const k = makeKit($.ui.resolve(e) as unknown as Elements, e.surface)
7// return k.stack([k.hero({ kicker: 'right now', title: 'Reading client.test.js' }), k.tiles([...])], 1)
8//
9// What v2 changed on purpose (v1 painted grey fills and dimmed grey text, unreadable on the desktop):
10// - colors are theme keys (suggestion, success, warning, error, claude, inactive) that the host
11// tunes for light and dark; PALETTE below swaps them for named colors in one line;
12// - no dimColor, no backgroundColor, no yellow, white, gray, black or subtle on content: secondary
13// text is `inactive`, state is a bold `● word` chip (glyph and word, so color is never the only cue);
14// - hero and card are one round frame (paddingX 1), tiles are single-border boxes with the label
15// over a bold value; terminal and desktop draw the same structure (the Svg doodle is desktop-only);
16// - kicker, section and tile labels keep the caller's case (v1 upper-cased kicker and section);
17// - additive: line(label, chips, right?) draws one band line.
18//
19// Calibration (claude plugin test, Claude Code 2.1.294): the test harness validates the tree a
20// ui.render hook returns (a Text with a bogus prop makes `$.ui.mount` reject on both surfaces), but
21// not how the desktop paints a theme key. A Button given `plain: false` throws when built (plain and
22// autoFocus take only `true`), so the kit leaves false and undefined props out; a Box inside a Text
23// is refused. doodle() draws its Svg on the desktop only (a pane holding only an Svg opens empty in
24// the terminal).
25//
26// What v3 adds (strictly additive: every v2 export above renders as before). The desktop paints
27// Svg, and its light theme made the v1 fills unreadable, so v3 draws small pictures as Svg wherever
28// the element table has one (desktop, mobile, vscode) and as text on the terminal:
29// - pill(text, tone?): a fully rounded Svg chip (PILL 'svg', the default); PILL 'fill' draws a
30// theme-key backgroundColor under `inverseText` instead (the only fill in the kit); the v2
31// `● text` chip on the terminal;
32// - header(label, right?): an upper-cased `inactive` section label with a right-hand count;
33// - icon(name, tone?), typeBadge(fileName) and progress(ratio, opts?): Svg icons, file-type badges
34// and a rounded bar; a glyph, a bold label and the v2 bar on the terminal;
35// - buttons() items take an optional `variant` ('primary' | 'secondary');
36// - nav(items, currentKey): a pane's nav bar, the current item bold Text, the others plain Buttons;
37// - pure helpers: pillSvg, shortPath and extOf; TONE_HEX, the Svg colors of icons and bars, and
38// CHIP_HEX, the darker pill fills that keep white text at 4.5:1 or more.
39// Svg markup cannot read theme keys, so it uses fixed mid-tone hexes that read on light and dark
40// grounds (never a color word), and each distinct drawing is built once and cached.
41//
42// What v3.1 adds (spacing; additive): on the desktop, blocks stacked with no gap touch border to
43// border and wrapped rows sit line on line. So every wrapping row (buttons, tiles, nav) gets rowGap 1,
44// buttons get columnGap 2, and two new columns space a pane out:
45// - page(children): a pane's top-level column, one row between blocks;
46// - list(children): a column of repeated cards or blocks, one row between them.
47// Rows inside one card or block stay tight (stack with no gap); stack() keeps its props. Bands that
48// draw buttons() are one row, so the new gaps change nothing there. buttons() items also take
49// `autoFocus` (passed only when true).
50
51export const KIT_VERSION = '3.1.0'
52
53/** How pill() draws on a surface with Svg: 'svg' (a fully rounded chip) or 'fill' (a theme-key
54 * background). makeKit's `pill` option overrides it for one kit. */
55export const PILL: 'fill' | 'svg' = 'svg'
56
57/** kit v3: makeKit's options. `pill` overrides PILL for this kit. */
58export type KitOptions = { pill?: 'fill' | 'svg' }
59
60/** 'theme': theme keys that follow the person's light or dark theme. 'named': named colors, for a
61 * host that does not paint theme keys (never yellow: warn is red plus its glyph). One-line switch. */
62export const PALETTE: 'theme' | 'named' = 'theme'
63
64export type Surface = 'terminal' | 'desktop' | (string & {})
65export type Tone = 'info' | 'ok' | 'warn' | 'danger' | 'muted' | 'accent'
66export type Role = 'main' | 'recon' | 'execute' | 'verify' | 'hard' | 'reviewer' | 'other'
67export type DoodleState = 'running' | 'done' | 'failed' | 'waiting' | 'idle'
68/** The tones of pill() and icon(): the six tones plus violet. */
69export type PillTone = Tone | 'violet'
70export type IconName =
71 | 'eye'
72 | 'pencil'
73 | 'plus'
74 | 'branch'
75 | 'search'
76 | 'folder'
77 | 'folder-open'
78 | 'file'
79 | 'shield'
80 | 'flag'
81 | 'check'
82 | 'dot'
83 | 'clock'
84/** What `$.ui.resolve(e)` returns, loosely typed so the kit compiles without the engine's types. */
85export type Elements = Record<string, (props: Record<string, unknown>) => unknown>
86
87export type Kit = {
88 hero(p: { kicker?: string; title: string; sub?: string; icon?: string; tone?: Tone }): unknown
89 card(p: { title?: string; right?: string; tone?: Tone; children: unknown[] }): unknown
90 section(label: string, right?: string): unknown
91 tiles(items: { label: string; value: string; tone?: Tone }[]): unknown
92 badge(text: string, tone?: Tone): unknown
93 legend(items: { tone: Tone; label: string }[]): unknown
94 bar(ratio: number, opts?: { width?: number; tone?: Tone }): unknown
95 /** One row, left side truncating, right side muted. `align: 'center'` (kit v3) centers a row holding a pill. */
96 row(p: { left: unknown[] | string; right?: unknown[] | string; key?: string; align?: 'center' }): unknown
97 kv(label: string, value: string, tone?: Tone): unknown
98 buttons(
99 items: {
100 key: string
101 label: string
102 hotkey?: string
103 onPress: (e?: unknown) => unknown
104 plain?: boolean
105 /** kit v3: the Button's variant, passed only when set. */
106 variant?: 'primary' | 'secondary'
107 /** kit 3.1: focus this Button when the pane opens; passed only when true. */
108 autoFocus?: boolean
109 }[],
110 ): unknown
111 doodle(role: Role, state?: DoodleState): unknown
112 stack(children: unknown[], gap?: number): unknown
113 /** kit 3.1: a pane's top-level column, one row between its blocks; null children are dropped. */
114 page(children: unknown[]): unknown
115 /** kit 3.1: a column of repeated cards or blocks, one row between them; null children are dropped. */
116 list(children: unknown[]): unknown
117 text(s: string, props?: Record<string, unknown>): unknown
118 /**
119 * One band line: `label ● a ● b right · text`. A chip is `{ text, tone? }`, a string (a muted
120 * chip) or an element such as badge(); at most four are drawn. `right` is a string or a list
121 * joined with ` · `, drawn `inactive` and truncated. One row high.
122 */
123 line(label: string, chips: unknown[], right?: string | string[]): unknown
124 /** kit v3: a rounded chip (Svg surfaces) or the v2 `● text` chip (terminal). */
125 pill(text: string, tone?: Tone | 'violet'): unknown
126 /** kit v3: one row, the label upper-cased, bold and `inactive`; `right` upper-cased, right-aligned. */
127 header(label: string, right?: string): unknown
128 /** kit v3: a 16×16 stroke icon (Svg surfaces) or one glyph in the tone (terminal). */
129 icon(name: IconName, tone?: Tone | 'violet'): unknown
130 /** kit v3: a file-type badge ('JS', 'PDF', …) for a file name; null when the name has no extension. */
131 typeBadge(fileName: string): unknown
132 /** kit v3: a rounded bar (Svg surfaces; `width` in CSS pixels, absent: the slot's width) or bar(). */
133 progress(ratio: number, opts?: { width?: number; tone?: Tone }): unknown
134 /**
135 * kit v3: a pane's nav bar, one row keyed 'nav': the item whose key is `currentKey` as bold Text in the accent
136 * tone (not pressable), the others plain Buttons with their hotkeys (`1: Stage`); one without onPress quiet Text.
137 */
138 nav(items: { key: string; label: string; hotkey?: string; onPress?: () => unknown }[], currentKey: string): unknown
139 surface: Surface
140}
141
142/** The quiet color: secondary text, labels, units, timestamps, frame and tile borders. */
143const QUIET = 'inactive'
144
145const THEME_TONES: Record<Tone, string> = {
146 info: 'suggestion',
147 ok: 'success',
148 warn: 'warning',
149 danger: 'error',
150 muted: QUIET,
151 accent: 'claude',
152}
153
154const NAMED_TONES: Record<Tone, string> = {
155 info: 'blue',
156 ok: 'green',
157 warn: 'red',
158 danger: 'red',
159 muted: QUIET,
160 accent: 'magenta',
161}
162
163const TONE_COLORS = PALETTE === 'named' ? NAMED_TONES : THEME_TONES
164
165/** fg: the color of the tone, a theme key (a named color when PALETTE is 'named'). bg equals fg: v2
166 * draws no fills; the field stays so v1 callers compile. */
167export const TONES: Record<Tone, { fg: string; bg: string }> = {
168 info: { fg: TONE_COLORS.info, bg: TONE_COLORS.info },
169 ok: { fg: TONE_COLORS.ok, bg: TONE_COLORS.ok },
170 warn: { fg: TONE_COLORS.warn, bg: TONE_COLORS.warn },
171 danger: { fg: TONE_COLORS.danger, bg: TONE_COLORS.danger },
172 muted: { fg: TONE_COLORS.muted, bg: TONE_COLORS.muted },
173 accent: { fg: TONE_COLORS.accent, bg: TONE_COLORS.accent },
174}
175
176/**
177 * kit v3: the Svg colors of icons and bars, a mid-tone hex per tone that reads on light and dark grounds
178 * (Svg markup cannot read theme keys). warn and accent share the Claude orange: the theme's warning
179 * color is unreadable as a fill. A pill fills with CHIP_HEX instead.
180 *
181 * info #3B82F6 ok #22A35A warn #D97757 danger #E5484D accent #D97757 muted #8A8F98
182 * violet #8B5CF6
183 */
184export const TONE_HEX: Record<PillTone, string> = {
185 info: '#3B82F6',
186 ok: '#22A35A',
187 warn: '#D97757',
188 danger: '#E5484D',
189 accent: '#D97757',
190 muted: '#8A8F98',
191 violet: '#8B5CF6',
192}
193
194/**
195 * kit v3: a pill's fill per tone, darker than TONE_HEX so its white text has a contrast ratio of at
196 * least 4.5:1 (WCAG AA for small text). The ratios against white:
197 *
198 * info #2563EB 5.17 ok #15803D 5.02 warn #C2410C 5.18 danger #B91C1C 6.47 accent #C2410C 5.18
199 * muted #5B6170 6.20 violet #7C3AED 5.70
200 */
201export const CHIP_HEX: Record<PillTone, string> = {
202 info: '#2563EB',
203 ok: '#15803D',
204 warn: '#C2410C',
205 danger: '#B91C1C',
206 accent: '#C2410C',
207 muted: '#5B6170',
208 violet: '#7C3AED',
209}
210
211/** kit v3: the theme key a pill fills with, per tone (warn is 'claude': a 'warning' fill is unreadable). */
212const PILL_FILLS: Record<PillTone, string> = {
213 info: 'suggestion',
214 ok: 'success',
215 warn: 'claude',
216 danger: 'error',
217 accent: 'claude',
218 muted: QUIET,
219 violet: 'permission',
220}
221
222/** kit v3: the stroke of an icon drawn without a tone. */
223const ICON_STROKE = '#6B7280'
224/** kit v3: the track of progress(): the muted hex at quarter opacity. */
225const TRACK_HEX = '#8A8F98'
226/** kit v3: the text on a light fill (the amber JS badge); light text everywhere else. */
227const INK_DARK = '#1A1A1A'
228const INK_LIGHT = '#FFFFFF'
229
230/** kit v3: icon markup on a 16×16 grid, stroked with `currentColor`'s stand-in `S`. */
231const ICONS: Record<IconName, string> = {
232 eye: '<path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z"/><circle cx="8" cy="8" r="2"/>',
233 pencil: '<path d="M11 2.5 13.5 5 5.5 13H3v-2.5z"/><path d="M9.5 4 12 6.5"/>',
234 plus: '<path d="M8 3v10M3 8h10"/>',
235 branch:
236 '<circle cx="4.5" cy="3.5" r="1.5"/><circle cx="4.5" cy="12.5" r="1.5"/><circle cx="11.5" cy="5" r="1.5"/>' +
237 '<path d="M4.5 5v6M11.5 6.5c0 2.5-2 3.5-4 3.8-1.6.2-3 .7-3 .7"/>',
238 search: '<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5 14 14"/>',
239 folder: '<path d="M1.75 3.25h4.5l1.5 1.75h6.5v7.75H1.75z"/>',
240 'folder-open': '<path d="M1.75 12.75v-9.5h4.5l1.5 1.75h5v2"/><path d="M1.75 12.75 3.75 7h10.5l-2 5.75z"/>',
241 file: '<path d="M9 1.75H3v12.5h10v-8.5z"/><path d="M9 1.75v4h4"/>',
242 shield: '<path d="M8 1.75 13 3.75v4c0 3.2-2.2 5.4-5 6.5-2.8-1.1-5-3.3-5-6.5v-4z"/>',
243 flag: '<path d="M3 14.5V2"/><path d="M3 2.5h8.5L9.75 5.5l1.75 3H3"/>',
244 check: '<path d="M3 8.5 6 11.5 13 4.5"/>',
245 dot: '<circle cx="8" cy="8" r="3" fill="S"/>',
246 clock: '<circle cx="8" cy="8" r="6"/><path d="M8 4.5V8l2.5 1.5"/>',
247}
248
249/** kit v3: the terminal glyph of each icon. */
250const ICON_GLYPHS: Record<IconName, string> = {
251 eye: '◉',
252 pencil: '✎',
253 plus: '+',
254 branch: '⎇',
255 search: '⌕',
256 folder: '▸',
257 'folder-open': '▾',
258 file: '·',
259 shield: '⛨',
260 flag: '⚑',
261 check: '✓',
262 dot: '●',
263 clock: '◷',
264}
265
266/** kit v3: file-type badges by extension: the label, its Svg fill and its terminal tone. */
267const FILE_TYPES: { exts: string[]; label: string; fill: string; tone: Tone }[] = [
268 { exts: ['js', 'jsx', 'mjs', 'cjs'], label: 'JS', fill: '#F0C419', tone: 'accent' },
269 { exts: ['ts', 'tsx'], label: 'TS', fill: '#3178C6', tone: 'info' },
270 { exts: ['pdf'], label: 'PDF', fill: '#E5484D', tone: 'danger' },
271 { exts: ['md', 'mdx'], label: 'MD', fill: '#4A7BD0', tone: 'info' },
272 { exts: ['doc', 'docx'], label: 'DOC', fill: '#2B579A', tone: 'info' },
273 { exts: ['csv'], label: 'CSV', fill: '#22A35A', tone: 'ok' },
274 { exts: ['json'], label: 'JSON', fill: '#64748B', tone: 'muted' },
275 { exts: ['yml', 'yaml'], label: 'YML', fill: '#64748B', tone: 'muted' },
276 { exts: ['html'], label: 'HTML', fill: '#E8743B', tone: 'accent' },
277 { exts: ['css'], label: 'CSS', fill: '#4A7BD0', tone: 'info' },
278 { exts: ['py'], label: 'PY', fill: '#3776AB', tone: 'info' },
279 { exts: ['sh'], label: 'SH', fill: '#64748B', tone: 'muted' },
280 { exts: ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'], label: 'IMG', fill: '#8B5CF6', tone: 'accent' },
281 { exts: ['txt'], label: 'TXT', fill: '#64748B', tone: 'muted' },
282]
283const OTHER_FILL = '#64748B'
284
285const GLYPHS: Record<Role, string> = {
286 main: '◉',
287 recon: '◈',
288 execute: '▣',
289 verify: '✓',
290 hard: '◆',
291 reviewer: '◎',
292 other: '●',
293}
294
295const STATE_TONES: Record<DoodleState, Tone> = {
296 running: 'info',
297 done: 'ok',
298 failed: 'danger',
299 waiting: 'warn',
300 idle: 'muted',
301}
302
303const STATE_COLORS: Record<DoodleState, string> = {
304 running: TONES[STATE_TONES.running].fg,
305 done: TONES[STATE_TONES.done].fg,
306 failed: TONES[STATE_TONES.failed].fg,
307 waiting: TONES[STATE_TONES.waiting].fg,
308 idle: TONES[STATE_TONES.idle].fg,
309}
310
311// Desktop doodles (svg fills are not text colors, so they stay hex): 12×12 cells, 3 px each. B = body (by role), A = accent (by state), . = empty.
312const BODY_FILL: Record<Role, string> = {
313 main: '#6c63ff',
314 recon: '#1f9eaa',
315 execute: '#3b7ddd',
316 verify: '#2f9e55',
317 hard: '#b5523b',
318 reviewer: '#c08a1e',
319 other: '#7d8590',
320}
321
322const ACCENT_FILL: Record<DoodleState, string> = {
323 running: '#22d3ee',
324 done: '#4ade80',
325 failed: '#f87171',
326 waiting: '#facc15',
327 idle: '#e5e7eb',
328}
329
330const SPRITES: Record<Role, string[]> = {
331 // a crowned blob
332 main: [
333 '............',
334 '..A..AA..A..',
335 '..AAAAAAAA..',
336 '..BBBBBBBB..',
337 '.BBBBBBBBBB.',
338 '.BBAABBAABB.',
339 '.BBAABBAABB.',
340 '.BBBBBBBBBB.',
341 '.BBBAAAABBB.',
342 '.BBBBBBBBBB.',
343 '..BB....BB..',
344 '..BB....BB..',
345 ],
346 // a one-eyed scout drone
347 recon: [
348 '....BBBB....',
349 '..BBBBBBBB..',
350 '.BBBBBBBBBB.',
351 'BBBBAAAABBBB',
352 'BBBAAAAAABBB',
353 'BBBAA..AABBB',
354 'BBBAAAAAABBB',
355 'BBBBAAAABBBB',
356 '.BBBBBBBBBB.',
357 '..BBBBBBBB..',
358 '...B.BB.B...',
359 '..B..BB..B..',
360 ],
361 // a boxy robot with an antenna
362 execute: [
363 '.....AA.....',
364 '.....BB.....',
365 '.BBBBBBBBBB.',
366 '.BBBBBBBBBB.',
367 '.BBAABBAABB.',
368 '.BBAABBAABB.',
369 '.BBBBBBBBBB.',
370 '.BBAAAAAABB.',
371 '.BBBBBBBBBB.',
372 '...BBBBBB...',
373 '..BBB..BBB..',
374 '..BB....BB..',
375 ],
376 // a shield with a check mark
377 verify: [
378 '.BBBBBBBBBB.',
379 'BBBBBBBBBBBB',
380 'BBBBBBBBBBBB',
381 'BBBBBBBBBAAB',
382 'BBBBBBBBAABB',
383 'BAABBBBAABBB',
384 'BBAABBAABBBB',
385 '.BBAAAABBBB.',
386 '.BBBAABBBBB.',
387 '..BBBBBBBB..',
388 '...BBBBBB...',
389 '.....BB.....',
390 ],
391 // a gem golem
392 hard: [
393 '.....BB.....',
394 '....BBBB....',
395 '...BBBBBB...',
396 '..BBBBBBBB..',
397 '.BBAABBAABB.',
398 'BBBAABBAABBB',
399 'BBBBBBBBBBBB',
400 '.BBBBAABBBB.',
401 '..BBBBBBBB..',
402 '...BBBBBB...',
403 '....BBBB....',
404 '.....BB.....',
405 ],
406 // a round face with glasses
407 reviewer: [
408 '...BBBBBB...',
409 '..BBBBBBBB..',
410 '.BBBBBBBBBB.',
411 'BBBBBBBBBBBB',
412 'BAAAABBAAAAB',
413 'BA..AAAA..AB',
414 'BAAAABBAAAAB',
415 'BBBBBBBBBBBB',
416 'BBBBAAAABBBB',
417 '.BBBBBBBBBB.',
418 '..BBBBBBBB..',
419 '...BBBBBB...',
420 ],
421 // a ghost
422 other: [
423 '....BBBB....',
424 '..BBBBBBBB..',
425 '.BBBBBBBBBB.',
426 '.BBBBBBBBBB.',
427 'BBBAABBAABBB',
428 'BBBAABBAABBB',
429 'BBBBBBBBBBBB',
430 'BBBBBBBBBBBB',
431 'BBBBBBBBBBBB',
432 'BBBBBBBBBBBB',
433 'BBB.BBBB.BBB',
434 'B....BB....B',
435 ],
436}
437
438const CELL = 3
439const GRID = 12
440
441// ---------------------------------------------------------------- formatters
442
443/** '≈$0.08', '≈$1.2', '≈$12'; '$0' for zero, '<$0.01' below a cent, '—' when unknown. */
444export function money(usd: number | null | undefined): string {
445 if (typeof usd !== 'number' || !Number.isFinite(usd)) return '—'
446 const v = Math.max(0, usd)
447 if (v === 0) return '$0'
448 if (v < 0.005) return '<$0.01'
449 const cents = Math.round(v * 100) / 100
450 if (cents < 1) return '≈$' + cents.toFixed(2)
451 const dimes = Math.round(v * 10) / 10
452 if (dimes < 10) return '≈$' + dimes.toFixed(1)
453 return '≈$' + String(Math.round(v))
454}
455
456/** '0', '950', '1.5k', '37k', '1.2M', '15M'. */
457export function tokensShort(n: number): string {
458 if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return '0'
459 if (n < 1000) return String(Math.round(n))
460 const k = n / 1000
461 if (k < 10) {
462 const r = Math.round(k * 10) / 10
463 if (r < 10) return trimZero(r.toFixed(1)) + 'k'
464 }
465 if (Math.round(k) < 1000) return String(Math.round(k)) + 'k'
466 const m = n / 1_000_000
467 const r = Math.round(m * 10) / 10
468 if (r < 10) return trimZero(r.toFixed(1)) + 'M'
469 return String(Math.round(m)) + 'M'
470}
471
472/** '0:40', '10:05', '1:02:03'. */
473export function clock(ms: number): string {
474 const total = typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0
475 const h = Math.floor(total / 3600)
476 const m = Math.floor((total % 3600) / 60)
477 const s = total % 60
478 if (h > 0) return `${h}:${pad2(m)}:${pad2(s)}`
479 return `${m}:${pad2(s)}`
480}
481
482/** '4%', '100%'. Not capped at 100 (a budget can run over); negative and NaN read 0%. */
483export function pct(ratio: number): string {
484 if (typeof ratio !== 'number' || !Number.isFinite(ratio) || ratio <= 0) return '0%'
485 return String(Math.round(ratio * 100)) + '%'
486}
487
488/** '━━━━╌╌╌╌╌╌' for 0.4 at width 10; the ratio is clamped to 0..1. */
489export function barText(ratio: number, width: number = 10): string {
490 const w = typeof width === 'number' && Number.isFinite(width) && width >= 1 ? Math.floor(width) : 10
491 const filled = Math.round(clamp01(ratio) * w)
492 return '━'.repeat(filled) + '╌'.repeat(w - filled)
493}
494
495function clamp01(ratio: number): number {
496 if (typeof ratio !== 'number' || !Number.isFinite(ratio)) return 0
497 return Math.min(1, Math.max(0, ratio))
498}
499
500function pad2(n: number): string {
501 return n < 10 ? '0' + n : String(n)
502}
503
504function trimZero(s: string): string {
505 return s.endsWith('.0') ? s.slice(0, -2) : s
506}
507
508function toneOf(tone: unknown, fallback: Tone): Tone {
509 return typeof tone === 'string' && Object.prototype.hasOwnProperty.call(TONES, tone) ? (tone as Tone) : fallback
510}
511
512function roleOf(role: unknown): Role {
513 return typeof role === 'string' && Object.prototype.hasOwnProperty.call(GLYPHS, role) ? (role as Role) : 'other'
514}
515
516function stateOf(state: unknown): DoodleState {
517 return typeof state === 'string' && Object.prototype.hasOwnProperty.call(STATE_COLORS, state)
518 ? (state as DoodleState)
519 : 'idle'
520}
521
522/** Drops undefined and false props: some props (Button plain, autoFocus) take only `true`. */
523function clean(props: Record<string, unknown>): Record<string, unknown> {
524 const out: Record<string, unknown> = {}
525 for (const k of Object.keys(props)) {
526 const v = props[k]
527 if (v === undefined || v === false) continue
528 out[k] = v
529 }
530 return out
531}
532
533function present(children: unknown[]): unknown[] {
534 return children.filter(c => c !== null && c !== undefined && c !== false && c !== '')
535}
536
537/** The SVG document of one doodle: a 12×12 sprite at 3 px per cell, runs merged per row. */
538export function doodleSvg(role: Role, state: DoodleState = 'idle'): string {
539 const r = roleOf(role)
540 const s = stateOf(state)
541 const fills: Record<string, string> = { B: BODY_FILL[r], A: ACCENT_FILL[s] }
542 const size = GRID * CELL
543 let rects = ''
544 SPRITES[r].forEach((line, y) => {
545 let x = 0
546 while (x < GRID) {
547 const ch = line[x] ?? '.'
548 let end = x + 1
549 while (end < GRID && line[end] === ch) end++
550 const fill = fills[ch]
551 if (fill) {
552 rects += `<rect x="${x * CELL}" y="${y * CELL}" width="${(end - x) * CELL}" height="${CELL}" fill="${fill}"/>`
553 }
554 x = end
555 }
556 })
557 return (
558 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}" ` +
559 `shape-rendering="crispEdges">${rects}</svg>`
560 )
561}
562
563/** The terminal glyph of a role, as doodle() draws it and as the desktop doodle's alt text. */
564export function glyph(role: Role): string {
565 return GLYPHS[roleOf(role)]
566}
567
568// ---------------------------------------------------------------- kit v3: pure helpers and Svg
569
570/** 'ts' for 'src/a.test.TS', '' for 'Makefile', '.gitignore' or 'notes.'. Lower case, no dot. */
571export function extOf(name: string): string {
572 const base = String(name ?? '').split(/[\\/]/).pop() ?? ''
573 const dot = base.lastIndexOf('.')
574 return dot > 0 && dot < base.length - 1 ? base.slice(dot + 1).toLowerCase() : ''
575}
576
577/**
578 * A path for display: inside `root` it is relative ('.' for the root itself), inside `home` it is
579 * '~/' and the rest, anywhere else '…/' and its last two segments, so no absolute path from outside
580 * the project is drawn. A relative path is already short and comes back as given. Trailing slashes
581 * are dropped.
582 */
583export function shortPath(path: string, root: string, home: string): string {
584 const p = trimSlash(path)
585 const r = trimSlash(root)
586 const h = trimSlash(home)
587 if (!p.startsWith('/')) return p
588 if (r && p === r) return '.'
589 if (r && r !== '/' && p.startsWith(r + '/')) return p.slice(r.length + 1)
590 if (h && p === h) return '~'
591 if (h && h !== '/' && p.startsWith(h + '/')) return '~/' + p.slice(h.length + 1)
592 return '…/' + p.split('/').filter(Boolean).slice(-2).join('/')
593}
594
595function trimSlash(s: string): string {
596 const t = String(s ?? '')
597 return t.length > 1 ? t.replace(/\/+$/, '') || '/' : t
598}
599
600const SVG_CACHE = new Map<string, string>()
601const SVG_CACHE_MAX = 512
602
603/** Builds each distinct drawing once; the cache is cleared when it grows past its bound. */
604function cached(key: string, build: () => string): string {
605 const hit = SVG_CACHE.get(key)
606 if (hit !== undefined) return hit
607 if (SVG_CACHE.size >= SVG_CACHE_MAX) SVG_CACHE.clear()
608 const svg = build()
609 SVG_CACHE.set(key, svg)
610 return svg
611}
612
613function xml(s: string): string {
614 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
615}
616
617/** Dark text on a light fill (relative luminance above one half), light text otherwise. */
618function inkOn(hex: string): string {
619 const n = parseInt(hex.slice(1), 16)
620 const lin = (c: number) => {
621 const v = c / 255
622 return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)
623 }
624 const l = 0.2126 * lin((n >> 16) & 255) + 0.7152 * lin((n >> 8) & 255) + 0.0722 * lin(n & 255)
625 return l > 0.5 ? INK_DARK : INK_LIGHT
626}
627
628function pillToneOf(tone: unknown, fallback: PillTone): PillTone {
629 return typeof tone === 'string' && Object.prototype.hasOwnProperty.call(TONE_HEX, tone) ? (tone as PillTone) : fallback
630}
631
632function iconOf(name: unknown): IconName {
633 return typeof name === 'string' && Object.prototype.hasOwnProperty.call(ICONS, name) ? (name as IconName) : 'dot'
634}
635
636const FONT = 'font-family="system-ui,-apple-system,Segoe UI,sans-serif"'
637
638/** A label in a rounded rect: the shape of a pill (height 18) and of a type badge (height 16). */
639function labelSvg(text: string, fill: string, width: number, height: number, rx: number, size: number, ink = inkOn(fill)): string {
640 return (
641 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">` +
642 `<rect width="${width}" height="${height}" rx="${rx}" fill="${fill}"/>` +
643 `<text x="${width / 2}" y="${height / 2}" text-anchor="middle" dominant-baseline="central" ${FONT} ` +
644 `font-size="${size}" font-weight="700" fill="${ink}">${xml(text)}</text></svg>`
645 )
646}
647
648/** A pill's height in CSS pixels; its corners are fully rounded (rx = height / 2). */
649const PILL_HEIGHT = 18
650
651/** A pill's width in CSS pixels: about 6.5 per character of 11 px bold text plus 7 each side. */
652function pillWidth(text: string): number {
653 return Math.max(PILL_HEIGHT, Math.round(text.length * 6.5 + 14))
654}
655
656/** The Svg of a pill: a fully rounded rect in the tone's CHIP_HEX fill, white text. */
657export function pillSvg(text: string, tone: Tone | 'violet' = 'muted'): string {
658 const t = pillToneOf(tone, 'muted')
659 const s = String(text)
660 return cached(`pill|${t}|${s}`, () => labelSvg(s, CHIP_HEX[t], pillWidth(s), PILL_HEIGHT, PILL_HEIGHT / 2, 11, INK_LIGHT))
661}
662
663function iconSvg(name: IconName, hex: string): string {
664 return cached(`icon|${name}|${hex}`, () =>
665 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="none" ` +
666 `stroke="${hex}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">` +
667 `${ICONS[name].replace(/"S"/g, `"${hex}"`)}</svg>`,
668 )
669}
670
671function fileType(fileName: string): { label: string; fill: string; tone: Tone } | null {
672 const ext = extOf(fileName)
673 if (!ext) return null
674 const known = FILE_TYPES.find(t => t.exts.includes(ext))
675 return known ?? { label: ext.slice(0, 3).toUpperCase(), fill: OTHER_FILL, tone: 'muted' }
676}
677
678/** A type badge is 16 high and about 7 px per letter plus 8 wide. */
679function badgeWidth(label: string): number {
680 return Math.max(16, label.length * 7 + 8)
681}
682
683function badgeSvg(label: string, fill: string): string {
684 return cached(`badge|${label}|${fill}`, () => labelSvg(label, fill, badgeWidth(label), 16, 3, 10))
685}
686
687/** A track and a fill, 6 high. Without a width the markup is 100% wide and stretches to the slot. */
688function progressSvg(ratio: number, hex: string, width: number | undefined): string {
689 const full = width ?? 100
690 const fill = Math.round(clamp01(ratio) * full * 10) / 10
691 return cached(`progress|${fill}|${hex}|${width ?? ''}`, () => {
692 const size = width === undefined ? 'width="100%" height="6" preserveAspectRatio="none"' : `width="${width}" height="6"`
693 return (
694 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${full} 6" ${size}>` +
695 `<rect width="${full}" height="6" rx="3" fill="${TRACK_HEX}" opacity="0.25"/>` +
696 (fill > 0 ? `<rect width="${fill}" height="6" rx="3" fill="${hex}"/>` : '') +
697 `</svg>`
698 )
699 })
700}
701
702// ---------------------------------------------------------------- the kit
703
704const MAX_CHIPS = 4
705
706export function makeKit(ui: Elements, surface: Surface, opts: KitOptions = {}): Kit {
707 const desktop = surface === 'desktop'
708 const pillMode = opts.pill ?? PILL
709
710 const el = (type: string, props: Record<string, unknown>, children?: unknown[]): unknown => {
711 const make = ui[type]
712 const p = clean(props)
713 if (children !== undefined) p.children = present(children)
714 return make(p)
715 }
716 const T = (s: string, props: Record<string, unknown> = {}): unknown => el('Text', props, [s])
717 const color = (tone: Tone | undefined): string | undefined => (tone ? TONES[tone].fg : undefined)
718 const pieces = (part: unknown[] | string, right: boolean): unknown[] =>
719 (typeof part === 'string' ? [part] : part).map(c =>
720 typeof c === 'string' || typeof c === 'number'
721 ? T(String(c), { color: right ? QUIET : undefined, wrap: 'truncate' })
722 : c,
723 )
724 const frame = (children: unknown[], tone: Tone | undefined): unknown =>
725 el('Box', { flexDirection: 'column', borderStyle: 'round', borderColor: color(tone) ?? QUIET, paddingX: 1 }, children)
726 const chip = (text: string, tone: Tone): unknown =>
727 T('● ' + text, { color: TONES[tone].fg, bold: tone !== 'muted', wrap: 'truncate' })
728 // kit v3: Svg wherever the table has it (desktop, mobile, vscode); the terminal draws text.
729 const svgOk = surface !== 'terminal' && typeof ui.Svg === 'function'
730 const svg = (source: string, alt: string, width?: number, height?: number): unknown =>
731 el('Svg', { source, alt: alt || ' ', width, height })
732 /** The theme key of a v3 tone on the terminal: the v2 tone's color, violet 'permission'. */
733 const fg = (tone: PillTone): string => (tone === 'violet' ? PILL_FILLS.violet : TONES[tone].fg)
734
735 const kit: Kit = {
736 surface,
737
738 hero(p) {
739 const tone = p.tone ? toneOf(p.tone, 'info') : undefined
740 const title = (p.icon ? p.icon + ' ' : '') + p.title
741 return frame(
742 [
743 p.kicker ? T(p.kicker, { color: QUIET, wrap: 'truncate' }) : null,
744 T(title, { bold: true, color: color(tone), wrap: 'truncate' }),
745 p.sub ? T(p.sub, { color: QUIET, wrap: 'wrap' }) : null,
746 ],
747 tone,
748 )
749 },
750
751 card(p) {
752 const tone = p.tone ? toneOf(p.tone, 'info') : undefined
753 const head =
754 p.title || p.right
755 ? el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
756 p.title ? T(p.title, { bold: true, wrap: 'truncate' }) : null,
757 p.right ? T(p.right, { color: QUIET, wrap: 'truncate' }) : null,
758 ])
759 : null
760 return frame([head, ...(Array.isArray(p.children) ? p.children : [])], tone)
761 },
762
763 section(label, right) {
764 return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
765 T(String(label), { bold: true, wrap: 'truncate' }),
766 right ? T(right, { color: QUIET, wrap: 'truncate' }) : null,
767 ])
768 },
769
770 tiles(items) {
771 return el(
772 'Box',
773 { flexDirection: 'row', flexWrap: 'wrap', columnGap: 1, rowGap: 1 },
774 items.map(it => {
775 const tone = it.tone ? toneOf(it.tone, 'info') : undefined
776 return el(
777 'Box',
778 { flexDirection: 'column', flexGrow: 1, borderStyle: 'single', borderColor: QUIET, paddingX: 1 },
779 [
780 T(it.label, { color: QUIET, wrap: 'truncate' }),
781 T(it.value, { bold: true, color: color(tone), wrap: 'truncate' }),
782 ],
783 )
784 }),
785 )
786 },
787
788 badge(text, tone) {
789 return chip(text, toneOf(tone, 'muted'))
790 },
791
792 legend(items) {
793 const parts: unknown[] = []
794 items.forEach((it, i) => {
795 if (i > 0) parts.push(' ')
796 parts.push(T('●', { color: TONES[toneOf(it.tone, 'muted')].fg }))
797 parts.push(' ' + it.label)
798 })
799 return el('Text', { wrap: 'wrap' }, parts)
800 },
801
802 bar(ratio, opts = {}) {
803 const t = toneOf(opts.tone, 'info')
804 return el('Text', { wrap: 'truncate' }, [
805 T(barText(ratio, opts.width ?? 10), { color: TONES[t].fg }),
806 T(' ' + pct(ratio), { color: QUIET }),
807 ])
808 },
809
810 row(p) {
811 const left = pieces(p.left, false)
812 const right = p.right === undefined || p.right === '' ? [] : pieces(p.right, true)
813 const alignItems = p.align === 'center' ? 'center' : undefined
814 return el('Box', { key: p.key, flexDirection: 'row', justifyContent: 'space-between', alignItems, columnGap: 1 }, [
815 el('Box', { flexDirection: 'row', flexShrink: 1, alignItems, columnGap: 1 }, left),
816 right.length ? el('Box', { flexDirection: 'row', alignItems, columnGap: 1 }, right) : null,
817 ])
818 },
819
820 kv(label, value, tone) {
821 const t = tone ? toneOf(tone, 'info') : undefined
822 return el('Text', { wrap: 'truncate' }, [
823 T(label + ' ', { color: QUIET }),
824 T(value, { bold: true, color: color(t) }),
825 ])
826 },
827
828 buttons(items) {
829 return el(
830 'Box',
831 { flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, rowGap: 1 },
832 items.map(b =>
833 el('Button', {
834 key: b.key,
835 label: b.label,
836 hotkey: b.hotkey,
837 onPress: b.onPress,
838 plain: b.plain === true ? true : undefined,
839 variant: b.variant === 'primary' || b.variant === 'secondary' ? b.variant : undefined,
840 autoFocus: b.autoFocus === true ? true : undefined,
841 }),
842 ),
843 )
844 },
845
846 doodle(role, state = 'idle') {
847 const r = roleOf(role)
848 const s = stateOf(state)
849 if (desktop && typeof ui.Svg === 'function') {
850 return el('Svg', { alt: GLYPHS[r], width: GRID * CELL, height: GRID * CELL, source: doodleSvg(r, s) })
851 }
852 return T(GLYPHS[r], { color: STATE_COLORS[s], bold: s === 'running' })
853 },
854
855 stack(children, gap = 0) {
856 return el('Box', { flexDirection: 'column', rowGap: gap > 0 ? Math.floor(gap) : undefined }, children)
857 },
858
859 page(children) {
860 return el('Box', { flexDirection: 'column', rowGap: 1 }, Array.isArray(children) ? children : [])
861 },
862
863 list(children) {
864 return el('Box', { flexDirection: 'column', rowGap: 1 }, Array.isArray(children) ? children : [])
865 },
866
867 text(s, props = {}) {
868 return el('Text', props, [String(s)])
869 },
870
871 line(label, chips, right) {
872 const drawn = (Array.isArray(chips) ? chips : [])
873 .filter(c => c !== null && c !== undefined && c !== false && c !== '')
874 .slice(0, MAX_CHIPS)
875 .map(c => {
876 if (typeof c === 'string') return chip(c, 'muted')
877 if (isChipSpec(c)) return chip(c.text, toneOf(c.tone, 'muted'))
878 return c
879 })
880 const quiet = (Array.isArray(right) ? right.filter(Boolean).join(' · ') : right ?? '').trim()
881 return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 2 }, [
882 el('Box', { flexDirection: 'row', flexShrink: 1, columnGap: 2 }, [
883 T(String(label), { bold: true, wrap: 'truncate' }),
884 ...drawn,
885 ]),
886 quiet ? T(quiet, { color: QUIET, wrap: 'truncate' }) : null,
887 ])
888 },
889
890 // ------------------------------------------------------------ kit v3
891
892 pill(text, tone) {
893 const t = pillToneOf(tone, 'muted')
894 const s = String(text)
895 if (!svgOk) return t === 'violet' ? T('● ' + s, { color: fg(t), bold: true, wrap: 'truncate' }) : chip(s, t)
896 if (pillMode === 'svg') return svg(pillSvg(s, t), s, pillWidth(s), PILL_HEIGHT)
897 return T(` ${s} `, { backgroundColor: PILL_FILLS[t], color: 'inverseText', bold: true, wrap: 'truncate' })
898 },
899
900 header(label, right) {
901 return el('Box', { flexDirection: 'row', justifyContent: 'space-between', columnGap: 1 }, [
902 T(String(label).toUpperCase(), { bold: true, color: QUIET, wrap: 'truncate' }),
903 right ? T(String(right).toUpperCase(), { color: QUIET, wrap: 'truncate' }) : null,
904 ])
905 },
906
907 icon(name, tone) {
908 const n = iconOf(name)
909 const t = tone === undefined ? undefined : pillToneOf(tone, 'muted')
910 if (svgOk) return svg(iconSvg(n, t ? TONE_HEX[t] : ICON_STROKE), n, 16, 16)
911 return T(ICON_GLYPHS[n], { color: t ? fg(t) : QUIET })
912 },
913
914 typeBadge(fileName) {
915 const type = fileType(fileName)
916 if (!type) return null
917 if (svgOk) {
918 return svg(badgeSvg(type.label, type.fill), type.label, badgeWidth(type.label), 16)
919 }
920 return T(type.label, { bold: true, color: TONES[type.tone].fg })
921 },
922
923 progress(ratio, opts = {}) {
924 if (!svgOk) return kit.bar(ratio, opts)
925 const t = toneOf(opts.tone, 'info')
926 const width = typeof opts.width === 'number' && Number.isFinite(opts.width) && opts.width >= 1 ? Math.floor(opts.width) : undefined
927 // Without a width the Svg gets no width prop and markup 100% wide: the user's look confirms the
928 // desktop draws it the full width of its slot.
929 return svg(progressSvg(ratio, TONE_HEX[t], width), pct(ratio), width, 6)
930 },
931
932 nav(items, currentKey) {
933 return el(
934 'Box',
935 { key: 'nav', flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, rowGap: 1 },
936 (Array.isArray(items) ? items : []).map(it => {
937 if (it.key === currentKey) return T(String(it.label), { bold: true, color: TONES.accent.fg, wrap: 'truncate' })
938 if (typeof it.onPress !== 'function') return T(String(it.label), { color: QUIET, wrap: 'truncate' })
939 return el('Button', { key: it.key, label: String(it.label), hotkey: it.hotkey, onPress: it.onPress, plain: true })
940 }),
941 )
942 },
943 }
944 return kit
945}
946
947function isChipSpec(c: unknown): c is { text: string; tone?: Tone } {
948 return (
949 typeof c === 'object' &&
950 c !== null &&
951 !('type' in c) &&
952 typeof (c as { text?: unknown }).text === 'string'
953 )
954}
955types/index.d.ts 66 lines1export type Kind = 'long-turn-done' | 'permission-waiting' | 'question-waiting' | 'subagent-done' | 'failed' | 'test'
2
3/** One moment that wanted the user's attention. */
4export type AttentionEvent = {
5 id: string
6 /** Epoch milliseconds, from $.clock.now(). */
7 at: number
8 kind: Kind
9 title: string
10 /** One line. */
11 body: string
12 /** false until the user is back (prompt.submit), the wait resolves, or Mark all seen. */
13 seen: boolean
14 agentId?: string
15 toolUseId?: string
16 /** The tool a permission wait is for. */
17 tool?: string
18 /** A permission or question wait that has been answered or ended. */
19 resolved?: boolean
20}
21
22/** Defaults, then ~/.claude/attention.json, then <cwd>/.claude/attention.json, then /attention settings. */
23export type Config = {
24 /** Master switch (/attention on|off). */
25 enabled: boolean
26 /** Play a sound with each notification. */
27 sound: boolean
28 /** Send a desktop notification ($.ui.notify or notify-send). */
29 native: boolean
30 /** Draw the one-line band above the prompt while unseen events exist. */
31 band: boolean
32 /** A main-loop turn at least this long raises long-turn-done. */
33 longTurnMs: number
34 /** A subagent that ran at least this long raises subagent-done. */
35 subagentMs: number
36 /** No native notification or sound this long after the user's own prompt. */
37 quietAfterPromptMs: number
38 /** Raise permission waits from subagents too (off: the user can't approve those calls). */
39 includeSubagents: boolean
40 /**
41 * What raises a permission wait. "dialog": only classic.PermissionRequest (a dialog really
42 * shown). "grace": a tool.check 'ask' still unanswered after permissionGraceMs, or the dialog.
43 */
44 permissionSignal: 'dialog' | 'grace'
45 /** How long a main-loop 'ask' must stay unanswered before it notifies (auto mode approves most within it). */
46 permissionGraceMs: number
47 /** A sound file to play instead of the system or bundled one; "" for the default. */
48 soundFile: string
49}
50
51declare module 'claude-code' {
52 interface PluginState {
53 'lk-attention': {
54 inbox: AttentionEvent[]
55 config: Config
56 /** Keys already raised (one per tool_use_id, render id or agent turn), newest last. */
57 claimed: string[]
58 /** Last native notification time per kind, for the rate limit. */
59 lastNative: Record<string, number>
60 /** When the user last submitted a prompt; null before the first. */
61 lastPromptAt: number | null
62 cwd: string
63 }
64 }
65}
66