SLOPSHOPPER

jr-mods

/jrmods: every installed plugin and mod in one list. Space ticks or unticks, Enter applies all the changes at once. /jrmods welcome explains what each jr mod…

newpanecommandtoastprocesstimer
★ 2v0.2.0MITupdated 2026-10-16jrlprost/claude-code-mods/plugins/jr-mods
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jr-mods
│ ┃ jr-mods ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./list.client.tsx │ ⏺ 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 │ │ › /jrmods │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · jr-mods
▣ client module ./list.client.tsx
Pane · jr-mods-welcome
jr mods what each mod shows, and how to hide it No jr mod with a band is installed. A bare command toggles its band; commands with options take hide/show. /jrmods turns whole mods on or off.
README

jr-mods

/jrmods opens every installed plugin and mod in one list, so turning several on or off takes a few keys instead of a command each.

jr mods    mods 3   plugins 22                   17 on · 8 off
❯ ◉ claude-seo        Comprehensive SEO analysis…
  ◉ code-review       Automated code review…
  ○ postiz            Social media scheduling…
←→ mods/plugins   space toggle   ↑↓ move   a/n all/none   enter apply 1 change   esc close
Key
← → (or h/l, tab)Switch between the mods tab and the plugins tab
↑ ↓ (or j/k)Move
spaceTick or untick
a / nTick all / none in the current tab
enterApply every change from both tabs at once
escClose

A mod is a plugin that ships function hooks (its hooks/hooks.json names modules); everything else is listed under plugins.

The terminal hands keys to the list only after a click on it, so the list opens with a "click here to use the keyboard" zone at the bottom: the first click only takes the keyboard. After that, a click on a row ticks it, a click on the tab line switches tabs, and a click on the last line applies.

Welcome

The first session after install opens a welcome pane once; /jrmods welcome opens it again. For the jr mods you have installed, it lists what shows by default, what one command brings up, and what appears only when there is something to say, with each command, and how to read your billing with /jrtime.

Changes go through claude plugin enable and claude plugin disable. Run /reload-plugins to load them in the current session.

Install

/plugin install jr-mods --marketplace jrlprost/claude-code-mods

Access

Runs claude plugin list, claude plugin enable and claude plugin disable. Reads each installed plugin's plugin.json and hooks/hooks.json for its description and kind. Remembers whether the welcome was shown. Sends nothing over the network.

Source 7 files
hooks/register.tsx 146 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { JrModsApply } from '../types'
5import { gradientText } from './jr/draw'
6import { GRADIENTS } from './jr/gradient'
7import { changesOf, itemsOf, kindOf, summary } from './plugins'
8import { BILLING, GROUP_TITLES, RULE, enabledNames, welcomeOf } from './welcome'
9
10const PANE = 'jr-mods'
11const WELCOME = 'jr-mods-welcome'
12const items = atom({ plugin: 'jr-mods', key: 'items' } as const, [])
13const installed = atom({ plugin: 'jr-mods', key: 'installed' } as const, [])
14
15/** Opens the welcome pane listing the installed jr mods; false when plugins could not be listed. */
16async function openWelcome($: EngineInterface) {
17  const { exitCode, stdout } = await $.process.run(['claude', 'plugin', 'list', '--json'])
18  if (exitCode !== 0) return false
19  const names = enabledNames(JSON.parse(stdout) as { id: string; enabled: boolean }[])
20  await update($, installed, () => names)
21  const rows = welcomeOf(names).reduce((n, g) => n + g.entries.length + 2, 0) + (names.includes('jr-time') ? BILLING.length : 0)
22  await $.ui.open({ id: WELCOME, title: 'jr mods · welcome', closeOnEscape: true, rows: Math.min(rows + 4, 40) })
23  return true
24}
25
26/** The first session after install shows the welcome once; the flag is set before the one attempt. */
27async function onboard($: EngineInterface) {
28  if ((await $.store.get('isOnboarded')) === true) return
29  await $.store.set('isOnboarded', true)
30  try {
31    await openWelcome($)
32  } catch {
33    // One attempt only: /jrmods welcome opens it again.
34  }
35}
36
37async function loadItems($: EngineInterface) {
38  const { exitCode, stdout } = await $.process.run(['claude', 'plugin', 'list', '--json'])
39  if (exitCode !== 0) return false
40  const listed = JSON.parse(stdout) as { id: string; enabled: boolean; installPath?: string }[]
41  const descriptions: Record<string, string> = {}
42  const kinds: Record<string, 'mod' | 'plugin'> = {}
43  for (const row of listed) {
44    if (!row.installPath) continue
45    try {
46      const manifest = JSON.parse(await $.fs.read(`${row.installPath}/.claude-plugin/plugin.json`)) as { description?: string }
47      if (manifest.description) descriptions[row.id] = manifest.description
48    } catch {
49      // A plugin without a readable manifest is listed by name alone.
50    }
51    try {
52      kinds[row.id] = kindOf(await $.fs.read(`${row.installPath}/hooks/hooks.json`))
53    } catch {
54      kinds[row.id] = 'plugin'
55    }
56  }
57  await update($, items, () => itemsOf(listed, descriptions, kinds))
58  return true
59}
60
61export const register: Register = on => {
62  on('session.start', async ($, e, next) => {
63    await $.command.register({ name: 'jrmods', description: 'Turn plugins and mods on or off: space ticks, enter applies. /jrmods welcome: what each jr mod shows' })
64    const started = await next(e)
65    $.clock.after(0, () => {
66      void onboard($)
67    })
68    return started
69  })
70
71  on('command.run', { command: 'jrmods' }, async ($, e) => {
72    if (e.args.trim() === 'welcome') {
73      if (!(await openWelcome($))) return { text: 'jr-mods: could not list plugins (claude plugin list failed).' }
74      return { text: 'jr-mods: welcome opened.' }
75    }
76    if (!(await loadItems($))) return { text: 'jr-mods: could not list plugins (claude plugin list failed).' }
77    // Tall enough for the longer tab, the tab line and the footer.
78    const list = await read($, items)
79    const longest = Math.max(...(['mod', 'plugin'] as const).map(kind => list.filter(i => i.kind === kind).length))
80    await $.ui.open({ id: PANE, title: 'jr mods', focus: true, closeOnEscape: true, rows: Math.min(longest + 3, 40) })
81    // Hand the keys straight to the list, so no click is needed before space and arrows work.
82    try {
83      const focused = await $.ui.focus({ requestId: PANE, key: 'list' })
84      if (focused && 'deny' in focused && focused.deny) $.ui.toast(`jr mods · click the list once to use the keys (${focused.deny})`)
85    } catch (error) {
86      $.ui.toast(`jr mods · click the list once to use the keys (${String(error).slice(0, 80)})`)
87    }
88    return { text: 'jr-mods opened: space ticks, enter applies.' }
89  })
90
91  // Enter in the list: apply every change in one go.
92  on('ui.message', { requestId: PANE }, async ($, e) => {
93    const data = e.data as JrModsApply
94    if (data?.type !== 'apply') return {}
95    const changes = changesOf(await read($, items), data.wanted)
96    const done = []
97    for (const change of changes) {
98      const { exitCode } = await $.process.run(['claude', 'plugin', change.enable ? 'enable' : 'disable', change.id])
99      done.push({ ...change, ok: exitCode === 0 })
100    }
101    await $.ui.close({ id: PANE })
102    $.ui.toast(changes.length ? `jr mods · ${summary(done)} · /reload-plugins to apply now` : 'jr mods · nothing changed')
103    return {}
104  })
105
106  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
107    const { Client } = $.ui.resolve(e) as unknown as { Client?: (p: Record<string, unknown>) => never }
108    const { Text } = $.ui.resolve(e)
109    const list = await read($, items)
110    if (!Client) return <Text>Open /jrmods in the terminal or the desktop app to toggle plugins.</Text>
111    return <Client key="list" module="./list.client.tsx" props={{ items: list }} height="100%" flexGrow={1} />
112  })
113
114  on('ui.render', { component: 'Pane', requestId: WELCOME }, async ($, e) => {
115    const { Box, Text } = $.ui.resolve(e)
116    const names = await read($, installed)
117    const groups = welcomeOf(names)
118    return (
119      <Box flexDirection="column">
120        <Text>{gradientText(Text, 'jr mods', GRADIENTS.brand)}<Text dimColor>{'  what each mod shows, and how to hide it'}</Text></Text>
121        {groups.length === 0 && <Text dimColor>No jr mod with a band is installed.</Text>}
122        {groups.map(g => (
123          <Box flexDirection="column" marginTop={1}>
124            <Text bold>{GROUP_TITLES[g.group]}</Text>
125            {g.entries.map(entry => (
126              <Box flexDirection="column">
127                <Text wrap="truncate-end">
128                  <Text color="#ff5ca8">{`  ${entry.command.padEnd(15)}`}</Text>
129                  <Text dimColor>{entry.what}</Text>
130                </Text>
131                {entry.name === 'jr-time' &&
132                  BILLING.map(line => (
133                    <Text wrap="truncate-end" dimColor>{`  ${''.padEnd(15)}${line}`}</Text>
134                  ))}
135              </Box>
136            ))}
137          </Box>
138        ))}
139        <Box marginTop={1}>
140          <Text dimColor wrap="wrap">{RULE}</Text>
141        </Box>
142      </Box>
143    )
144  })
145}
146
hooks/jr/draw.tsx 35 lines
1// Drawing helpers shared by every jr mod. Synced into each plugin's hooks/jr/.
2import type { Elements } from 'claude-code'
3
4import { bar, paint } from './gradient'
5
6type TextElement = Elements['terminal']['Text']
7
8/** `text` in a gradient, one color per character, bold by default. */
9export const gradientText = (Text: TextElement, text: string, stops: readonly string[], bold = true) => (
10  <Text bold={bold}>
11    {paint(text, stops).map(({ char, color }) => (
12      <Text color={color}>{char}</Text>
13    ))}
14  </Text>
15)
16
17/** A gauge: filled cells in the gradient, empty cells dim. */
18export const gauge = (Text: TextElement, ratio: number, width: number, stops: readonly string[]) => (
19  <Text>
20    {bar(ratio, width, stops).map(cell =>
21      cell.empty ? (
22        <Text color={cell.color} dimColor>
23          {cell.char}
24        </Text>
25      ) : (
26        <Text color={cell.color}>{cell.char}</Text>
27      ),
28    )}
29  </Text>
30)
31
32/** The jr tag that opens every band line: a small gradient label. */
33export const tag = (Text: TextElement, label: string, stops: readonly string[]) =>
34  gradientText(Text, `${label} `, stops)
35
hooks/jr/gradient.ts 75 lines
1// Shared look of every jr mod: one palette, gradients drawn one cell at a time.
2// Synced into each plugin's hooks/jr/ by scripts/sync-shared.sh: edit it here only.
3
4export type Rgb = readonly [number, number, number]
5
6const hexToRgb = (hex: string): Rgb => {
7  const n = Number.parseInt(hex.replace('#', ''), 16)
8  return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
9}
10
11const rgbToHex = ([r, g, b]: Rgb) =>
12  `#${[r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`
13
14export const GRADIENTS = {
15  // The jr signature: violet to pink to amber.
16  brand: ['#7f5af0', '#ff5ca8', '#ffb86b'],
17  // Calm to alarm, for gauges where full is bad.
18  heat: ['#2cb67d', '#f4d35e', '#ff8c42', '#ef4565'],
19  // Cool, for neutral figures.
20  ice: ['#3da9fc', '#7f5af0'],
21  // Money and growth.
22  money: ['#2cb67d', '#a8e6cf'],
23} as const satisfies Record<string, readonly string[]>
24
25export type GradientName = keyof typeof GRADIENTS
26
27/** The color at `t` (0..1) along the stops. */
28export const colorAt = (stops: readonly string[], t: number): string => {
29  if (stops.length === 1) return stops[0]!
30  const clamped = Math.min(1, Math.max(0, t))
31  const span = clamped * (stops.length - 1)
32  const i = Math.min(stops.length - 2, Math.floor(span))
33  const a = hexToRgb(stops[i]!)
34  const b = hexToRgb(stops[i + 1]!)
35  const local = span - i
36  return rgbToHex([0, 1, 2].map(k => a[k]! + (b[k]! - a[k]!) * local) as unknown as Rgb)
37}
38
39/** One color per character of `text`, spread across the stops. */
40export const paint = (text: string, stops: readonly string[]) => {
41  const chars = [...text]
42  return chars.map((char, i) => ({ char, color: colorAt(stops, chars.length > 1 ? i / (chars.length - 1) : 0) }))
43}
44
45const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
46
47/**
48 * A bar `width` cells wide filled to `ratio` (0..1), with eighth-cell precision.
49 * Each filled cell takes the gradient color of its position along the whole bar,
50 * so a nearly full heat bar ends red and a low one stays green.
51 */
52export const bar = (ratio: number, width: number, stops: readonly string[]) => {
53  const filled = Math.min(1, Math.max(0, ratio)) * width
54  const full = Math.floor(filled)
55  const partial = EIGHTHS[Math.round((filled - full) * 7)] ?? ''
56  const cells: { char: string; color: string; empty?: true }[] = []
57  for (let i = 0; i < width; i++) {
58    const color = colorAt(stops, width > 1 ? i / (width - 1) : 0)
59    if (i < full) cells.push({ char: '█', color })
60    else if (i === full && partial) cells.push({ char: partial, color })
61    else cells.push({ char: '·', color, empty: true })
62  }
63  return cells
64}
65
66export const formatTokens = (n: number) =>
67  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 10_000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
68
69export const formatDuration = (ms: number) => {
70  const minutes = Math.max(0, Math.round(ms / 60_000))
71  const h = Math.floor(minutes / 60)
72  const m = minutes % 60
73  return h > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${m}min`
74}
75
hooks/plugins.ts 36 lines
1import type { JrModsItem, JrModsKind } from '../types'
2
3type Listed = { id: string; enabled: boolean; installPath?: string }
4
5/** `claude plugin list --json` rows, sorted: enabled first, then by name. */
6export const itemsOf = (listed: readonly Listed[], descriptions: Record<string, string>, kinds: Record<string, JrModsKind> = {}): JrModsItem[] =>
7  listed
8    .map(row => {
9      const [name = row.id, source = ''] = row.id.split('@')
10      return { id: row.id, name, source, description: descriptions[row.id] ?? '', enabled: row.enabled, kind: kinds[row.id] ?? 'plugin' }
11    })
12    .sort((a, b) => Number(b.enabled) - Number(a.enabled) || a.name.localeCompare(b.name))
13
14/** The plugins whose wanted state differs from the current one. */
15export const changesOf = (items: readonly JrModsItem[], wanted: Record<string, boolean>) =>
16  items.filter(item => wanted[item.id] !== undefined && wanted[item.id] !== item.enabled).map(item => ({ id: item.id, enable: wanted[item.id]! }))
17
18export const summary = (done: { id: string; enable: boolean; ok: boolean }[]) => {
19  const on = done.filter(d => d.ok && d.enable).length
20  const off = done.filter(d => d.ok && !d.enable).length
21  const failed = done.filter(d => !d.ok).map(d => d.id.split('@')[0])
22  const parts = [on && `${on} enabled`, off && `${off} disabled`, failed.length && `failed: ${failed.join(', ')}`].filter(Boolean)
23  return parts.length ? parts.join(' · ') : 'nothing changed'
24}
25
26/** A hooks.json that names `modules` makes the plugin a mod. */
27export const kindOf = (hooksJson: string | undefined): JrModsKind => {
28  if (!hooksJson) return 'plugin'
29  try {
30    const parsed = JSON.parse(hooksJson) as { modules?: unknown }
31    return Array.isArray(parsed.modules) && parsed.modules.length > 0 ? 'mod' : 'plugin'
32  } catch {
33    return 'plugin'
34  }
35}
36
hooks/welcome.ts 44 lines
1// What the welcome pane says about each jr mod: where it shows, the command, one line.
2export type WelcomeGroup = 'shown' | 'hidden' | 'alert'
3
4export type WelcomeEntry = { name: string; group: WelcomeGroup; command: string; what: string }
5
6export const CATALOGUE: readonly WelcomeEntry[] = [
7  { name: 'jr-context', group: 'shown', command: '/jrcontext', what: 'context fill · /jrcontext details for every category' },
8  { name: 'jr-fleet', group: 'shown', command: '/jrfleet', what: 'your sessions, and the ones waiting for you' },
9  { name: 'jr-limits', group: 'shown', command: '/jrlimits', what: '5-hour and 7-day plan limits' },
10  { name: 'jr-time', group: 'shown', command: '/jrtime', what: 'billable time today' },
11  { name: 'jr-badge', group: 'hidden', command: '/jrbadge', what: 'project, branch and folder' },
12  { name: 'jr-cache-clock', group: 'hidden', command: '/jrcache', what: 'prompt cache countdown' },
13  { name: 'jr-next-cost', group: 'hidden', command: '/jrnextcost', what: 'what the next prompt costs' },
14  { name: 'jr-awake', group: 'hidden', command: '/jrawake show', what: 'keep-awake status (it warns before sleep anyway)' },
15  { name: 'jr-commit-coach', group: 'alert', command: '/jrcommit', what: 'too many uncommitted lines' },
16  { name: 'jr-scope', group: 'alert', command: '/jrscope', what: 'sensitive files written unasked' },
17  { name: 'jr-outage', group: 'alert', command: '/jroutage', what: 'Claude, GitHub, npm or Vercel degraded' },
18  { name: 'jr-uptime', group: 'alert', command: '/jruptime', what: 'one of your sites is down' },
19  { name: 'jr-quota', group: 'alert', command: '/jrquota', what: 'an API balance runs low' },
20  { name: 'jr-record', group: 'alert', command: '/jrrec', what: 'recording mode is on' },
21  { name: 'jr-goal', group: 'alert', command: '/jrgoal', what: 'once you set a goal' },
22  { name: 'jr-stripemrr', group: 'alert', command: '/jrmrr', what: 'once your Stripe key is set' },
23  { name: 'jr-visitors', group: 'alert', command: '/jrvisitors', what: 'once PostHog is set' },
24]
25
26export const GROUP_TITLES: Record<WelcomeGroup, string> = {
27  shown: 'Shown by default, one quiet row each',
28  hidden: 'Hidden by default, one command to show',
29  alert: 'Only when there is something to say',
30}
31
32export const BILLING = ['your billing: /jrtime shows the breakdown by category', '/jrtime rate 80 · /jrtime client <name> · /jrtime export']
33
34export const RULE = 'A bare command toggles its band; commands with options take hide/show. /jrmods turns whole mods on or off.'
35
36/** Names of the enabled plugins in `claude plugin list --json`. */
37export const enabledNames = (listed: readonly { id: string; enabled: boolean }[]) => listed.filter(r => r.enabled).map(r => r.id.split('@')[0] ?? r.id)
38
39/** The catalogue entries for the installed mods, grouped in display order; empty groups left out. */
40export const welcomeOf = (installed: readonly string[]) =>
41  (['shown', 'hidden', 'alert'] as const)
42    .map(group => ({ group, entries: CATALOGUE.filter(e => e.group === group && installed.includes(e.name)) }))
43    .filter(g => g.entries.length > 0)
44
hooks/list.client.tsx 124 lines
1// The /jrmods list, drawn on the surface itself so it reads raw keys:
2// left/right (or h/l, tab) switch between mods and plugins, up/down (or j/k) move,
3// space ticks, a/n tick all or none of the tab, enter applies every change at once.
4// The terminal hands the keys to the list only after a click on it, so the list opens with a
5// focus zone at the bottom: the first click lands anywhere and ticks nothing. After it, a click
6// on the tab line switches tabs, a click on a row ticks it and a click on the last line applies.
7import type { ClientElements, ClientModule } from 'claude-code'
8
9import type { JrModsItem, JrModsKind } from '../types'
10
11type Props = { items: JrModsItem[] }
12type State = { tab: JrModsKind; cursor: number; wanted: Record<string, boolean>; ready?: boolean }
13
14const BRAND = ['#7f5af0', '#a259e8', '#c658df', '#e85bc4', '#ff5ca8', '#ff7a90', '#ff9a7c', '#ffb86b']
15const ON = '#2cb67d'
16const DIM = '#6b7089'
17const KEY = '#ffb86b'
18const POINTER = '#ff5ca8'
19const ZONE_ROWS = 5
20const TABS: JrModsKind[] = ['mod', 'plugin']
21const TAB_LABEL: Record<JrModsKind, string> = { mod: 'mods', plugin: 'plugins' }
22
23const gradient = (Text: ClientElements['Text'], text: string) =>
24  [...text].map((c, i, all) => <Text color={BRAND[Math.floor((i / all.length) * BRAND.length)] ?? POINTER}>{c}</Text>)
25
26const List: ClientModule<Props, State> = (props, surface) => {
27  const { Box, Text } = surface.elements
28  const all = props.items
29  const firstTab: JrModsKind = all.some(i => i.kind === 'mod') ? 'mod' : 'plugin'
30  const state: State = surface.state ?? { tab: firstTab, cursor: 0, wanted: Object.fromEntries(all.map(i => [i.id, i.enabled])) }
31  const isOn = (item: JrModsItem) => state.wanted[item.id] ?? item.enabled
32  const items = all.filter(i => i.kind === state.tab)
33  const changes = all.filter(item => isOn(item) !== item.enabled).length
34
35  const switchTab = () => surface.setState({ ...state, ready: true, tab: state.tab === 'mod' ? 'plugin' : 'mod', cursor: 0 })
36
37  surface.onKey(({ key }) => {
38    const move = (d: number) => surface.setState({ ...state, ready: true, cursor: (state.cursor + d + items.length) % Math.max(1, items.length) })
39    const item = items[state.cursor]
40    if (key === 'up' || key === 'k') move(-1)
41    else if (key === 'down' || key === 'j') move(1)
42    else if (key === 'left' || key === 'right' || key === 'h' || key === 'l' || key === 'tab') switchTab()
43    else if ((key === ' ' || key === 'space') && item) surface.setState({ ...state, ready: true, wanted: { ...state.wanted, [item.id]: !isOn(item) } })
44    else if (key === 'a' || key === 'n') surface.setState({ ...state, ready: true, wanted: { ...state.wanted, ...Object.fromEntries(items.map(i => [i.id, key === 'a'])) } })
45    else if (key === 'return' || key === 'enter') surface.post({ type: 'apply', wanted: state.wanted })
46  })
47
48  const width = Math.max(40, surface.columns)
49  const nameWidth = Math.min(28, Math.max(...items.map(i => i.name.length), 8) + 2)
50  // Fill the pane: every row but the tab line and the footer lists the tab, the cursor kept in view.
51  const zone = state.ready ? 0 : ZONE_ROWS
52  const room = surface.rows > 0 ? Math.max(3, surface.rows - 2 - zone) : Math.max(1, items.length)
53  const start = Math.min(Math.max(0, state.cursor - Math.floor(room / 2)), Math.max(0, items.length - room))
54  const visible = items.slice(start, start + room)
55  const listRows = Math.max(1, visible.length)
56
57  surface.onPointer(event => {
58    if (event.type !== 'down') return
59    // The first click only takes the keyboard.
60    if (!state.ready) return surface.setState({ ...state, ready: true })
61    if (event.y === 0) return switchTab()
62    const item = visible[event.y - 1]
63    if (item) surface.setState({ ...state, cursor: start + event.y - 1, wanted: { ...state.wanted, [item.id]: !isOn(item) } })
64    else if (event.y === listRows + 1) surface.post({ type: 'apply', wanted: state.wanted })
65  })
66
67  const onCount = items.filter(isOn).length
68
69  return (
70    <Box flexDirection="column" width={width}>
71      <Box justifyContent="space-between">
72        <Text>
73          <Text bold>{gradient(Text, 'jr mods')}</Text>
74          <Text>{'   '}</Text>
75          {TABS.map(tab => {
76            const count = all.filter(i => i.kind === tab).length
77            return tab === state.tab ? (
78              <Text backgroundColor={POINTER} color="black" bold>{` ${TAB_LABEL[tab]} ${count} `}</Text>
79            ) : (
80              <Text color={DIM}>{` ${TAB_LABEL[tab]} ${count} `}</Text>
81            )
82          })}
83        </Text>
84        <Text color={DIM}>{`${onCount} on · ${items.length - onCount} off`}</Text>
85      </Box>
86      {visible.length === 0 && <Text color={DIM}>{`  no ${TAB_LABEL[state.tab]} installed`}</Text>}
87      {visible.map((item, i) => {
88        const isCursor = start + i === state.cursor
89        const on = isOn(item)
90        const changed = on !== item.enabled
91        return (
92          <Text wrap="truncate-end">
93            <Text color={POINTER} bold>{isCursor ? '❯ ' : '  '}</Text>
94            <Text color={on ? ON : DIM}>{on ? '◉ ' : '○ '}</Text>
95            <Text bold={isCursor} color={changed ? KEY : undefined}>{item.name.padEnd(nameWidth)}</Text>
96            <Text color={DIM}>{item.description || item.source}</Text>
97          </Text>
98        )
99      })}
100      <Text wrap="truncate-end">
101        <Text color={KEY} bold>←→</Text>
102        <Text color={DIM}>{' mods/plugins   '}</Text>
103        <Text color={KEY} bold>space</Text>
104        <Text color={DIM}>{' toggle   '}</Text>
105        <Text color={KEY} bold>↑↓</Text>
106        <Text color={DIM}>{' move   '}</Text>
107        <Text color={KEY} bold>a/n</Text>
108        <Text color={DIM}>{' all/none   '}</Text>
109        <Text color={KEY} bold>enter</Text>
110        <Text color={DIM}>{changes ? ` apply ${changes} change${changes > 1 ? 's' : ''}   ` : ' apply   '}</Text>
111        <Text color={KEY} bold>esc</Text>
112        <Text color={DIM}>{' close'}</Text>
113      </Text>
114      {!state.ready && (
115        <Box borderStyle="round" borderColor={POINTER} height={ZONE_ROWS} justifyContent="center" alignItems="center">
116          <Text bold>{gradient(Text, '⌨  click here to use the keyboard')}</Text>
117        </Box>
118      )}
119    </Box>
120  )
121}
122
123export default List
124
types/index.d.ts 13 lines
1/** A mod ships function hooks (a hooks module); a plugin does not. */
2export type JrModsKind = 'mod' | 'plugin'
3
4export type JrModsItem = { id: string; name: string; source: string; description: string; enabled: boolean; kind: JrModsKind }
5
6export type JrModsApply = { type: 'apply'; wanted: Record<string, boolean> }
7
8declare module 'claude-code' {
9  interface PluginState {
10    'jr-mods': { items: JrModsItem[]; installed: string[] }
11  }
12}
13