Lists installed plugins and mods with enable/disable toggles (/mods)

Three mods for the Claude Code terminal (CLI). Claude Desktop, VS Code and mobile keep their normal look.
| Mod | What it does |
|---|---|
| session-panel | A side panel docked on the right: live 5-hour and weekly limits, context use with a per-category breakdown, the model and effort with switchers, skills with their context cost, connector status, the working directory, the Claude Code version and toggles for your mods. /panel opens it; the ✕ collapses it to a « button. |
| message-style | Your prompts as opencode-style panels: full width, text on the right, a thin blue stripe on the right edge. |
| mod-manager | /mods lists your installed plugins and mods with on/off toggles that apply in the open session, no restart. The side panel's Mods section uses it. |
You need Claude Code 2.1.300 or newer.
In a Claude Code terminal session, run each of these, answering y to add the marketplace and choosing the user scope:
/plugin install session-panel --marketplace Ilja-Garbuz/claude-code-mods
/plugin install message-style --marketplace Ilja-Garbuz/claude-code-mods
/plugin install mod-manager --marketplace Ilja-Garbuz/claude-code-mods
Or from any shell (PowerShell, bash, fish):
claude plugin marketplace add Ilja-Garbuz/claude-code-mods
claude plugin install session-panel@ilja-mods --scope user
claude plugin install message-style@ilja-mods --scope user
claude plugin install mod-manager@ilja-mods --scope user
session-panel brings mod-manager along, so installing it alone also works.
Start a new session (or run /reload-plugins) and the panel appears once the terminal is wide enough (144 columns in the fullscreen layout). Anywhere else, type /panel.
To update later: claude plugin marketplace update ilja-mods, then /reload-plugins.
/plugin configure message-style@ilja-mods sets the panel colour and the stripe colour./mods toggle <name>, or claude plugin disable <name>@ilja-mods./model, so it also becomes your default for new sessions. Mid-session the panel warns first, because a switch drops the prompt cache.claude -p /usage --safe-mode every 3 minutes and claude mcp list every 15 minutes. Neither calls the model or uses your limits.claude and git by name, so both need to be on your PATH. Nothing depends on the shell.See DEVELOPMENT.md. Each mod checks with:
claude plugin validate mods/session-panel
claude plugin test mods/session-panelhooks/register.tsx 156 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ModEntry } from '../types'
5
6const PANE = 'mod-manager'
7const mods = atom({ plugin: 'mod-manager', key: 'mods' } as const, [] as ModEntry[])
8const busy = atom({ plugin: 'mod-manager', key: 'busy' } as const, null as string | null)
9const note = atom({ plugin: 'mod-manager', key: 'note' } as const, null as string | null)
10
11type Listed = {
12 id: string
13 version?: string
14 scope: string
15 enabled: boolean
16 installPath?: string
17 readFromFolder?: string
18}
19
20// Scopes `claude plugin enable/disable --scope` accepts; synced and managed
21// plugins are toggled without one and the CLI decides.
22const SCOPES = ['user', 'project', 'local']
23
24function runClaude($: EngineInterface, args: string[]) {
25 return $.process.run(['claude', ...args], { timeoutMs: 60_000 })
26}
27
28async function hasModule($: EngineInterface, root: string | undefined) {
29 if (!root) return false
30 try {
31 return /"modules"/.test(await $.fs.read(`${root}/hooks/hooks.json`))
32 } catch {
33 return false
34 }
35}
36
37async function refresh($: EngineInterface) {
38 const ran = await runClaude($, ['plugin', 'list', '--json'])
39 if (ran.exitCode !== 0) {
40 await update($, note, () => `plugin list failed: ${ran.stderr.trim().slice(0, 160)}`)
41 return
42 }
43 const listed = JSON.parse(ran.stdout) as Listed[]
44 const entries = await Promise.all(
45 listed.map(async (p): Promise<ModEntry> => ({
46 id: p.id,
47 name: p.id.split('@')[0] ?? p.id,
48 version: p.version,
49 scope: p.scope,
50 enabled: p.enabled,
51 isMod: await hasModule($, p.readFromFolder ?? p.installPath),
52 })),
53 )
54 entries.sort((a, b) => Number(b.isMod) - Number(a.isMod) || a.name.localeCompare(b.name))
55 await update($, mods, () => entries)
56}
57
58async function toggle($: EngineInterface, id: string): Promise<string> {
59 let list = await read($, mods)
60 if (list.length === 0) {
61 await refresh($)
62 list = await read($, mods)
63 }
64 const entry = list.find(m => m.id === id || m.name === id)
65 if (!entry) return `No installed plugin named ${id}.`
66 if ((await read($, busy)) !== null) return 'Another toggle is still running.'
67
68 await update($, busy, () => entry.id)
69 try {
70 const verb = entry.enabled ? 'disable' : 'enable'
71 const args = ['plugin', verb, entry.id]
72 if (SCOPES.includes(entry.scope)) args.push('--scope', entry.scope)
73 const ran = await runClaude($, args)
74 if (ran.exitCode !== 0) {
75 const why = (ran.stderr || ran.stdout).trim().slice(0, 160)
76 await update($, note, () => `${entry.name}: ${why}`)
77 return `Could not ${verb} ${entry.name}: ${why}`
78 }
79 await refresh($)
80
81 // /reload-plugins applies the change to this session. It can't run inside
82 // the command that asked for it, so it runs from a timer right after; where
83 // it can't run at all (a -p run), the change lands on the next start.
84 $.clock.after(100, async () => {
85 try {
86 await $.command.run({ command: 'reload-plugins' })
87 await update($, note, () => `${entry.name} ${verb}d and plugins reloaded.`)
88 } catch (err) {
89 $.ui.log(`reload-plugins not run: ${String(err).slice(0, 200)}`)
90 await update($, note, () => `${entry.name} ${verb}d; restart Claude Code to apply it.`)
91 }
92 })
93 const said = `${entry.name} ${verb}d; reloading plugins…`
94 await update($, note, () => said)
95 return said
96 } finally {
97 await update($, busy, () => null)
98 }
99}
100
101export const register: Register = on => {
102 on('session.start', async ($, e, next) => {
103 await $.command.register({
104 name: 'mods',
105 description: 'Show installed mods and plugins with enable/disable toggles',
106 argumentHint: '[toggle <plugin> | refresh]',
107 })
108 void refresh($)
109 return next(e)
110 })
111
112 // /clear wipes the session's values; read the list again after it.
113 on('session.end', async ($, e, next) => {
114 const ended = await next(e)
115 if (e.reason === 'clear') $.clock.after(300, () => refresh($))
116 return ended
117 })
118
119 on('command.run', { command: 'mods' }, async ($, e) => {
120 const [verb, target] = (e.args ?? '').trim().split(/\s+/)
121 if (verb === 'toggle' && target) return { text: await toggle($, target) }
122 await refresh($)
123 if (verb === 'refresh') return { text: 'Plugin list refreshed.' }
124 await $.ui.open({ id: PANE, title: 'Mods' })
125 return { text: 'Mods pane opened.' }
126 })
127
128 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
129 const { Box, Text, Button } = $.ui.resolve(e)
130 const list = await read($, mods)
131 const running = await read($, busy)
132 const said = await read($, note)
133
134 return (
135 <Box flexDirection="column">
136 {list.length === 0 && <Text dimColor>Reading installed plugins…</Text>}
137 {list.map(m => (
138 <Button
139 key={`toggle-${m.id}`}
140 label={m.id}
141 plain
142 onPress={() => void toggle($, m.id)}
143 >
144 <Text color={m.enabled ? 'success' : 'inactive'}>
145 {running === m.id ? '[ … ]' : m.enabled ? '[ on]' : '[off]'}
146 </Text>{' '}
147 {m.name}
148 <Text dimColor>{m.isMod ? ' mod' : ''} {m.scope}</Text>
149 </Button>
150 ))}
151 {said && <Text dimColor wrap="wrap">{said}</Text>}
152 </Box>
153 )
154 })
155}
156types/index.d.ts 19 lines1export type ModEntry = {
2 id: string
3 name: string
4 version?: string
5 scope: string
6 enabled: boolean
7 isMod: boolean
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 'mod-manager': {
13 mods: ModEntry[]
14 busy: string | null
15 note: string | null
16 }
17 }
18}
19