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

Two mods for Claude Code's function-hooks plugin system:
Both are tested on Claude Code 2.1.296. Mods are an early-access API that can change between releases.
A sidebar on the right of the terminal conversation with one pair of bars per Claude account:
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 ]
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
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:
claude auth login --email <address> fills in the address.--sso forces single sign-on.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.order (below) if you set it./accounts | Show or hide the sidebar. |
/accounts refresh | Re-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 → order | Your preferred order, e.g. work1,work2,personal. Unlisted accounts follow alphabetically. |
/config → account-bars → claudePath | The 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:
/accounts).Status lines under each account:
| Line | Meaning |
|---|---|
as of 40s ago | Live reading. Other accounts refresh every 5 minutes. The account you are signed into also updates after every reply. |
reading… | Not read yet. |
sign-in expired | That profile's login lapsed. Run claude auth login for it again (see above). The mod tries it again every 10 minutes. |
no reading, retrying | The 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 in | The folder exists but holds no login. |
other org, never suggested | Tracked, but in a different organization from the account you are signed into. |
paused | CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC is set, so other accounts are not polled. |
| a yellow line at the top | A setup problem: claude, or the program that reads other accounts, was not found at its fixed path. |
At the session limit:
The policy lives in one function, pickNext() in account-bars/hooks/next.ts. Each candidate carries:
order;The rule:
WEEKLY_CEILING), since they would block soon after.order.Change the function to suit how you work.
| Account | Where its numbers come from | Credential involved |
|---|---|---|
| The one this session is signed into | Readings Claude Code itself pushes to mods after every reply, plus the usage endpoint through Claude Code's credential handle | none: 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 process | that 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.
%SystemRoot%./bin/sh with /usr/bin/curl. The token reaches curl on its standard input, never on a command line.account-bars/hooks/probe.ts.To recognise accounts and organizations, the mod runs claude auth status --json:
/login, on refresh, and every 10 minutes.https://api.anthropic.com.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.tr, sed, tail) come only from /usr/bin and /bin. curl ignores ~/.curlrc and allows https only.error, never the error text, which could quote the login it failed to read.claude auth status results, which another installed mod could hook.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC stops everything that touches other accounts: both their usage reads and their identity checks.no reading, retrying instead of breaking. The account you are signed into keeps updating from Claude Code's own readings..credentials.json). Keep your disk encrypted..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.claude auth status for that profile so Claude Code can renew it. If a profile still lapses, it shows sign-in expired.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.
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.
(3 lines) ….tool-icons only redraws transcript rows, never the approval dialog./icons off to see Claude Code's own rows, output and error text included.hooks/register.tsx 227 lines1import { 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}
227hooks/icons.ts 334 lines1// 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}
334types/index.d.ts 13 lines1/**
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