SLOPSHOPPER

menu

An optional /mods menu that lists the installed mods (status line, clean screen, mode) and opens each one's page in a pane.

newpanespinnercommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · menu
│ ┃ mods ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ mods │ ┃ ⏺ Read(src/auth.ts) │ ┃ No mods installed ⎿ Read 6 lines │ ┃ Install one with /plugin install ⏺ Update(src/auth.ts) │ ┃ statusline@malka-claude-mods ⎿ Added 2 lines, removed 1 line │ ┃ (also cleanscreen, mode or permissions). ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ↑↓ move · enter select · esc close │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /mods │ ⎿ menu: The mods menu is open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ◆ mods

Draws

Pane · mods
◆ mods No mods installed Install one with /plugin install statusline@malka-claude-mods (also cleanscreen, mode or permissions). ↑↓ move · enter select · esc close
Prompt hint
? for shortcuts ◆ mods
README

menu

English

An optional /mods menu. It opens a small pane that lists the mods you have installed from this collection (status line, clean screen, mode, permissions), each with a one-line summary. Pick one to open its settings page; "back" returns to the list.

  • Optional: the other mods work fine without it. It lists only what is installed; with none installed it says so.
  • A ◆ mods button sits in the footer under the prompt (the status line draws it when it is on).
  • Hotkeys: 1 to 4 open the rows; b goes back; esc closes.

Install:

/plugin install menu@malka-claude-mods

Use: /mods, or /mods statusline, /mods cleanscreen, /mods mode, /mods permissions to open straight on a page.

Uninstall: /plugin uninstall menu@malka-claude-mods. The other mods keep working; they simply stop showing the ◆ mods button.

Known limits: the open/closed state is kept in memory, so a hot reload can leave it out of step with the pane; if the menu is disabled mid-session, the other mods still think it is present until you restart.

Português

Um menu /mods opcional. Abre um painel pequeno que lista os mods desta coleção que você instalou (status line, clean screen, mode, permissions), cada um com um resumo de uma linha. Escolha um para abrir a página de configurações; "back" volta à lista.

  • Opcional: os outros mods funcionam sem ele. Lista só o que está instalado; sem nenhum, avisa.
  • Um botão ◆ mods fica no rodapé, sob o prompt (a status line o desenha quando está ligada).
  • Atalhos: 1 a 4 abrem as linhas; b volta; esc fecha.

Instalar:

/plugin install menu@malka-claude-mods

Usar: /mods, ou /mods statusline, /mods cleanscreen, /mods mode, /mods permissions para abrir direto numa página.

Desinstalar: /plugin uninstall menu@malka-claude-mods. Os outros mods continuam funcionando; só deixam de mostrar o botão ◆ mods.

Limites conhecidos: o estado aberto/fechado fica em memória, então um hot reload pode deixá-lo fora de sincronia com o painel; se o menu for desativado no meio da sessão, os outros mods ainda o veem como presente até reiniciar.

Source 2 files
hooks/register.tsx 173 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type {} from '../types/statusline'
5import type {} from '../types/cleanscreen'
6import type {} from '../types/mode'
7import type {} from '../types/permissions'
8
9const panelAtom = atom({ plugin: 'menu', key: 'panel' } as const, 'main')
10const presentAtom = atom({ plugin: 'menu', key: 'present' } as const, false)
11
12// Members known statically (state refs must be source literals). Each publishes `menuEntry` (apiVersion 1).
13const statuslineEntry = { plugin: 'statusline', key: 'menuEntry' } as const
14const cleanscreenEntry = { plugin: 'cleanscreen', key: 'menuEntry' } as const
15const modeEntry = { plugin: 'mode', key: 'menuEntry' } as const
16const permissionsEntry = { plugin: 'permissions', key: 'menuEntry' } as const
17
18const PANE = 'mods-menu'
19const NAMES = ['statusline', 'cleanscreen', 'mode', 'permissions']
20
21type Row = { name: string; title: string; summary: string; order: number }
22
23// The installed members' entries: missing, malformed or another apiVersion are skipped; sorted by `order`.
24async function entries($: EngineInterface): Promise<Row[]> {
25  const none = () => ({ value: undefined })
26  const found = [
27    (await $.state.get(statuslineEntry).catch(none)).value,
28    (await $.state.get(cleanscreenEntry).catch(none)).value,
29    (await $.state.get(modeEntry).catch(none)).value,
30    (await $.state.get(permissionsEntry).catch(none)).value,
31  ]
32  const rows: Row[] = []
33  found.forEach((v, i) => {
34    if (v && v.apiVersion === 1 && typeof v.title === 'string') rows.push({ name: NAMES[i]!, title: v.title, summary: String(v.summary), order: Number(v.order) || 0 })
35  })
36  return rows.sort((a, b) => a.order - b.order)
37}
38
39// The engine's hint on one row: it can carry a second line holding only a separator, which drew as a stray dot.
40const hintText = (hint: string) =>
41  hint
42    .split(/\r?\n/)
43    .map(line => line.trim())
44    .filter(line => /[\p{L}\p{N}]/u.test(line))
45    .join(' · ')
46
47let isOpen = false
48
49async function toggle($: EngineInterface, panel = 'main') {
50  if (isOpen) {
51    isOpen = false
52    await $.ui.close({ id: PANE })
53    return
54  }
55  await update($, panelAtom, () => panel)
56  isOpen = true
57  await $.ui.open({ id: PANE, title: 'mods', focus: true, closeOnEscape: true, rows: 16, columns: 56 })
58}
59
60const goTo = ($: EngineInterface, panel: string) => update($, panelAtom, () => panel)
61
62// A member page only when its entry is present now; otherwise main, with a toast.
63async function goToMember($: EngineInterface, name: string) {
64  if ((await entries($)).some(r => r.name === name)) return goTo($, name)
65  $.ui.toast(`${name} is not installed`)
66  return goTo($, 'main')
67}
68
69async function back($: EngineInterface, e: any, next: any) {
70  await goTo($, 'main')
71  return next(e)
72}
73
74function mainPage({ Box, Text, Button }: { Box: any; Text: any; Button: any }, rows: Row[], onOpen: (name: string) => unknown) {
75  return (
76    <Box flexDirection="column" paddingX={1}>
77      <Text bold color="claude">
78        ◆ mods
79      </Text>
80      {rows.length === 0 && (
81        <Box flexDirection="column" marginTop={1}>
82          <Text>No mods installed</Text>
83          <Text dimColor>Install one with /plugin install statusline@malka-claude-mods</Text>
84          <Text dimColor>(also cleanscreen, mode or permissions).</Text>
85        </Box>
86      )}
87      {rows.map((r, i) => (
88        <Box key={`row-${r.name}`} flexDirection="column" marginTop={1}>
89          <Button
90            key={`open-${r.name}`}
91            plain
92            hotkey={String(i + 1)}
93            autoFocus={i === 0 ? true : undefined}
94            label={`${r.title}  ›`}
95            onPress={() => onOpen(r.name)}
96          />
97          <Text dimColor>   {r.summary}</Text>
98        </Box>
99      ))}
100      <Box marginTop={1}>
101        <Text dimColor>↑↓ move · enter select · esc close</Text>
102      </Box>
103    </Box>
104  )
105}
106
107export const register: Register = on => {
108  on('session.start', async ($, e, next) => {
109    await update($, presentAtom, () => true)
110    await update($, panelAtom, () => 'main')
111    isOpen = false
112    await $.command
113      .register({ name: 'mods', description: 'Open the mods menu', argumentHint: '[statusline|cleanscreen|mode|permissions]', immediate: true })
114      .catch(() => undefined)
115    return next(e)
116  })
117
118  on('command.run', { command: 'mods' }, async ($, e) => {
119    try {
120      const arg = e.args.trim().toLowerCase()
121      const known = NAMES.includes(arg)
122      const panel = known && (await entries($)).some(r => r.name === arg) ? arg : 'main'
123      if (known && panel === 'main') $.ui.toast(`${arg} is not installed`)
124      if (isOpen) await goTo($, panel)
125      else await toggle($, panel)
126      return { text: 'The mods menu is open.' }
127    } catch (error) {
128      return { text: `The mods menu could not open: ${String(error)}` }
129    }
130  })
131
132  // Main page only: a member draws its own page when the panel is its name (the hooks beneath).
133  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
134    const rows = await entries($)
135    const panel = await read($, panelAtom)
136    // a member page whose entry is gone falls back to main
137    if (panel !== 'main' && rows.some(r => r.name === panel)) return next(e)
138    return mainPage($.ui.resolve(e), rows, name => goToMember($, name))
139  })
140
141  // However the pane closes (esc, its mark, the footer button), the menu resets.
142  on('ui.close', { id: PANE }, async ($, e, next) => {
143    isOpen = false
144    await update($, panelAtom, () => 'main').catch(() => undefined)
145    return next(e)
146  }).catch(() => undefined)
147
148  on('ui.press', { plugin: 'statusline', element: 'mods-back' }, back)
149  on('ui.press', { plugin: 'cleanscreen', element: 'mods-back' }, back)
150  on('ui.press', { plugin: 'mode', element: 'mods-back' }, back)
151  on('ui.press', { plugin: 'permissions', element: 'mods-back' }, back)
152
153  on('ui.press', { plugin: 'statusline', element: 'mods-open' }, async ($, e, next) => {
154    await toggle($)
155    return next(e)
156  })
157
158  // Footer, disjoint with the status line: when it is on, it draws the row and the ◆ mods button itself.
159  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
160    const { value } = await $.state.get(statuslineEntry).catch(() => ({ value: undefined }))
161    if (value?.summary === 'on') return next(e)
162    const { Box, Text, Button } = $.ui.resolve(e)
163    return (
164      <Box justifyContent="space-between" flexGrow={1} gap={1}>
165        <Text dimColor wrap="truncate">
166          {hintText(e.props.hint)}
167        </Text>
168        <Button key="mods-open" plain label="◆ mods" onPress={() => toggle($)} />
169      </Box>
170    )
171  })
172}
173
types/index.d.ts 13 lines
1// Menu contract, apiVersion 1.
2export type Panel = string
3declare module 'claude-code' {
4  interface PluginState {
5    menu: {
6      // 'main' or the name of the member whose page the pane shows
7      panel: Panel
8      // true while this mod is loaded (set at session.start)
9      present: boolean
10    }
11  }
12}
13