SLOPSHOPPER

loopkeeper Attention

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.

newpanebandrowsguardcommand
★ 1v0.3.1MITupdated 2026-10-12connectwithub/loopkeeper/plugins/lk-attention
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lk-attention
│ ┃ Attention ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ attention │ ⏺ Read(src/auth.ts) │ ┃ │ Claude finished (42s) │ ⎿ Read 6 lines │ ┃ │ done · 0s ago · Done. I made refresh │ ⏺ Update(src/auth.ts) │ ┃ │ reject expired claims, added an audit │ ⎿ Added 2 lines, removed 1 line │ ┃ │ call, and created src/audit.ts. One test │ ⏺ Bash(bun test) │ ┃ │ still fails (revoke… │ ⎿ 3 pass, 1 fail │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ┌──────────────────────────────────────────┐ │ ┃ │ Unseen │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ 1 │ │ ┃ └──────────────────────────────────────────┘ › /attention │ ┃ │ ┃ ● on native on · sound on · band on · long t │ ┃ │ ┃ INBOX · 1 1 UNSEEN · NEWEST FIRST │ ┃ ● ● done Claude finished (42s) Done. I made │ ┃ │ ┃ m: Mark all seen c: Close │ attention ● 1 new done ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
attention ● 1 new done ⟨Claude Code's own drawing⟩
Pane · Attention
╭──────────────────────────────────────────────────────────╮ │ attention │ │ Claude finished (42s) │ │ done · 0s ago · Done. I made refresh reject expired │ │ claims, added an audit call, and created src/audit.ts. │ │ One test still fails (revoke… │ ╰──────────────────────────────────────────────────────────╯ ┌──────────────────────────────────────────────────────────┐ │ Unseen │ │ 1 │ └──────────────────────────────────────────────────────────┘ ● on native on · sound on · band on · long turn 20s INBOX · 1 1 UNSEEN · NEWEST FIRST ● ● done Claude finished (42s) Done. I made refresh reject e m: Mark all seen c: Close
README

lk-attention

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

  • You switch to another window while Claude works and want to know the moment a permission prompt or a question waits on you.
  • Long turns or background subagents finish while you are away, and you want a banner and a sound when they do.
  • You want one list of what happened while you were not looking.

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:

  1. Toast: $.ui.toast(title), always.
  2. Native notification: $.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.
  3. Sound: your 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).

Install

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.

Commands

| 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.

Pane

/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.

Configuration

Two optional JSON files, merged in this order (later wins, field by field):

  1. Defaults
  2. ~/.claude/attention.json
  3. <project>/.claude/attention.json
  4. Settings you changed with /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.

Auto mode and the permission signal

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.

Linux notes

  • 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.
  • The freedesktop sound theme (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.

Credits

  • From charlie947's done-ping (no license, so these are ideas re-implemented, with no code copied): the long-turn test on 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.
  • From zycck's plan-progress (MIT): a short bundled clip through $.audio.play({ asset }), with a player of our own as the fallback.
  • From hamzafer's review-watch (MIT): claiming an event in one update() callback, so a repeat (a second tool.check, a re-drawn dialog) can never raise it twice.
  • From community mods: 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.
  • House style shared with the loopkeeper set: $.state atoms, config in $.store plus JSON files, a one-line band that keeps what other plugins draw under it, and the test layout.

Footprint

What claude plugin validate --strict . reports for this plugin (Claude Code 2.1.294), and what that means:

AreaWhat the plugin has
Hooks moduleOne: ./register.ts, in hooks/hooks.json.
Hook eventssession.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).
RefusalsNone. 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).
State6 atoms under lk-attention.
StoreThe settings saved by /attention.
EnvReads HOME; writes nothing.

What it runs:

  • Processes: notify-send (when $.ui.notify is missing) and paplay or pw-play for the sound. Linux only.
  • Files: reads ~/.claude/attention.json and <project>/.claude/attention.json; checks that the sound files exist; writes nothing.
  • Model calls: none.
  • Network: none.

Notes for directory reviewers

The directory's validator listed these items for this plugin. Each bullet says what the code does and where (hooks/register.ts unless named).

  • Programs it runs (policy holds 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.

  • Local data and the conversation (policy holds 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.
  • The permission hooks (policy hold 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.
  • The name (policy hold 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.
  • Fields in 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 (warning ICON_MISSING). Added at .claude-plugin/icon.png (512 × 512); its source is docs/assets/icons/lk-attention.svg in the repo.
  • Calls named in text (note 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.
  • A call the directory does not know (note 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.
  • Its command (note MOD_REGISTERS_SURFACES). It registers one slash command, /attention, at session start (line 550). It registers no tools or agents.
  • Classic hooks (note 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).
  • Hooks on events that are also calls (note 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.

Limits

  • $.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.
  • In grace mode, an approved call is recognised as running only from the terminal's ToolProgress pill. On the desktop app, an approved call that runs past the grace can still notify once, so dialog mode is the default.
  • A permission wait is cleared when its tool call settles: approved and run, or refused. Between your approval and the end of a long-running tool, the inbox can still list it as waiting, though the band stops counting it once you submit a prompt or the turn ends.
  • The question event is raised from a zero-delay timer, because a ui.render hook can't write state. It comes a moment after the dialog appears.
  • Whether you are "clearly active" is judged only from your own prompts and interrupts. The mod can't see whether the terminal window has focus, so a long turn you watched to the end still notifies, unless you set quietAfterPromptMs.
  • Subagent labels come from 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.
  • The inbox lives in $.state: it keeps its last 50 events through a mod reload but not into a new session.
  • Bash commands and questions appear in the notification body, clipped to one line. Your desktop's notification history keeps them.

Files

.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

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.

Source 4 files
hooks/register.ts 815 lines
1// 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}
815
hooks/attention.ts 202 lines
1// 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}
202
hooks/kit.ts 955 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
955
types/index.d.ts 66 lines
1export 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