SLOPSHOPPER

Session Activity

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

panebandguardcommandtoaststatus
★ 1v1.2.0MITupdated 2026-10-24tinesoft/claude-mods/mods/session-activity
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-activity
│ ┃ session-activity ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ 1: tab-activity2: tab-settings │ session-activity │ │ ┃ Session activity ⏺ Read(src/auth.ts) │ session-activity needs Claude Code 2.1.299 │ │ ┃ 0 items · 0 activations · started just now·c ⎿ Read 6 lines │ or later. Run claude update. │ │ ┃ a: filter-alls: filter-skillg: filter-agentp ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ Nothing has activated yet in this session. ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /session-activity │ ⎿ session-activity: Nothing has activated yet in this session. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · session-activity
1: tab-activity2: tab-settings Session activity 0 items · 0 activations · started just now·customize a: filter-alls: filter-skillg: filter-agentp: filter-mcph: f Nothing has activated yet in this session.
README

<img alt="session-activity — see what took part in your Claude Code session" src="../../assets/images/session-activity-logo-light.svg" width="128">

Session Activity

CI License: MIT Claude Code

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

📸 Screenshots

Terminal

session-activity in the terminal: chips arrive as Claude works, a blocked hook's card and an agent's card open above the row, the gear opens the settings and /session-activity the full pane

Claude Desktop app

session-activity in the Claude Desktop app: the same chips with icons and rounded borders, a blocked hook's card above the row, then the activity and settings pane beside the transcript


✨ Features

  • 🧩 One chip per participant — skills, subagents, MCP servers, permission decisions, settings hooks and rule files, each with its own glyph
  • 🔍 Why is it there? — press a chip for a detail card with the reason and the source it came from
  • 🚨 Problems first — blocked, denied and errored chips lead the row, stay visible when their type is hidden, and raise one toast
  • 📋 A full pane — /session-activity opens every activation grouped by type, with filters, muting and live settings
  • 🎛️ Optional types — permission decisions, slash commands memory writes, context use, model switches and background Bash tasks
  • 🌓 Follows your theme — tinted chips in the design's light or dark colours, picked from your Claude Code theme, your terminal or your system, or pinned with chip_theme
  • ♿ Never colour alone — every status is also a text badge
  • 🖥️ Terminal, Desktop, VS Code and mobile — chips above the prompt in the terminal and on Desktop, the pane everywhere, a text summary where nothing can be drawn

📦 Installation

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

Surfaces

SurfaceWhat you get
Terminal (claude, editor terminals)The full UI, drawn with glyphs and one-row tinted chips
Claude Desktop app, Code tabThe full UI with Svg icons, exact colours, rounded bordered chips, a darker tint on hover, and clicks on every control
VS CodeThe pane and the status line, drawn as on Desktop. Claude Code draws no band here, so open the pane with /session-activity
MobileThe 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 sessionsText only: /session-activity replies with a summary, and text_summary adds a line after each answer
claude.ai chat, claude -pNothing 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.


🚀 Usage

What the chips show

ChipGlyphRecorded whenStates
Skill▤Claude expands a skill (/name, the Skill tool, a preload)active
Agent◉Claude starts a subagent, keyed by its typerunning, ok, error
MCP server⇌Claude calls one of the server's tools; the pane lists the toolsok, error
Permission⚷A rule or hook allowed a call, a call was denied, or Claude asked youok, running (asked), denied
Hook↯A settings hook configured for the event ran, or the event was blockedok, blocked
Rule▣A CLAUDE.md or .claude/rules file was loadedactive

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.

Key bindings

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.

KeysAction
Ctrl+X TabFocus the band
EnterOpen the selected chip's detail card
Enter againClose the card (as does its x: Close)
oCopy the source path (a mod cannot open an editor)
mMute the chip, hiding it from the band
1 to 9, 0Open 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.

The pane

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

  • Activity (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.
  • Settings (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.

⚙️ Settings

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.

OptionDefaultEffect
enabledtrueShow chips above the prompt
layoutrowrow of chips, or one summary chip such as ▤2 ◉2 ⇌2 · ● 1 blocked
max_chips6Chips shown before +N more (1 to 20)
show_<type>core on, optional offShow that type: skills, agents, mcp, permissions, hooks, rules, commands, memory, plugins, context, checkpoints, model, background
chip_themeautolight or dark chip colours, or auto to follow the Claude Code theme, then the terminal, then the system
chip_styletintedtinted chips, or text for coloured text only
chip_hotkeysfalseDigit hotkeys on chips
toast_on_blocktrueToast when an action is blocked or denied
text_summaryfalseWhere 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.


🔒 Data, programs and permissions

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

  • The events Claude Code raises: skills, subagents, MCP calls, tool permission checks, hooks, slash commands, and /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.
  • Your settings, to tell which configured hooks match an event.
  • The rule and memory files Claude loads, to count their lines ($.fs.read). Contents are not kept.
  • The COLORFGBG environment variable, to guess whether the terminal is light or dark. No other variable and no credential is read.

What it stores

  • The chip list lives in the session's state and goes with it. Your switches and muted chips are saved with $.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.

PlatformCommand
macOSdefaults read -g AppleInterfaceStyle
Linuxgsettings get org.gnome.desktop.interface color-scheme
Windowsreg 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.

⚠️ Limits

  • A hook chip is keyed by its full command when one configured command matches the event, and is one <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.
  • Permission chips leave out calls your permission mode allows on its own, such as reads, to keep the band readable. A call a hook allowed or denied shows on that hook's chip instead. Whether you approved "once" or "always" is not visible to a mod.
  • Rule chips mean a file was loaded, not that a line in it shaped an action.
  • Where an organization manages Claude Code, skill, hook, rule and compaction events reach only plugins the organization lists, so those chips stay empty.
  • Checkpoints are not supported yet: Claude Code 2.1.302 sends no event for them, so the toggle exists and records nothing. Memory chips come only from writes Claude makes with Write, Edit or MultiEdit.
  • Claude Code draws the band above the prompt only in the terminal and on Desktop. On VS Code and mobile, use the status line and /session-activity.
  • Background task chips never show as running, because no event reports a background shell exiting.
  • The ANSI themes cannot be told apart as light or dark, so chips fall back to coloured text there. Under the auto theme the tone is a guess from COLORFGBG or the system, and dark when neither is known; pin it with chip_theme.
  • The system appearance is read with defaults (macOS), gsettings (Linux) or reg (Windows) every five seconds. Where none is available the guess stops at the Claude Code theme.
  • Where the design promises more than the mod can do, the pane keeps the accurate text: rules mean a file was loaded, memory covers writes only, checkpoints record nothing yet, and background tasks cannot be stopped from the chip.
  • When another mod also draws in the band, its content comes first in the row and this mod's chips are sized as if they had the row to themselves, so the row can wrap.

🛠️ Development

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.


🤝 Contributing

Contributions are welcome! See the repository README for setup, branch flow, commit conventions and releases.


📄 License

MIT © 2026 Tine Kondo. Not affiliated with or endorsed by Anthropic.

Source 11 files
hooks/register.tsx 537 lines
1import { 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}
537
src/classify.ts 335 lines
1import 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}
335
src/model.ts 184 lines
1import 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  }))
184
src/appearance.ts 45 lines
1import 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]
45
src/engine.ts 14 lines
1/** 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}
14
src/pane.tsx 810 lines
1import 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}
810
src/summary.ts 49 lines
1import 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}
49
src/render.tsx 812 lines
1import 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}
812
src/theme.ts 467 lines
1import 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])
467
src/icons.ts 67 lines
1import 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>`
67
types/index.d.ts 79 lines
1export 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