A chip with your git worktrees and a pane that shows each one's branch, uncommitted changes and pull request, opens it in the app's Terminal and removes the…

Dracula-themed mods for Claude Code: a live diff pane, Kubernetes, Docker, CodeBuild and worktree panes, a pull request dashboard, AWS SSO status, guardrails for risky shell commands and a few workflow helpers. Each mod is its own plugin, so install only the ones you want.
Mods use Claude Code's function hooks API, which is in early access and may change between releases. Mods run on your machine with your permissions: read the code before installing, like any plugin.
In Claude Code (terminal or the desktop app's Code tab):
/plugin marketplace add lucasleandro08/claude-mods
/plugin install mod-manager@dracula-mods
The marketplace is named dracula-mods. Open /mods (or click the ⚙ Mods chip above the prompt; the pane also opens by itself while no mod is on): install the mods you want from there (Install or Install all) and turn them on. Every mod ships turned off. Your choices are saved in ~/.claude/dracula-mods.json, so they hold in every session; each mod's other options are in /config.
| Mod | What it does | Commands | |||
|---|---|---|---|---|---|
| mod-manager | The only mod on by default: a pane that lists every mod with an on/off switch and an Install button for the ones not installed yet (it runs claude plugin install, or puts the /plugin install command in your prompt when the CLI is not found), plus a ⚙ Mods · n/12 chip above the prompt. After an install it runs /reload-plugins, so the new mod shows up in the session you are in; ↻ Reload does the same by hand. | /mods, /mods reload | |||
| live-diff | A pane with the diff of every Edit and Write Claude makes: file navigator with M/A status, +/- counts, line numbers and red/green rows. A Δ Diff chip above the prompt reopens it. | /diff, /diff-clear | |||
| kube-pane | A ⎈ context chip above the prompt (red on production) and a pods pane: context and namespace pickers, filter, pages, unhealthy pods first, and a ⌘ Shell button that opens kubectl exec in the app's Terminal, with a container picker for multi-container pods. Switching context only affects Claude's session: your kubeconfig is never written. | /pods | |||
| pr-pane | Your open pull requests grouped by repo, with CI status, the Codex review state and whether each PR already reached your integration branch. A ⑂ PRs chip flags failing CI and Codex findings. Needs the GitHub CLI. | /prs | |||
| guardrails | Blocks risky commands before they run: killing a port owned by a Docker container, pkill docker, printing secrets (env, printenv, echo $API_TOKEN), rewriting the kubeconfig. Asks before kubectl writes on production contexts. Each rule can be switched off. | — | |||
| promote-branch | /promote previews merging the current branch into your integration branch (commits, files, test files touched). /promote go does a full merge in a temporary worktree and pushes it, refusing dirty trees and protected branches. | /promote, /promote go | |||
| codex-loop | Hands Claude the review loop with the Codex GitHub bot: request a review, weigh each finding, fix or 👎 it, reply, resolve, repeat until 👍, for at most 5 rounds (and 15 minutes of waiting per round) before it stops and reports what is still open. | /codex-loop <PR> [--max <rounds>] | |||
| turn-done | A toast and a chime when a long turn finishes. | — | |||
| aws-profile | Each AWS profile above the prompt with whether its login still works (aws sts get-caller-identity, only the exit code is kept). Expired ones get a login button that runs aws sso login --profile … in the app's Terminal and turns green once you finish. The AWS_PROFILE Claude Code runs with is marked ●; production profiles are red. | — | |||
| docker-pane | A 🐳 Docker · n up chip (unhealthy and stale counts) and a pane grouped by compose project: ports, health, Start/Stop/Restart by container name (never by port), Logs and compose Recreate in the app's Terminal. Flags containers created before your last git checkout in the session's repo, the usual cause of a test failing for no reason. | /docker | |||
| deploy-watch | Watches AWS CodeBuild projects: a 🚀 chip with the last build, a warning (chip and toast) when a git push to a project's branch started no build after a few minutes, and a pane with recent builds, their logs in the Terminal and a Start build button that asks first. | /deploys | |||
| worktrees | A 🌳 n worktrees chip and a pane with every worktree of the session's repo and of the repos under your roots: branch, uncommitted changes, PR state, a Terminal button, and Remove (asks first, never --force) for clean worktrees whose branch is merged. | /worktrees | |||
| dot | An always-on assistant with a name and a face: Vlad, a chibi vampire drawn in SVG that lives in its own pane and animates by mood (working, waiting on you, idle, paused), with what it is doing right under its name; the mods bar keeps a one-word tile that opens it. Rename it with /dot name. Give it goals in its pane or with /dot <goal>; background rounds (the desktop app's scheduled tasks, every 30 min) pick one goal at a time and work on it, following ~/.claude/dot/rules.md: act alone, ask first, or hand off to you. When it needs a decision it notifies you and the goal shows under Waiting on you, where you answer it. A weekday morning briefing sums up what needs you. Its memory is a file you can read and edit. Rounds run while the desktop app is open. | /dot, /dot <goal>, `/dot on \ | pause \ | resume \ | run` |
| Mod | Option | Default |
|---|---|---|
| mod-manager | Show the Mods chip · open on start until a mod is on · claude CLI path | on · on · PATH |
| kube-pane | Production context pattern · default namespace · refresh interval · pods per page · kubectl path | prod · default · 15 s · 25 · PATH |
| pr-pane | Integration branch · Codex bot login · refresh interval · PRs to load · gh path | staging · chatgpt-codex-connector[bot] · 5 min · 50 · PATH |
| guardrails | Production pattern · confirm production writes · block kubeconfig changes · block printing secrets · protect Docker ports | prod · on · on · on · on |
| promote-branch | Target branch · protected branches | staging · main,master |
| live-diff | Context lines · edits kept | 2 · 100 |
| turn-done | Long turn threshold · play sound | 60 s · on |
| codex-loop | Codex bot login · maximum review rounds · wait for Codex | chatgpt-codex-connector[bot] · 5 · 15 min |
| aws-profile | Production pattern · profiles · check interval · aws path | prod · all · 5 min · PATH |
| docker-pane | Refresh interval · docker path | 60 s (10 s while open) · PATH |
| deploy-watch | Projects (name=branch, comma-separated) · AWS profile · region · poll interval · missing build warning · production pattern · aws path | none · CLI default · profile's · 60 s · 3 min · prod · PATH |
| worktrees | Roots (folders holding your repos) · refresh interval · gh path | session repo only · 5 min · PATH |
| dot | Work round interval · morning briefing (weekdays) · open its pane on start | 30 min · 09:00 · on |
With the mod-manager on, every mod's chip is drawn in one bar above the prompt: a tile per mod, its name dimmed over its value (like a usage band), wrapping between tiles with a blank row between lines; each mod publishes its chip as state and the bar sends presses back to it. Without the manager, or if it stops, each mod draws its own row again. Either way the band keeps whatever other mods draw beneath it.
Admins can register the marketplace and install mods for everyone through managed settings (extraKnownMarketplaces and enabledPlugins); people then turn on what they use in /mods. See Plugins for organizations and Mods for admins.
git clone https://github.com/lucasleandro08/claude-mods
cd claude-mods
scripts/check.sh # validate + test every plugin (CLAUDE_BIN overrides the CLI path)
scripts/check.sh kube-pane # only one
Load your checkout instead of the published version by adding it as a local marketplace and installing from it:
claude plugin marketplace add ~/claude-mods
claude plugin install mod-manager@dracula-mods --scope user
Plugins of a marketplace added from a local folder load in place, so edits and newly installed mods reach open sessions with /reload-plugins (or ↻ Reload in /mods); see Plugin loading. Avoid CLAUDE_CODE_PLUGIN_DIRS for this: it is read only when a session starts, so a mod added to it shows up only in new sessions.
.claude-plugin/marketplace.json
plugins/<mod>/
.claude-plugin/plugin.json manifest and /config options
hooks/register.tsx event wiring, state atoms, everything that touches $
src/ pure logic (diffing, parsing kubectl and gh output, rules)
src/shared/ vendored copies of scripts/shared (theme, binary lookup)
ui/ components the panes and chips draw
types/index.d.ts state contract
tests/ claude plugin test suites
scripts/shared/ source of the shared files; scripts/sync-shared.sh copies them
Two rules of the hooks runtime shape this layout: $ can only be passed to functions declared in register.tsx itself, and state atoms must be declared there too. Plugins cannot import files from each other, which is why small shared helpers are vendored.
🇧🇷 Em português: mods para o Claude Code com tema Dracula. Instale com /plugin marketplace add lucasleandro08/claude-mods e /plugin install mod-manager@dracula-mods, instale os mods que quiser e ligue cada um em /mods (todos vêm desligados); as demais opções ficam em /config.
hooks/register.tsx 290 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { RepoRow, WorktreeRow } from '../types'
5import { isMerged, parseBranchList, parsePorcelain, parseRoots, parseStatusCount, prsByBranch, type PullRequest } from '../src/worktrees'
6import { binaryCandidates } from '../src/shared/bin'
7import { isOn, OFF_TEXT, parseState, statePath } from '../src/shared/toggle'
8import { barIsLive, sameChip, type Chip, type ChipPress } from '../src/shared/chip'
9import { WorktreesChip, worktreesChip } from '../ui/chip'
10import { canRemove, PaneError, WorktreesPane } from '../ui/pane'
11
12const MOD = 'worktrees'
13const CHECK_MS = 5000
14const active = atom({ plugin: 'worktrees', key: 'active' } as const, false)
15let checkedAt = 0
16
17async function isActive($: EngineInterface) {
18 if (Date.now() - checkedAt > CHECK_MS) {
19 checkedAt = Date.now()
20 const home = await $.env.get('HOME').catch(() => undefined)
21 const text = await $.fs.read(statePath(home)).catch(() => '')
22 const on = isOn(parseState(text), MOD)
23 await update($, active, prev => (prev === on ? prev : on))
24 }
25 return read($, active)
26}
27
28const chip = atom({ plugin: 'worktrees', key: 'chip' } as const, null)
29
30async function barOwnsChips($: EngineInterface) {
31 const { value } = await $.state.get({ plugin: 'mod-manager', key: 'bar' })
32 return barIsLive(value, Date.now())
33}
34
35async function setChip($: EngineInterface, next: Chip | null) {
36 await update($, chip, prev => (sameChip(prev, next) ? prev : next))
37}
38
39const PANE_ID = 'worktrees'
40const BATCH = 8
41
42const repos = atom({ plugin: 'worktrees', key: 'repos' } as const, [])
43const isOpen = atom({ plugin: 'worktrees', key: 'isOpen' } as const, false)
44const isLoading = atom({ plugin: 'worktrees', key: 'isLoading' } as const, false)
45const lastError = atom({ plugin: 'worktrees', key: 'error' } as const, '')
46const updatedAt = atom({ plugin: 'worktrees', key: 'updatedAt' } as const, 0)
47const busy = atom({ plugin: 'worktrees', key: 'busy' } as const, '')
48let polledAt = 0
49
50type Settings = { roots: string; ghPath: string; pollMs: number }
51
52function readSettings(options: PluginOptions): Settings {
53 return {
54 roots: String(options.roots ?? ''),
55 ghPath: String(options.ghPath ?? ''),
56 pollMs: Math.max(1, Number(options.pollMinutes ?? 5)) * 60_000,
57 }
58}
59
60async function git($: EngineInterface, cwd: string, args: readonly string[]) {
61 return $.process.run(['git', '-C', cwd, ...args], { timeoutMs: 15_000 }).catch(() => undefined)
62}
63
64async function gitText($: EngineInterface, cwd: string, args: readonly string[]) {
65 const ran = await git($, cwd, args)
66 return ran?.exitCode === 0 ? ran.stdout : undefined
67}
68
69async function myPullRequests($: EngineInterface, settings: Settings, cwd: string): Promise<PullRequest[]> {
70 const args = ['pr', 'list', '--author', '@me', '--state', 'all', '--limit', '100', '--json', 'number,state,headRefName,url']
71 for (const bin of binaryCandidates('gh', await $.env.get('HOME'), settings.ghPath)) {
72 try {
73 const ran = await $.process.run([bin, ...args], { cwd, timeoutMs: 20_000 })
74 return ran.exitCode === 0 ? (JSON.parse(ran.stdout) as PullRequest[]) : []
75 } catch {
76 continue
77 }
78 }
79 return []
80}
81
82async function candidates($: EngineInterface, settings: Settings, home: string) {
83 const dirs = [await $.session.cwd()]
84 for (const root of parseRoots(settings.roots, home)) {
85 const entries = await $.fs.list(root).catch(() => [])
86 for (const entry of entries) {
87 if (entry.kind === 'dir' && !entry.name.startsWith('.')) dirs.push(`${root}/${entry.name}`)
88 }
89 }
90 return [...new Set(dirs)]
91}
92
93async function loadRepo($: EngineInterface, settings: Settings, dir: string, seen: Set<string>): Promise<RepoRow | undefined> {
94 const listed = await gitText($, dir, ['worktree', 'list', '--porcelain'])
95 const all = parsePorcelain(listed ?? '')
96 const main = all[0]
97 if (!main || all.length < 2 || seen.has(main.path)) return undefined
98 seen.add(main.path)
99
100 const remoteHead = (await gitText($, main.path, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']))?.trim() || 'origin/main'
101 const [mergedText, baseHead, prs] = await Promise.all([
102 gitText($, main.path, ['branch', '--merged', remoteHead]),
103 gitText($, main.path, ['rev-parse', remoteHead]),
104 myPullRequests($, settings, main.path),
105 ])
106 const byBranch = prsByBranch(prs)
107 const extra = all.slice(1)
108 // A branch with no commits of its own is "merged" too: only trust it when the worktree moved past the base
109 const merged = parseBranchList(mergedText ?? '').filter(branch => extra.some(w => w.branch === branch && w.head !== baseHead?.trim()))
110
111 const worktrees: WorktreeRow[] = await Promise.all(
112 extra.map(async worktree => {
113 const status = worktree.isPrunable ? '' : await gitText($, worktree.path, ['status', '--porcelain'])
114 const pr = byBranch[worktree.branch]
115 return { ...worktree, changes: parseStatusCount(status ?? ''), pr, isMerged: isMerged(worktree.branch, merged, pr) }
116 }),
117 )
118 return { main: main.path, branch: main.branch, worktrees }
119}
120
121async function load($: EngineInterface, settings: Settings) {
122 if (await read($, isLoading)) return
123 await update($, isLoading, () => true)
124 polledAt = Date.now()
125 try {
126 const home = (await $.env.get('HOME')) ?? ''
127 const dirs = await candidates($, settings, home)
128 const seen = new Set<string>()
129 const found: RepoRow[] = []
130 for (let i = 0; i < dirs.length; i += BATCH) {
131 const batch = await Promise.all(dirs.slice(i, i + BATCH).map(dir => loadRepo($, settings, dir, seen)))
132 found.push(...batch.filter((repo): repo is RepoRow => repo !== undefined))
133 }
134 await update($, repos, () => found.sort((a, b) => a.main.localeCompare(b.main)))
135 await update($, lastError, () => '')
136 await update($, updatedAt, () => Date.now())
137 } catch (err) {
138 await update($, lastError, () => (err instanceof Error ? err.message : String(err)))
139 } finally {
140 await update($, isLoading, () => false)
141 }
142}
143
144async function tick($: EngineInterface, settings: Settings) {
145 if (!(await isActive($))) return
146 if (Date.now() - polledAt >= settings.pollMs) await load($, settings)
147}
148
149async function openPane($: EngineInterface, settings: Settings) {
150 const opened = await $.ui.open({ id: PANE_ID, title: 'Worktrees' })
151 if (opened.isPlaced) await update($, isOpen, () => true)
152 await load($, settings)
153}
154
155function quote(path: string) {
156 return /^[\w@%+=:,./-]+$/.test(path) ? path : `'${path.replace(/'/g, `'\\''`)}'`
157}
158
159async function openTerminal($: EngineInterface, row: WorktreeRow) {
160 try {
161 await $.tool.call({ tool: 'mcp__terminal__run_in_terminal', command: `cd ${quote(row.path)}`, title: (row.branch || row.path).slice(0, 40) })
162 } catch (err) {
163 $.ui.toast(`Could not open the terminal: ${err instanceof Error ? err.message : String(err)}`)
164 }
165}
166
167async function confirmRemove($: EngineInterface, rows: WorktreeRow[]) {
168 const what = rows.length === 1 ? `the worktree of ${rows[0]?.branch}` : `${rows.length} merged worktrees`
169 try {
170 const answer = await $.ui.ask(`Remove ${what}? Branches stay; only clean worktrees are removed.`, {
171 header: 'Worktrees',
172 options: ['Remove', 'Cancel'],
173 })
174 return answer === 'Remove'
175 } catch {
176 return false
177 }
178}
179
180async function remove($: EngineInterface, settings: Settings, repo: RepoRow, rows: WorktreeRow[]) {
181 const cwd = await $.session.cwd()
182 const targets = rows.filter(row => canRemove(row, cwd))
183 if (targets.length === 0 || !(await confirmRemove($, targets))) return
184 const failed: string[] = []
185 for (const row of targets) {
186 await update($, busy, () => row.path)
187 const ran = await git($, repo.main, ['worktree', 'remove', row.path])
188 if (ran?.exitCode !== 0) failed.push(`${row.branch}: ${ran?.stderr.trim().split('\n')[0] ?? 'git failed'}`)
189 }
190 await update($, busy, () => '')
191 $.ui.toast(failed.length === 0 ? `Removed ${targets.length} worktree${targets.length === 1 ? '' : 's'}.` : `Not removed: ${failed.join('; ')}`)
192 await load($, settings)
193}
194
195async function publishChip($: EngineInterface) {
196 const all = await read($, repos)
197 const total = all.reduce((n, repo) => n + repo.worktrees.length, 0)
198 if (!(await read($, active)) || total === 0) return setChip($, null)
199 const cwd = await $.session.cwd()
200 await setChip($, worktreesChip(total, all.reduce((n, repo) => n + repo.worktrees.filter(row => canRemove(row, cwd)).length, 0)))
201}
202
203export const register: Register = (on, options) => {
204 const settings = readSettings(options)
205
206 on('state.set', async ($, e, next) => {
207 const ran = await next(e)
208 if (e.plugin === 'mod-manager' && e.key === 'press') {
209 const pressed = e.value as ChipPress | null
210 if (pressed?.plugin === MOD && (await isActive($))) await openPane($, settings)
211 }
212 return ran
213 })
214
215 on('session.start', async ($, e, next) => {
216 polledAt = 0
217 await $.command.register({ name: 'worktrees', description: 'Open the git worktrees pane' })
218 $.clock.every(CHECK_MS, () => void tick($, settings).catch(() => undefined))
219 $.clock.every(CHECK_MS, () => void publishChip($).catch(() => undefined))
220 return next(e)
221 })
222
223 on('command.run', { command: 'worktrees' }, async $ => {
224 if (!(await isActive($))) return { text: OFF_TEXT }
225 await openPane($, settings)
226 return { text: 'Worktrees pane opened.' }
227 })
228
229 on('ui.close', async ($, e, next) => {
230 if (!(await isActive($))) return next(e)
231 if (e.id === PANE_ID) await update($, isOpen, () => false)
232 return next(e)
233 })
234
235 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
236 if (!(await isActive($))) return next(e)
237 const ran = await next(e)
238 if (/\bgit\s+(?:-C\s+\S+\s+)?worktree\b|\bgh\s+pr\s+(merge|create|close)\b/.test(e.command)) polledAt = 0
239 return ran
240 })
241
242 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
243 if (!(await read($, active)) || (await barOwnsChips($))) return next(e)
244 const rest = await next(e)
245 if (e.props.hasSurvey) return rest
246
247 const all = await read($, repos)
248 const total = all.reduce((n, repo) => n + repo.worktrees.length, 0)
249 if (total === 0) return rest
250 const cwd = await $.session.cwd()
251 const merged = all.reduce((n, repo) => n + repo.worktrees.filter(row => canRemove(row, cwd)).length, 0)
252 const ui = $.ui.resolve(e)
253 return (
254 <ui.Box flexDirection="column">
255 <WorktreesChip ui={ui} total={total} merged={merged} onOpen={() => openPane($, settings)} />
256 {rest}
257 </ui.Box>
258 )
259 })
260
261 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
262 const ui = $.ui.resolve(e)
263 if (!(await read($, active))) {
264 return <ui.Box padding={1}><ui.Text color="#6272a4">{OFF_TEXT}</ui.Text></ui.Box>
265 }
266 try {
267 return (
268 <WorktreesPane
269 ui={ui}
270 width={e.props.bodyColumns}
271 home={(await $.env.get('HOME')) ?? ''}
272 cwd={await $.session.cwd()}
273 repos={await read($, repos)}
274 isLoading={await read($, isLoading)}
275 error={await read($, lastError)}
276 updatedAt={await read($, updatedAt)}
277 busy={await read($, busy)}
278 onRefresh={() => load($, settings)}
279 onTerminal={row => openTerminal($, row)}
280 onRemove={(repo, rows) => remove($, settings, repo, rows)}
281 />
282 )
283 } catch (err) {
284 const message = err instanceof Error ? err.message : String(err)
285 $.ui.log(`worktrees render failed: ${message}`)
286 return <PaneError ui={ui} message={message} onRefresh={() => load($, settings)} />
287 }
288 })
289}
290src/worktrees.ts 66 lines1import type { PullRequest, Worktree } from '../types'
2
3export type { PullRequest, Worktree }
4
5export function parsePorcelain(stdout: string): Worktree[] {
6 return stdout.split(/\n\s*\n/).flatMap((block, index) => {
7 const lines = block.split('\n')
8 const path = lines.find(line => line.startsWith('worktree '))?.slice(9)
9 if (!path || lines.some(line => line === 'bare')) return []
10 const branchLine = lines.find(line => line.startsWith('branch '))
11 return [{
12 path,
13 head: lines.find(line => line.startsWith('HEAD '))?.slice(5) ?? '',
14 branch: branchLine?.startsWith('branch refs/heads/') ? branchLine.slice(18) : '',
15 isMain: index === 0,
16 isLocked: lines.some(line => line.startsWith('locked')),
17 isPrunable: lines.some(line => line.startsWith('prunable')),
18 }]
19 })
20}
21
22export function parseBranchList(stdout: string): string[] {
23 return stdout.split('\n')
24 .map(line => line.replace(/^[*+]\s+|^\s+/, '').trim())
25 .filter(line => line !== '' && !line.startsWith('(HEAD detached'))
26}
27
28export function prsByBranch(prs: PullRequest[]): Record<string, PullRequest> {
29 const result: Record<string, PullRequest> = {}
30 const rank = { OPEN: 2, MERGED: 1, CLOSED: 0 }
31 for (const pr of prs) {
32 const current = result[pr.headRefName]
33 if (!current || rank[pr.state] > rank[current.state] || (rank[pr.state] === rank[current.state] && pr.number > current.number)) result[pr.headRefName] = pr
34 }
35 return result
36}
37
38export function isMerged(branch: string, mergedBranches: string[], pr: PullRequest | undefined): boolean {
39 return branch !== '' && (pr?.state === 'MERGED' || (pr?.state !== 'OPEN' && mergedBranches.includes(branch)))
40}
41
42export function expandHome(path: string, home: string): string {
43 if (path === '~') return home
44 return path.startsWith('~/') ? `${home}${path.slice(1)}` : path
45}
46
47export function shortPath(path: string, home: string): string {
48 const base = home.replace(/\/+$/, '')
49 if (path === base) return '~'
50 return path.startsWith(`${base}/`) ? `~${path.slice(base.length)}` : path
51}
52
53export function parseRoots(option: string, home: string): string[] {
54 const roots = new Set<string>()
55 for (const value of option.split(/[,:]/)) {
56 const expanded = expandHome(value.trim(), home)
57 const root = expanded.replace(/\/+$/, '') || (expanded.startsWith('/') ? '/' : '')
58 if (root !== '') roots.add(root)
59 }
60 return [...roots]
61}
62
63export function parseStatusCount(porcelain: string): number {
64 return porcelain.split('\n').filter(line => line.trim() !== '').length
65}
66src/shared/bin.ts 9 lines1const COMMON_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin']
2
3export function binaryCandidates(name: string, home: string | undefined, override?: string): string[] {
4 const homeDirs = home ? [`${home}/bin`, `${home}/.local/bin`] : []
5 return [override, name, ...[...COMMON_DIRS, ...homeDirs].map(dir => `${dir}/${name}`)].filter(
6 (bin): bin is string => typeof bin === 'string' && bin !== '',
7 )
8}
9src/shared/toggle.ts 31 lines1export const MARKETPLACE = 'dracula-mods'
2export const STATE_FILE = '.claude/dracula-mods.json'
3export const OFF_TEXT = 'This mod is off. Turn it on in /mods.'
4
5export type ModsState = { enabled: Record<string, boolean> }
6
7export function statePath(home: string | undefined) {
8 return `${home ?? ''}/${STATE_FILE}`
9}
10
11export function parseState(text: string): ModsState {
12 try {
13 const parsed = JSON.parse(text) as Partial<ModsState>
14 return { enabled: typeof parsed.enabled === 'object' && parsed.enabled !== null ? parsed.enabled : {} }
15 } catch {
16 return { enabled: {} }
17 }
18}
19
20export function isOn(state: ModsState, name: string) {
21 return state.enabled[name] === true
22}
23
24export function withToggle(state: ModsState, name: string, on: boolean): ModsState {
25 return { enabled: { ...state.enabled, [name]: on } }
26}
27
28export function serializeState(state: ModsState) {
29 return `${JSON.stringify(state, null, 2)}\n`
30}
31src/shared/chip.ts 28 lines1import { DRACULA } from './theme'
2
3// The mod-manager draws every mod's chip in one bar; each mod publishes its chip as data
4export type ChipTone = 'text' | 'muted' | 'ok' | 'warn' | 'bad' | 'info' | 'accent'
5export type ChipPart = { text: string; tone: ChipTone; bold?: boolean; action?: string }
6export type Chip = { icon: string; label: string; tone: ChipTone; parts: ChipPart[] }
7export type ChipPress = { plugin: string; action: string; at: number }
8
9export const TONE_COLOR: Record<ChipTone, string> = {
10 text: DRACULA.foreground,
11 muted: DRACULA.comment,
12 ok: DRACULA.green,
13 warn: DRACULA.orange,
14 bad: DRACULA.red,
15 info: DRACULA.cyan,
16 accent: DRACULA.purple,
17}
18
19export function sameChip(a: Chip | null, b: Chip | null) {
20 return JSON.stringify(a) === JSON.stringify(b)
21}
22
23export const BAR_STALE_MS = 30_000
24
25export function barIsLive(beat: number | undefined, now: number) {
26 return typeof beat === 'number' && now - beat < BAR_STALE_MS
27}
28ui/chip.tsx 31 lines1import type { Elements, RenderSurface } from 'claude-code'
2
3import type { Chip } from '../src/shared/chip'
4import { DRACULA } from '../src/shared/theme'
5
6type Ui = Elements[RenderSurface]
7
8export function WorktreesChip({ ui, total, merged, onOpen }: { ui: Ui; total: number; merged: number; onOpen: () => unknown }) {
9 const { Box, Text, Button } = ui
10
11 return (
12 <Box flexDirection="row" gap={1} alignItems="center" marginBottom={1}>
13 <Text color={DRACULA.green}>🌳</Text>
14 <Button key="open-worktrees" label={`${total} worktree${total === 1 ? '' : 's'}`} dimColor onPress={onOpen} />
15 {merged > 0 && <Text color={DRACULA.purple}>{merged} merged, can go</Text>}
16 </Box>
17 )
18}
19
20export function worktreesChip(total: number, merged: number): Chip {
21 return {
22 icon: '🌳',
23 label: 'worktrees',
24 tone: 'ok',
25 parts: [
26 { text: `${total} worktree${total === 1 ? '' : 's'}`, tone: 'text', action: 'open' },
27 ...(merged > 0 ? [{ text: `${merged} merged`, tone: 'accent' as const }] : []),
28 ],
29 }
30}
31ui/pane.tsx 109 lines1import type { Elements, RenderSurface } from 'claude-code'
2
3import type { RepoRow, WorktreeRow } from '../types'
4import { shortPath } from '../src/worktrees'
5import { DRACULA } from '../src/shared/theme'
6
7type Ui = Elements[RenderSurface]
8
9export type WorktreesPaneProps = {
10 ui: Ui
11 width: number
12 home: string
13 cwd: string
14 repos: RepoRow[]
15 isLoading: boolean
16 error: string
17 updatedAt: number
18 busy: string
19 onRefresh: () => unknown
20 onTerminal: (row: WorktreeRow) => unknown
21 onRemove: (repo: RepoRow, rows: WorktreeRow[]) => unknown
22}
23
24export function canRemove(row: WorktreeRow, cwd: string) {
25 return !row.isMain && row.isMerged && row.changes === 0 && !row.isLocked && cwd !== row.path && !cwd.startsWith(`${row.path}/`)
26}
27
28export function WorktreesPane(props: WorktreesPaneProps) {
29 const { ui, repos, isLoading, error, updatedAt } = props
30 const { Box, Text, Button } = ui
31 const total = repos.reduce((n, repo) => n + repo.worktrees.length, 0)
32
33 return (
34 <Box flexDirection="column" backgroundColor={DRACULA.background} paddingX={1} paddingY={1}>
35 <Box flexDirection="row" alignItems="center" gap={1}>
36 <Text color={DRACULA.green} bold>🌳 Worktrees</Text>
37 <Text color={DRACULA.comment}>{total} in {repos.length} repo{repos.length === 1 ? '' : 's'}</Text>
38 <Box flexGrow={1} />
39 <Text color={DRACULA.comment}>
40 {isLoading ? 'refreshing…' : updatedAt > 0 ? `updated ${new Date(updatedAt).toTimeString().slice(0, 8)}` : ''}
41 </Text>
42 <Button key="refresh" label="Refresh" dimColor onPress={props.onRefresh} />
43 </Box>
44 {error !== '' && <Text color={DRACULA.red}>{error}</Text>}
45 {repos.length === 0 && !isLoading && (
46 <Text color={DRACULA.comment} italic>No extra worktrees here. Add folders that hold your repos in /config → worktrees → Roots.</Text>
47 )}
48 {repos.map(repo => <RepoView {...props} repo={repo} />)}
49 </Box>
50 )
51}
52
53function RepoView(props: WorktreesPaneProps & { repo: RepoRow }) {
54 const { ui, repo, home, cwd } = props
55 const { Box, Text, Button } = ui
56 const removable = repo.worktrees.filter(row => canRemove(row, cwd))
57 const branchWidth = Math.max(14, Math.min(44, props.width - 46))
58
59 return (
60 <Box key={`repo-${repo.main}`} flexDirection="column" marginTop={1}>
61 <Box flexDirection="row" alignItems="center" gap={1} backgroundColor={DRACULA.currentLine} paddingX={1}>
62 <Text color={DRACULA.purple} bold>{shortPath(repo.main, home)}</Text>
63 {repo.branch !== '' && <Text color={DRACULA.comment}>⑂ {repo.branch}</Text>}
64 <Box flexGrow={1} />
65 {removable.length > 1 && (
66 <Button key={`remove-merged-${repo.main}`} label={`Remove ${removable.length} merged`} dimColor plain onPress={() => props.onRemove(repo, removable)} />
67 )}
68 </Box>
69 {repo.worktrees.map(row => (
70 <Box key={`worktree-${row.path}`} flexDirection="row" paddingX={1} gap={1}>
71 <Box width={branchWidth}>
72 <Text color={row.branch === '' ? DRACULA.comment : DRACULA.foreground} wrap="truncate-middle">{row.branch || `detached ${row.head.slice(0, 7)}`}</Text>
73 </Box>
74 <Box width={10}>
75 <Text color={row.changes > 0 ? DRACULA.orange : DRACULA.comment}>{row.changes > 0 ? `${row.changes} changed` : 'clean'}</Text>
76 </Box>
77 <Box width={12}><PrLabel ui={ui} row={row} /></Box>
78 {props.busy === row.path ? <Text color={DRACULA.yellow}>removing…</Text> : (
79 <Box flexDirection="row" gap={1}>
80 <Button key={`terminal-${row.path}`} label="Terminal" dimColor plain onPress={() => props.onTerminal(row)} />
81 {canRemove(row, cwd) && <Button key={`remove-${row.path}`} label="Remove" dimColor plain onPress={() => props.onRemove(repo, [row])} />}
82 </Box>
83 )}
84 </Box>
85 ))}
86 </Box>
87 )
88}
89
90function PrLabel({ ui, row }: { ui: Ui; row: WorktreeRow }) {
91 const { Text } = ui
92 if (row.pr) {
93 const color = row.pr.state === 'OPEN' ? DRACULA.green : row.pr.state === 'MERGED' ? DRACULA.purple : DRACULA.red
94 return <Text color={color}>#{row.pr.number} {row.pr.state.toLowerCase()}</Text>
95 }
96 if (row.isMerged) return <Text color={DRACULA.purple}>merged</Text>
97 return <Text color={DRACULA.comment}>no PR</Text>
98}
99
100export function PaneError({ ui, message, onRefresh }: { ui: Ui; message: string; onRefresh: () => unknown }) {
101 const { Box, Text, Button } = ui
102 return (
103 <Box flexDirection="column" padding={1}>
104 <Text color={DRACULA.red}>Worktrees pane failed to draw: {message}</Text>
105 <Button key="refresh" label="Refresh" dimColor onPress={onRefresh} />
106 </Box>
107 )
108}
109src/shared/theme.ts 15 lines1// Dracula palette: https://draculatheme.com/contribute#color-palette
2export const DRACULA = {
3 background: '#282a36',
4 currentLine: '#44475a',
5 foreground: '#f8f8f2',
6 comment: '#6272a4',
7 cyan: '#8be9fd',
8 green: '#50fa7b',
9 orange: '#ffb86c',
10 pink: '#ff79c6',
11 purple: '#bd93f9',
12 red: '#ff5555',
13 yellow: '#f1fa8c',
14} as const
15types/index.d.ts 40 lines1export type ChipTone = 'text' | 'muted' | 'ok' | 'warn' | 'bad' | 'info' | 'accent'
2export type ChipPart = { text: string; tone: ChipTone; bold?: boolean; action?: string }
3export type Chip = { icon: string; label: string; tone: ChipTone; parts: ChipPart[] }
4export type ChipPress = { plugin: string; action: string; at: number }
5
6export type Worktree = {
7 path: string
8 head: string
9 branch: string
10 isMain: boolean
11 isLocked: boolean
12 isPrunable: boolean
13}
14
15export type PullRequest = {
16 number: number
17 state: 'OPEN' | 'MERGED' | 'CLOSED'
18 headRefName: string
19 url: string
20}
21
22export type WorktreeRow = Worktree & { changes: number; pr?: PullRequest; isMerged: boolean }
23export type RepoRow = { main: string; branch: string; worktrees: WorktreeRow[] }
24
25declare module 'claude-code' {
26 interface PluginState {
27 'mod-manager': { bar: number; press: ChipPress | null }
28 worktrees: {
29 chip: Chip | null
30 active: boolean
31 repos: RepoRow[]
32 isOpen: boolean
33 isLoading: boolean
34 error: string
35 updatedAt: number
36 busy: string
37 }
38 }
39}
40