Chips above the prompt for each skill, subagent, MCP server, permission decision, hook and rule that activated this session. Press one to see why.

<img alt="session-activity — see what took part in your Claude Code session" src="../../assets/images/session-activity-logo-light.svg" width="128">
A Claude Code mod that shows what took part in your session. A row of chips above the prompt names each skill, subagent, MCP server, permission decision, settings hook and rule file that activated. Press a chip to see why it is there. The detail card opens above the row, next to the prompt, or the pane opens when the band is too short for it.
↯ block-secrets BLOCKED ◉ code-reviewer ⇌ github ×3 ⚷ git push +10 more
╭──────────────────────────────────────────────────────────────────────────────────────────╮
│ > _ │
╰──────────────────────────────────────────────────────────────────────────────────────────╯
16 active · /session-activity


/session-activity opens every activation grouped by type, with filters, muting and live settingschip_themeRequires Claude Code 2.1.299 or later. On an older version the mod draws nothing and says to run claude update. Tested with 2.1.302.
Install the mod from main, which holds released code only:
claude plugin install session-activity --marketplace tinesoft/claude-mods@main
Cloud sessions do not install plugins from your settings. If your cloud environment's setup script can run this command, the mod works there in text form:
claude plugin install session-activity --marketplace tinesoft/claude-mods@main
This is unverified until the cloud spike confirms that a setup script can install a plugin and that the text shows in the transcript.
| Surface | What you get |
|---|---|
Terminal (claude, editor terminals) | The full UI, drawn with glyphs and one-row tinted chips |
| Claude Desktop app, Code tab | The full UI with Svg icons, exact colours, rounded bordered chips, a darker tint on hover, and clicks on every control |
| VS Code | The pane and the status line, drawn as on Desktop. Claude Code draws no band here, so open the pane with /session-activity |
| Mobile | The pane and the status line, with an N active chip (and a red dot when something stopped) heading the pane. No band |
| claude.ai/code cloud sessions | Text only: /session-activity replies with a summary, and text_summary adds a line after each answer |
claude.ai chat, claude -p | Nothing is drawn. Hooks still run, so /session-activity replies in text where it is available |
Remote Control from claude.ai shows the chips in the terminal on your machine. Desktop WSL sessions do not load plugins.
| Chip | Glyph | Recorded when | States |
|---|---|---|---|
| Skill | ▤ | Claude expands a skill (/name, the Skill tool, a preload) | active |
| Agent | ◉ | Claude starts a subagent, keyed by its type | running, ok, error |
| MCP server | ⇌ | Claude calls one of the server's tools; the pane lists the tools | ok, error |
| Permission | ⚷ | A rule or hook allowed a call, a call was denied, or Claude asked you | ok, running (asked), denied |
| Hook | ↯ | A settings hook configured for the event ran, or the event was blocked | ok, blocked |
| Rule | ▣ | A CLAUDE.md or .claude/rules file was loaded | active |
Optional types are off by default: permission decisions (⚷; denied calls still show), slash commands (/), memory (✎), context use and compaction (◔), model switches (≡), background Bash tasks (▶), and checkpoints (⚑, not yet supported). The plugin option tags every chip that a plugin supplied with a dim via <plugin>; it is a tag, not a chip of its own.
A memory chip appears when Claude writes or edits CLAUDE.md, CLAUDE.local.md or a markdown file in a memory/ folder, labelled Remembered: <file>.
A chip that was just activated is bold with a ● for four seconds. A running agent or asked permission cycles ◐◓◑◒. A failed one shows a red ▲ and an ERROR badge (bold red Error on a tinted chip, with a dashed red border on Desktop). When an action is blocked or denied, one toast names it, such as ↯ block-secrets blocked Bash; turn it off with toast_on_block. The status line under the prompt keeps the count: 16 active · /session-activity.
Blocked, denied and errored chips sit first in the row and stay visible even when their type is hidden or muted. Each status is also a text badge, so the colour is never the only signal.
Chips are tinted with the design's light or dark colours. With chip_theme on auto, the terminal follows a light or dark Claude Code theme (daltonized included). Under the auto theme, which asks the terminal for its colours in a way a mod cannot see, it reads COLORFGBG, then the system appearance, then settles on dark. Desktop, VS Code and mobile follow the system appearance, then the Claude Code theme, then light. The ANSI themes keep coloured text. Set chip_theme to light or dark to pin the colours, or chip_style to text to always use coloured text.
A gear (⚙) at the right end of the row opens the pane on its settings view, and pressing it again closes the pane. It sits beside Claude Code's own [-] collapse mark.
Press Ctrl+X then Tab to focus the band. In fullscreen mode and in the Desktop app a click works too.
| Keys | Action |
|---|---|
Ctrl+X Tab | Focus the band |
Enter | Open the selected chip's detail card |
Enter again | Close the card (as does its x: Close) |
o | Copy the source path (a mod cannot open an editor) |
m | Mute the chip, hiding it from the band |
1 to 9, 0 | Open a chip, or +N more (off by default; chip_hotkeys) |
Digit hotkeys are off by default, because a bare digit typed into an empty prompt would open a chip. A mod cannot read Esc in the band, so the card closes with the chip or its x: Close button, not with Esc.
Open it with /session-activity, +N more, or a chip when the band is too short for the card (the pane then opens on that item).
1). A header counts the items and activations. Filter by type with the letter hotkeys (a all, s skills, g agents, p MCP, k permissions, h hooks, r rules, with a count each). Rows are grouped by type. Every row shows its why line and a Mute or Unmute button; stopped and errored rows always show, so they carry a note instead. Select a row to see where it came from: m mutes or unmutes it in the band, c copies its path, and Customize jumps to the settings. A muted row is dim with a Muted badge.2). The master switch, a live preview drawn by the band renderer with Chip row and Collapsed summary, one switch per type in "Shown by default" and "Optional", then chip theme, chip style, digit hotkeys, the toast and the text summary. Reset to defaults returns to your plugin options and Turn everything on shows every type.The plugin options (/config, or pluginConfigs in settings) are the defaults. Changes made in the pane override them, persist across sessions, and are reloaded when a session starts, including after /clear.
| Option | Default | Effect |
|---|---|---|
enabled | true | Show chips above the prompt |
layout | row | row of chips, or one summary chip such as ▤2 ◉2 ⇌2 · ● 1 blocked |
max_chips | 6 | Chips shown before +N more (1 to 20) |
show_<type> | core on, optional off | Show that type: skills, agents, mcp, permissions, hooks, rules, commands, memory, plugins, context, checkpoints, model, background |
chip_theme | auto | light or dark chip colours, or auto to follow the Claude Code theme, then the terminal, then the system |
chip_style | tinted | tinted chips, or text for coloured text only |
chip_hotkeys | false | Digit hotkeys on chips |
toast_on_block | true | Toast when an action is blocked or denied |
text_summary | false | Where nothing can be drawn, add a one-line Activity: ▤ docx · ◉ code-reviewer · ↯ block-secrets BLOCKED beneath each answer |
Chips belong to one conversation. /clear and /resume empty them; muting lasts until then too.
The mod makes no network calls and sends nothing anywhere. It reads session data to draw chips, and keeps that data in the session.
What it reads
/clear and /resume. The conversation text is not stored; a chip keeps only a label (a command, a path, a server name) and a one-line reason.$.fs.read). Contents are not kept.COLORFGBG environment variable, to guess whether the terminal is light or dark. No other variable and no credential is read.What it stores
$.store, on your machine, so they survive a restart. Nothing is uploaded.c in the pane copies a path to the clipboard when you press it.What it runs
Every five seconds, to read the system light or dark setting, it runs one fixed command per platform, with a 1.5 second timeout. No argument comes from the conversation or from your files.
| Platform | Command |
|---|---|
| macOS | defaults read -g AppleInterfaceStyle |
| Linux | gsettings get org.gnome.desktop.interface color-scheme |
| Windows | reg query HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize /v AppsUseLightTheme |
It starts no other program. If a command is missing, the mod falls back to COLORFGBG and then to the Claude Code theme.
What its hooks decide
tool.check runs when Claude Code asks whether a tool call may run. The hook only watches: it calls next, records the verdict as a permission chip, and returns that same verdict. It never allows, denies or asks for itself, so the decision stays with you, your rules and your permission mode.tool.call, agent.spawn, skill.prompt, command.run and turn.complete also pass their result through unchanged. The one exception is turn.complete with text_summary on, which, where nothing can be drawn (cloud sessions), adds a one-line activity summary beneath the answer.classic.* observes the settings hooks that run, and returns what each one answered without changing it./session-activity is the only command the mod registers. It opens the pane, or prints a text summary where no pane can draw.<Event> hooks chip when several match. Claude Code does not expose a hook's exit code to a mod, so a hook chip is running, ok or blocked and never shows an error. Hooks that plugins configure are not in your settings, so they show only when they block.Write, Edit or MultiEdit./session-activity.auto theme the tone is a guess from COLORFGBG or the system, and dark when neither is known; pin it with chip_theme.defaults (macOS), gsettings (Linux) or reg (Windows) every five seconds. Where none is available the guess stops at the Claude Code theme.From the repository root:
pnpm nx dev session-activity # claude --plugin-dir mods/session-activity
pnpm nx run-many -t lint typecheck validate test
hooks/register.tsx holds every hook and every $ call. src/ holds pure helpers that take plain data: model.ts (the activation list and its reducer), classify.ts (event data to chips), render.tsx (band row and detail card), pane.tsx (the pane tabs), icons.ts (the Desktop Svg icons), summary.ts (the text summaries) and theme.ts (the registry of types with their glyphs, colours and settings copy). Tests in tests/ run under claude plugin test and drive the mod through the engine's events and drawings.
Contributions are welcome! See the repository README for setup, branch flow, commit conventions and releases.
MIT © 2026 Tine Kondo. Not affiliated with or endorsed by Anthropic.
hooks/register.tsx 537 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface, Timer } from 'claude-code'
3
4import type { Overrides, PaneView, Settings, Toggle } from '../types'
5import {
6 fromBackground,
7 fromCommand,
8 fromCompaction,
9 fromContextUsage,
10 fromHook,
11 fromInstructions,
12 fromMcpCall,
13 hostServerOf,
14 fromMemoryWrite,
15 fromModel,
16 fromPermission,
17 fromSkill,
18 fromSpawn,
19 hookCommands,
20 hookTarget,
21 lineCount,
22 stopNotice,
23} from '../src/classify'
24import {
25 bandCandidates,
26 finishAgent,
27 forget,
28 inPane,
29 nextFadeAt,
30 record,
31 recordOnce,
32 resolveSettings,
33 settleAsked,
34 type Observed,
35} from '../src/model'
36import { parseColorFgBg, SYSTEM_PROBES } from '../src/appearance'
37import { ENGINE_FLOOR, meetsFloor } from '../src/engine'
38import { renderPane, type PaneFlag } from '../src/pane'
39import { lineSummary, markdownSummary } from '../src/summary'
40import { renderBand, type Ui } from '../src/render'
41import { resolveTone, SPIN_MS, TOGGLE_ORDER, type Tone } from '../src/theme'
42
43const PANE = 'session-activity'
44const COMMAND = 'session-activity'
45const STORE_OVERRIDES = 'overrides'
46const TOAST_MS = 4000
47const APPEARANCE_MS = 5_000
48const PROBE_TIMEOUT_MS = 1500
49const DETAIL_PANE_ROWS = 10
50
51type Options = Readonly<Record<string, unknown>>
52
53const activations = atom({ plugin: 'session-activity', key: 'activations' } as const, [])
54const muted = atom({ plugin: 'session-activity', key: 'muted' } as const, [])
55const selected = atom({ plugin: 'session-activity', key: 'selected' } as const, null)
56const paneView = atom({ plugin: 'session-activity', key: 'paneView' } as const, 'activity')
57const filter = atom({ plugin: 'session-activity', key: 'filter' } as const, 'all')
58const hostServers = atom({ plugin: 'session-activity', key: 'hostServers' } as const, [])
59const model = atom({ plugin: 'session-activity', key: 'model' } as const, null)
60const overrides = atom({ plugin: 'session-activity', key: 'overrides' } as const, null)
61const appearance = atom({ plugin: 'session-activity', key: 'appearance' } as const, {
62 system: null,
63 colorFgBg: null,
64})
65
66let spinner: Timer | undefined
67let appearanceTimer: Timer | undefined
68let fadeTimer: Timer | undefined
69
70async function probeSystem($: EngineInterface): Promise<Tone | null> {
71 for (const probe of SYSTEM_PROBES) {
72 try {
73 const result = await $.process.run(probe.argv, { timeoutMs: PROBE_TIMEOUT_MS })
74 const tone = probe.read(result)
75 if (tone !== undefined) return tone
76 } catch {
77 // The command is not installed here, or this surface cannot run commands.
78 }
79 }
80 return null
81}
82
83async function refreshAppearance($: EngineInterface) {
84 const current = {
85 system: await probeSystem($),
86 colorFgBg: parseColorFgBg(await $.env.get('COLORFGBG')) ?? null,
87 }
88 const old = await read($, appearance)
89 if (old.system === current.system && old.colorFgBg === current.colorFgBg) return
90 await update($, appearance, () => current)
91 // A write from the timer leaves the band and pane drawn in the old tone on Desktop.
92 $.ui.invalidate('ui.render')
93}
94
95/** Reads the system appearance off the render path, now and every five seconds. */
96function watchAppearance($: EngineInterface) {
97 appearanceTimer?.cancel()
98 appearanceTimer = $.clock.every(APPEARANCE_MS, () => refreshAppearance($))
99 $.clock.after(0, () => refreshAppearance($))
100}
101
102async function readOverrides($: EngineInterface): Promise<Overrides> {
103 const held = await read($, overrides)
104 if (held !== null) return held
105 const stored = await $.store.get(STORE_OVERRIDES)
106 return stored !== null && typeof stored === 'object' ? (stored as Overrides) : {}
107}
108
109const settingsOf = async ($: EngineInterface, options: Options) =>
110 resolveSettings(options, await readOverrides($))
111
112async function refresh($: EngineInterface, options: Options) {
113 const list = await read($, activations)
114 const settings = await settingsOf($, options)
115 const active = settings.enabled ? bandCandidates(list, settings, await read($, muted)).length : 0
116 $.ui.status(active > 0 ? `${active} active · /session-activity` : undefined)
117 const isRunning = list.some(a => a.status === 'running' && inPane(a, settings))
118 if (isRunning && spinner === undefined) {
119 spinner = $.clock.every(SPIN_MS, () => $.ui.invalidate('ui.render'))
120 } else if (!isRunning && spinner !== undefined) {
121 spinner.cancel()
122 spinner = undefined
123 }
124}
125
126async function commit(
127 $: EngineInterface,
128 options: Options,
129 change: (list: Parameters<typeof record>[0]) => ReturnType<typeof record>,
130) {
131 await update($, activations, change)
132 await refresh($, options)
133 await scheduleFade($, options)
134}
135
136/** One timer redraws as the next fresh dots fade, instead of one per activation. */
137async function scheduleFade($: EngineInterface, options: Options) {
138 if (fadeTimer !== undefined) return
139 const now = await $.clock.now()
140 const at = nextFadeAt(await read($, activations), await settingsOf($, options), now)
141 if (at === undefined) return
142 fadeTimer = $.clock.after(at - now, async () => {
143 fadeTimer = undefined
144 $.ui.invalidate('ui.render')
145 await scheduleFade($, options)
146 })
147}
148
149async function notify(
150 $: EngineInterface,
151 options: Options,
152 stopped: Parameters<typeof stopNotice>[0],
153) {
154 const text = stopNotice(stopped)
155 if (text !== undefined && (await settingsOf($, options)).toastOnBlock) {
156 $.ui.toast(text, { timeoutMs: TOAST_MS })
157 }
158}
159
160async function observe(
161 $: EngineInterface,
162 options: Options,
163 seen: Observed | undefined,
164 { isSettling = false } = {},
165) {
166 if (seen === undefined) return
167 const at = await $.clock.now()
168 await commit($, options, list => record(list, seen, at, { isSettling }))
169 await notify($, options, seen)
170}
171
172/** For a source that may repeat another's, such as `prompt.context` after `InstructionsLoaded`. */
173async function observeOnce($: EngineInterface, options: Options, seen: Observed) {
174 const at = await $.clock.now()
175 await commit($, options, list => recordOnce(list, seen, at))
176}
177
178/** Drops a host-run server's chip, once its provenance shows up, and keeps it out from then on. */
179async function learnHostServer($: EngineInterface, options: Options, server: string) {
180 if ((await read($, hostServers)).includes(server)) return
181 await update($, hostServers, list => [...list, server])
182 await commit($, options, list => forget(list, 'mcp', server))
183}
184
185async function loadOverrides($: EngineInterface, options: Options) {
186 const stored = await $.store.get(STORE_OVERRIDES)
187 if (stored !== null && typeof stored === 'object') {
188 await update($, overrides, () => stored as Overrides)
189 }
190 await refresh($, options)
191}
192
193async function writeOverrides(
194 $: EngineInterface,
195 options: Options,
196 change: (old: Overrides) => Overrides,
197) {
198 const base = await readOverrides($)
199 let written = base
200 await update($, overrides, held => (written = change(held ?? base)))
201 await $.store.set(STORE_OVERRIDES, written)
202 await refresh($, options)
203}
204
205async function toneOf(
206 $: EngineInterface,
207 settings: Settings,
208 surface: RenderSurface,
209): Promise<Tone | undefined> {
210 const theme = (await $.config.list()).find(row => row.key === 'theme')
211 const { system, colorFgBg } = await read($, appearance)
212 return resolveTone({
213 chipStyle: settings.chipStyle,
214 chipTheme: settings.chipTheme,
215 claudeTheme: theme?.value,
216 surface,
217 colorFgBg: colorFgBg ?? undefined,
218 system: system ?? undefined,
219 })
220}
221
222async function openPane($: EngineInterface, view: PaneView, id?: string, rows?: number) {
223 if (id !== undefined) {
224 await update($, selected, () => id)
225 await update($, filter, () => 'all')
226 }
227 await update($, paneView, () => view)
228 await $.ui.open({
229 id: PANE,
230 title: 'Session activity',
231 focus: true,
232 closeOnEscape: true,
233 ...(rows !== undefined && { rows }),
234 })
235}
236
237const isSupported = async ($: EngineInterface) => meetsFloor((await $.session.version()).base)
238
239const drawsAnything = async ($: EngineInterface) =>
240 (await isSupported($)) && (await $.session.surfaces()).length > 0
241
242/** `Svg` is handed to every surface whose element table has it; the terminal never draws one. */
243function uiFor($: EngineInterface, event: Parameters<EngineInterface['ui']['resolve']>[0]): Ui {
244 const table = $.ui.resolve(event)
245 const { Box, Text, Button } = table
246 return event.surface !== 'terminal' && 'Svg' in table
247 ? { Box, Text, Button, Svg: table.Svg }
248 : { Box, Text, Button }
249}
250
251async function copyPath($: EngineInterface, surface: RenderSurface, text: string) {
252 await $.ui.copy({ text, surface })
253 $.ui.toast(`Copied ${text}`)
254}
255
256async function toggleMuted($: EngineInterface, options: Options, id: string) {
257 await update($, muted, list => (list.includes(id) ? list.filter(m => m !== id) : [...list, id]))
258 await refresh($, options)
259}
260
261async function resetActivity($: EngineInterface, options: Options) {
262 await update($, activations, () => [])
263 await update($, muted, () => [])
264 await update($, selected, () => null)
265 await refresh($, options)
266}
267
268function blockedBy(result: Record<string, unknown>): string | undefined {
269 if (typeof result.deny === 'string') return result.deny
270 if (typeof result.block === 'string') return result.block
271 if (result.preventContinuation === true) {
272 return typeof result.stopReason === 'string' ? result.stopReason : 'stopped the turn'
273 }
274 return undefined
275}
276
277export const register: Register = (on, options) => {
278 on('session.start', async ($, e, next) => {
279 await $.command.register({
280 name: 'session-activity',
281 description:
282 'Show the skills, agents, MCP servers, permissions, hooks and rules of this session',
283 immediate: true,
284 })
285 await loadOverrides($, options)
286 watchAppearance($)
287 if (!(await isSupported($))) {
288 $.ui.toast(`session-activity needs Claude Code ${ENGINE_FLOOR} or later. Run claude update.`)
289 }
290 return next(e)
291 })
292
293 on('classic.SessionStart', async ($, e, next) => {
294 if (e.source === 'fork') await resetActivity($, options)
295 await loadOverrides($, options)
296 return next(e)
297 })
298
299 on('command.run', { command: 'session-activity' }, async $ => {
300 if (!(await drawsAnything($))) {
301 const settings = await settingsOf($, options)
302 return { text: markdownSummary(await read($, activations), settings, await read($, muted)) }
303 }
304 await openPane($, 'activity')
305 return { text: 'Opened the session activity pane.' }
306 })
307
308 on('command.run', async ($, e, next) => {
309 if (e.command !== COMMAND) await observe($, options, fromCommand(e.command, e.args))
310 return next(e)
311 })
312
313 on('session.end', async ($, e, next) => {
314 if (e.reason === 'clear' || e.reason === 'resume') await resetActivity($, options)
315 return next(e)
316 })
317
318 on('skill.prompt', async ($, e, next) => {
319 await observe($, options, fromSkill(e))
320 return next(e)
321 })
322
323 on('agent.spawn', async ($, e, next) => {
324 const spawned = await next(e)
325 if (spawned.deny === undefined) await observe($, options, fromSpawn(e, spawned.agentId))
326 return spawned
327 })
328
329 on('turn.complete', async ($, e, next) => {
330 const agentId = e.agentId
331 if (agentId !== undefined) {
332 const status = e.reason === 'answer' ? 'ok' : 'error'
333 const at = await $.clock.now()
334 await commit($, options, list => finishAgent(list, agentId, status, at))
335 return next(e)
336 }
337 const answered = await next(e)
338 const settings = await settingsOf($, options)
339 if (!settings.textSummary || !settings.enabled || (await drawsAnything($))) return answered
340 const line = lineSummary(await read($, activations), settings, await read($, muted))
341 if (line === undefined) return answered
342 // A text other than the answer is shown beneath it, so the answer is neither lost nor repeated.
343 return { ...answered, text: answered.text === e.answer ? line : `${answered.text}\n${line}` }
344 })
345
346 on('tool.check', async ($, e, next) => {
347 const verdict = await next(e)
348 if (e.tool_use_id === undefined) return next(e)
349 const cwd = await $.session.cwd()
350 await observe($, options, fromPermission(e.tool, e.input, verdict, cwd, e.tool_use_id))
351 // At tool.check next(e) replays the settled verdict, it does not ask again.
352 return next(e)
353 })
354
355 on('tool.call', async ($, e, next) => {
356 const ran = await next(e)
357 if (ran.deny === undefined) {
358 await observe(
359 $,
360 options,
361 fromMcpCall(e.tool, ran.isError === true, await read($, hostServers)),
362 )
363 if (ran.isError !== true) await observe($, options, fromMemoryWrite(e.tool, e))
364 if (e.tool === 'Bash' && e.run_in_background === true) {
365 await observe($, options, fromBackground(e.command, await $.session.cwd()))
366 }
367 }
368 const asked = (await read($, activations)).find(a => a.askedCallId === e.tool_use_id)
369 if (asked === undefined) return ran
370 const at = await $.clock.now()
371 await commit($, options, list => settleAsked(list, e.tool_use_id, ran.deny === undefined, at))
372 if (ran.deny !== undefined) {
373 await notify($, options, { status: 'denied', label: asked.label, meta: asked.meta })
374 }
375 return ran
376 })
377
378 on('classic.*', async ($, e, next) => {
379 const host = hostServerOf('mcp_server' in e ? e.mcp_server : undefined)
380 if (host !== undefined) await learnHostServer($, options, host)
381 const event = 'hook_event_name' in e ? String(e.hook_event_name) : 'PreToolUse'
382 const target = hookTarget(e)
383 const commands = hookCommands(await $.settings.read(), event, target)
384 const isStarted = commands.length > 0
385 if (isStarted) await observe($, options, fromHook(event, commands, 'running', target))
386 const result = (await next(e)) as Record<string, unknown>
387 const blocked = blockedBy(result)
388 await observe(
389 $,
390 options,
391 fromHook(event, commands, blocked === undefined ? {} : { blocked }, target),
392 { isSettling: isStarted },
393 )
394 return result as never
395 })
396
397 on('classic.InstructionsLoaded', async ($, e, next) => {
398 const lines = await $.fs.read(e.file_path).then(lineCount, () => undefined)
399 const seen = fromInstructions(
400 { path: e.file_path, tier: e.memory_type, reason: e.load_reason, lines },
401 await $.session.cwd(),
402 )
403 // `prompt.context` may have listed it already.
404 if (e.load_reason === 'session_start') await observeOnce($, options, seen)
405 else await observe($, options, seen)
406 return next(e)
407 })
408
409 // On Desktop and under `claude -p`, no `InstructionsLoaded` reaches the mod for the files
410 // loaded at session start; the first message's context still lists them.
411 on('prompt.context', async ($, e, next) => {
412 const cwd = await $.session.cwd()
413 for (const file of e.instructionFiles ?? []) {
414 await observeOnce(
415 $,
416 options,
417 fromInstructions(
418 {
419 path: file.path,
420 tier: file.kind,
421 reason: 'session_start',
422 lines: lineCount(file.content),
423 },
424 cwd,
425 ),
426 )
427 }
428 return next(e)
429 })
430
431 on('session.compact', async ($, e, next) => {
432 const compacted = await next(e)
433 if (!('skip' in compacted)) await observe($, options, fromCompaction(e.trigger))
434 return compacted
435 })
436
437 on('session.measure', async ($, e, next) => {
438 if (e.context.percent !== undefined) {
439 await observe($, options, fromContextUsage(e.context.percent))
440 }
441 return next(e)
442 })
443
444 on('turn.step', async function* ($, e, next) {
445 if (e.agentId === undefined) {
446 const last = await read($, model)
447 await update($, model, () => e.model)
448 if (last !== null && last !== e.model) await observe($, options, fromModel(e.model))
449 }
450 return yield* next(e)
451 })
452
453 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
454 const below = await next(e)
455 if (e.props.hasSurvey || !(await isSupported($))) return below
456 const settings = await settingsOf($, options)
457 if (!settings.enabled) return below
458 const chips = bandCandidates(await read($, activations), settings, await read($, muted))
459 if (chips.length === 0) return below
460 return renderBand({
461 ui: uiFor($, e),
462 surface: e.surface,
463 columns: e.props.bodyColumns,
464 maxRows: e.props.maxRows,
465 now: await $.clock.now(),
466 tone: await toneOf($, settings, e.surface),
467 chips,
468 settings,
469 selected: await read($, selected),
470 below,
471 on: {
472 pick: async (id, fitsCard) => {
473 if (fitsCard) await update($, selected, old => (old === id ? null : id))
474 else await openPane($, 'detail', id, DETAIL_PANE_ROWS)
475 },
476 open: async () => {
477 const isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
478 if (isUp && (await read($, paneView)) === 'activity') await $.ui.close({ id: PANE })
479 else await openPane($, 'activity')
480 },
481 settings: async () => {
482 const isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
483 if (isUp && (await read($, paneView)) === 'settings') await $.ui.close({ id: PANE })
484 else await openPane($, 'settings')
485 },
486 close: () => update($, selected, () => null),
487 mute: async id => {
488 await update($, selected, () => null)
489 await toggleMuted($, options, id)
490 },
491 copy: text => copyPath($, e.surface, text),
492 },
493 })
494 })
495
496 on('ui.render', { component: 'Pane', requestId: 'session-activity' }, async ($, e) => {
497 const settings = await settingsOf($, options)
498 const change = (patch: (old: Overrides) => Overrides) => writeOverrides($, options, patch)
499 return renderPane({
500 ui: uiFor($, e),
501 surface: e.surface,
502 columns: e.props.bodyColumns,
503 now: await $.clock.now(),
504 tone: await toneOf($, settings, e.surface),
505 tintedTone: await toneOf($, { ...settings, chipStyle: 'tinted' }, e.surface),
506 activations: await read($, activations),
507 settings,
508 muted: await read($, muted),
509 selected: await read($, selected),
510 view: await read($, paneView),
511 filter: await read($, filter),
512 on: {
513 view: view => update($, paneView, () => view),
514 filter: value => update($, filter, () => value),
515 select: id => update($, selected, old => (old === id ? null : id)),
516 mute: id => toggleMuted($, options, id),
517 copy: text => copyPath($, e.surface, text),
518 set: patch => change(old => ({ ...old, ...patch })),
519 toggle: (flag: PaneFlag) =>
520 change(old => ({ ...old, [flag]: !resolveSettings(options, old)[flag] })),
521 toggleShown: (toggle: Toggle) =>
522 change(old => {
523 const shown = !resolveSettings(options, old).shown[toggle]
524 return { ...old, shown: { ...old.shown, [toggle]: shown } }
525 }),
526 reset: () => change(() => ({})),
527 allOn: () =>
528 change(old => ({
529 ...old,
530 enabled: true,
531 shown: Object.fromEntries(TOGGLE_ORDER.map(toggle => [toggle, true])),
532 })),
533 },
534 })
535 })
536}
537src/classify.ts 335 lines1import type { Observed } from './model'
2
3export const mcpParts = (tool: string) => {
4 const match = /^mcp__(.+?)__(.+)$/.exec(tool)
5 return match ? { server: match[1]!, name: match[2]! } : undefined
6}
7
8export const relativePath = (path: string, cwd: string) => {
9 const root = cwd.endsWith('/') ? cwd : `${cwd}/`
10 return path.startsWith(root) ? path.slice(root.length) : path
11}
12
13const lastSegments = (path: string, n: number) =>
14 path.split('/').filter(Boolean).slice(-n).join('/')
15
16const field = (input: unknown, name: string) => {
17 const value = (input as Record<string, unknown> | null)?.[name]
18 return typeof value === 'string' ? value : undefined
19}
20
21export const callSummary = (tool: string, input: unknown, cwd: string) => {
22 const command = field(input, 'command')
23 if (tool === 'Bash' && command !== undefined) {
24 const words = command
25 .trim()
26 .split(/\s+/)
27 .filter(w => !/^\w+=/.test(w))
28 return words.slice(0, 2).join(' ') || 'Bash'
29 }
30 const path = field(input, 'file_path') ?? field(input, 'notebook_path') ?? field(input, 'path')
31 if (path !== undefined) return relativePath(path, cwd)
32 const url = field(input, 'url')
33 if (url !== undefined) {
34 try {
35 return new URL(url).hostname
36 } catch {
37 return tool
38 }
39 }
40 const mcp = mcpParts(tool)
41 return mcp ? `${mcp.server} · ${mcp.name}` : tool
42}
43
44/**
45 * The server's name when the host runs it in process (`source: "sdk"`), such as Desktop's
46 * `ccd_turn`: its tools are the host's plumbing, not a server the person added.
47 */
48export const hostServerOf = (server: unknown) => {
49 const { name, source } = (server ?? {}) as { name?: unknown; source?: unknown }
50 return source === 'sdk' && typeof name === 'string' ? name : undefined
51}
52
53export const fromMcpCall = (
54 tool: string,
55 isError: boolean,
56 hostServers: readonly string[] = [],
57): Observed | undefined => {
58 const mcp = mcpParts(tool)
59 if (!mcp || hostServers.includes(mcp.server)) return undefined
60 return {
61 kind: 'mcp',
62 key: mcp.server,
63 label: mcp.server,
64 detail: isError ? `${mcp.name} returned an error` : `Called ${mcp.name}`,
65 meta: mcp.name,
66 status: isError ? 'error' : 'ok',
67 }
68}
69
70const SKILL_SOURCES: Record<string, string> = {
71 userSettings: 'your user skills',
72 projectSettings: 'this project',
73 policySettings: 'your organization',
74 plugin: 'a plugin',
75 'built-in': 'Claude Code',
76 mcp: 'an MCP server',
77}
78
79export const fromSkill = (e: {
80 skill: string
81 source: string
82 pluginName?: string
83 path?: string
84}): Observed => ({
85 kind: 'skill',
86 key: e.skill,
87 label: e.skill,
88 meta: 'Loaded',
89 detail: `Claude loaded this skill from ${e.pluginName ?? SKILL_SOURCES[e.source] ?? e.source}.`,
90 ...(e.path !== undefined && { source: e.path }),
91 ...(e.pluginName !== undefined && { plugin: e.pluginName }),
92 status: 'active',
93})
94
95export const fromSpawn = (
96 e: {
97 subagentType: string
98 description: string
99 background: boolean
100 provider: { plugin: string }
101 },
102 agentId: string | undefined,
103): Observed => ({
104 kind: 'agent',
105 key: e.subagentType,
106 label: e.subagentType,
107 detail: e.description ? `Started for: ${e.description}` : 'Started a subagent.',
108 meta: e.background ? 'background' : 'foreground',
109 ...(e.provider.plugin !== 'engine' && { plugin: e.provider.plugin }),
110 status: agentId === undefined ? 'ok' : 'running',
111 ...(agentId !== undefined && { startedAgentId: agentId, agentId }),
112})
113
114export type Verdict = {
115 decision: 'allow' | 'ask' | 'deny'
116 reason?: string
117 rule?: string
118 hook?: string
119}
120
121const earnsChip = (verdict: Verdict) =>
122 verdict.decision === 'ask' ||
123 verdict.rule !== undefined ||
124 (verdict.decision === 'deny' && verdict.hook === undefined)
125
126export const fromPermission = (
127 tool: string,
128 input: unknown,
129 verdict: Verdict,
130 cwd: string,
131 callId: string,
132): Observed | undefined => {
133 if (!earnsChip(verdict)) return undefined
134 const summary = callSummary(tool, input, cwd)
135 const by = verdict.rule ?? 'the permission mode'
136 const detail =
137 verdict.decision === 'allow'
138 ? `Auto-allowed by ${by}.`
139 : verdict.decision === 'deny'
140 ? `Denied by ${by}${verdict.reason ? `: ${verdict.reason}` : '.'}`
141 : 'Claude asked for permission before running it.'
142 return {
143 kind: 'perm',
144 key: verdict.rule ?? `${tool}:${summary}`,
145 label: verdict.decision === 'allow' ? `Allowed: ${summary}` : summary,
146 detail,
147 meta: tool,
148 ...(verdict.decision === 'ask' && { askedCallId: callId }),
149 status: verdict.decision === 'deny' ? 'denied' : verdict.decision === 'ask' ? 'running' : 'ok',
150 }
151}
152
153type HookEntry = { matcher?: unknown; hooks?: unknown }
154
155export const hookCommands = (settings: unknown, event: string, target?: string): string[] => {
156 const hooks = (settings as { hooks?: Record<string, unknown> } | null)?.hooks
157 const entries = hooks?.[event]
158 if (!Array.isArray(entries)) return []
159 return (entries as HookEntry[]).flatMap(entry => {
160 if (!matches(entry.matcher, target) || !Array.isArray(entry.hooks)) return []
161 return (entry.hooks as { command?: unknown; type?: unknown }[]).map(h =>
162 typeof h.command === 'string' ? h.command : `${String(h.type ?? 'unknown')} hook`,
163 )
164 })
165}
166
167/** What a hook's matcher is tested against: the tool, or the start source or compaction trigger. */
168export const hookTarget = (e: object): string | undefined => {
169 for (const name of ['tool_name', 'tool', 'source', 'trigger']) {
170 const value = (e as Record<string, unknown>)[name]
171 if (typeof value === 'string') return value
172 }
173 return undefined
174}
175
176const matches = (matcher: unknown, target: string | undefined) => {
177 if (typeof matcher !== 'string' || matcher === '' || matcher === '*' || target === undefined) {
178 return true
179 }
180 try {
181 return new RegExp(`^(?:${matcher})$`).test(target)
182 } catch {
183 return matcher === target
184 }
185}
186
187const LAUNCHER_WORD =
188 /^(npx|pnpx|bunx|npm|pnpm|yarn|bun|deno|node|uv|python3?|bash|sh|zsh|exec|dlx|run|-.*)$/
189
190export const commandName = (command: string) => {
191 const words = command.trim().split(/\s+/)
192 const named = words.find(w => !LAUNCHER_WORD.test(w))
193 const script =
194 words.find(w => w.includes('/') || /\.(sh|js|mjs|ts|py)$/.test(w)) ?? named ?? words[0] ?? ''
195 const base = script.split('/').pop() ?? script
196 return base.replace(/\.(sh|js|mjs|cjs|ts|py)$/, '') || command
197}
198
199export type HookOutcome = 'running' | { blocked?: string }
200
201export const fromHook = (
202 event: string,
203 commands: readonly string[],
204 outcome: HookOutcome,
205 target: string | undefined,
206): Observed | undefined => {
207 const blocked = outcome === 'running' ? undefined : outcome.blocked
208 if (commands.length === 0 && blocked === undefined) return undefined
209 const only = commands.length === 1 ? commands[0] : undefined
210 const n = commands.length === 1 ? '1 command' : `${commands.length} commands`
211 return {
212 kind: 'hook',
213 key: only ?? event,
214 label: only !== undefined ? commandName(only) : `${event} hooks`,
215 detail:
216 outcome === 'running'
217 ? `Running ${n}.`
218 : blocked !== undefined
219 ? `Blocked: ${blocked}`
220 : `Ran ${n}.`,
221 meta: target !== undefined ? `${event} · ${target}` : event,
222 ...(only !== undefined && { source: only }),
223 status: outcome === 'running' ? 'running' : blocked !== undefined ? 'blocked' : 'ok',
224 }
225}
226
227const REASONS: Record<string, string> = {
228 session_start: 'at session start',
229 nested_traversal: 'when Claude entered its folder',
230 path_glob_match: 'when a matching file was read',
231 include: 'through an @ import',
232 compact: 'after compaction',
233}
234
235export const fromInstructions = (
236 file: { path: string; tier: string; reason?: string; lines?: number | undefined },
237 cwd: string,
238): Observed => {
239 const relative = relativePath(file.path, cwd)
240 const isOutside = relative === file.path
241 const when =
242 file.reason !== undefined ? (REASONS[file.reason] ?? file.reason) : 'at session start'
243 const lines = file.lines !== undefined ? ` · ${file.lines} lines` : ''
244 return {
245 kind: 'rule',
246 key: file.path,
247 label: isOutside ? lastSegments(file.path, 2) : relative,
248 detail: `Loaded ${when}${lines}.`,
249 meta: file.tier.toLowerCase(),
250 source: file.path,
251 status: 'active',
252 }
253}
254
255export const lineCount = (text: string) => (text === '' ? 0 : text.split('\n').length)
256
257export const fromCommand = (command: string, args: string): Observed => ({
258 kind: 'command',
259 key: command,
260 label: command,
261 detail: args ? `Ran /${command} ${args}` : `Ran /${command}`,
262 status: 'ok',
263})
264
265export const fromCompaction = (trigger: string): Observed => ({
266 kind: 'context',
267 key: 'compaction',
268 label: 'Compacted',
269 detail: `The conversation was compacted (${trigger}).`,
270 status: 'active',
271})
272
273export const fromContextUsage = (percent: number): Observed => ({
274 kind: 'context',
275 key: 'usage',
276 label: `${Math.round(percent)}% used`,
277 detail: `The context window is ${Math.round(percent)}% full.`,
278 status: 'active',
279})
280
281export const fromModel = (model: string): Observed => ({
282 kind: 'model',
283 key: 'model',
284 label: `Model: ${model}`,
285 detail: `Switched the model to ${model}.`,
286 status: 'active',
287})
288
289export const fromBackground = (command: string, cwd: string): Observed => ({
290 kind: 'background',
291 key: callSummary('Bash', { command }, cwd),
292 label: callSummary('Bash', { command }, cwd),
293 detail: 'Started in the background.',
294 source: command,
295 status: 'ok',
296})
297
298const MEMORY_TOOLS = new Set(['Write', 'Edit', 'MultiEdit'])
299
300const isMemoryFile = (path: string) => {
301 const name = path.split('/').pop() ?? path
302 return (
303 name === 'CLAUDE.md' ||
304 name === 'CLAUDE.local.md' ||
305 (name.endsWith('.md') && path.split('/').includes('memory'))
306 )
307}
308
309export const fromMemoryWrite = (tool: string, input: unknown): Observed | undefined => {
310 const path = field(input, 'file_path')
311 if (!MEMORY_TOOLS.has(tool) || path === undefined || !isMemoryFile(path)) return undefined
312 const name = path.split('/').pop() ?? path
313 return {
314 kind: 'memory',
315 key: path,
316 label: `Remembered: ${name}`,
317 detail: `Claude updated ${name} with ${tool}.`,
318 meta: tool,
319 source: path,
320 status: 'active',
321 }
322}
323
324/** The one-line notice for an action that was just stopped; undefined for anything else. */
325export const stopNotice = (
326 seen: Pick<Observed, 'status' | 'label' | 'meta'>,
327): string | undefined => {
328 if (seen.status === 'blocked') {
329 const tool = seen.meta?.split(' · ')[1]
330 return `↯ ${seen.label} blocked ${tool ?? 'an action'}`
331 }
332 if (seen.status === 'denied') return `⚷ ${seen.meta ?? 'A tool'} denied: ${seen.label}`
333 return undefined
334}
335src/model.ts 184 lines1import type { Activation, ChipTheme, Kind, Overrides, Settings, Status, Toggle } from '../types'
2import { FRESH_MS, isUrgent, KIND_ORDER, TOGGLE_ORDER, TOGGLES } from './theme'
3
4export type Observed = Omit<Activation, 'count' | 'firstAt' | 'lastAt' | 'runningAgentIds'> & {
5 startedAgentId?: string
6}
7
8const MAX_ACTIVATIONS = 500
9
10const trim = (list: Activation[]) => {
11 if (list.length <= MAX_ACTIVATIONS) return list
12 const oldest = list.findIndex(a => !isUrgent(a.status))
13 return list.filter((_, i) => i !== (oldest === -1 ? 0 : oldest))
14}
15
16export const chipId = (a: Pick<Activation, 'kind' | 'key'>) => `${a.kind}:${a.key}`
17
18const mergeMeta = (old: string | undefined, next: string | undefined) => {
19 if (next === undefined || old === next) return old ?? next
20 if (old === undefined) return next
21 const parts = old.split(' · ')
22 return parts.includes(next) ? old : [...parts, next].join(' · ')
23}
24
25export const record = (
26 list: readonly Activation[],
27 seen: Observed,
28 at: number,
29 { isSettling = false } = {},
30): Activation[] => {
31 const { startedAgentId, ...fields } = seen
32 const index = list.findIndex(a => a.kind === seen.kind && a.key === seen.key)
33 if (index === -1) {
34 const fresh: Activation = { ...fields, count: 1, firstAt: at, lastAt: at }
35 if (startedAgentId !== undefined) fresh.runningAgentIds = [startedAgentId]
36 return trim([...list, fresh])
37 }
38 const old = list[index]!
39 const running =
40 startedAgentId === undefined
41 ? old.runningAgentIds
42 : [...(old.runningAgentIds ?? []), startedAgentId]
43 const hasRecovered = seen.kind === 'mcp' && old.status === 'error' && seen.status === 'ok'
44 const keepsUrgent = isUrgent(old.status) && !isUrgent(seen.status) && !hasRecovered
45 const merged: Activation = {
46 ...old,
47 ...fields,
48 detail: keepsUrgent ? old.detail : fields.detail,
49 meta: seen.kind === 'mcp' ? mergeMeta(old.meta, seen.meta) : (seen.meta ?? old.meta),
50 count: isSettling ? old.count : old.count + 1,
51 lastAt: at,
52 status: keepsUrgent ? old.status : running?.length ? 'running' : seen.status,
53 }
54 if (running !== undefined) merged.runningAgentIds = running
55 return list.map((a, i) => (i === index ? merged : a))
56}
57
58/** Records `seen` only when its chip is not listed yet, for a source that may repeat another's. */
59export const recordOnce = (list: readonly Activation[], seen: Observed, at: number) =>
60 list.some(a => a.kind === seen.kind && a.key === seen.key) ? list.slice() : record(list, seen, at)
61
62export const forget = (list: readonly Activation[], kind: Activation['kind'], key: string) =>
63 list.filter(a => a.kind !== kind || a.key !== key)
64
65export const settleAsked = (
66 list: readonly Activation[],
67 callId: string,
68 hasRun: boolean,
69 at: number,
70): Activation[] =>
71 list.map(({ askedCallId, ...a }) => {
72 if (askedCallId !== callId) return askedCallId === undefined ? a : { ...a, askedCallId }
73 return {
74 ...a,
75 label: hasRun ? `Allowed: ${a.label}` : a.label,
76 status: hasRun ? 'ok' : 'denied',
77 lastAt: at,
78 detail: hasRun
79 ? 'Asked for permission, and it ran.'
80 : 'Asked for permission, and it did not run.',
81 }
82 })
83
84export const finishAgent = (
85 list: readonly Activation[],
86 agentId: string,
87 status: Status,
88 at: number,
89): Activation[] =>
90 list.map(a => {
91 if (!a.runningAgentIds?.includes(agentId)) return a
92 const running = a.runningAgentIds.filter(id => id !== agentId)
93 const keepsRunning = running.length > 0
94 return {
95 ...a,
96 runningAgentIds: running,
97 lastAt: at,
98 status: keepsRunning ? 'running' : a.status === 'error' ? 'error' : status,
99 }
100 })
101
102const rank = (status: Status) => (isUrgent(status) ? 0 : status === 'running' ? 1 : 2)
103
104export const bandOrder = (list: readonly Activation[]): Activation[] =>
105 [...list].sort((a, b) => rank(a.status) - rank(b.status) || b.lastAt - a.lastAt)
106
107const flag = (value: unknown, fallback: boolean) => (typeof value === 'boolean' ? value : fallback)
108
109const chipTheme = (value: unknown): ChipTheme =>
110 value === 'light' || value === 'dark' ? value : 'auto'
111
112export const resolveSettings = (
113 options: Readonly<Record<string, unknown>>,
114 overrides: Overrides | undefined,
115): Settings => {
116 const shown = {} as Record<Toggle, boolean>
117 for (const toggle of TOGGLE_ORDER) {
118 const { option, optional } = TOGGLES[toggle]
119 shown[toggle] = overrides?.shown?.[toggle] ?? flag(options[option], !optional)
120 }
121 const max = Number(options.max_chips)
122 return {
123 enabled: overrides?.enabled ?? options.enabled !== false,
124 layout: overrides?.layout ?? (options.layout === 'summary' ? 'summary' : 'row'),
125 maxChips: Number.isFinite(max) ? Math.min(20, Math.max(1, Math.round(max))) : 6,
126 chipStyle: overrides?.chipStyle ?? (options.chip_style === 'text' ? 'text' : 'tinted'),
127 chipTheme: overrides?.chipTheme ?? chipTheme(options.chip_theme),
128 hotkeys: overrides?.hotkeys ?? flag(options.chip_hotkeys, false),
129 toastOnBlock: overrides?.toastOnBlock ?? flag(options.toast_on_block, true),
130 textSummary: overrides?.textSummary ?? flag(options.text_summary, false),
131 shown,
132 }
133}
134
135export const bandCandidates = (
136 list: readonly Activation[],
137 settings: Settings,
138 muted: readonly string[],
139): Activation[] =>
140 bandOrder(list).filter(
141 a => isUrgent(a.status) || (settings.shown[a.kind] && !muted.includes(chipId(a))),
142 )
143
144/** Hidden types still list their stopped and errored chips, which the band always shows. */
145export const inPane = (a: Activation, settings: Settings) =>
146 settings.shown[a.kind] || isUrgent(a.status)
147
148/** When the next drawn fresh dot fades, or undefined when none is fresh. */
149export const nextFadeAt = (list: readonly Activation[], settings: Settings, now: number) => {
150 const fades = list
151 .filter(a => inPane(a, settings))
152 .map(a => a.lastAt + FRESH_MS)
153 .filter(at => at > now)
154 return fades.length === 0 ? undefined : Math.min(...fades)
155}
156
157export const paneGroups = (
158 list: readonly Activation[],
159 settings: Settings,
160 filter: Kind | 'all',
161): { kind: Kind; rows: Activation[] }[] =>
162 KIND_ORDER.filter(kind => filter === 'all' || kind === filter)
163 .map(kind => ({
164 kind,
165 rows: bandOrder(list.filter(a => a.kind === kind && inPane(a, settings))),
166 }))
167 .filter(group => group.rows.length > 0)
168
169export const startedAgo = (list: readonly Activation[], now: number) =>
170 list.length === 0 ? 0 : now - Math.min(...list.map(a => a.firstAt))
171
172export const sampleActivations = (settings: Settings, at: number): Activation[] =>
173 KIND_ORDER.filter(kind => settings.shown[kind]).map((kind, index) => ({
174 kind,
175 key: TOGGLES[kind].example,
176 label: TOGGLES[kind].example,
177 detail: TOGGLES[kind].description,
178 ...(index === 0 && settings.shown.plugin && { plugin: 'acme-devtools' }),
179 status: settings.layout === 'summary' && kind === 'hook' ? 'blocked' : 'ok',
180 count: 1,
181 firstAt: at,
182 lastAt: at - 60_000,
183 }))
184src/appearance.ts 45 lines1import type { Tone } from '../types'
2
3/** The last field of `COLORFGBG` is the background colour index: 0-6 and 8 are dark, 7 and 9-15 light. */
4export const parseColorFgBg = (value: string | undefined): Tone | undefined => {
5 const last = value?.split(';').at(-1)?.trim()
6 if (last === undefined || !/^\d{1,2}$/.test(last)) return undefined
7 const index = Number(last)
8 if (index > 15) return undefined
9 return index <= 6 || index === 8 ? 'dark' : 'light'
10}
11
12export type Probe = {
13 argv: readonly string[]
14 /** The tone the command's result names, or undefined when it says nothing about it. */
15 read: (result: { exitCode: number; stdout: string }) => Tone | undefined
16}
17
18/** Tried in order; the first to name a tone wins. A binary that is not installed rejects and is skipped. */
19export const SYSTEM_PROBES: readonly Probe[] = [
20 {
21 argv: ['defaults', 'read', '-g', 'AppleInterfaceStyle'],
22 read: ({ exitCode, stdout }) => (exitCode === 0 && stdout.trim() === 'Dark' ? 'dark' : 'light'),
23 },
24 {
25 argv: ['gsettings', 'get', 'org.gnome.desktop.interface', 'color-scheme'],
26 read: ({ exitCode, stdout }) =>
27 exitCode !== 0 ? undefined : stdout.includes('prefer-dark') ? 'dark' : 'light',
28 },
29 {
30 argv: [
31 'reg',
32 'query',
33 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize',
34 '/v',
35 'AppsUseLightTheme',
36 ],
37 read: ({ exitCode, stdout }) =>
38 exitCode !== 0
39 ? undefined
40 : /AppsUseLightTheme\s+REG_DWORD\s+0x0\b/.test(stdout)
41 ? 'dark'
42 : 'light',
43 },
44]
45src/engine.ts 14 lines1/** The oldest Claude Code that draws the band and pane: a Button of Text children, Box gaps. */
2export const ENGINE_FLOOR = '2.1.299'
3
4const numbers = (release: string) => release.split('-')[0]!.split('.').map(Number)
5
6/** Whether a release `base` is at or past the floor; a build that names no release is trusted. */
7export const meetsFloor = (base: string | undefined) => {
8 if (base === undefined) return true
9 const have = numbers(base)
10 const need = numbers(ENGINE_FLOOR)
11 const differs = need.findIndex((n, i) => (have[i] ?? 0) !== n)
12 return differs === -1 || (have[differs] ?? 0) > need[differs]!
13}
14src/pane.tsx 810 lines1import type { Color, RenderSurface, Space } from 'claude-code'
2
3import type {
4 Activation,
5 ChipStyle,
6 ChipTheme,
7 Filter,
8 Layout,
9 Overrides,
10 PaneView,
11 Settings,
12 Toggle,
13} from '../types'
14import { bandCandidates, chipId, inPane, paneGroups, sampleActivations, startedAgo } from './model'
15import { switchSvg, SWITCH_SIZE } from './icons'
16import {
17 agoLabel,
18 Badge,
19 badgeOf,
20 ago,
21 ChipRow,
22 clip,
23 DetailCard,
24 inkOf,
25 isRich,
26 kindIcon,
27 lookOf,
28 type Ui,
29} from './render'
30import {
31 across,
32 HOVER_MIX,
33 isUrgent,
34 KIND_ORDER,
35 mix,
36 SPACE,
37 TOGGLE_ORDER,
38 TOGGLES,
39 TOKENS,
40 type Tone,
41} from './theme'
42
43/** Cells, not a step: the why line lines up under the row's name, past the icon and the cursor. */
44const WHY_INDENT = 6
45
46/** The row's type mark: the terminal's ` ◉ ` is three cells. */
47const ICON_CELLS = 3
48
49const MIN_META = 5
50
51export type PaneFlag = 'enabled' | 'hotkeys' | 'toastOnBlock' | 'textSummary'
52
53export type PaneArgs = {
54 ui: Ui
55 surface: RenderSurface
56 columns: number
57 now: number
58 tone: Tone | undefined
59 tintedTone: Tone | undefined
60 activations: readonly Activation[]
61 settings: Settings
62 muted: readonly string[]
63 selected: string | null
64 view: PaneView
65 filter: Filter
66 on: {
67 view: (view: PaneView) => void
68 filter: (filter: Filter) => void
69 select: (id: string) => void
70 mute: (id: string) => void
71 copy: (text: string) => void
72 set: (patch: Overrides) => void
73 toggle: (flag: PaneFlag) => void
74 toggleShown: (toggle: Toggle) => void
75 reset: () => void
76 allOn: () => void
77 }
78}
79
80const withTint = (tint: Color | undefined) => (tint === undefined ? {} : { backgroundColor: tint })
81
82const Tabs = ({ ui, view, on }: Pick<PaneArgs, 'ui' | 'view' | 'on'>) => {
83 const { Box, Button, Text } = ui
84 const tabs: [PaneView, string, string][] = [
85 ['activity', '1', 'Activity'],
86 ['settings', '2', 'Settings'],
87 ]
88 return (
89 <Box flexDirection="row" columnGap={SPACE.tabGap}>
90 {tabs.map(([id, hotkey, title]) => (
91 <Button key={`tab-${id}`} plain hotkey={hotkey} onPress={() => on.view(id)}>
92 <Text bold={view === id} dimColor={view !== id}>
93 {title}
94 </Text>
95 </Button>
96 ))}
97 </Box>
98 )
99}
100
101type Segment = {
102 key: string
103 boxKey: string
104 label: string
105 count?: number
106 isOn: boolean
107 hotkey?: string
108 onPress: () => void
109}
110
111/** Choices side by side: bold and dim in the terminal, a filled track with a raised pick on rich surfaces. */
112const Segmented = ({
113 ui,
114 tone,
115 segments,
116}: {
117 ui: Ui
118 tone: Tone | undefined
119 segments: Segment[]
120}) => {
121 const { Box, Button, Text } = ui
122 const segmented = isRich(ui) && tone !== undefined
123 const t = tone === undefined ? undefined : TOKENS[tone]
124 return (
125 <Box
126 flexDirection="row"
127 flexWrap="wrap"
128 columnGap={segmented ? 0 : SPACE.tabGap}
129 {...(segmented && { backgroundColor: t!.fill2, padding: SPACE.segmentTrack })}
130 >
131 {segments.map(({ key, boxKey, label, count, isOn, hotkey, onPress }) => {
132 const bg = segmented && isOn ? t!.surface : undefined
133 return (
134 <Box key={boxKey} paddingX={segmented ? 1 : 0} {...withTint(bg)}>
135 <Button key={key} plain {...(hotkey !== undefined && { hotkey })} onPress={onPress}>
136 <Text
137 bold={isOn}
138 {...(segmented ? inkOf(ui, tone, isOn ? 'ink' : 'text2', bg) : { dimColor: !isOn })}
139 >
140 {label}
141 </Text>
142 {count !== undefined && (
143 <Text mono size="caption" {...inkOf(ui, tone, 'muted', bg)}>
144 {` ${count}`}
145 </Text>
146 )}
147 </Button>
148 </Box>
149 )
150 })}
151 </Box>
152 )
153}
154
155const filterKinds = (listed: readonly Activation[], settings: Settings) =>
156 KIND_ORDER.filter(
157 kind => (!TOGGLES[kind].optional && settings.shown[kind]) || listed.some(a => a.kind === kind),
158 )
159
160const Filters = ({ ui, tone, activations, settings, filter, on }: PaneArgs) => {
161 const listed = activations.filter(a => inPane(a, settings))
162 const kinds = filterKinds(listed, settings)
163 const choices: [Filter, string, string, number][] = [
164 ['all', 'a', 'All', listed.length],
165 ...kinds.map((kind): [Filter, string, string, number] => [
166 kind,
167 TOGGLES[kind].hotkey,
168 TOGGLES[kind].plural,
169 listed.filter(a => a.kind === kind).length,
170 ]),
171 ]
172 const segments = choices.map(([id, hotkey, title, n]) => ({
173 key: `filter-${id}`,
174 boxKey: `filterbox-${id}`,
175 label: title,
176 count: n,
177 isOn: filter === id,
178 hotkey,
179 onPress: () => on.filter(id),
180 }))
181 return <Segmented ui={ui} tone={tone} segments={segments} />
182}
183
184/** The row's type mark: a rounded tile holding the icon on rich surfaces, a tinted glyph in the terminal. */
185const RowIcon = ({ args, a, muted }: { args: PaneArgs; a: Activation; muted: boolean }) => {
186 const { ui, tone } = args
187 const { Box, Text } = ui
188 const look = lookOf(a.kind, tone)
189 if (isRich(ui)) {
190 return (
191 <Box
192 paddingX={SPACE.tilePadX}
193 {...withTint(look.tint)}
194 {...(tone !== undefined && { borderStyle: 'round', borderColor: look.border })}
195 >
196 {kindIcon(ui, a.kind, look)}
197 </Box>
198 )
199 }
200 return (
201 <Text color={look.color} dimColor={look.dim || muted} {...withTint(look.tint)}>
202 {` ${TOGGLES[a.kind].glyph} `}
203 </Text>
204 )
205}
206
207const MutedBadge = ({ ui, tone }: { ui: Ui; tone: Tone | undefined }) => {
208 const { Text } = ui
209 if (tone === undefined) {
210 return (
211 <Text bold inverse dimColor>
212 {' Muted '}
213 </Text>
214 )
215 }
216 const { text2, fill } = TOKENS[tone]
217 return (
218 <Text color={text2} backgroundColor={fill}>
219 {' Muted '}
220 </Text>
221 )
222}
223
224const Row = ({ args, a }: { args: PaneArgs; a: Activation }) => {
225 const { ui, columns, now, tone, muted, selected, on } = args
226 const { Box, Button, Text } = ui
227 const id = chipId(a)
228 const isSelected = selected === id
229 const isMuted = muted.includes(id)
230 const badgeText = badgeOf(a.status, tone, isRich(ui))
231 const urgent = isUrgent(a.status)
232 const tally = `×${a.count} · ${ago(now - a.lastAt)}`
233 const right =
234 tally.length + (urgent ? 0 : across(SPACE.rowSide) + (isMuted ? 'Unmute' : 'Mute').length)
235 const left =
236 ICON_CELLS +
237 across(SPACE.rowGrid) +
238 2 +
239 Math.min(a.label.length, 28) +
240 1 +
241 (badgeText === '' ? 0 : badgeText.length + 2) +
242 (isMuted ? ' Muted '.length : 0)
243 const metaRoom = columns - right - across(SPACE.rowSide) - left
244 return (
245 <Box
246 key={`rowbox-${id}`}
247 flexDirection="column"
248 rowGap={SPACE.rowTextGap}
249 {...(isRich(ui) &&
250 tone !== undefined && {
251 hover: { backgroundColor: mix(TOKENS[tone].surface, TOKENS[tone].ink, HOVER_MIX) },
252 })}
253 >
254 <Box flexDirection="row" justifyContent="space-between" columnGap={SPACE.rowSide}>
255 <Box
256 flexDirection="row"
257 columnGap={SPACE.rowGrid}
258 flexShrink={1}
259 minWidth={0}
260 overflow="hidden"
261 >
262 <RowIcon args={args} a={a} muted={isMuted} />
263 <Button key={`row-${a.kind}-${a.key}`} plain onPress={() => on.select(id)}>
264 <Text>{isSelected ? '▸' : ' '}</Text>
265 <Text bold dimColor={isMuted}>{` ${clip(a.label, 28)}`}</Text>
266 {a.meta !== undefined && metaRoom >= MIN_META && (
267 <Text mono size="footnote" {...inkOf(ui, tone, 'muted')}>
268 {` ${clip(a.meta, metaRoom)}`}
269 </Text>
270 )}
271 {badgeText !== '' && <Badge ui={ui} text={badgeText} tone={tone} status={a.status} />}
272 {isMuted && <MutedBadge ui={ui} tone={tone} />}
273 </Button>
274 </Box>
275 <Box flexDirection="row" columnGap={SPACE.rowSide} flexShrink={0}>
276 <Text mono size="footnote" {...inkOf(ui, tone, 'text2')}>
277 {tally}
278 </Text>
279 {!urgent && (
280 <Button
281 key={`mute-${a.kind}-${a.key}`}
282 plain
283 {...(isSelected && { hotkey: 'm' })}
284 label={isMuted ? 'Unmute' : 'Mute'}
285 onPress={() => on.mute(id)}
286 />
287 )}
288 </Box>
289 </Box>
290 <Box flexDirection="column" rowGap={SPACE.rowTextGap} paddingLeft={WHY_INDENT}>
291 <Text
292 size="footnote"
293 wrap={isSelected ? 'wrap' : 'truncate-end'}
294 {...inkOf(ui, tone, isSelected ? 'ink2' : 'text2')}
295 >
296 {a.detail}
297 </Text>
298 {urgent && (
299 <Text {...inkOf(ui, tone, 'muted')}>Stopped and errored chips always show.</Text>
300 )}
301 {isSelected && (
302 <Box flexDirection="column">
303 {a.plugin !== undefined && (
304 <Text {...inkOf(ui, tone, 'muted')}>{`from plugin ${a.plugin}`}</Text>
305 )}
306 {a.source !== undefined && (
307 <>
308 <Text wrap="truncate-middle" {...inkOf(ui, tone, 'muted')}>
309 {a.source}
310 </Text>
311 <Button
312 key="copy"
313 plain
314 hotkey="c"
315 label="Copy path"
316 onPress={() => on.copy(a.source!)}
317 />
318 </>
319 )}
320 </Box>
321 )}
322 </Box>
323 </Box>
324 )
325}
326
327const counted = (n: number, noun: string) => `${n} ${noun}${n === 1 ? '' : 's'}`
328
329const Activity = (args: PaneArgs) => {
330 const { ui, now, tone, activations, settings, on } = args
331 const { Box, Button, Text } = ui
332 const listed = activations.filter(a => inPane(a, settings))
333 const filter =
334 args.filter !== 'all' && filterKinds(listed, settings).includes(args.filter)
335 ? args.filter
336 : 'all'
337 const groups = paneGroups(activations, settings, filter)
338 const total = listed.reduce((sum, a) => sum + a.count, 0)
339 const started = startedAgo(listed, now)
340 const link = isRich(ui) && tone !== undefined
341 return (
342 <Box flexDirection="column" rowGap={SPACE.paneGap}>
343 <Box flexDirection="column" rowGap={SPACE.paneTitleGap}>
344 <Text bold>Session activity</Text>
345 <Box flexDirection="row" flexWrap="wrap" columnGap={SPACE.statsGap}>
346 <Text key="stats" {...inkOf(ui, tone, 'muted')}>
347 {`${counted(listed.length, 'item')} · ${counted(total, 'activation')} · started ${started < 60_000 ? 'just now' : agoLabel(started)}`}
348 </Text>
349 <Box key="customize-link" flexDirection="row" columnGap={SPACE.statsGap} flexShrink={0}>
350 <Text {...inkOf(ui, tone, 'muted')}>·</Text>
351 <Button
352 key="customize"
353 plain
354 {...(link && { hover: { color: TOKENS[tone].linkHover } })}
355 onPress={() => on.view('settings')}
356 >
357 <Text {...(link ? { color: TOGGLES.skill.palette[tone].fg } : { dimColor: true })}>
358 Customize
359 </Text>
360 </Button>
361 </Box>
362 </Box>
363 </Box>
364 <Filters {...args} filter={filter} />
365 {listed.length === 0 && (
366 <Text {...inkOf(ui, tone, 'muted')}>Nothing has activated yet in this session.</Text>
367 )}
368 {groups.map(group => {
369 const { color, dim } = lookOf(group.kind, tone)
370 return (
371 <Box key={`group-${group.kind}`} flexDirection="column" rowGap={SPACE.groupGap}>
372 <Box flexDirection="row" columnGap={SPACE.rowGrid}>
373 <Text mono size="caption" color={color} dimColor={dim}>
374 {TOGGLES[group.kind].title.toUpperCase()}
375 </Text>
376 <Text mono size="caption" {...inkOf(ui, tone, 'muted')}>
377 {String(group.rows.length)}
378 </Text>
379 </Box>
380 <Box flexDirection="column" rowGap={SPACE.listGap}>
381 {group.rows.map(a => (
382 <Row args={args} a={a} />
383 ))}
384 </Box>
385 </Box>
386 )
387 })}
388 </Box>
389 )
390}
391
392const Detail = ({ ui, tone, now, activations, selected, muted, on }: PaneArgs) => {
393 const { Box, Button, Text } = ui
394 const a = activations.find(x => chipId(x) === selected)
395 return (
396 <Box flexDirection="column">
397 {a ? (
398 <DetailCard
399 ui={ui}
400 tone={tone}
401 now={now}
402 a={a}
403 isMuted={muted.includes(chipId(a))}
404 density="roomy"
405 on={{ mute: on.mute, copy: on.copy }}
406 />
407 ) : (
408 <Text dimColor>Nothing is selected.</Text>
409 )}
410 <Button
411 key="back"
412 plain
413 hotkey="a"
414 label="All activity"
415 onPress={() => on.view('activity')}
416 />
417 </Box>
418 )
419}
420
421/** The mark beside a setting: a square rounded tile holding the icon on rich surfaces, a tinted glyph in the terminal. */
422const ToggleMark = ({ ui, tone, toggle }: { ui: Ui; tone: Tone | undefined; toggle: Toggle }) => {
423 const { Box, Text } = ui
424 const look = lookOf(toggle, tone)
425 if (isRich(ui)) {
426 return (
427 <Box
428 key={`mark-${toggle}`}
429 alignSelf="center"
430 flexShrink={0}
431 padding={SPACE.tilePadX}
432 {...withTint(look.tint)}
433 {...(tone !== undefined && { borderStyle: 'round', borderColor: look.border })}
434 >
435 {kindIcon(ui, toggle, look)}
436 </Box>
437 )
438 }
439 return (
440 <Text color={look.color} dimColor={look.dim} {...withTint(look.tint)}>
441 {` ${TOGGLES[toggle].glyph} `}
442 </Text>
443 )
444}
445
446const Switch = ({
447 ui,
448 tone,
449 tintedTone,
450 id,
451 isOn,
452 title,
453 description,
454 toggle,
455 onPress,
456}: {
457 ui: Ui
458 tone: Tone | undefined
459 tintedTone: Tone | undefined
460 id: string
461 isOn: boolean
462 title: string
463 description: string
464 toggle?: Toggle
465 onPress: () => void
466}) => {
467 const { Box, Button, Text } = ui
468 const track = tintedTone === undefined ? undefined : TOKENS[tintedTone]
469 const Svg = track === undefined ? undefined : ui.Svg
470 return (
471 <Box
472 key={`row-${id}`}
473 flexDirection="row"
474 justifyContent="space-between"
475 columnGap={SPACE.switchSide}
476 >
477 <Box flexDirection="row" columnGap={SPACE.switchGrid} flexShrink={1} minWidth={0}>
478 {toggle !== undefined && <ToggleMark ui={ui} tone={tone} toggle={toggle} />}
479 <Box flexDirection="column" rowGap={SPACE.rowTextGap} flexShrink={1} minWidth={0}>
480 <Text
481 bold
482 {...(Svg ? inkOf(ui, tone, 'ink') : { dimColor: !isOn && toggle !== undefined })}
483 >
484 {title}
485 </Text>
486 <Text wrap="wrap" {...inkOf(ui, tone, 'text2')}>
487 {description}
488 </Text>
489 </Box>
490 </Box>
491 <Box flexDirection="row" columnGap={SPACE.chipInner} flexShrink={0}>
492 {Svg && track && (
493 <Svg
494 source={switchSvg(isOn, track.switchOn, track.switchOff)}
495 alt={isOn ? 'On' : 'Off'}
496 width={SWITCH_SIZE.width}
497 height={SWITCH_SIZE.height}
498 />
499 )}
500 <Button key={id} plain onPress={onPress}>
501 <Text {...(Svg ? inkOf(ui, tone, 'muted') : { dimColor: !isOn })}>
502 {Svg ? (isOn ? 'On' : 'Off') : isOn ? '●' : '○'}
503 </Text>
504 </Button>
505 </Box>
506 </Box>
507 )
508}
509
510const Eyebrow = ({ ui, tone, text }: { ui: Ui; tone: Tone | undefined; text: string }) => (
511 <ui.Text mono size="caption" {...inkOf(ui, tone, 'muted')}>
512 {text.toUpperCase()}
513 </ui.Text>
514)
515
516const Segments = <T extends string>({
517 ui,
518 tone,
519 title,
520 isSection = false,
521 note,
522 current,
523 choices,
524 onPick,
525}: {
526 ui: Ui
527 tone: Tone | undefined
528 title: string
529 isSection?: boolean
530 note?: string
531 current: T
532 choices: [T, string, string][]
533 onPick: (value: T) => void
534}) => {
535 const { Box, Text } = ui
536 const segments = choices.map(([value, key, label]) => ({
537 key,
538 boxKey: `${key}-box`,
539 label,
540 isOn: current === value,
541 onPress: () => onPick(value),
542 }))
543 return (
544 <Box flexDirection="column" rowGap={SPACE.rowTextGap}>
545 <Box flexDirection="row" columnGap={SPACE.segmentsHeadGap}>
546 {isSection ? <Eyebrow ui={ui} tone={tone} text={title} /> : <Text bold>{title}</Text>}
547 <Segmented ui={ui} tone={tone} segments={segments} />
548 </Box>
549 {note !== undefined && (
550 <Text wrap="wrap" {...inkOf(ui, tone, 'text2')}>
551 {note}
552 </Text>
553 )}
554 </Box>
555 )
556}
557
558const card = (ui: Ui, tone: Tone | undefined, padding: Record<string, Space>) =>
559 isRich(ui) && tone !== undefined
560 ? {
561 borderStyle: 'round',
562 borderColor: TOKENS[tone].line,
563 backgroundColor: TOKENS[tone].surface,
564 ...padding,
565 }
566 : {}
567
568const SWITCH_CARD = {
569 paddingLeft: SPACE.switchCardPadLeft,
570 paddingRight: SPACE.switchCardPadRight,
571 paddingY: SPACE.switchCardPadY,
572}
573
574const INTRO =
575 'Choose what shows above the prompt bar when it activates during a session. Optional types stay off until you turn them on.'
576
577const SettingsTab = (args: PaneArgs) => {
578 const { ui, surface, columns, now, tone, tintedTone, settings, on } = args
579 const { Box, Button, Text } = ui
580 const optional = TOGGLE_ORDER.filter(t => TOGGLES[t].optional)
581 const optionalOn = optional.filter(t => settings.shown[t]).length
582 const toggleRow = (toggle: Toggle) => (
583 <Switch
584 ui={ui}
585 tone={tone}
586 tintedTone={tintedTone}
587 id={`toggle-${toggle}`}
588 isOn={settings.shown[toggle]}
589 title={TOGGLES[toggle].title}
590 description={TOGGLES[toggle].description}
591 toggle={toggle}
592 onPress={() => on.toggleShown(toggle)}
593 />
594 )
595 const samples = sampleActivations(settings, now)
596 const layouts: [Layout, string, string][] = [
597 ['row', 'layout-row', 'Chip row'],
598 ['summary', 'layout-summary', 'Collapsed summary'],
599 ]
600 const themes: [ChipTheme, string, string][] = [
601 ['auto', 'theme-auto', 'Auto'],
602 ['light', 'theme-light', 'Light'],
603 ['dark', 'theme-dark', 'Dark'],
604 ]
605 const styles: [ChipStyle, string, string][] = [
606 ['tinted', 'style-tinted', 'Tinted'],
607 ['text', 'style-text', 'Text'],
608 ]
609 return (
610 <Box flexDirection="column" rowGap={SPACE.settingsGap}>
611 <Box flexDirection="column" rowGap={SPACE.introGap}>
612 <Text bold>Session activity chips</Text>
613 <Text wrap="wrap" {...inkOf(ui, tone, 'text2')}>
614 {INTRO}
615 </Text>
616 </Box>
617 <Box key="card-master" flexDirection="column" {...card(ui, tone, SWITCH_CARD)}>
618 <Switch
619 ui={ui}
620 tone={tone}
621 tintedTone={tintedTone}
622 id="toggle-enabled"
623 isOn={settings.enabled}
624 title="Show chips above the prompt bar"
625 description="Blocked and denied actions are always shown while this is on."
626 onPress={() => on.toggle('enabled')}
627 />
628 </Box>
629 <Box flexDirection="column" rowGap={SPACE.previewGap}>
630 <Segments
631 ui={ui}
632 tone={tone}
633 title="Preview"
634 isSection
635 current={settings.layout}
636 choices={layouts}
637 onPick={layout => on.set({ layout })}
638 />
639 <Box
640 key="card-preview"
641 flexDirection="column"
642 rowGap={SPACE.previewInnerGap}
643 {...card(ui, tone, { padding: SPACE.previewPad })}
644 >
645 {settings.enabled ? (
646 <ChipRow
647 ui={ui}
648 surface={surface}
649 columns={columns}
650 now={now}
651 tone={tone}
652 chips={samples}
653 settings={{ ...settings, hotkeys: false, maxChips: 20 }}
654 selected={null}
655 fits={() => false}
656 on={{ pick: () => undefined, open: () => undefined }}
657 />
658 ) : (
659 <Text {...inkOf(ui, tone, 'muted')}>
660 Chips are hidden. Activity is still listed in the session tray.
661 </Text>
662 )}
663 <Box
664 key="prompt-standin"
665 {...(isRich(ui) &&
666 tone !== undefined && {
667 borderStyle: 'round',
668 borderColor: TOKENS[tone].line,
669 paddingX: SPACE.promptPadX,
670 })}
671 >
672 <Text {...inkOf(ui, tone, 'muted')}>Reply to Claude…</Text>
673 </Box>
674 </Box>
675 </Box>
676 <Box flexDirection="column" rowGap={SPACE.sectionGap}>
677 <Eyebrow ui={ui} tone={tone} text="Shown by default" />
678 <Box
679 key="card-shown"
680 flexDirection="column"
681 rowGap={SPACE.listGap}
682 {...card(ui, tone, SWITCH_CARD)}
683 >
684 {TOGGLE_ORDER.filter(t => !TOGGLES[t].optional).map(toggleRow)}
685 </Box>
686 </Box>
687 <Box flexDirection="column" rowGap={SPACE.sectionGap}>
688 <Box flexDirection="row" columnGap={SPACE.optionalHeadGap}>
689 <Eyebrow ui={ui} tone={tone} text="Optional" />
690 <Text {...inkOf(ui, tone, 'text2')}>
691 {`Off by default · ${optionalOn} of ${optional.length} on`}
692 </Text>
693 </Box>
694 <Box
695 key="card-optional"
696 flexDirection="column"
697 rowGap={SPACE.listGap}
698 {...card(ui, tone, SWITCH_CARD)}
699 >
700 {optional.map(toggleRow)}
701 </Box>
702 </Box>
703 <Segments
704 ui={ui}
705 tone={tone}
706 title="Chip theme"
707 note="Auto follows the Claude Code theme, then the terminal, then the system."
708 current={settings.chipTheme}
709 choices={themes}
710 onPick={chipTheme => on.set({ chipTheme })}
711 />
712 <Segments
713 ui={ui}
714 tone={tone}
715 title="Chip style"
716 current={settings.chipStyle}
717 choices={styles}
718 onPick={chipStyle => on.set({ chipStyle })}
719 />
720 <Box flexDirection="column" rowGap={SPACE.listGap}>
721 <Switch
722 ui={ui}
723 tone={tone}
724 tintedTone={tintedTone}
725 id="toggle-hotkeys"
726 isOn={settings.hotkeys}
727 title="Digit hotkeys on chips"
728 description="Type 1-9 alone in an empty prompt to open a chip."
729 onPress={() => on.toggle('hotkeys')}
730 />
731 <Switch
732 ui={ui}
733 tone={tone}
734 tintedTone={tintedTone}
735 id="toggle-toast"
736 isOn={settings.toastOnBlock}
737 title="Toast when blocked or denied"
738 description="Show a short notice when an action is blocked or denied."
739 onPress={() => on.toggle('toastOnBlock')}
740 />
741 <Switch
742 ui={ui}
743 tone={tone}
744 tintedTone={tintedTone}
745 id="toggle-text-summary"
746 isOn={settings.textSummary}
747 title="Text summary after each answer"
748 description="Where nothing can be drawn (cloud sessions), add a one-line activity summary."
749 onPress={() => on.toggle('textSummary')}
750 />
751 </Box>
752 <Box flexDirection="row" columnGap={SPACE.footerGap}>
753 <Button key="reset" label="Reset to defaults" onPress={() => on.reset()} />
754 <Button key="all-on" label="Turn everything on" onPress={() => on.allOn()} />
755 </Box>
756 </Box>
757 )
758}
759
760const Lead = ({ ui, tone, activations, settings, muted }: PaneArgs) => {
761 const { Box, Text } = ui
762 const chips = bandCandidates(activations, settings, muted)
763 const surface = tone === undefined ? undefined : TOKENS[tone].surface
764 return (
765 <Box
766 key="lead"
767 flexDirection="row"
768 paddingX={tone === undefined ? 0 : SPACE.chipPadX}
769 {...withTint(surface)}
770 {...(tone !== undefined && { borderStyle: 'round', borderColor: TOKENS[tone].line })}
771 >
772 <Text bold {...inkOf(ui, tone, 'ink', surface)}>
773 {`${chips.length} active`}
774 </Text>
775 {chips.some(a => isUrgent(a.status)) && (
776 <Text {...inkOf(ui, tone, 'danger', surface)}>{' ●'}</Text>
777 )}
778 </Box>
779 )
780}
781
782export const renderPane = (args: PaneArgs) => {
783 const { Box } = args.ui
784 const inner = args.columns - across(SPACE.paneLeft) - across(SPACE.paneRight)
785 return (
786 <Box
787 flexDirection="column"
788 paddingLeft={SPACE.paneLeft}
789 paddingRight={SPACE.paneRight}
790 rowGap={SPACE.tabsBelow}
791 >
792 {args.surface === 'mobile' ? (
793 <Box flexDirection="row" columnGap={SPACE.tabGap}>
794 <Lead {...args} />
795 <Tabs ui={args.ui} view={args.view} on={args.on} />
796 </Box>
797 ) : (
798 <Tabs ui={args.ui} view={args.view} on={args.on} />
799 )}
800 {args.view === 'settings' ? (
801 <SettingsTab {...args} columns={inner} />
802 ) : args.view === 'detail' ? (
803 <Detail {...args} columns={inner} />
804 ) : (
805 <Activity {...args} columns={inner} />
806 )}
807 </Box>
808 )
809}
810src/summary.ts 49 lines1import type { Activation, Settings } from '../types'
2import { bandCandidates, chipId, paneGroups } from './model'
3import { clip } from './render'
4import { STATUSES, TOGGLES } from './theme'
5
6const LINE_LABEL_MAX = 24
7
8const badgeOf = (a: Activation) => STATUSES[a.status].badge
9
10/** The tray as Markdown, for sessions where nothing can be drawn. */
11export const markdownSummary = (
12 list: readonly Activation[],
13 settings: Settings,
14 muted: readonly string[],
15) => {
16 const groups = paneGroups(list, settings, 'all')
17 if (groups.length === 0) return 'Nothing has activated yet in this session.'
18 const sections = groups.map(({ kind, rows }) => {
19 const items = rows.map(a => {
20 const parts = [
21 `${TOGGLES[kind].glyph} ${a.label}`,
22 a.count > 1 ? `×${a.count}` : '',
23 a.meta ?? '',
24 badgeOf(a) === undefined ? '' : `**${badgeOf(a)}**`,
25 muted.includes(chipId(a)) ? '_muted_' : '',
26 ]
27 return `- ${parts.filter(Boolean).join(' · ')}`
28 })
29 return [`### ${TOGGLES[kind].plural} (${rows.length})`, ...items].join('\n')
30 })
31 return ['## Session activity', ...sections].join('\n\n')
32}
33
34/** One line for the end of an answer: the band's chips as text. */
35export const lineSummary = (
36 list: readonly Activation[],
37 settings: Settings,
38 muted: readonly string[],
39) => {
40 const chips = bandCandidates(list, settings, muted)
41 if (chips.length === 0) return undefined
42 const shown = chips.slice(0, settings.maxChips).map(a => {
43 const badge = badgeOf(a)
44 return `${TOGGLES[a.kind].glyph} ${clip(a.label, LINE_LABEL_MAX)}${badge ? ` ${badge}` : ''}`
45 })
46 const hidden = chips.length - shown.length
47 return `Activity: ${[...shown, ...(hidden > 0 ? [`+${hidden} more`] : [])].join(' · ')}`
48}
49src/render.tsx 812 lines1import type { Color, ElementTable, RenderElement, RenderSurface, Space } from 'claude-code'
2
3import type { Activation, Kind, Settings, Status } from '../types'
4import { dotSvg, errorSvg, hexOr, iconSvg, OFF_TRACK, spinnerSvg, stoppedSvg } from './icons'
5import { chipId } from './model'
6import {
7 across,
8 CARD_DENSITIES,
9 CARD_SPACE,
10 type CardDensity,
11 down,
12 FRESH_MS,
13 HOVER_MIX,
14 isStopped,
15 isUrgent,
16 KIND_ORDER,
17 mix,
18 SPACE,
19 SPIN_MS,
20 SPINNER,
21 STATUSES,
22 STOPPED,
23 type Tone,
24 TOGGLES,
25 TOKENS,
26} from './theme'
27
28/** `Svg` exists on every surface that draws rich (remote) elements, never on the terminal. */
29export type Ui = Pick<ElementTable, 'Box' | 'Text' | 'Button'> & {
30 Svg?: ElementTable<'desktop'>['Svg']
31}
32
33export const isRich = (ui: Ui) => ui.Svg !== undefined
34
35const isPill = (ui: Ui, tone: Tone | undefined) => isRich(ui) && tone !== undefined
36
37const ICON_PX = 14
38
39const SUMMARY_ICON_PX = 13
40
41const STOPPED_ICON_PX = 11
42
43const FRESH_DOT_PX = 7
44
45const LABEL_MAX = 24
46const MORE_RESERVE = 2
47const GEAR_RESERVE = 2
48const PROMPT_ROWS = 6
49
50export const clip = (text: string, max: number) =>
51 text.length <= max ? text : `${text.slice(0, Math.max(1, max - 1))}…`
52
53export const ago = (ms: number) => {
54 const minutes = Math.floor(ms / 60_000)
55 if (minutes < 1) return 'now'
56 if (minutes < 60) return `${minutes} min`
57 return `${Math.floor(minutes / 60)} h`
58}
59
60export const agoLabel = (ms: number) => (ago(ms) === 'now' ? 'now' : `${ago(ms)} ago`)
61
62export type Look = {
63 color: Color
64 tint: Color | undefined
65 dim: boolean
66 border: Color | undefined
67 hover: Color | undefined
68 /** The wash behind a `×N` count, blended over the chip. */
69 count: Color | undefined
70}
71
72const countWash = (tint: string, tone: Tone) => {
73 const { base, alpha } = TOKENS[tone].countBg
74 return mix(tint, base, alpha)
75}
76
77export const lookOf = (kind: Kind | 'plugin', tone: Tone | undefined, status?: Status): Look => {
78 const { palette, dim } = TOGGLES[kind]
79 if (tone === undefined) {
80 return {
81 color: palette.key,
82 tint: undefined,
83 dim,
84 border: undefined,
85 hover: undefined,
86 count: undefined,
87 }
88 }
89 if (status === 'error') {
90 const { danger: fg, surface: tint } = TOKENS[tone]
91 return {
92 color: fg,
93 tint,
94 dim: false,
95 border: fg,
96 hover: mix(tint, fg, HOVER_MIX),
97 count: countWash(tint, tone),
98 }
99 }
100 const { fg, bg: tint, bd: border } = palette[tone]
101 return {
102 color: fg,
103 tint,
104 dim: false,
105 border,
106 hover: mix(tint, fg, HOVER_MIX),
107 count: countWash(tint, tone),
108 }
109}
110
111type Role = 'ink' | 'ink2' | 'text2' | 'muted' | 'danger'
112
113/**
114 * Paint for text in a neutral role. A token colour needs a known background: rich surfaces
115 * draw their own, and a `bg` pins one in the terminal. Elsewhere the terminal keeps its
116 * own colours, dimmed for quiet text, and `error` stands for danger.
117 */
118export const inkOf = (ui: Ui, tone: Tone | undefined, role: Role, bg?: Color) => {
119 const back = bg === undefined ? {} : { backgroundColor: bg }
120 if (tone !== undefined && (isRich(ui) || bg !== undefined)) {
121 return { color: TOKENS[tone][role], ...back }
122 }
123 if (role === 'danger') return { color: 'error' as Color, ...back }
124 return role === 'text2' || role === 'muted' ? { dimColor: true, ...back } : back
125}
126
127type IconArgs = { ui: Ui; svg: string; alt: string; width?: number; height?: number }
128
129export const Icon = ({ ui, svg, alt, width = ICON_PX, height = ICON_PX }: IconArgs) => {
130 const { Svg } = ui
131 return Svg && <Svg source={svg} alt={alt} width={width} height={height} />
132}
133
134export const kindIcon = (ui: Ui, kind: Kind | 'plugin', look: Look, px = ICON_PX) => (
135 <Icon
136 ui={ui}
137 svg={iconSvg(kind, hexOr(look.color))}
138 alt={TOGGLES[kind].noun}
139 width={px}
140 height={px}
141 />
142)
143
144const withTint = (tint: Color | undefined) => (tint === undefined ? {} : { backgroundColor: tint })
145
146/** The untinted text look keeps its two-cell gap so plain chips stay apart. */
147const chipGapOf = (tone: Tone | undefined) => (tone === undefined ? 2 : SPACE.chipGap)
148
149const chipPad = (tone: Tone | undefined) => (tone === undefined ? 0 : SPACE.chipPadX)
150
151export const gapOf = (tone: Tone | undefined) => across(chipGapOf(tone))
152
153type Parts = {
154 glyph: string
155 isFresh: boolean
156 lead: string
157 label: string
158 count: string
159 via: string
160 badge: string
161}
162
163/** The badge a status wears: the design's mixed-case word on rich tinted chips, else the upper-case one. */
164export const badgeOf = (status: Status, tone: Tone | undefined, rich: boolean) => {
165 if (status === 'error' && tone !== undefined) return 'Error'
166 const theme = STATUSES[status]
167 return rich && tone !== undefined && isStopped(status) ? theme.label : (theme.badge ?? '')
168}
169
170const partsOf = (
171 a: Activation,
172 settings: Settings,
173 now: number,
174 tone: Tone | undefined,
175 rich: boolean,
176 labelMax = LABEL_MAX,
177): Parts => {
178 const status = STATUSES[a.status]
179 const frame = Math.floor(now / SPIN_MS)
180 const tally = a.count > 1 && TOGGLES[a.kind].showsTally ? `×${a.count}` : ''
181 const isFresh = a.lastAt > now - FRESH_MS
182 return {
183 glyph:
184 a.status === 'running'
185 ? SPINNER[frame % SPINNER.length]!
186 : (status.glyph ?? TOGGLES[a.kind].glyph),
187 isFresh,
188 lead: isFresh && !(rich && tone !== undefined) ? '● ' : '',
189 label: clip(a.label, labelMax),
190 count: tone !== undefined && tally !== '' ? ` ${tally} ` : tally,
191 via: settings.shown.plugin && a.plugin !== undefined ? `via ${clip(a.plugin, 16)}` : '',
192 badge: badgeOf(a.status, tone, rich),
193 }
194}
195
196export const chipWidth = (
197 a: Activation,
198 settings: Settings,
199 now: number,
200 tone: Tone | undefined,
201 hasHotkey: boolean,
202 rich = false,
203) => {
204 const p = partsOf(a, settings, now, tone, rich)
205 const padding = a.status === 'error' && tone !== undefined ? 0 : 2
206 const segments = [
207 1,
208 p.lead.length + p.label.length + (hasHotkey ? 3 : 0),
209 p.count.length,
210 p.via.length,
211 p.badge.length > 0 ? p.badge.length + padding : 0,
212 ].filter(n => n > 0)
213 return (
214 segments.reduce((sum, n) => sum + n, 0) +
215 (segments.length - 1) * across(SPACE.chipInner) +
216 2 * across(chipPad(tone)) +
217 (rich && tone ? 2 : 0) +
218 (rich && tone && isStopped(a.status) ? 1 : 0)
219 )
220}
221
222const moreLabel = (n: number) => `+${n} more`
223
224const MORE_BOX = 2 + 2 * across(SPACE.morePadX)
225
226export const fitChips = (
227 chips: readonly Activation[],
228 columns: number,
229 settings: Settings,
230 now: number,
231 tone: Tone | undefined,
232 rich = false,
233) => {
234 const gap = gapOf(tone)
235 const shown: Activation[] = []
236 let used = 0
237 let labelMax = LABEL_MAX
238 for (const [index, chip] of chips.entries()) {
239 if (shown.length >= settings.maxChips) break
240 const width =
241 chipWidth(chip, settings, now, tone, settings.hotkeys && index < 9, rich) +
242 (shown.length > 0 ? gap : 0)
243 const rest = chips.length - shown.length - 1
244 const reserve =
245 rest > 0
246 ? gap + moreLabel(rest).length + (settings.hotkeys ? 3 : 0) + (rich && tone ? MORE_BOX : 0)
247 : 0
248 const excess = used + width + reserve - columns
249 if (shown.length > 0 && excess > 0) break
250 if (excess > 0) labelMax = Math.max(2, Math.min(chip.label.length, LABEL_MAX) - excess)
251 shown.push(chip)
252 used += width
253 }
254 return { shown, hidden: chips.length - shown.length, labelMax }
255}
256
257const stoppedCount = (chips: readonly Activation[]) => chips.filter(a => isStopped(a.status)).length
258
259export const Badge = ({
260 ui,
261 text,
262 tone,
263 status,
264 bg,
265}: {
266 ui: Ui
267 text: string
268 tone: Tone | undefined
269 status?: Status
270 bg?: Color
271}) => {
272 const { Text } = ui
273 if (tone === undefined) return <Text inverse bold color="error">{` ${text} `}</Text>
274 if (status === 'error') {
275 return (
276 <Text bold size="caption" {...inkOf(ui, tone, 'danger', bg)}>
277 {text}
278 </Text>
279 )
280 }
281 return (
282 <Text bold size="caption" color={STOPPED.fg} backgroundColor={STOPPED.bg}>
283 {` ${text} `}
284 </Text>
285 )
286}
287
288export type Actions = {
289 pick: (id: string, fitsCard: boolean) => void
290 open: () => void
291 settings: () => void
292 close: () => void
293 mute: (id: string) => void
294 copy: (text: string) => void
295}
296
297type RowArgs = {
298 ui: Ui
299 surface: RenderSurface
300 columns: number
301 now: number
302 tone: Tone | undefined
303 chips: readonly Activation[]
304 settings: Settings
305 selected: string | null
306 fits: (a: Activation) => boolean
307 on: Pick<Actions, 'pick' | 'open'>
308}
309
310type ChipIconArgs = { ui: Ui; a: Activation; look: Look; now: number; isFresh: boolean }
311
312const ChipIcon = ({ ui, a, look, now, isFresh }: ChipIconArgs) => {
313 const noun = TOGGLES[a.kind].noun
314 if (a.status === 'running') {
315 const frame = Math.floor(now / SPIN_MS)
316 const svg = spinnerSvg(frame, hexOr(look.color), hexOr(look.border, OFF_TRACK))
317 return <Icon ui={ui} svg={svg} alt={noun} />
318 }
319 if (a.status === 'error') return <Icon ui={ui} svg={errorSvg(hexOr(look.color))} alt={noun} />
320 if (isFresh && look.tint !== undefined) {
321 const svg = dotSvg(hexOr(look.color))
322 return <Icon ui={ui} svg={svg} alt={noun} width={FRESH_DOT_PX} height={FRESH_DOT_PX} />
323 }
324 return kindIcon(ui, a.kind, look)
325}
326
327const chipBorder = (
328 look: Look,
329 a: Activation,
330 tone: Tone | undefined,
331 p: Parts,
332 isSelected: boolean,
333) => {
334 if (isSelected) return look.color
335 if (tone !== undefined && isStopped(a.status)) return TOKENS[tone].dangerBd
336 return p.isFresh ? look.color : look.border
337}
338
339type ChipArgs = { args: RowArgs; a: Activation; index: number; labelMax: number }
340
341const Chip = ({ args, a, index, labelMax }: ChipArgs) => {
342 const { ui, surface, now, tone, settings, selected, fits, on } = args
343 const { Box, Button, Text } = ui
344 const rich = isRich(ui)
345 const p = partsOf(a, settings, now, tone, rich, labelMax)
346 const look = lookOf(a.kind, tone, a.status)
347 const { color, tint, dim } = look
348 const paint = { color, dimColor: dim, ...withTint(tint) }
349 const id = chipId(a)
350 const pill = isPill(ui, tone)
351 const badge =
352 p.badge === '' ? undefined : (
353 <Badge ui={ui} text={p.badge} tone={tone} status={a.status} bg={tint} />
354 )
355 return (
356 <Box
357 key={`box-${id}`}
358 flexDirection="row"
359 columnGap={SPACE.chipInner}
360 paddingX={chipPad(tone)}
361 {...withTint(tint)}
362 {...(pill && {
363 borderStyle: a.status === 'error' ? 'dashed' : 'round',
364 borderColor: chipBorder(look, a, tone, p, selected === id),
365 })}
366 {...(rich && look.hover !== undefined && { hover: { backgroundColor: look.hover } })}
367 >
368 {rich && <ChipIcon ui={ui} a={a} look={look} now={now} isFresh={p.isFresh} />}
369 <Button
370 key={`chip-${a.kind}-${a.key}`}
371 plain
372 {...(settings.hotkeys && index < 9 && { hotkey: String(index + 1) })}
373 onPress={() => on.pick(id, fits(a))}
374 >
375 {!rich && (
376 <Text {...paint} {...(a.status === 'error' && { color: 'error', dimColor: false })}>
377 {`${p.glyph} `}
378 </Text>
379 )}
380 <Text {...paint} bold={p.lead !== ''} underline={selected === id}>
381 {p.lead + p.label}
382 </Text>
383 </Button>
384 {p.count !== '' && (
385 <Text
386 mono
387 size="caption"
388 {...paint}
389 {...(look.count !== undefined && { backgroundColor: look.count })}
390 >
391 {p.count}
392 </Text>
393 )}
394 {p.via && (
395 <Text dimColor {...withTint(tint)}>
396 {p.via}
397 </Text>
398 )}
399 {badge !== undefined &&
400 (surface === 'desktop' && tone !== undefined && isStopped(a.status) ? (
401 <Box flexDirection="row" backgroundColor={STOPPED.bg}>
402 <Icon
403 ui={ui}
404 svg={stoppedSvg(STOPPED.fg)}
405 alt=""
406 width={STOPPED_ICON_PX}
407 height={STOPPED_ICON_PX}
408 />
409 {badge}
410 </Box>
411 ) : (
412 badge
413 ))}
414 </Box>
415 )
416}
417
418const MoreBox = ({
419 ui,
420 tone,
421 children,
422}: {
423 ui: Ui
424 tone: Tone | undefined
425 children: RenderElement
426}) =>
427 isPill(ui, tone) ? (
428 <ui.Box borderStyle="dashed" borderColor={TOKENS[tone!].lineDashed} paddingX={SPACE.morePadX}>
429 {[children]}
430 </ui.Box>
431 ) : (
432 <>{[children]}</>
433 )
434
435const Summary = ({ args }: { args: RowArgs }) => {
436 const { ui, tone, chips, settings, on } = args
437 const { Box, Button, Text } = ui
438 const rich = isRich(ui)
439 const neutral = tone === undefined ? undefined : TOKENS[tone].surface
440 const stopped = stoppedCount(chips)
441 const first = KIND_ORDER.findIndex(kind => chips.some(a => a.kind === kind))
442 return (
443 <Box
444 flexDirection="row"
445 columnGap={SPACE.summaryGap}
446 paddingX={tone === undefined ? 0 : SPACE.summaryPadX}
447 {...withTint(neutral)}
448 {...(isPill(ui, tone) && { borderStyle: 'round', borderColor: TOKENS[tone!].line })}
449 >
450 {KIND_ORDER.flatMap((kind, index) => {
451 const n = chips.filter(a => a.kind === kind).length
452 if (n === 0) return []
453 const look = lookOf(kind, tone)
454 const paint = { color: look.color, dimColor: look.dim, ...withTint(neutral) }
455 return [
456 <Box key={`sum-${kind}`} flexDirection="row" columnGap={SPACE.chipInner}>
457 {rich && kindIcon(ui, kind, look, SUMMARY_ICON_PX)}
458 <Button
459 key={`summary-${kind}`}
460 plain
461 {...(settings.hotkeys && index === first && { hotkey: '1' })}
462 onPress={() => on.open()}
463 >
464 <Text {...paint}>{rich ? String(n) : `${TOGGLES[kind].glyph}${n}`}</Text>
465 </Button>
466 </Box>,
467 ]
468 })}
469 {stopped > 0 && isPill(ui, tone) && <Text color={TOKENS[tone!].line}>│</Text>}
470 {stopped > 0 && (
471 <Button key="summary-stopped" plain onPress={() => on.open()}>
472 <Text {...inkOf(ui, tone, 'danger', neutral)}>{`● ${stopped} blocked`}</Text>
473 </Button>
474 )}
475 </Box>
476 )
477}
478
479export const ChipRow = (args: RowArgs) => {
480 const { ui, columns, now, tone, chips, settings, on } = args
481 const { Box, Button, Text } = ui
482 const gap = chipGapOf(tone)
483 if (settings.layout === 'summary') return <Summary args={args} />
484 const { shown, hidden, labelMax } = fitChips(
485 chips,
486 columns - MORE_RESERVE - GEAR_RESERVE,
487 settings,
488 now,
489 tone,
490 isRich(ui),
491 )
492 return (
493 <Box flexDirection="row" columnGap={gap}>
494 {shown.map((a, index) => (
495 <Chip args={args} a={a} index={index} labelMax={index === 0 ? labelMax : LABEL_MAX} />
496 ))}
497 {hidden > 0 && (
498 <MoreBox ui={ui} tone={tone}>
499 <Button
500 key="more"
501 plain
502 {...(tone === undefined && { dimColor: true })}
503 {...(settings.hotkeys && { hotkey: '0' })}
504 onPress={() => on.open()}
505 >
506 <Text {...(tone ? { color: TOKENS[tone].text2 } : { dimColor: true })}>
507 {moreLabel(hidden)}
508 </Text>
509 </Button>
510 </MoreBox>
511 )}
512 </Box>
513 )
514}
515
516const Tile = ({
517 ui,
518 tone,
519 title,
520 value,
521 danger = false,
522}: {
523 ui: Ui
524 tone: Tone | undefined
525 title: string
526 value: string
527 danger?: boolean
528}) => {
529 const { Box, Text } = ui
530 const tint = tone === undefined ? undefined : TOKENS[tone].tile
531 return (
532 <Box
533 key={`tile-${title}`}
534 flexDirection="column"
535 flexGrow={1}
536 paddingX={SPACE.tilePadX}
537 paddingY={SPACE.tilePadY}
538 rowGap={SPACE.tileGap}
539 {...withTint(tint)}
540 >
541 <Text size="footnote" {...inkOf(ui, tone, 'muted', tint)}>
542 {title}
543 </Text>
544 <Text bold size="footnote" {...inkOf(ui, tone, danger ? 'danger' : 'ink', tint)}>
545 {value}
546 </Text>
547 </Box>
548 )
549}
550
551const canMute = (a: Activation) => !isUrgent(a.status)
552
553const hasActions = (a: Activation) => a.source !== undefined || canMute(a)
554
555/** Terminal rows the card takes: border, padding, its parts and the gaps between them. */
556export const cardRows = (a: Activation, columns: number, density: CardDensity) => {
557 const space = CARD_SPACE[density]
558 const inner = Math.max(10, columns - 2 - 2 * across(space.padX))
559 const why = Math.max(1, Math.ceil(a.detail.length / inner))
560 const header = 2 + down(SPACE.cardLinesGap)
561 const tiles = 2 + down(SPACE.tileGap) + 2 * down(SPACE.tilePadY)
562 const lines = [a.source !== undefined, hasActions(a)].filter(Boolean).length
563 const parts = 3 + lines
564 const content = header + why + tiles + lines
565 return 2 + 2 * down(space.padY) + content + (parts - 1) * down(space.gap)
566}
567
568/**
569 * Points the card takes on desktop, measured off the Desktop app: there the band shows
570 * `maxRows` lines of the code font whole and scrolls anything taller, so the card must fit.
571 */
572const DESKTOP_PT = {
573 /** A band line: a 248 pt band of 12 showed whole. */
574 line: 20.5,
575 step: { xs: 6, sm: 9, md: 12, lg: 16, xl: 24 },
576 border: 2,
577 header: 36,
578 whyLine: 20,
579 /** The why line's proportional font fits about 1.25 characters per code-font cell. */
580 whyPerCell: 1.2,
581 tiles: 48,
582 source: 16,
583 actions: 20,
584 /** The engine's padding around the band, and the bordered chip row. */
585 band: 13,
586 chipRow: 36,
587} as const
588
589const points = (space: Space) => (typeof space === 'number' ? space : DESKTOP_PT.step[space])
590
591export const cardPoints = (
592 a: Activation,
593 columns: number,
594 density: CardDensity,
595 isWhyBrief = false,
596) => {
597 const space = CARD_SPACE[density]
598 const inner = Math.max(10, columns - 2 * across(space.padX))
599 const perLine = inner * DESKTOP_PT.whyPerCell
600 const whyLines = isWhyBrief ? 1 : Math.max(1, Math.ceil(a.detail.length / perLine))
601 const why = whyLines * DESKTOP_PT.whyLine
602 const lines =
603 (a.source !== undefined ? DESKTOP_PT.source : 0) + (hasActions(a) ? DESKTOP_PT.actions : 0)
604 const parts = 3 + [a.source !== undefined, hasActions(a)].filter(Boolean).length
605 const content = DESKTOP_PT.header + why + DESKTOP_PT.tiles + lines
606 return DESKTOP_PT.border + 2 * points(space.padY) + content + (parts - 1) * points(space.gap)
607}
608
609const fitsDesktop = (
610 a: Activation,
611 columns: number,
612 density: CardDensity,
613 maxRows: number,
614 isWhyBrief = false,
615) =>
616 maxRows * DESKTOP_PT.line >=
617 DESKTOP_PT.band +
618 DESKTOP_PT.chipRow +
619 points(SPACE.cardAbove) +
620 cardPoints(a, columns, density, isWhyBrief)
621
622/** How the card fits the band: its density, and whether its why line keeps to one line. */
623type CardFit = { density: CardDensity; isWhyBrief: boolean }
624
625export type CardArgs = {
626 ui: Ui
627 tone: Tone | undefined
628 now: number
629 a: Activation
630 isMuted: boolean
631 density: CardDensity
632 /** The band sizes the card by its lines, so there the head and the name keep to one each. */
633 isInBand?: boolean
634 /** On desktop, where even the compact card is too tall, the why line keeps to one line too. */
635 isWhyBrief?: boolean
636 on: Pick<Actions, 'mute' | 'copy'> & { close?: () => void }
637}
638
639export const DetailCard = ({
640 ui,
641 tone,
642 now,
643 a,
644 isMuted,
645 density,
646 isInBand = false,
647 isWhyBrief = false,
648 on,
649}: CardArgs) => {
650 const { Box, Button, Text } = ui
651 const space = CARD_SPACE[density]
652 const look = lookOf(a.kind, tone)
653 const { color, dim } = look
654 const theme = TOGGLES[a.kind]
655 const status = STATUSES[a.status]
656 const id = chipId(a)
657 const surface = tone !== undefined && isRich(ui) ? TOKENS[tone].surface : undefined
658 const nameWrap = isInBand ? { wrap: 'truncate-end' as const } : {}
659 return (
660 <Box
661 key="card"
662 flexDirection="column"
663 borderStyle="round"
664 borderColor={tone === undefined ? color : TOKENS[tone].line}
665 borderDimColor={dim}
666 paddingX={space.padX}
667 paddingY={space.padY}
668 rowGap={space.gap}
669 {...withTint(surface)}
670 >
671 <Box flexDirection="column" rowGap={SPACE.cardLinesGap}>
672 <Box flexDirection="row" justifyContent="space-between" columnGap={SPACE.cardHeadGap}>
673 <Box flexShrink={1} minWidth={0}>
674 <Text
675 mono
676 size="caption"
677 color={color}
678 dimColor={dim}
679 {...(isInBand && { wrap: 'truncate-end' as const })}
680 {...withTint(surface)}
681 >
682 {[theme.noun.toUpperCase(), a.meta].filter(Boolean).join(' · ')}
683 </Text>
684 </Box>
685 {on.close && (
686 <Button key="close" plain hotkey="x" label="Close" onPress={() => on.close?.()} />
687 )}
688 </Box>
689 {isRich(ui) ? (
690 <Box flexDirection="row" columnGap={SPACE.cardHeadGap} minWidth={0}>
691 {kindIcon(ui, a.kind, look)}
692 <Text bold size="heading" {...nameWrap} {...inkOf(ui, tone, 'ink', surface)}>
693 {a.label}
694 </Text>
695 </Box>
696 ) : (
697 <Text bold {...nameWrap}>
698 {a.label}
699 </Text>
700 )}
701 </Box>
702 <Text wrap={isWhyBrief ? 'truncate-end' : 'wrap'} {...inkOf(ui, tone, 'ink2', surface)}>
703 {a.detail}
704 </Text>
705 <Box flexDirection="row" columnGap={SPACE.tilesGap}>
706 <Tile ui={ui} tone={tone} title="Status" value={status.label} danger={isUrgent(a.status)} />
707 <Tile ui={ui} tone={tone} title="Activations" value={String(a.count)} />
708 <Tile ui={ui} tone={tone} title="Last" value={agoLabel(now - a.lastAt)} />
709 </Box>
710 {a.source !== undefined && (
711 <Text mono size="footnote" wrap="truncate-middle" {...inkOf(ui, tone, 'muted', surface)}>
712 {a.source}
713 </Text>
714 )}
715 {hasActions(a) && (
716 <Box flexDirection="row" columnGap={SPACE.actionsGap}>
717 {a.source !== undefined && (
718 <Button
719 key="open"
720 hotkey="o"
721 label="o: Open source"
722 onPress={() => on.copy(a.source!)}
723 />
724 )}
725 {canMute(a) && (
726 <Button
727 key="mute"
728 hotkey="m"
729 label={isMuted ? 'm: Unmute' : 'm: Mute for this session'}
730 onPress={() => on.mute(id)}
731 />
732 )}
733 </Box>
734 )}
735 </Box>
736 )
737}
738
739const Gear = ({
740 ui,
741 tone,
742 on,
743}: {
744 ui: Ui
745 tone: Tone | undefined
746 on: Pick<Actions, 'settings'>
747}) => {
748 const { Button, Text } = ui
749 return (
750 <Button key="settings-gear" plain onPress={() => on.settings()}>
751 <Text {...(tone ? { color: TOKENS[tone].text2 } : { dimColor: true })}>{'\u2699'}</Text>
752 </Button>
753 )
754}
755
756export type BandArgs = Omit<RowArgs, 'fits' | 'on'> & {
757 maxRows: number
758 below: RenderElement
759 on: Actions
760}
761
762export const renderBand = (args: BandArgs) => {
763 const { ui, columns, maxRows, now, tone, chips, settings, selected, below, on } = args
764 const { Box } = ui
765 const chipRowRows = isPill(ui, tone) ? 3 : 1
766 const fitOf = (a: Activation): CardFit | undefined => {
767 const density = CARD_DENSITIES.find(density =>
768 isRich(ui)
769 ? fitsDesktop(a, columns, density, maxRows)
770 : maxRows >=
771 PROMPT_ROWS + chipRowRows + down(SPACE.cardAbove) + cardRows(a, columns, density),
772 )
773 if (density !== undefined) return { density, isWhyBrief: false }
774 // How a why line wraps on desktop is a guess, so one that may not fit keeps to one line.
775 return isRich(ui) && fitsDesktop(a, columns, 'compact', maxRows, true)
776 ? { density: 'compact', isWhyBrief: true }
777 : undefined
778 }
779 const fits = (a: Activation) => fitOf(a) !== undefined
780 const current = settings.layout === 'row' ? chips.find(a => chipId(a) === selected) : undefined
781 const fit = current && fitOf(current)
782 return (
783 <Box flexDirection="column">
784 {below}
785 {current && fit && (
786 <Box key="card-slot" flexDirection="column" marginBottom={SPACE.cardAbove}>
787 <DetailCard
788 ui={ui}
789 tone={tone}
790 now={now}
791 a={current}
792 isMuted={false}
793 density={fit.density}
794 isWhyBrief={fit.isWhyBrief}
795 isInBand
796 on={{ mute: on.mute, copy: on.copy, close: on.close }}
797 />
798 </Box>
799 )}
800 <Box
801 key="band-row"
802 flexDirection="row"
803 justifyContent="space-between"
804 columnGap={GEAR_RESERVE - 1}
805 >
806 <ChipRow {...args} fits={fits} on={on} />
807 <Gear ui={ui} tone={tone} on={on} />
808 </Box>
809 </Box>
810 )
811}
812src/theme.ts 467 lines1import type { Color, RenderSurface, Space } from 'claude-code'
2
3import type { ChipStyle, ChipTheme, Kind, Status, Toggle, Tone } from '../types'
4
5export type { Tone }
6
7export type KindPaint = { fg: string; bg: string; bd: string }
8
9export type TogglePalette = Record<Tone, KindPaint> & { key: Color }
10
11/** The design's neutral tokens, one set per tone. `countBg` is a translucent wash over the chip. */
12export type Tokens = {
13 page: string
14 surface: string
15 surface2: string
16 tile: string
17 fill: string
18 fill2: string
19 line: string
20 lineSoft: string
21 lineStrong: string
22 lineDashed: string
23 ink: string
24 ink2: string
25 text2: string
26 muted: string
27 inverseBg: string
28 onInk: string
29 switchOn: string
30 switchOff: string
31 countBg: { base: string; alpha: number }
32 linkHover: string
33 danger: string
34 dangerBd: string
35}
36
37export const TOKENS: Record<Tone, Tokens> = {
38 light: {
39 page: '#F5F6F8',
40 surface: '#FFFFFF',
41 surface2: '#F9FAFB',
42 tile: '#F5F6F8',
43 fill: '#F0F1F4',
44 fill2: '#E8EAEE',
45 line: '#DADCE1',
46 lineSoft: '#ECEDF0',
47 lineStrong: '#CFD2D8',
48 lineDashed: '#B9BCC4',
49 ink: '#17181C',
50 ink2: '#2A2D35',
51 text2: '#3B3F48',
52 muted: '#5A5E68',
53 inverseBg: '#17181C',
54 onInk: '#FFFFFF',
55 switchOn: '#17181C',
56 switchOff: '#B9BCC4',
57 countBg: { base: '#17181C', alpha: 0.07 },
58 linkHover: '#163A8F',
59 danger: '#B42318',
60 dangerBd: '#E8A39C',
61 },
62 dark: {
63 page: '#111214',
64 surface: '#1B1D21',
65 surface2: '#1F2125',
66 tile: '#26282D',
67 fill: '#2A2C31',
68 fill2: '#2E3036',
69 line: '#34373E',
70 lineSoft: '#2A2C31',
71 lineStrong: '#4A4E57',
72 lineDashed: '#5A5F69',
73 ink: '#ECEDEF',
74 ink2: '#D5D8DE',
75 text2: '#B4B8C1',
76 muted: '#9499A3',
77 inverseBg: '#ECEDEF',
78 onInk: '#17181C',
79 switchOn: '#5B7FE0',
80 switchOff: '#4A4E57',
81 countBg: { base: '#FFFFFF', alpha: 0.1 },
82 linkHover: '#B7CBFF',
83 danger: '#F97066',
84 dangerBd: '#7A2E28',
85 },
86}
87
88type Neutral = Exclude<keyof Tokens, 'countBg'>
89
90const neutral = (bg: Neutral, bd: Neutral, key: Color): TogglePalette => {
91 const paint = (tone: Tone): KindPaint => {
92 const t = TOKENS[tone]
93 return { fg: t.ink2, bg: t[bg], bd: t[bd] }
94 }
95 return { light: paint('light'), dark: paint('dark'), key }
96}
97
98const RULE = neutral('surface', 'lineStrong', 'text')
99
100const OPTIONAL = neutral('fill', 'line', 'subtle')
101
102export type ToggleTheme = {
103 option: string
104 title: string
105 noun: string
106 plural: string
107 description: string
108 example: string
109 glyph: string
110 hotkey: string
111 optional: boolean
112 showsTally: boolean
113 dim: boolean
114 palette: TogglePalette
115}
116
117export const TOGGLES: Record<Toggle, ToggleTheme> = {
118 skill: {
119 option: 'show_skills',
120 title: 'Skills',
121 noun: 'Skill',
122 plural: 'Skills',
123 description: 'SKILL.md files Claude loaded because your request matched them.',
124 example: 'docx',
125 glyph: '▤',
126 hotkey: 's',
127 optional: false,
128 showsTally: true,
129 dim: false,
130 palette: {
131 light: { fg: '#1F4FC1', bg: '#EDF2FD', bd: '#C9D7F7' },
132 dark: { fg: '#8FB0FF', bg: '#1B2A4A', bd: '#2D4373' },
133 key: 'suggestion',
134 },
135 },
136 agent: {
137 option: 'show_agents',
138 title: 'Agents',
139 noun: 'Agent',
140 plural: 'Agents',
141 description: 'Subagents Claude handed work to, built-in or from .claude/agents.',
142 example: 'code-reviewer',
143 glyph: '◉',
144 hotkey: 'g',
145 optional: false,
146 showsTally: true,
147 dim: false,
148 palette: {
149 light: { fg: '#5B3A99', bg: '#F3EEFB', bd: '#D9CCF0' },
150 dark: { fg: '#C3A8F5', bg: '#2C2147', bd: '#463470' },
151 key: 'merged',
152 },
153 },
154 mcp: {
155 option: 'show_mcp',
156 title: 'MCP servers',
157 noun: 'MCP',
158 plural: 'MCP',
159 description: 'Servers whose tools were called, with a count and any auth failures.',
160 example: 'github',
161 glyph: '⇌',
162 hotkey: 'p',
163 optional: false,
164 showsTally: true,
165 dim: true,
166 palette: {
167 light: { fg: '#9C1F62', bg: '#FCEEF5', bd: '#F0C8DC' },
168 dark: { fg: '#F29BC8', bg: '#3D1A2D', bd: '#63304A' },
169 key: 'merged',
170 },
171 },
172 perm: {
173 option: 'show_permissions',
174 title: 'Permissions',
175 noun: 'Permission',
176 plural: 'Permissions',
177 description: 'Actions you approved, auto-allowed by a rule, or denied.',
178 example: 'git push',
179 glyph: '⚷',
180 hotkey: 'k',
181 optional: true,
182 showsTally: true,
183 dim: false,
184 palette: {
185 light: { fg: '#0E6159', bg: '#E8F5F2', bd: '#BCE0D8' },
186 dark: { fg: '#7FD6C8', bg: '#123A35', bd: '#22594F' },
187 // Every Claude Code theme gives `permission` the same blue as `suggestion` (Skill); `planMode` is its teal.
188 key: 'planMode',
189 },
190 },
191 hook: {
192 option: 'show_hooks',
193 title: 'Hooks',
194 noun: 'Hook',
195 plural: 'Hooks',
196 description: 'Commands from settings that ran on events like PreToolUse or Stop.',
197 example: 'lint-staged',
198 glyph: '↯',
199 hotkey: 'h',
200 optional: false,
201 showsTally: true,
202 dim: false,
203 palette: {
204 light: { fg: '#9A4A0B', bg: '#FDF1E6', bd: '#F0CFAE' },
205 dark: { fg: '#F5B47A', bg: '#3D2614', bd: '#643F20' },
206 key: 'warning',
207 },
208 },
209 rule: {
210 option: 'show_rules',
211 title: 'Rules',
212 noun: 'Rule',
213 plural: 'Rules',
214 description: 'CLAUDE.md and .claude/rules files Claude loaded.',
215 example: 'CLAUDE.md',
216 glyph: '▣',
217 hotkey: 'r',
218 optional: false,
219 showsTally: true,
220 dim: false,
221 palette: RULE,
222 },
223 command: {
224 option: 'show_commands',
225 title: 'Slash commands',
226 noun: 'Slash command',
227 plural: 'Commands',
228 description: 'Custom commands from .claude/commands that ran.',
229 example: 'release-notes',
230 glyph: '/',
231 hotkey: 'o',
232 optional: true,
233 showsTally: true,
234 dim: true,
235 palette: OPTIONAL,
236 },
237 memory: {
238 option: 'show_memory',
239 title: 'Memory',
240 noun: 'Memory',
241 plural: 'Memory',
242 description: 'When Claude writes a memory file, so you can see what it learned.',
243 example: 'Remembered: CLAUDE.md',
244 glyph: '✎',
245 hotkey: 'e',
246 optional: true,
247 showsTally: true,
248 dim: true,
249 palette: OPTIONAL,
250 },
251 plugin: {
252 option: 'show_plugins',
253 title: 'Plugins',
254 noun: 'Plugin',
255 plural: 'Plugins',
256 description: 'Tags each chip with the plugin that supplied it.',
257 example: 'via acme-devtools',
258 glyph: '⬡',
259 hotkey: 'u',
260 optional: true,
261 showsTally: false,
262 dim: true,
263 palette: OPTIONAL,
264 },
265 context: {
266 option: 'show_context',
267 title: 'Context',
268 noun: 'Context',
269 plural: 'Context',
270 description: 'Compaction events and a context-usage gauge.',
271 example: 'Compacted',
272 glyph: '◔',
273 hotkey: 'x',
274 optional: true,
275 showsTally: false,
276 dim: true,
277 palette: OPTIONAL,
278 },
279 checkpoint: {
280 option: 'show_checkpoints',
281 title: 'Checkpoints',
282 noun: 'Checkpoint',
283 plural: 'Checkpoints',
284 description: 'Restore points made before edits. Not supported yet: Claude Code sends no event.',
285 example: 'Checkpoint 3',
286 glyph: '⚑',
287 hotkey: 'f',
288 optional: true,
289 showsTally: true,
290 dim: true,
291 palette: OPTIONAL,
292 },
293 model: {
294 option: 'show_model',
295 title: 'Model switches',
296 noun: 'Model',
297 plural: 'Model',
298 description: 'A quiet chip when the model changes mid-session.',
299 example: 'Model: opus',
300 glyph: '≡',
301 hotkey: 'l',
302 optional: true,
303 showsTally: false,
304 dim: true,
305 palette: OPTIONAL,
306 },
307 background: {
308 option: 'show_background',
309 title: 'Background tasks',
310 noun: 'Background task',
311 plural: 'Background',
312 description: 'Long-running shells or dev servers still alive.',
313 example: 'pnpm dev',
314 glyph: '▶',
315 hotkey: 'b',
316 optional: true,
317 showsTally: true,
318 dim: true,
319 palette: OPTIONAL,
320 },
321}
322
323export const TOGGLE_ORDER = Object.keys(TOGGLES) as Toggle[]
324
325export const isKind = (toggle: Toggle): toggle is Kind => toggle !== 'plugin'
326
327export const KIND_ORDER = TOGGLE_ORDER.filter(isKind)
328
329const channels = (hex: string) =>
330 [1, 3, 5].map(i => Number.parseInt(hex.slice(i, i + 2), 16)) as [number, number, number]
331
332/** Blends `#RRGGBB` a toward b by t (0 is a, 1 is b). */
333export const mix = (a: string, b: string, t: number) => {
334 const [from, to] = [channels(a), channels(b)]
335 const hex = from.map((c, i) => {
336 const value = Math.round(c + (to[i]! - c) * t)
337 return value.toString(16).padStart(2, '0')
338 })
339 return `#${hex.join('').toUpperCase()}`
340}
341
342export const HOVER_MIX = 0.1
343
344/** The Blocked and Denied badge: white on red in both tones. */
345export const STOPPED = { fg: '#FFFFFF', bg: '#B42318' } as const
346
347export const SPINNER = ['◐', '◓', '◑', '◒'] as const
348
349export const SPIN_MS = 150
350
351export const FRESH_MS = 4000
352
353const CLAUDE_THEME = /^(light|dark)(-daltonized|-ansi)?$/
354
355export type ToneInput = {
356 chipStyle: ChipStyle
357 chipTheme: ChipTheme
358 /** The Claude Code `theme` setting. */
359 claudeTheme: unknown
360 surface: RenderSurface
361 /** The tone `COLORFGBG` names, when the terminal sets it. */
362 colorFgBg: Tone | undefined
363 /** The tone the operating system appearance names. */
364 system: Tone | undefined
365}
366
367/**
368 * The tone chips are drawn in, or undefined for the untinted look. Claude Code's own
369 * `auto` theme answers an OSC 11 query mods cannot see, so the terminal falls back to
370 * `COLORFGBG`, then the OS appearance. Other surfaces follow the OS or app first.
371 */
372export const resolveTone = (input: ToneInput): Tone | undefined => {
373 const { chipStyle, chipTheme, claudeTheme, surface, colorFgBg, system } = input
374 if (chipStyle === 'text') return undefined
375 if (chipTheme !== 'auto') return chipTheme
376 const match = typeof claudeTheme === 'string' ? CLAUDE_THEME.exec(claudeTheme) : null
377 const isAnsi = match?.[2] === '-ansi'
378 const claude = match && !isAnsi ? (match[1] as Tone) : undefined
379 if (surface !== 'terminal') return system ?? claude ?? 'light'
380 return isAnsi ? undefined : (claude ?? colorFgBg ?? system ?? 'dark')
381}
382
383export type StatusTheme = { label: string; badge?: string; glyph?: string }
384
385export const STATUSES: Record<Status, StatusTheme> = {
386 active: { label: 'Active' },
387 ok: { label: 'OK' },
388 running: { label: 'Running' },
389 blocked: { label: 'Blocked', badge: 'BLOCKED' },
390 denied: { label: 'Denied', badge: 'DENIED' },
391 error: { label: 'Error', badge: 'ERROR', glyph: '▲' },
392}
393
394export const isStopped = (status: Status) => status === 'blocked' || status === 'denied'
395
396export const isUrgent = (status: Status) => isStopped(status) || status === 'error'
397
398/**
399 * Layout spacing by design role, as the step each board pixel value rounds to
400 * (6px xs, 8-10px sm, 12-14px md, 16-20px lg, 24px and up xl). Desktop and the
401 * phones size a step in pixels; the terminal and VS Code turn it into cells with `across` and `down`.
402 */
403export const SPACE = {
404 chipGap: 'xs',
405 chipInner: 'xs',
406 chipPadX: 'sm',
407 morePadX: 'sm',
408 summaryGap: 'sm',
409 summaryPadX: 'md',
410 cardAbove: 'sm',
411 cardHeadGap: 'sm',
412 cardLinesGap: 'xs',
413 tilesGap: 'sm',
414 tilePadX: 'sm',
415 tilePadY: 'sm',
416 tileGap: 'xs',
417 actionsGap: 'sm',
418 paneLeft: 'md',
419 paneRight: 'sm',
420 paneGap: 'md',
421 paneTitleGap: 'xs',
422 statsGap: 'xs',
423 tabsBelow: 'md',
424 tabGap: 'lg',
425 groupGap: 'xs',
426 listGap: 'lg',
427 rowGrid: 'sm',
428 rowTextGap: 'xs',
429 rowSide: 'sm',
430 settingsGap: 'xl',
431 introGap: 'xs',
432 sectionGap: 'sm',
433 previewGap: 'sm',
434 segmentsHeadGap: 'md',
435 segmentTrack: 'xs',
436 promptPadX: 'md',
437 previewPad: 'lg',
438 previewInnerGap: 'sm',
439 switchCardPadLeft: 'lg',
440 switchCardPadRight: 'sm',
441 switchCardPadY: 'xs',
442 optionalHeadGap: 'sm',
443 switchGrid: 'md',
444 switchSide: 'md',
445 footerGap: 'sm',
446} as const satisfies Record<string, Space>
447
448/** How much air the detail card keeps: roomy where the band has the rows, compact where it is short. */
449export type CardDensity = 'roomy' | 'compact'
450
451export const CARD_DENSITIES = ['roomy', 'compact'] as const satisfies readonly CardDensity[]
452
453export const CARD_SPACE = {
454 roomy: { padX: 'lg', padY: 'lg', gap: 'md' },
455 // `xs` is no row on the terminal, and a little air on desktop.
456 compact: { padX: 'lg', padY: 'xs', gap: 'xs' },
457} as const satisfies Record<CardDensity, { padX: Space; padY: Space; gap: Space }>
458
459const ACROSS = { xs: 1, sm: 1, md: 2, lg: 3, xl: 4 } as const
460const DOWN = { xs: 0, sm: 0, md: 1, lg: 1, xl: 2 } as const
461
462/** Cells a step takes along a row on the terminal and VS Code, and a plain number as itself. */
463export const across = (space: Space) => (typeof space === 'number' ? space : ACROSS[space])
464
465/** Cells a step takes down a column on the terminal and VS Code. */
466export const down = (space: Space) => (typeof space === 'number' ? space : DOWN[space])
467src/icons.ts 67 lines1import type { Toggle } from '../types'
2
3/** 16x16 stroke icons redrawn from the artboards; Desktop only (the terminal has no `Svg`). */
4export const ICONS: Record<Toggle, string> = {
5 skill: 'M8 2L14 5 8 8 2 5zM2 8l6 3 6-3M2 11l6 3 6-3',
6 agent:
7 'M4.5 5h7a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zM8 2.5V5M6 8.5v1M10 8.5v1',
8 mcp: 'M6 1.5v3M10 1.5v3M4 4.5h8v3a4 4 0 0 1-8 0zM8 11.5v3',
9 perm: 'M8.5 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM7.6 8.4l5.9-5.9M11 5l1.5 1.5M12.5 3.5L14 5',
10 hook: 'M9 1.5L3.5 9H8l-1 5.5L12.5 7H8z',
11 rule: 'M8 1.8l5 2v4c0 3-2.2 5.2-5 6.4-2.8-1.2-5-3.4-5-6.4v-4z',
12 command: 'M5.5 13.5l5-11',
13 memory: 'M4.5 2h7v12L8 11.5 4.5 14z',
14 context: 'M2.5 11a5.5 5.5 0 1 1 11 0M8 11l2.5-3',
15 checkpoint: 'M3.5 14V2.5M3.5 3h8l-2 3 2 3h-8',
16 model: 'M2.5 4.5h11M2.5 11.5h11M10 3v3M6 10v3',
17 background: 'M2.5 3.5h11v9h-11zM5 7l2 1.5L5 10M8.5 10.5h2.5',
18 plugin: 'M2.5 5.5L8 2.5l5.5 3v5L8 13.5l-5.5-3zM2.5 5.5L8 8.5l5.5-3M8 8.5v5',
19}
20
21const ERROR_PATH = 'M8 2L14.5 13.5h-13zM8 6.5v3M8 11.6v.1'
22
23const RING_ARC = 'M8 2.5a5.5 5.5 0 0 1 5.5 5.5'
24
25const NEUTRAL = '#8B909A'
26
27export const OFF_TRACK = '#C4C8D0'
28
29const XMLNS = 'xmlns="http://www.w3.org/2000/svg"'
30
31/** Images don't inherit `currentColor`, so a theme key like `suggestion` falls back to grey. */
32export const hexOr = (color: string | undefined, fallback = NEUTRAL) =>
33 color !== undefined && /^#[0-9a-f]{6}$/i.test(color) ? color : fallback
34
35const strokeSvg = (body: string, color: string) =>
36 `<svg ${XMLNS} viewBox="0 0 16 16" fill="none" stroke="${color}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`
37
38export const iconSvg = (toggle: Toggle, color: string) =>
39 strokeSvg(`<path d="${ICONS[toggle]}"/>`, color)
40
41export const errorSvg = (color: string) => strokeSvg(`<path d="${ERROR_PATH}"/>`, color)
42
43export const dotSvg = (color: string) =>
44 `<svg ${XMLNS} viewBox="0 0 8 8"><circle cx="4" cy="4" r="4" fill="${color}"/></svg>`
45
46export const stoppedSvg = (color: string) =>
47 `<svg ${XMLNS} viewBox="0 0 16 16" fill="none" stroke="${color}" stroke-width="2">` +
48 `<circle cx="8" cy="8" r="6"/><path d="M3.8 12.2l8.4-8.4"/></svg>`
49
50export const SPIN_FRAMES = 4
51
52export const spinnerSvg = (frame: number, color: string, track: string) => {
53 const degrees = (frame % SPIN_FRAMES) * (360 / SPIN_FRAMES)
54 return (
55 `<svg ${XMLNS} viewBox="0 0 16 16" fill="none" stroke-width="2" stroke-linecap="round">` +
56 `<circle cx="8" cy="8" r="5.5" stroke="${track}"/>` +
57 `<path d="${RING_ARC}" stroke="${color}" transform="rotate(${degrees} 8 8)"/></svg>`
58 )
59}
60
61export const SWITCH_SIZE = { width: 40, height: 24 } as const
62
63export const switchSvg = (isOn: boolean, onColor: string, offColor: string) =>
64 `<svg ${XMLNS} viewBox="0 0 40 24">` +
65 `<rect width="40" height="24" rx="12" fill="${isOn ? onColor : offColor}"/>` +
66 `<circle cx="${isOn ? 28 : 12}" cy="12" r="9" fill="#FFFFFF"/></svg>`
67types/index.d.ts 79 lines1export type Kind =
2 | 'skill'
3 | 'agent'
4 | 'mcp'
5 | 'perm'
6 | 'hook'
7 | 'rule'
8 | 'command'
9 | 'memory'
10 | 'context'
11 | 'checkpoint'
12 | 'model'
13 | 'background'
14
15export type Toggle = Kind | 'plugin'
16
17export type Status = 'active' | 'running' | 'ok' | 'blocked' | 'denied' | 'error'
18
19export type Activation = {
20 kind: Kind
21 key: string
22 label: string
23 detail: string
24 source?: string
25 meta?: string
26 plugin?: string
27 status: Status
28 count: number
29 firstAt: number
30 lastAt: number
31 runningAgentIds?: string[]
32 askedCallId?: string
33 agentId?: string
34}
35
36export type Layout = 'row' | 'summary'
37
38export type ChipStyle = 'tinted' | 'text'
39
40export type ChipTheme = 'auto' | 'light' | 'dark'
41
42export type Tone = 'light' | 'dark'
43
44export type Settings = {
45 enabled: boolean
46 layout: Layout
47 maxChips: number
48 chipStyle: ChipStyle
49 chipTheme: ChipTheme
50 hotkeys: boolean
51 toastOnBlock: boolean
52 textSummary: boolean
53 shown: Record<Toggle, boolean>
54}
55
56export type Overrides = Partial<Omit<Settings, 'maxChips' | 'shown'>> & {
57 shown?: Partial<Settings['shown']>
58}
59
60export type Filter = Kind | 'all'
61
62export type PaneView = 'detail' | 'activity' | 'settings'
63
64declare module 'claude-code' {
65 interface PluginState {
66 'session-activity': {
67 activations: Activation[]
68 muted: string[]
69 selected: string | null
70 paneView: PaneView
71 filter: Filter
72 overrides: Overrides | null
73 appearance: { system: Tone | null; colorFgBg: Tone | null }
74 model: string | null
75 hostServers: string[]
76 }
77 }
78}
79