SLOPSHOPPER

Live Diff

A Dracula-themed pane with the diff of every Edit and Write Claude makes in the session, a file navigator and line numbers.

newpanebandguardcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · live-diff
│ ┃ live-diff ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ This mod is off. Turn it on in /mods. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /diff │ ⎿ live-diff: This mod is off. Turn it on in /mods. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · live-diff
This mod is off. Turn it on in /mods.
README

claude-mods

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.

Install

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.

Mods

ModWhat it doesCommands
mod-managerThe 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-diffA 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-paneA ⎈ 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-paneYour 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
guardrailsBlocks 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-loopHands 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-doneA toast and a chime when a long turn finishes.—
aws-profileEach 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-paneA 🐳 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-watchWatches 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
worktreesA 🌳 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
dotAn 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`

Options

ModOptionDefault
mod-managerShow the Mods chip · open on start until a mod is on · claude CLI pathon · on · PATH
kube-paneProduction context pattern · default namespace · refresh interval · pods per page · kubectl pathprod · default · 15 s · 25 · PATH
pr-paneIntegration branch · Codex bot login · refresh interval · PRs to load · gh pathstaging · chatgpt-codex-connector[bot] · 5 min · 50 · PATH
guardrailsProduction pattern · confirm production writes · block kubeconfig changes · block printing secrets · protect Docker portsprod · on · on · on · on
promote-branchTarget branch · protected branchesstaging · main,master
live-diffContext lines · edits kept2 · 100
turn-doneLong turn threshold · play sound60 s · on
codex-loopCodex bot login · maximum review rounds · wait for Codexchatgpt-codex-connector[bot] · 5 · 15 min
aws-profileProduction pattern · profiles · check interval · aws pathprod · all · 5 min · PATH
docker-paneRefresh interval · docker path60 s (10 s while open) · PATH
deploy-watchProjects (name=branch, comma-separated) · AWS profile · region · poll interval · missing build warning · production pattern · aws pathnone · CLI default · profile's · 60 s · 3 min · prod · PATH
worktreesRoots (folders holding your repos) · refresh interval · gh pathsession repo only · 5 min · PATH
dotWork round interval · morning briefing (weekdays) · open its pane on start30 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.

Rolling out to a whole organization

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.

Development

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.

Layout

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

License

MIT


🇧🇷 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.

Source 9 files
hooks/register.tsx 171 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Hunk } from '../types'
5import { diffLines, hasChanges, lineOf } from '../src/diff'
6import { DiffChip, diffChip } from '../ui/chip'
7import { DiffPane, PaneError } from '../ui/pane'
8import { isOn, OFF_TEXT, parseState, statePath } from '../src/shared/toggle'
9import { barIsLive, sameChip, type Chip, type ChipPress } from '../src/shared/chip'
10
11const MOD = 'live-diff'
12const CHECK_MS = 5000
13const active = atom({ plugin: 'live-diff', key: 'active' } as const, false)
14let checkedAt = 0
15
16async function isActive($: EngineInterface) {
17  if (Date.now() - checkedAt > CHECK_MS) {
18    checkedAt = Date.now()
19    const home = await $.env.get('HOME').catch(() => undefined)
20    const text = await $.fs.read(statePath(home)).catch(() => '')
21    const on = isOn(parseState(text), MOD)
22    await update($, active, prev => (prev === on ? prev : on))
23  }
24  return read($, active)
25}
26
27const chip = atom({ plugin: 'live-diff', key: 'chip' } as const, null)
28
29async function barOwnsChips($: EngineInterface) {
30  const { value } = await $.state.get({ plugin: 'mod-manager', key: 'bar' })
31  return barIsLive(value, Date.now())
32}
33
34async function setChip($: EngineInterface, next: Chip | null) {
35  await update($, chip, prev => (sameChip(prev, next) ? prev : next))
36}
37
38const PANE_ID = 'live-diff'
39
40const hunks = atom({ plugin: 'live-diff', key: 'hunks' } as const, [])
41const selected = atom({ plugin: 'live-diff', key: 'selected' } as const, null)
42
43async function readOrUndefined($: EngineInterface, path: string) {
44  try {
45    return await $.fs.read(path)
46  } catch {
47    return undefined
48  }
49}
50
51async function openPane($: EngineInterface) {
52  await $.ui.open({ id: PANE_ID, title: 'Diff' })
53}
54
55async function record($: EngineInterface, hunk: Hunk, maxEdits: number) {
56  if (!hasChanges(hunk)) return
57  await update($, hunks, list => [hunk, ...list].slice(0, maxEdits))
58  await publishChip($)
59}
60
61async function publishChip($: EngineInterface) {
62  await setChip($, (await read($, active)) ? diffChip((await read($, hunks)).length) : null)
63}
64
65export const register: Register = (on, options) => {
66  const contextLines = Number(options.contextLines ?? 2)
67  const maxEdits = Number(options.maxEdits ?? 100)
68
69  on('state.set', async ($, e, next) => {
70    const ran = await next(e)
71    if (e.plugin === 'mod-manager' && e.key === 'press') {
72      const pressed = e.value as ChipPress | null
73      if (pressed?.plugin === MOD && (await isActive($))) await openPane($)
74    }
75    return ran
76  })
77
78  on('session.start', async ($, e, next) => {
79    void isActive($)
80    $.clock.every(CHECK_MS, () => void isActive($))
81    await $.command.register({ name: 'diff', description: 'Open the live diff pane' })
82    await $.command.register({ name: 'diff-clear', description: 'Clear the live diff pane' })
83
84    $.clock.every(CHECK_MS, () => void publishChip($).catch(() => undefined))
85    return next(e)
86  })
87
88  on('command.run', { command: 'diff' }, async $ => {
89    if (!(await isActive($))) return { text: OFF_TEXT }
90    await openPane($)
91    return { text: 'Diff pane opened.' }
92  })
93
94  on('command.run', { command: 'diff-clear' }, async $ => {
95    if (!(await isActive($))) return { text: OFF_TEXT }
96    await update($, hunks, () => [])
97    return { text: 'Diff pane cleared.' }
98  })
99
100  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
101    if (!(await isActive($))) return next(e)
102    const before = await readOrUndefined($, e.file_path)
103    const ran = await next(e)
104    if (ran.deny !== undefined || ran.isError) return ran
105
106    await record($, {
107      id: e.tool_use_id,
108      path: e.file_path,
109      tool: 'Edit',
110      isNewFile: false,
111      lines: diffLines(e.old_string, e.new_string, before ? lineOf(before, e.old_string) : 1, contextLines),
112    }, maxEdits)
113    return ran
114  })
115
116  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
117    if (!(await isActive($))) return next(e)
118    const before = await readOrUndefined($, e.file_path)
119    const ran = await next(e)
120    if (ran.deny !== undefined || ran.isError) return ran
121
122    await record($, {
123      id: e.tool_use_id,
124      path: e.file_path,
125      tool: 'Write',
126      isNewFile: before === undefined,
127      lines: diffLines(before ?? '', e.content, 1, contextLines),
128    }, maxEdits)
129    return ran
130  })
131
132  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
133    if (!(await read($, active)) || (await barOwnsChips($))) return next(e)
134    const rest = await next(e)
135    if (e.props.hasSurvey) return rest
136
137    const ui = $.ui.resolve(e)
138    const count = (await read($, hunks)).length
139
140    return (
141      <ui.Box flexDirection="column">
142        <DiffChip ui={ui} count={count} onOpen={() => openPane($)} />
143        {rest}
144      </ui.Box>
145    )
146  })
147
148  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
149    const ui = $.ui.resolve(e)
150    if (!(await read($, active))) {
151      return <ui.Box padding={1}><ui.Text color="#6272a4">{OFF_TEXT}</ui.Text></ui.Box>
152    }
153    try {
154      return (
155        <DiffPane
156          ui={ui}
157          width={e.props.bodyColumns}
158          hunks={await read($, hunks)}
159          selected={await read($, selected)}
160          onPick={path => update($, selected, () => path)}
161          onClear={() => update($, hunks, () => [])}
162        />
163      )
164    } catch (err) {
165      const message = err instanceof Error ? err.message : String(err)
166      $.ui.log(`live-diff render failed: ${message}`)
167      return <PaneError ui={ui} message={message} />
168    }
169  })
170}
171
src/diff.ts 55 lines
1import type { DiffLine, Hunk } from '../types'
2
3export function diffLines(before: string, after: string, firstLine = 1, contextLines = 2): DiffLine[] {
4  const old = before === '' ? [] : before.split('\n')
5  const next = after === '' ? [] : after.split('\n')
6
7  let prefix = 0
8  while (prefix < old.length && prefix < next.length && old[prefix] === next[prefix]) prefix++
9
10  let suffix = 0
11  while (
12    suffix < old.length - prefix &&
13    suffix < next.length - prefix &&
14    old[old.length - 1 - suffix] === next[next.length - 1 - suffix]
15  ) suffix++
16
17  const headStart = Math.max(0, prefix - contextLines)
18  const oldTail = old.length - suffix
19  const newTail = next.length - suffix
20  const tailCount = Math.min(contextLines, suffix)
21
22  return [
23    ...old.slice(headStart, prefix).map((text, i) => ({
24      kind: 'ctx' as const, text, oldNo: firstLine + headStart + i, newNo: firstLine + headStart + i,
25    })),
26    ...old.slice(prefix, oldTail).map((text, i) => ({ kind: 'del' as const, text, oldNo: firstLine + prefix + i })),
27    ...next.slice(prefix, newTail).map((text, i) => ({ kind: 'add' as const, text, newNo: firstLine + prefix + i })),
28    ...old.slice(oldTail, oldTail + tailCount).map((text, i) => ({
29      kind: 'ctx' as const, text, oldNo: firstLine + oldTail + i, newNo: firstLine + newTail + i,
30    })),
31  ]
32}
33
34export function lineOf(content: string, needle: string) {
35  const at = content.indexOf(needle)
36  return at < 0 ? 1 : content.slice(0, at).split('\n').length
37}
38
39export function hasChanges(hunk: Hunk) {
40  return hunk.lines.some(line => line.kind !== 'ctx')
41}
42
43export function countLines(kind: 'add' | 'del', hunks: readonly Hunk[]) {
44  return hunks.reduce((sum, h) => sum + h.lines.filter(l => l.kind === kind).length, 0)
45}
46
47export type FileSummary = { path: string; hunks: Hunk[]; added: number; removed: number; isNewFile: boolean }
48
49export function summarizeFiles(hunks: readonly Hunk[]): FileSummary[] {
50  return [...new Set(hunks.map(h => h.path))].map(path => {
51    const own = hunks.filter(h => h.path === path).reverse()
52    return { path, hunks: own, added: countLines('add', own), removed: countLines('del', own), isNewFile: own.some(h => h.isNewFile) }
53  })
54}
55
ui/chip.tsx 23 lines
1import 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 DiffChip({ ui, count, onOpen }: { ui: Ui; count: number; onOpen: () => unknown }) {
9  const { Box, Text, Button } = ui
10  const label = count === 0 ? 'Diff' : `Diff · ${count} ${count === 1 ? 'edit' : 'edits'}`
11
12  return (
13    <Box flexDirection="row" gap={1} alignItems="center" marginBottom={1}>
14      <Text color={DRACULA.purple} bold>Δ</Text>
15      <Button key="open-diff" label={label} dimColor onPress={onOpen} />
16    </Box>
17  )
18}
19
20export function diffChip(count: number): Chip {
21  return { icon: 'Δ', label: 'diff', tone: 'accent', parts: [{ text: count === 0 ? 'none' : `${count} ${count === 1 ? 'edit' : 'edits'}`, tone: 'text', action: 'open' }] }
22}
23
ui/pane.tsx 233 lines
1import type { Elements, RenderSurface } from 'claude-code'
2
3import type { DiffLine, Hunk } from '../types'
4import { countLines, summarizeFiles, type FileSummary } from '../src/diff'
5import { commonDir, splitPath, tilde } from '../src/paths'
6import { DRACULA } from '../src/shared/theme'
7
8type Ui = Elements[RenderSurface]
9
10const LINE_BUDGET = { all: 120, file: 400 } as const
11// The engine unmounts a pane whose tree passes 100,000 serialized characters: keep well under it
12const SIZE_BUDGET = 70_000
13const LINE_OVERHEAD = 300
14const FILE_ROW_COST = 900
15const GUTTER_COLUMNS = 14
16const ROW_TINT = { add: '#2b3b33', del: '#3d2b33' } as const
17
18export type DiffPaneProps = {
19  ui: Ui
20  width: number
21  hunks: Hunk[]
22  selected: string | null
23  onPick: (path: string | null) => unknown
24  onClear: () => unknown
25}
26
27export function DiffPane({ ui, width, hunks, selected, onPick, onClear }: DiffPaneProps) {
28  const { Box, Text, Button } = ui
29  const files = summarizeFiles(hunks)
30  const root = commonDir(files.map(f => f.path))
31  const current = selected !== null && files.some(f => f.path === selected) ? selected : null
32  const textWidth = Math.max(20, width - GUTTER_COLUMNS)
33  const sizeLeft = SIZE_BUDGET - FILE_ROW_COST * (files.length + 1)
34  const shown = budgetLines(current === null ? files : files.filter(f => f.path === current), LINE_BUDGET[current === null ? 'all' : 'file'], sizeLeft, textWidth)
35  const rule = <Text color={DRACULA.currentLine}>{'─'.repeat(Math.max(20, width - 2))}</Text>
36
37  return (
38    <Box flexDirection="column" backgroundColor={DRACULA.background} paddingX={1} paddingY={1}>
39      <Box flexDirection="row" alignItems="center" gap={1}>
40        <Text color={DRACULA.purple} bold>Δ Changes</Text>
41        {files.length > 0 && <Text color={DRACULA.comment}>{files.length} {files.length === 1 ? 'file' : 'files'}</Text>}
42        {files.length > 0 && <Counts ui={ui} added={countLines('add', hunks)} removed={countLines('del', hunks)} />}
43        <Box flexGrow={1} />
44        {hunks.length > 0 && <Button key="clear" label="Clear" dimColor onPress={onClear} />}
45      </Box>
46      {root !== '' && <Text color={DRACULA.comment} wrap="truncate-start">{tilde(root)}</Text>}
47
48      {hunks.length === 0 && (
49        <Box flexDirection="column" marginTop={1} alignItems="center">
50          <Text color={DRACULA.purple}>Δ</Text>
51          <Text color={DRACULA.comment} italic>No edits yet. Changes show up here as Claude makes them.</Text>
52        </Box>
53      )}
54
55      {files.length > 0 && (
56        <Box flexDirection="column" marginTop={1}>
57          {rule}
58          <Box key="file-all" flexDirection="row" gap={1} alignItems="center" paddingX={1}
59            backgroundColor={current === null ? DRACULA.currentLine : undefined}>
60            <Text color={DRACULA.purple}>{current === null ? '›' : ' '}</Text>
61            {current === null
62              ? <Text color={DRACULA.foreground} bold>All files</Text>
63              : <Button key="pick-all" label="All files" dimColor onPress={() => onPick(null)} />}
64          </Box>
65          {files.map(file => (
66            <FileRow ui={ui} file={file} root={root} isCurrent={file.path === current} onPick={onPick} />
67          ))}
68          {rule}
69        </Box>
70      )}
71
72      {shown.files.map(file => <FileDiff ui={ui} file={file} root={root} />)}
73      {shown.hiddenLines > 0 && (
74        <Box marginTop={1}>
75          <Text color={DRACULA.comment} italic>
76            ⋯ {shown.hiddenLines} more lines{current === null ? '; pick a file above to see its whole diff' : ''}
77          </Text>
78        </Box>
79      )}
80    </Box>
81  )
82}
83
84function Counts({ ui, added, removed }: { ui: Ui; added: number; removed: number }) {
85  const { Text } = ui
86  return (
87    <Text>
88      <Text color={DRACULA.green}>+{added}</Text>
89      <Text color={DRACULA.comment}> </Text>
90      <Text color={DRACULA.red}>-{removed}</Text>
91    </Text>
92  )
93}
94
95function Status({ ui, isNewFile }: { ui: Ui; isNewFile: boolean }) {
96  const { Text } = ui
97  return <Text color={isNewFile ? DRACULA.green : DRACULA.orange} bold>{isNewFile ? 'A' : 'M'}</Text>
98}
99
100function ChangeBar({ ui, added, removed }: { ui: Ui; added: number; removed: number }) {
101  const { Text } = ui
102  const total = added + removed
103  const plus = total === 0 ? 0 : Math.round((added / total) * 5)
104  const minus = total === 0 ? 0 : Math.min(5 - plus, Math.max(removed > 0 ? 1 : 0, 5 - plus))
105  return (
106    <Text>
107      <Text color={DRACULA.green}>{'■'.repeat(plus)}</Text>
108      <Text color={DRACULA.red}>{'■'.repeat(minus)}</Text>
109      <Text color={DRACULA.currentLine}>{'■'.repeat(5 - plus - minus)}</Text>
110    </Text>
111  )
112}
113
114type FileRowProps = { ui: Ui; file: FileSummary; root: string; isCurrent: boolean; onPick: (path: string) => unknown }
115
116function FileRow({ ui, file, root, isCurrent, onPick }: FileRowProps) {
117  const { Box, Text, Button } = ui
118  const { dir, name } = splitPath(file.path.slice(root.length))
119
120  return (
121    <Box key={`file-${file.path}`} flexDirection="row" gap={1} alignItems="center" paddingX={1}
122      backgroundColor={isCurrent ? DRACULA.currentLine : undefined}>
123      <Text color={DRACULA.purple}>{isCurrent ? '›' : ' '}</Text>
124      <Status ui={ui} isNewFile={file.isNewFile} />
125      {isCurrent
126        ? <Text color={DRACULA.pink} bold>{name}</Text>
127        : <Button key={`pick-${file.path}`} label={name} dimColor onPress={() => onPick(file.path)} />}
128      <Box flexShrink={1} flexGrow={1}>
129        <Text color={DRACULA.comment} wrap="truncate-start">{dir}</Text>
130      </Box>
131      <Counts ui={ui} added={file.added} removed={file.removed} />
132      <ChangeBar ui={ui} added={file.added} removed={file.removed} />
133    </Box>
134  )
135}
136
137export function PaneError({ ui, message }: { ui: Ui; message: string }) {
138  const { Box, Text } = ui
139  return (
140    <Box padding={1}>
141      <Text color={DRACULA.red}>Diff pane failed to draw: {message}</Text>
142    </Box>
143  )
144}
145
146type ShownHunk = { hunk: Hunk; lines: DiffLine[]; hidden: number }
147type ShownFile = FileSummary & { shown: ShownHunk[] }
148
149function budgetLines(files: readonly FileSummary[], budget: number, size: number, textWidth: number) {
150  let left = budget
151  let sizeLeft = size
152  let hiddenLines = 0
153  const shownFiles: ShownFile[] = []
154
155  for (const file of files) {
156    const shown: ShownHunk[] = []
157    for (const hunk of file.hunks) {
158      const lines: DiffLine[] = []
159      for (const line of hunk.lines) {
160        const text = line.text.length > textWidth ? line.text.slice(0, textWidth) : line.text
161        const cost = text.length + LINE_OVERHEAD
162        if (left === 0 || cost > sizeLeft) break
163        lines.push(text === line.text ? line : { ...line, text })
164        left -= 1
165        sizeLeft -= cost
166      }
167      if (lines.length > 0) shown.push({ hunk, lines, hidden: hunk.lines.length - lines.length })
168      else hiddenLines += hunk.lines.length
169    }
170    if (shown.length > 0) shownFiles.push({ ...file, shown })
171  }
172
173  hiddenLines += shownFiles.reduce((sum, f) => sum + f.shown.reduce((s, h) => s + h.hidden, 0), 0)
174  return { files: shownFiles, hiddenLines }
175}
176
177function FileDiff({ ui, file, root }: { ui: Ui; file: ShownFile; root: string }) {
178  const { Box, Text } = ui
179  const { dir, name } = splitPath(file.path.slice(root.length))
180
181  return (
182    <Box flexDirection="column" marginTop={1}>
183      <Box flexDirection="row" gap={1} alignItems="center" backgroundColor={DRACULA.currentLine} paddingX={1}>
184        <Status ui={ui} isNewFile={file.isNewFile} />
185        <Box flexShrink={1} flexGrow={1}>
186          <Text wrap="truncate-start">
187            <Text color={DRACULA.comment}>{dir}</Text>
188            <Text color={DRACULA.pink} bold>{name}</Text>
189          </Text>
190        </Box>
191        <Counts ui={ui} added={file.added} removed={file.removed} />
192      </Box>
193      {file.shown.map(part => <HunkView ui={ui} part={part} />)}
194    </Box>
195  )
196}
197
198function HunkView({ ui, part }: { ui: Ui; part: ShownHunk }) {
199  const { Box, Text } = ui
200  const { hunk, lines: shown, hidden } = part
201  const oldStart = hunk.lines.find(l => l.oldNo !== undefined)?.oldNo
202  const newStart = hunk.lines.find(l => l.newNo !== undefined)?.newNo
203  const range = oldStart !== undefined && newStart !== undefined ? `@@ -${oldStart} +${newStart} @@ ` : '@@ '
204
205  return (
206    <Box flexDirection="column" marginTop={1}>
207      <Text color={DRACULA.purple} dimColor>{range}{hunk.isNewFile ? 'new file' : hunk.tool.toLowerCase()}</Text>
208      {shown.map(line => <LineRow ui={ui} line={line} />)}
209      {hidden > 0 && <Text color={DRACULA.comment} italic>  ⋯ {hidden} more lines</Text>}
210    </Box>
211  )
212}
213
214function LineRow({ ui, line }: { ui: Ui; line: DiffLine }) {
215  const { Box, Text } = ui
216  const marker = line.kind === 'add' ? ' + ' : line.kind === 'del' ? ' - ' : '   '
217  const markerColor = line.kind === 'add' ? DRACULA.green : line.kind === 'del' ? DRACULA.red : DRACULA.comment
218
219  return (
220    <Box flexDirection="row" backgroundColor={line.kind === 'ctx' ? undefined : ROW_TINT[line.kind]}>
221      <Text color={DRACULA.comment} dimColor>{gutter(line.oldNo)} {gutter(line.newNo)}</Text>
222      <Text color={markerColor} bold>{marker}</Text>
223      <Text wrap="truncate-end" color={line.kind === 'ctx' ? DRACULA.comment : DRACULA.foreground}>
224        {line.text === '' ? ' ' : line.text}
225      </Text>
226    </Box>
227  )
228}
229
230function gutter(n: number | undefined) {
231  return (n === undefined ? '' : String(n)).padStart(4)
232}
233
src/shared/toggle.ts 31 lines
1export 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}
31
src/shared/chip.ts 28 lines
1import { 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}
28
src/shared/theme.ts 15 lines
1// 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
15
src/paths.ts 19 lines
1export function tilde(path: string) {
2  return path.replace(/^\/(Users|home)\/[^/]+/, '~')
3}
4
5export function splitPath(path: string) {
6  const at = path.lastIndexOf('/')
7  return { dir: at >= 0 ? tilde(path.slice(0, at + 1)) : '', name: path.slice(at + 1) }
8}
9
10export function commonDir(paths: readonly string[]) {
11  if (paths.length === 0) return ''
12  const dirs = paths.map(p => p.slice(0, p.lastIndexOf('/') + 1))
13  let prefix = dirs[0] ?? ''
14  for (const dir of dirs) {
15    while (!dir.startsWith(prefix)) prefix = prefix.slice(0, prefix.slice(0, -1).lastIndexOf('/') + 1)
16  }
17  return prefix === '/' ? '' : prefix
18}
19
types/index.d.ts 16 lines
1export 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 LineKind = 'ctx' | 'del' | 'add'
7export type DiffLine = { kind: LineKind; text: string; oldNo?: number; newNo?: number }
8export type Hunk = { id: string; path: string; tool: string; isNewFile: boolean; lines: DiffLine[] }
9
10declare module 'claude-code' {
11  interface PluginState {
12    'mod-manager': { bar: number; press: ChipPress | null }
13    'live-diff': { active: boolean; hunks: Hunk[]; selected: string | null; chip: Chip | null }
14  }
15}
16