SLOPSHOPPER

tool-icons

Colored icons at the start of each tool line (read, edit, write, delete, shell, search, web, MCP, agents), in switchable styles and themes: /icons.

newrowscommand
v0.1.0no licenseupdated 2026-10-10raoofaltaher/claude-code-mods/tool-icons
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tool-icons
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines Ⓔ Edit src/auth.ts ⏺ 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 › /icons ⎿ tool-icons: Tool icons on: circled icons, ocean theme, plain look. ⎿ tool-icons: /icons preview every style, theme and look ⎿ tool-icons: /icons <style> circled | symbols | emoji | codicons | awesome | material | octicons ⎿ tool-icons: /icons <theme> ocean | sunset | neon | forest | claude | aurora | pastel ⎿ tool-icons: /icons badge | gradient toggle a look (icon on a chip, label in a gradient) ⎿ tool-icons: /icons plain both looks off ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Tool row
Ⓔ Edit src/auth.ts
README

Claude Code mods

Two mods for Claude Code's function-hooks plugin system:

  • account-bars: live session and weekly limit bars for every Claude account you use, in a sidebar beside the conversation.
  • tool-icons: colored icons on tool lines.

Both are tested on Claude Code 2.1.296. Mods are an early-access API that can change between releases.


account-bars

A sidebar on the right of the terminal conversation with one pair of bars per Claude account:

  • S: the session (5-hour) limit.
  • W: the weekly (7-day) limit.

Bars are green below 50 %, yellow from 50 %, orange from 75 % and red from 90 %. Each bar shows when its window resets. When the account you are signed into reaches its session limit, the mod tells you which account is next in line. It never switches for you: you switch with /login.

S session (5-hour) · W weekly (7-day)

▶ work1
  S ███████████████████████░  96% 3h 30m
  W █████░░░░░░░░░░░░░░░░░░░  22% 2d 6h
    as of just now

  work2                                 next
  S ░░░░░░░░░░░░░░░░░░░░░░░░   0%
  W ████████████████░░░░░░░░  67% 11h 10m
    as of just now

  personal
  S ████░░░░░░░░░░░░░░░░░░░░  15% 3h 00m
  W ███░░░░░░░░░░░░░░░░░░░░░  11% 4d 18h
    as of just now · other org, never suggested

[ compact ] [ refresh ]

Install

You need Claude Code with mods (function hooks), a Claude subscription login (Pro, Max, Team or Enterprise), and git able to reach GitHub.

From GitHub, typed at the prompt of a Claude Code terminal session:

/plugin install account-bars --marketplace raoofaltaher/claude-code-mods

Answer y to add the marketplace, then pick a scope (user scope loads it in every session).

From a local copy of this repository:

claude plugin marketplace add <path-to-this-repo>
claude plugin install account-bars@my-mods

A marketplace added from a folder is read from that folder itself. After you edit the mod there, run /reload-plugins.

To try it for one session without installing:

claude --plugin-dir <path-to-this-repo>/account-bars

Add your accounts

The account you are signed into works with no setup. Every other account is signed in once into its own Claude Code profile folder under ~/.claude-accounts/. The folder name is the label the sidebar shows.

Use a separate terminal for this, not a Claude Code session, because the sign-in opens a browser.

PowerShell:

$env:CLAUDE_CONFIG_DIR = "$HOME\.claude-accounts\work2"
claude auth login
Remove-Item Env:CLAUDE_CONFIG_DIR

bash / zsh:

CLAUDE_CONFIG_DIR="$HOME/.claude-accounts/work2" claude auth login

Then press r in the sidebar, or run /accounts refresh.

Tips:

  • Make sure the browser approves the right account. It is usually still signed in to claude.ai as your current account. Either switch accounts on claude.ai first, or open the sign-in link the terminal prints in a private window.
  • claude auth login --email <address> fills in the address.
  • --sso forces single sign-on.
  • Add the account you are signed into as well (for example work1). The top row then shows its name instead of "this session". After you /login to another account, that profile is what keeps the first account tracked.
  • Labels:
  • Don't use an e-mail address as a folder name, since it would show on screen.
  • To rename an account, close anything using that profile and rename its folder. Then update order (below) if you set it.

Use

/accountsShow or hide the sidebar.
/accounts refreshRe-read every account now.
c or [ compact ] / [ expand ]One row per account, or the full view with reset times and status.
r or [ refresh ]Same as /accounts refresh.
/config → account-bars → orderYour preferred order, e.g. work1,work2,personal. Unlisted accounts follow alphabetically.
/config → account-bars → claudePathThe full path to claude, if it isn't at ~/.local/bin/claude (claude.exe on Windows), the native installer's place. It must be a full path outside the project folder; the mod never looks claude up by name.

Where the sidebar sits:

  • It docks on the right in Claude Code's full-screen layout when the terminal is at least 110 columns wide (opened with /accounts).
  • It opens by itself from 144 columns.
  • Outside the full-screen layout it sits above the prompt.

Status lines under each account:

LineMeaning
as of 40s agoLive reading. Other accounts refresh every 5 minutes. The account you are signed into also updates after every reply.
reading…Not read yet.
sign-in expiredThat profile's login lapsed. Run claude auth login for it again (see above). The mod tries it again every 10 minutes.
no reading, retryingThe last read failed. The mod retries with back-off (up to 40 minutes). If Anthropic answers "too many requests" (HTTP 429), that account waits 15 minutes.
not signed inThe folder exists but holds no login.
other org, never suggestedTracked, but in a different organization from the account you are signed into.
pausedCLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC is set, so other accounts are not polled.
a yellow line at the topA setup problem: claude, or the program that reads other accounts, was not found at its fixed path.

At the session limit:

  • A toast and a desktop notification name the next account, for example: "Session limit reached. Next in line: work2 (session 0%, weekly 67%). Run /login and pick it."
  • The sidebar marks that account next.
  • You get one notice per session window.
  • Only accounts in the same organization as the one you are signed into are ever suggested.

Choosing who is "next"

The policy lives in one function, pickNext() in account-bars/hooks/next.ts. Each candidate carries:

  • its place in your order;
  • its status;
  • its session and weekly percentages (already 0 once a window has reset);
  • when it was last read.

The rule:

  1. Skip accounts whose login lapsed or that have none.
  2. Skip accounts at 90 % weekly or more (WEEKLY_CEILING), since they would block soon after.
  3. Of the rest, take the one with the most session room (a window past its reset counts as empty), then your order.
  4. If none is fit, suggest nothing rather than an account about to block.

Change the function to suit how you work.

How it works

AccountWhere its numbers come fromCredential involved
The one this session is signed intoReadings Claude Code itself pushes to mods after every reply, plus the usage endpoint through Claude Code's credential handlenone: the mod never sees the secret
Every profile under ~/.claude-accounts/GET https://api.anthropic.com/api/oauth/usage (the endpoint Claude Code's own /usage reads), every 5 minutes, made by a short-lived helper processthat profile's access token, which only the helper ever reads

The helper reads the profile's .credentials.json, makes the request, and prints only three things: when the login expires, the HTTP status, and the usage numbers.

  • On Windows it is Windows PowerShell 5.1, at its fixed path under %SystemRoot%.
  • On Linux it is /bin/sh with /usr/bin/curl. The token reaches curl on its standard input, never on a command line.
  • The profile folder is passed to the helper in an environment variable, never pasted into a command.
  • The helper's script is in account-bars/hooks/probe.ts.

To recognise accounts and organizations, the mod runs claude auth status --json:

  • When: at start, after /login, on refresh, and every 10 minutes.
  • What it keeps: the e-mail and organization ID it returns, in memory only.

Security and privacy

  • Tokens never enter the mod.
  • The session's own account is read through Claude Code's credential handle, which keeps the secret inside Claude Code.
  • Every other account is read by the helper process above.
  • So no token passes through the mod, or through the events other installed mods can hook (file reads, web requests, process runs).
  • Tokens are only ever sent to https://api.anthropic.com.
  • Programs run only by full path, from your home folder. That is claude (or your claudePath) and the helper. A program looked up by name could resolve to a file planted in a cloned repository's folder.
  • The Linux helper's own tools (tr, sed, tail) come only from /usr/bin and /bin. curl ignores ~/.curlrc and allows https only.
  • The Windows helper loads PowerShell's own modules only, and names every command with its module.
  • When a helper fails, it prints only error, never the error text, which could quote the login it failed to read.
  • Text from outside is cleaned: folder names have control and format characters removed (terminal escapes, bidi overrides, zero-width marks) before they are drawn or put in a notification.
  • What is saved: only labels, percentages and reset times.
  • E-mails and organization IDs: held in this mod's memory only, and never saved or shown. Other mods can read a mod's shared state, so these are kept out of it. They do pass through the claude auth status results, which another installed mod could hook.
  • Credentials files: the mod itself never reads or writes them, and never switches accounts. It never refreshes a login itself either: it asks Claude Code to (see Token renewal below). Claude Code manages every profile's sign-in.
  • Same organization only: suggestions never move a work conversation to an account outside its organization.
  • No-traffic setting: CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC stops everything that touches other accounts: both their usage reads and their identity checks.

Things to know

  • Undocumented endpoint: Anthropic can change or restrict the usage endpoint without notice. If it does, the affected accounts show no reading, retrying instead of breaking. The account you are signed into keeps updating from Claude Code's own readings.
  • Plain-text logins: each profile is a full Claude Code login on disk, stored like your main one (.credentials.json). Keep your disk encrypted.
  • PowerShell logging: if your organization turns on PowerShell module or script-block logging, Windows records the helper's runs. The script itself holds no token (it reads it at run time), but check what your logging captures before relying on that.
  • macOS: Claude Code keeps logins in the macOS Keychain, not in .credentials.json, so extra profiles show not signed in there. The account you are signed into still works. Tested on Windows. The Linux helper was tested under Git Bash with curl.
  • Token renewal: access tokens last about 8 hours. When one is close to expiry, the mod runs claude auth status for that profile so Claude Code can renew it. If a profile still lapses, it shows sign-in expired.
  • Terms of use: switching stays manual by design. Anthropic's terms restrict account sharing and getting around usage limits, so check your plan's terms before relying on several accounts.

Develop

account-bars/
  .claude-plugin/plugin.json   manifest, the `order` option
  hooks/hooks.json             names the hooks module
  hooks/register.tsx           sidebar, /accounts, polling, limit notice
  hooks/usage.ts               parsing, bands, bars, time text
  hooks/probe.ts               the helper that reads other accounts (Windows PowerShell, sh + curl)
  hooks/next.ts                pickNext(): who is suggested
  types/index.d.ts             the mod's state contract
  tests/account-bars.test.ts   colour bands, parsing, drawing, credential handling, limit notice
claude plugin validate account-bars
claude plugin test account-bars

Claude Code writes the API's type declarations into account-bars/.claude-plugin/types/ whenever it loads the mod; tsconfig.json extends them. That folder is git-ignored: it is regenerated per build and lists the MCP tools connected on the machine that loaded it.


tool-icons

Colored icons at the start of each tool line, such as read, edit, write, delete, shell, search, web, MCP and agents. It has switchable styles, color themes, and two looks: a badge chip and a gradient label.

/plugin install tool-icons --marketplace raoofaltaher/claude-code-mods

/icons previews everything. /icons <style>, /icons <theme>, /icons badge | gradient | plain and /icons off | on change it. Some styles need a Nerd Font, such as Cascadia Mono NF.

  • Any built-in tool that runs a command (Bash, PowerShell, Monitor) shows the command itself, never the model's description of it.
  • A multi-line command is marked, e.g. (3 lines) ….
  • A long one keeps its start and its end, so what it ends with stays in sight.
  • Other tools show what they act on (a recipient, a schedule, a link) ahead of any description.
  • The red delete icon is a hint read from the command's wording, not a safety check.
  • tool-icons only redraws transcript rows, never the approval dialog.
  • Its rows show the call, not the tool's output. Run /icons off to see Claude Code's own rows, output and error text included.
  • Control and format characters are removed from every row: terminal escapes, bidi overrides and zero-width marks.
Source 3 files
hooks/register.tsx 227 lines
1import { atom, read, update } from 'claude-code'
2import type { ElementTable, EngineInterface, Register } from 'claude-code'
3import type { ToolIconsPrefs } from '../types'
4import {
5  DEFAULT_STYLE,
6  DEFAULT_THEME,
7  GLOW,
8  KINDS,
9  LABELS,
10  NERD_STYLES,
11  STYLES,
12  THEMES,
13  fit,
14  gradient,
15  groupSummary,
16  isStyle,
17  isTheme,
18  kindOf,
19  label,
20  printable,
21  target,
22} from './icons'
23import type { FitMode, Kind, ThemeName } from './icons'
24
25const DEFAULTS: ToolIconsPrefs = { style: DEFAULT_STYLE, theme: DEFAULT_THEME, isOn: true, isBadge: false, isGradient: false }
26const prefsAtom = atom({ plugin: 'tool-icons', key: 'prefs' } as const, DEFAULTS)
27
28const PATH_KINDS: ReadonlySet<Kind> = new Set(['read', 'edit', 'write'])
29const SAMPLE: readonly Kind[] = ['read', 'edit', 'write', 'delete', 'shell', 'search', 'web', 'mcp', 'agent']
30const GALLERY_ARGS = new Set(['', 'preview', 'list', 'help'])
31// Text drawn on a badge's colored chip.
32const ON_CHIP = '#111111'
33const USAGE = `/icons                 preview every style, theme and look
34/icons <style>         ${Object.keys(STYLES).join(' | ')}
35/icons <theme>         ${Object.keys(THEMES).join(' | ')}
36/icons badge | gradient   toggle a look (icon on a chip, label in a gradient)
37/icons plain           both looks off
38/icons off | on        hide or show the icons`
39
40type Ui = Pick<ElementTable, 'Box' | 'Text'>
41type Look = {
42  icons: Readonly<Record<Kind, string>>
43  colors: Readonly<Record<Kind, string>>
44  glow: string
45  prefs: ToolIconsPrefs
46}
47
48export function lookOf(prefs: ToolIconsPrefs): Look {
49  const theme: ThemeName = isTheme(prefs.theme) ? prefs.theme : DEFAULT_THEME
50  return {
51    icons: STYLES[isStyle(prefs.style) ? prefs.style : DEFAULT_STYLE],
52    colors: THEMES[theme],
53    glow: GLOW[theme],
54    prefs,
55  }
56}
57
58function describe(prefs: ToolIconsPrefs): string {
59  const looks = [prefs.isBadge && 'badge', prefs.isGradient && 'gradient'].filter(Boolean).join(' + ') || 'plain'
60  const font = NERD_STYLES.has(prefs.style) ? ' (needs a Nerd Font, like Cascadia Mono NF)' : ''
61  return `Tool icons ${prefs.isOn ? 'on' : 'off'}: ${prefs.style} icons${font}, ${prefs.theme} theme, ${looks} look.`
62}
63
64/** What `/icons <args>` changes, or a message when the words name nothing. */
65export function applyArgs(prefs: ToolIconsPrefs, args: string): ToolIconsPrefs | string {
66  const words = args.trim().toLowerCase().split(/\s+/).filter(w => w !== '' && w !== 'style' && w !== 'theme')
67  let next = prefs
68  for (const word of words) {
69    if (word === 'off' || word === 'on') next = { ...next, isOn: word === 'on' }
70    else if (word === 'badge') next = { ...next, isBadge: !next.isBadge, isOn: true }
71    else if (word === 'gradient') next = { ...next, isGradient: !next.isGradient, isOn: true }
72    else if (word === 'plain') next = { ...next, isBadge: false, isGradient: false, isOn: true }
73    else if (isStyle(word)) next = { ...next, style: word, isOn: true }
74    else if (isTheme(word)) next = { ...next, theme: word, isOn: true }
75    else return `Unknown "${printable(word)}".\n${USAGE}`
76  }
77  return next
78}
79
80/** The icon, plain in its color or on a chip of it. */
81function iconCell(ui: Ui, look: Look, kind: Kind, key?: string) {
82  const color = look.colors[kind]
83  return look.prefs.isBadge ? (
84    <ui.Text key={key} backgroundColor={color} color={ON_CHIP} bold>{` ${look.icons[kind]} `}</ui.Text>
85  ) : (
86    <ui.Text key={key} color={color} bold>{look.icons[kind]}</ui.Text>
87  )
88}
89
90/** The label, in its color or fading toward the theme's glow. */
91function labelCells(ui: Ui, look: Look, kind: Kind, text: string) {
92  if (!look.prefs.isGradient) return [<ui.Text color={look.colors[kind]} bold>{text}</ui.Text>]
93  return gradient(text, look.colors[kind], look.glow).map(([ch, color]) => (
94    <ui.Text color={color} bold>{ch}</ui.Text>
95  ))
96}
97
98async function save($: EngineInterface, prefs: ToolIconsPrefs): Promise<void> {
99  await update($, prefsAtom, () => prefs)
100  await $.store.set('prefs', prefs)
101}
102
103export const register: Register = on => {
104  on('session.start', async ($, e, next) => {
105    const started = await next(e)
106    await $.command.register({
107      name: 'icons',
108      description: 'Tool icons: preview, pick a style, theme or look, or turn them off',
109      argumentHint: '[style | theme | badge | gradient | plain | off | on]',
110    })
111    const saved = (await $.store.get('prefs').catch(() => undefined)) as Partial<ToolIconsPrefs> | undefined
112    if (saved !== undefined && saved !== null) {
113      await update($, prefsAtom, () => ({
114        style: typeof saved.style === 'string' && isStyle(saved.style) ? saved.style : DEFAULT_STYLE,
115        theme: typeof saved.theme === 'string' && isTheme(saved.theme) ? saved.theme : DEFAULT_THEME,
116        isOn: saved.isOn !== false,
117        isBadge: saved.isBadge === true,
118        isGradient: saved.isGradient === true,
119      }))
120    }
121    return started
122  })
123
124  on('command.run', { command: 'icons' }, async ($, e) => {
125    const prefs = await read($, prefsAtom)
126    if (GALLERY_ARGS.has(e.args.trim().toLowerCase())) return { text: `${describe(prefs)}\n${USAGE}` }
127    const next = applyArgs(prefs, e.args)
128    if (typeof next === 'string') return { text: next }
129    await save($, next)
130    return { text: describe(next) }
131  })
132
133  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
134    const prefs = await read($, prefsAtom)
135    if (!prefs.isOn) return next(e)
136
137    const ui = $.ui.resolve(e)
138    const look = lookOf(prefs)
139    const { tool, input, isRunning, isErrored, isInterrupted } = e.props
140    const kind = kindOf(tool, input)
141    const name = printable(label(kind, tool))
142    const cwd = await $.session.cwd().catch(() => '')
143    const room = (e.viewport?.columns ?? 100) - name.length - 20
144    const mode: FitMode = PATH_KINDS.has(kind) ? 'path' : kind === 'shell' || kind === 'delete' ? 'command' : 'text'
145    const what = fit(target(tool, input, cwd), room, mode)
146
147    const parts = [iconCell(ui, look, kind), <ui.Text> </ui.Text>, ...labelCells(ui, look, kind, name)]
148    if (what !== '') parts.push(<ui.Text dimColor>  {what}</ui.Text>)
149    if (isRunning) parts.push(<ui.Text dimColor> …</ui.Text>)
150    else if (isInterrupted) parts.push(<ui.Text dimColor> · interrupted</ui.Text>)
151    else if (isErrored) parts.push(<ui.Text color={look.colors.delete}> ✗ failed</ui.Text>)
152    return <ui.Box flexDirection="row">{parts}</ui.Box>
153  })
154
155  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
156    const prefs = await read($, prefsAtom)
157    if (!prefs.isOn || e.props.isExpanded) return next(e)
158
159    const ui = $.ui.resolve(e)
160    const look = lookOf(prefs)
161    const { kinds, text } = groupSummary(e.props.calls)
162    const parts = kinds.flatMap(kind => [iconCell(ui, look, kind), <ui.Text> </ui.Text>])
163    parts.push(...labelCells(ui, look, kinds[0] ?? 'other', text))
164    if (e.props.calls.some(call => call.isRunning)) parts.push(<ui.Text dimColor> …</ui.Text>)
165    return <ui.Box flexDirection="row">{parts}</ui.Box>
166  })
167
168  // `/icons` alone: a gallery of every style, theme and look, drawn in the transcript.
169  on('ui.render', { component: 'CommandOutput', props: { command: 'icons' } }, async ($, e, next) => {
170    if (!GALLERY_ARGS.has(e.props.args.trim().toLowerCase())) return next(e)
171
172    const ui = $.ui.resolve(e)
173    const prefs = await read($, prefsAtom)
174    const look = lookOf(prefs)
175    const name = (text: string, isCurrent: boolean) => (
176      <ui.Text bold={isCurrent} dimColor={!isCurrent}>{`${isCurrent ? '▸' : ' '} ${text.padEnd(10)}`}</ui.Text>
177    )
178    const iconRow = (key: string, title: string, rowLook: Look, isCurrent: boolean) => (
179      <ui.Box key={key} flexDirection="row">
180        {name(title, isCurrent)}
181        {SAMPLE.flatMap((kind, i) => [<ui.Text> </ui.Text>, iconCell(ui, rowLook, kind, `${key}-${i}`)])}
182      </ui.Box>
183    )
184    const plain = { ...prefs, isBadge: false, isGradient: false }
185    const lookRow = (key: string, title: string, rowPrefs: ToolIconsPrefs, isCurrent: boolean) => {
186      const rowLook = lookOf(rowPrefs)
187      return (
188        <ui.Box key={key} flexDirection="row">
189          {name(title, isCurrent)}
190          {(['read', 'edit', 'shell'] as const).flatMap(kind => [
191            <ui.Text>  </ui.Text>,
192            iconCell(ui, rowLook, kind),
193            <ui.Text> </ui.Text>,
194            ...labelCells(ui, rowLook, kind, LABELS[kind]),
195          ])}
196        </ui.Box>
197      )
198    }
199
200    return (
201      <ui.Box flexDirection="column">
202        <ui.Text bold>{describe(prefs)}</ui.Text>
203        <ui.Box flexDirection="row">
204          {KINDS.filter(k => k !== 'other').flatMap(kind => [
205            iconCell(ui, look, kind),
206            <ui.Text color={look.colors[kind]}>{` ${LABELS[kind].toLowerCase()}  `}</ui.Text>,
207          ])}
208        </ui.Box>
209        <ui.Text dimColor>Styles, in the {prefs.theme} theme (the last four need a Nerd Font):</ui.Text>
210        {Object.keys(STYLES).map(style =>
211          iconRow(`style-${style}`, style, lookOf({ ...prefs, style }), style === prefs.style),
212        )}
213        <ui.Text dimColor>Themes, with {prefs.style} icons:</ui.Text>
214        {Object.keys(THEMES).map(theme =>
215          iconRow(`theme-${theme}`, theme, lookOf({ ...prefs, theme }), theme === prefs.theme),
216        )}
217        <ui.Text dimColor>Looks:</ui.Text>
218        {lookRow('look-plain', 'plain', plain, !prefs.isBadge && !prefs.isGradient)}
219        {lookRow('look-badge', 'badge', { ...plain, isBadge: true }, prefs.isBadge && !prefs.isGradient)}
220        {lookRow('look-gradient', 'gradient', { ...plain, isGradient: true }, !prefs.isBadge && prefs.isGradient)}
221        {lookRow('look-both', 'both', { ...plain, isBadge: true, isGradient: true }, prefs.isBadge && prefs.isGradient)}
222        <ui.Text dimColor>{USAGE}</ui.Text>
223      </ui.Box>
224    )
225  })
226}
227
hooks/icons.ts 334 lines
1// What a tool call is, which icon and color it gets, and the short target
2// shown after its label. Pure: the hooks module draws with it.
3
4export type Kind =
5  | 'read'
6  | 'edit'
7  | 'write'
8  | 'delete'
9  | 'shell'
10  | 'search'
11  | 'web'
12  | 'mcp'
13  | 'agent'
14  | 'skill'
15  | 'ask'
16  | 'plan'
17  | 'other'
18
19export const KINDS: readonly Kind[] = [
20  'read', 'edit', 'write', 'delete', 'shell', 'search', 'web', 'mcp', 'agent', 'skill', 'ask', 'plan', 'other',
21]
22
23export const LABELS: Readonly<Record<Kind, string>> = {
24  read: 'Read',
25  edit: 'Edit',
26  write: 'Write',
27  delete: 'Delete',
28  shell: 'Run',
29  search: 'Search',
30  web: 'Web',
31  mcp: 'MCP',
32  agent: 'Agent',
33  skill: 'Skill',
34  ask: 'Ask',
35  plan: 'Plan',
36  other: 'Tool',
37}
38
39// Icon sets. Circled letters and symbols take the theme's colors; emoji
40// keep their own. Every emoji here defaults to emoji presentation, so none
41// needs a variation selector that some terminals draw half-width. The
42// symbols avoid glyphs Windows draws as color emoji (✖ ⚙): those ignore the
43// theme and take two cells.
44export const STYLES = {
45  circled: {
46    read: 'Ⓡ', edit: 'Ⓔ', write: 'Ⓦ', delete: 'Ⓧ', shell: 'Ⓣ', search: 'Ⓢ', web: 'Ⓘ',
47    mcp: 'Ⓜ', agent: 'Ⓐ', skill: 'Ⓚ', ask: 'Ⓠ', plan: 'Ⓟ', other: 'Ⓞ',
48  },
49  symbols: {
50    read: '▤', edit: '✎', write: '✚', delete: '✕', shell: '❯', search: '◎', web: '⊕',
51    mcp: '⬡', agent: '✦', skill: '★', ask: '?', plan: '☰', other: '•',
52  },
53  emoji: {
54    read: '📖', edit: '🔧', write: '📝', delete: '❌', shell: '💻', search: '🔍', web: '🌐',
55    mcp: '🔌', agent: '🤖', skill: '🎯', ask: '💬', plan: '📋', other: '🔹',
56  },
57  // The four designer sets below are Nerd Font glyphs (private-use code
58  // points): they draw with a Nerd Font such as Microsoft's Cascadia Mono NF,
59  // and as empty boxes without one. Every code point was checked against
60  // Cascadia Mono NF 2407.24.
61  codicons: {
62    read: '\u{ea7b}', edit: '\u{ea73}', write: '\u{ea7f}', delete: '\u{ea81}', shell: '\u{ea85}',
63    search: '\u{ea6d}', web: '\u{eb01}', mcp: '\u{eb2d}', agent: '\u{eb08}', skill: '\u{ebcf}',
64    ask: '\u{eb32}', plan: '\u{eab3}', other: '\u{eb6d}',
65  },
66  awesome: {
67    read: '\u{f15c}', edit: '\u{f040}', write: '\u{f067}', delete: '\u{f1f8}', shell: '\u{f120}',
68    search: '\u{f002}', web: '\u{f0ac}', mcp: '\u{f1e6}', agent: '\u{f21b}', skill: '\u{f0d0}',
69    ask: '\u{f059}', plan: '\u{f0ae}', other: '\u{f013}',
70  },
71  material: {
72    read: '\u{f0219}', edit: '\u{f03eb}', write: '\u{f0752}', delete: '\u{f01b4}', shell: '\u{f018d}',
73    search: '\u{f0349}', web: '\u{f059f}', mcp: '\u{f06a5}', agent: '\u{f06a9}', skill: '\u{f0674}',
74    ask: '\u{f02d7}', plan: '\u{f014e}', other: '\u{f0493}',
75  },
76  octicons: {
77    read: '\u{f4a5}', edit: '\u{f448}', write: '\u{f4d0}', delete: '\u{f48e}', shell: '\u{f489}',
78    search: '\u{f422}', web: '\u{f484}', mcp: '\u{f492}', agent: '\u{f4b8}', skill: '\u{f51b}',
79    ask: '\u{f420}', plan: '\u{f45e}', other: '\u{f423}',
80  },
81} as const satisfies Record<string, Record<Kind, string>>
82
83/** The styles that need a Nerd Font installed and chosen in the terminal. */
84export const NERD_STYLES: ReadonlySet<string> = new Set(['codicons', 'awesome', 'material', 'octicons'])
85
86export type StyleName = keyof typeof STYLES
87
88// Color themes: one family per theme, a shade per kind. Delete is always
89// the warmest, most alarming shade of its family.
90export const THEMES = {
91  ocean: {
92    read: '#5FB3F9', edit: '#4DD0C8', write: '#3FA7D6', delete: '#F47174', shell: '#7AA2F7', search: '#89DDFF',
93    web: '#64B5F6', mcp: '#A78BFA', agent: '#2DD4BF', skill: '#93C5FD', ask: '#FBBF24', plan: '#67E8F9', other: '#94A3B8',
94  },
95  sunset: {
96    read: '#FFB86B', edit: '#FF8E72', write: '#FF6F91', delete: '#E5484D', shell: '#FFC75F', search: '#F9A26C',
97    web: '#FF9671', mcp: '#D65DB1', agent: '#FF7EB6', skill: '#FFD580', ask: '#FFE066', plan: '#F7A072', other: '#C9A9A6',
98  },
99  neon: {
100    read: '#00E5FF', edit: '#39FF14', write: '#B3FF00', delete: '#FF2E63', shell: '#FFE600', search: '#00FFC6',
101    web: '#3DA5FF', mcp: '#FF00E5', agent: '#BF5AF2', skill: '#FF9F0A', ask: '#FFD60A', plan: '#64D2FF', other: '#8E8E93',
102  },
103  forest: {
104    read: '#8FBC8F', edit: '#A3BE8C', write: '#6B8E23', delete: '#BF616A', shell: '#D08770', search: '#88C0D0',
105    web: '#81A1C1', mcp: '#B48EAD', agent: '#9CCFA0', skill: '#EBCB8B', ask: '#E5C07B', plan: '#8FBCBB', other: '#7F8C8D',
106  },
107  claude: {
108    read: '#E3A587', edit: '#D97757', write: '#C96442', delete: '#B5442B', shell: '#F0A884', search: '#E8B9A0',
109    web: '#D9906F', mcp: '#BF6A4C', agent: '#E08A66', skill: '#F2C3A8', ask: '#F5D0B5', plan: '#CC8566', other: '#A89080',
110  },
111  aurora: {
112    read: '#7FDBCA', edit: '#C792EA', write: '#82AAFF', delete: '#FF5370', shell: '#ADDB67', search: '#89DDFF',
113    web: '#7FB3FF', mcp: '#F78CFF', agent: '#B2CCD6', skill: '#FFCB6B', ask: '#F3E5AB', plan: '#80CBC4', other: '#8C9DB5',
114  },
115  pastel: {
116    read: '#A0C4FF', edit: '#BDB2FF', write: '#9BF6FF', delete: '#FFADAD', shell: '#CAFFBF', search: '#B5EAD7',
117    web: '#A0E7E5', mcp: '#FFC6FF', agent: '#E2C2FF', skill: '#FDFFB6', ask: '#FFD6A5', plan: '#C7CEEA', other: '#D3D3D3',
118  },
119} as const satisfies Record<string, Record<Kind, string>>
120
121export type ThemeName = keyof typeof THEMES
122
123// Where a gradient label ends: each label fades from its kind's color to this.
124export const GLOW: Readonly<Record<ThemeName, string>> = {
125  ocean: '#E0F7FF',
126  sunset: '#FFF1C1',
127  neon: '#FFFFFF',
128  forest: '#F0EAD2',
129  claude: '#FBE9DF',
130  aurora: '#F8F8F2',
131  pastel: '#FFFFFF',
132}
133
134function rgb(hex: string): [number, number, number] {
135  const n = Number.parseInt(hex.replace('#', ''), 16)
136  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
137}
138
139/** Each character of `text` with its color, fading from `from` to `to`. */
140export function gradient(text: string, from: string, to: string): [string, string][] {
141  const chars = [...text]
142  const [a, b] = [rgb(from), rgb(to)]
143  return chars.map((ch, i) => {
144    const t = chars.length <= 1 ? 0 : i / (chars.length - 1)
145    const mix = a.map((v, k) => Math.round(v + (b[k]! - v) * t))
146    return [ch, `#${mix.map(v => v.toString(16).padStart(2, '0')).join('')}`]
147  })
148}
149
150export const DEFAULT_STYLE: StyleName = 'circled'
151export const DEFAULT_THEME: ThemeName = 'ocean'
152
153export function isStyle(name: string): name is StyleName {
154  return Object.hasOwn(STYLES, name)
155}
156
157export function isTheme(name: string): name is ThemeName {
158  return Object.hasOwn(THEMES, name)
159}
160
161const READ = new Set(['Read', 'NotebookRead'])
162const EDIT = new Set(['Edit', 'MultiEdit', 'NotebookEdit'])
163const SEARCH = new Set(['Grep', 'Glob', 'ToolSearch', 'LSP', 'ListMcpResourcesTool'])
164const WEB = new Set(['WebFetch', 'WebSearch'])
165const AGENT = new Set(['Agent', 'Task', 'SendMessage', 'ListAgents', 'TaskStop', 'Workflow'])
166const PLAN = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'EnterPlanMode', 'ExitPlanMode', 'CronCreate', 'ScheduleWakeup'])
167// A delete at a command position: start of line or after ; | & ( and spaces.
168const DELETE_COMMAND = /(^|[;&|(]\s*|\bsudo\s+)(rm|rmdir|del|erase|rd|remove-item|ri)\b/im
169
170const SHELLS = new Set(['Bash', 'PowerShell'])
171
172function commandOf(input: unknown): string {
173  const command = (input as { command?: unknown } | null)?.command
174  return typeof command === 'string' ? command : ''
175}
176
177/** A WebSocket a tool watches (Monitor's `ws.url`), when it names one. */
178function socketOf(input: unknown): string {
179  const ws = (input as { ws?: { url?: unknown } } | null)?.ws
180  return typeof ws?.url === 'string' ? ws.url : ''
181}
182
183export function kindOf(tool: string, input: unknown): Kind {
184  // Any built-in tool that carries a shell command (Bash, PowerShell, Monitor)
185  // is a shell row, so the row shows the command rather than prose about it.
186  // An MCP tool keeps its server's name, whatever its fields are called.
187  if (SHELLS.has(tool) || (!tool.startsWith('mcp__') && commandOf(input) !== '')) {
188    return DELETE_COMMAND.test(commandOf(input).trim()) ? 'delete' : 'shell'
189  }
190  if (!tool.startsWith('mcp__') && socketOf(input) !== '') return 'web'
191  if (READ.has(tool)) return 'read'
192  if (EDIT.has(tool)) return 'edit'
193  if (tool === 'Write') return 'write'
194  if (SEARCH.has(tool)) return 'search'
195  if (WEB.has(tool)) return 'web'
196  if (AGENT.has(tool)) return 'agent'
197  if (PLAN.has(tool)) return 'plan'
198  if (tool === 'Skill') return 'skill'
199  if (tool === 'AskUserQuestion') return 'ask'
200  if (tool.startsWith('mcp__')) return 'mcp'
201  return 'other'
202}
203
204/** `mcp__claude_ai_Example__search_records` → `Example · search_records`. */
205export function mcpName(tool: string): string {
206  const [, server = '', ...rest] = tool.split('__')
207  const short = server.replace(/^claude_ai_/, '').replace(/^plugin_[^_]+_/, '')
208  return `${short} · ${rest.join('__')}`
209}
210
211export function label(kind: Kind, tool: string): string {
212  if (kind === 'mcp') return mcpName(tool)
213  if (kind === 'other' || kind === 'agent' || kind === 'plan') return tool
214  // a shell or web row from a tool other than the usual ones keeps its own name
215  if ((kind === 'shell' || kind === 'delete') && !SHELLS.has(tool)) return tool
216  if (kind === 'web' && !WEB.has(tool)) return tool
217  return LABELS[kind]
218}
219
220/** Control and format characters (escapes, bidi overrides, zero-width marks) removed. */
221export function printable(text: string): string {
222  return text.replace(/[\p{Cc}\p{Cf}]/gu, '')
223}
224
225function field(input: unknown, ...names: string[]): string {
226  const record = (input ?? {}) as Record<string, unknown>
227  for (const name of names) {
228    const value = record[name]
229    if (typeof value === 'string' && value.trim() !== '') return value
230  }
231  return ''
232}
233
234/** A path relative to the session folder when it lies under it. */
235export function shortPath(path: string, cwd: string): string {
236  const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
237  const p = norm(path)
238  const root = norm(cwd)
239  if (root !== '' && p.toLowerCase().startsWith(`${root.toLowerCase()}/`)) return p.slice(root.length + 1)
240  return p
241}
242
243/** The few words after the label: a file, a command, a pattern, a query. */
244export function target(tool: string, input: unknown, cwd: string): string {
245  const kind = kindOf(tool, input)
246  switch (kind) {
247    case 'read':
248    case 'edit':
249    case 'write':
250      return shortPath(field(input, 'file_path', 'notebook_path', 'path'), cwd)
251    case 'shell':
252    case 'delete': {
253      // The command itself, never the model's own description of it: the row
254      // is the record of what ran. A multi-line command says so up front,
255      // where cutting the line to fit cannot hide it.
256      const lines = commandOf(input).trim().split(/\r?\n/)
257      return lines.length > 1 ? `(${lines.length} lines) ${lines[0]!.trim()}` : lines[0]!.trim()
258    }
259    case 'search':
260      return field(input, 'pattern', 'query')
261    case 'web': {
262      const url = field(input, 'url') || socketOf(input)
263      if (url !== '') {
264        try {
265          return new URL(url).host
266        } catch {
267          return url
268        }
269      }
270      return field(input, 'query')
271    }
272    // What a call acts on comes before any prose the model wrote about it.
273    case 'agent':
274      return field(input, 'to', 'name', 'scriptPath', 'subagent_type', 'description', 'summary')
275    case 'plan':
276      return [field(input, 'cron'), field(input, 'prompt')].filter(Boolean).join(' ') || field(input, 'description')
277    case 'skill':
278      return field(input, 'skill')
279    default:
280      return field(input, 'command', 'url', 'to', 'cron', 'file_path', 'path', 'query', 'name', 'title', 'description')
281  }
282}
283
284/** How `fit` cuts: a path keeps its end, a command its start and end, other text its start. */
285export type FitMode = 'path' | 'command' | 'text'
286
287/**
288 * Cut to `room` characters. A command is never left out: it keeps at least
289 * 16, its head and its tail, so what a long line ends with stays in sight.
290 * Control and format characters are dropped: a file name or command must not
291 * restyle, reorder or rewrite the screen.
292 */
293export function fit(text: string, room: number, mode: FitMode): string {
294  const flat = printable(text.replace(/\s+/g, ' '))
295  const space = mode === 'command' ? Math.max(room, 16) : room
296  if (space < 4) return ''
297  if (flat.length <= space) return flat
298  if (mode === 'path') return `…${flat.slice(flat.length - space + 1)}`
299  if (mode === 'text') return `${flat.slice(0, space - 1)}…`
300  const head = Math.ceil((space - 1) / 2)
301  return `${flat.slice(0, head)}…${flat.slice(flat.length - (space - 1 - head))}`
302}
303
304const PHRASE: Readonly<Record<Kind, [string, string]>> = {
305  read: ['read', 'file'],
306  edit: ['edited', 'file'],
307  write: ['wrote', 'file'],
308  delete: ['deleted with', 'command'],
309  shell: ['ran', 'command'],
310  search: ['searched', 'pattern'],
311  web: ['fetched', 'page'],
312  mcp: ['called', 'MCP tool'],
313  agent: ['started', 'agent'],
314  skill: ['used', 'skill'],
315  ask: ['asked', 'question'],
316  plan: ['updated', 'plan'],
317  other: ['ran', 'tool'],
318}
319
320/** A folded group as `read 3 files · searched 2 patterns`, kinds in first-seen order. */
321export function groupSummary(calls: ReadonlyArray<{ tool: string; input: unknown }>): { kinds: Kind[]; text: string } {
322  const counts = new Map<Kind, number>()
323  for (const call of calls) {
324    const kind = kindOf(call.tool, call.input)
325    counts.set(kind, (counts.get(kind) ?? 0) + 1)
326  }
327  const parts = [...counts].map(([kind, n]) => {
328    const [verb, noun] = PHRASE[kind]
329    return `${verb} ${n} ${noun}${n === 1 ? '' : 's'}`
330  })
331  const text = parts.join(' · ')
332  return { kinds: [...counts.keys()], text: text.charAt(0).toUpperCase() + text.slice(1) }
333}
334
types/index.d.ts 13 lines
1/**
2 * The person's choice: which icon set and color theme, whether icons sit on
3 * a colored chip (badge), whether labels fade in a gradient, and whether
4 * icons draw at all.
5 */
6export type ToolIconsPrefs = { style: string; theme: string; isOn: boolean; isBadge: boolean; isGradient: boolean }
7
8declare module 'claude-code' {
9  interface PluginState {
10    'tool-icons': { prefs: ToolIconsPrefs }
11  }
12}
13