SLOPSHOPPER

mods-hub

◆ button on the mode line and /mods side pane to toggle mods and edit their settings live.

newpanespinnercommandtoasttimer
v0.1.0no licenseupdated 2026-10-14Sales16/claude-mods/plugins/mods-hub
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mods-hub
│ ┃ ◆ mods ✕ › fix the failing auth test and add an audit log call │ ┃ Nenhum mod com configurações carregado. │ ⏺ 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 │ │ › /mods │ ⎿ mods-hub: Painel de mods aberto. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › mods-hub-open ⟨Claude Code's own drawing⟩

Draws

Pane · ◆ mods
Nenhum mod com configurações carregado.
Prompt hint
mods-hub-open ⟨Claude Code's own drawing⟩
README

claude-mods

Mods para o Claude Code: plugins de function hooks que mudam a interface e o comportamento do próprio Claude Code.

ModO que faz
mods-hubBotão ◆ mods na linha do mode e janela lateral para ligar, desligar e configurar os mods.
status-inlineMedidores compactos (contexto, rate limits, modelo, custo, tokens) na linha do mode.
model-pickerTroca o modelo e o effort da sessão pela janela de mods.

<img width="1084" height="101" alt="image" src="https://github.com/user-attachments/assets/ac343e6d-1300-4469-9443-7b241f6e7b14" /> <img width="216" height="427" alt="image" src="https://github.com/user-attachments/assets/b03d8f45-afad-45ec-b53d-415e3e89bba8" />

Instalar

Em todos os terminais (recomendado)

Em qualquer sessão do Claude Code:

/plugin marketplace add Sales16/claude-mods
/plugin install mods-hub@claude-mods
/plugin install status-inline@claude-mods
/plugin install model-picker@claude-mods
  • Escolha o escopo user na instalação: os mods passam a carregar em todo terminal novo, em qualquer projeto.
  • Instale o mods-hub junto: o status-inline depende dele.

Para pegar uma versão nova do repositório:

/plugin marketplace update claude-mods
/reload-plugins

Para remover:

/plugin uninstall model-picker@claude-mods
/plugin uninstall status-inline@claude-mods
/plugin uninstall mods-hub@claude-mods

Direto das pastas, sem instalar

Útil enquanto você mexe nos mods: o terminal vigia as pastas e recarrega o mod sozinho quando um arquivo muda.

Clone o repositório primeiro. Os exemplos abaixo usam C:\MyProjects\claude-mods; troque pelo caminho do seu clone.

git clone https://github.com/Sales16/claude-mods.git C:\MyProjects\claude-mods

Para todos os terminais, em ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "C:\\MyProjects\\claude-mods\\plugins\\mods-hub;C:\\MyProjects\\claude-mods\\plugins\\status-inline;C:\\MyProjects\\claude-mods\\plugins\\model-picker"
  }
}

Para um terminal só:

claude --plugin-dir C:\MyProjects\claude-mods\plugins\mods-hub --plugin-dir C:\MyProjects\claude-mods\plugins\status-inline --plugin-dir C:\MyProjects\claude-mods\plugins\model-picker

Use um jeito só. Instalado e carregado pela pasta ao mesmo tempo, o mod roda duas vezes (dois ◆ mods na linha).

Configurações

Cada opção dos mods fica salva no settings.json do usuário, em pluginConfigs, e vale entre sessões. O nome da chave muda com o jeito de carregar (status-inline@claude-mods instalado, status-inline@inline pela pasta): ao trocar de jeito, refaça as escolhas pelo ◆ mods.

mods-hub

Adiciona ◆ mods no começo da linha do mode:

◆ mods ⏵⏵ auto mode on │ ctx ▰▰▰▰▰▱▱▱ 62% │ …

Clicar em ◆ mods (ou rodar /mods) abre uma janela lateral com cada mod que tem configurações:

● model-picker
  ■ Ativo
  ▸ Modelo: opus
  ▸ Effort: high

● status-inline
  ■ Ativo
  ▾ Barra: ▰▱
      ● ▰▱
      ○ █░
  ■ ctx %
  Amarelo a partir de % − 70 +
  • ●/○ liga ou desliga o mod inteiro; ■/□ liga ou desliga cada opção.
  • Uma opção com vários valores (▸) abre a lista de valores embaixo dela; escolher um aplica na hora.
  • Números têm − e +, de 5 em 5.
  • Passar o mouse numa opção mostra, no rodapé da janela, o que ela faz e um exemplo já na cor e no estilo daquela escolha.
  • Toda mudança vale na hora: o mod mexido recarrega com a opção nova.

Opção própria: Cor do ◆ mods (verde-neon, ciano, magenta, laranja, azul, roxo, branco).

Clique e hover dependem de o terminal enviar o mouse para o Claude Code, o que acontece no modo tela cheia. Sem mouse, use /mods, Tab e Enter.

status-inline

Desenha medidores depois da linha do mode, em verde neon:

⏵⏵ auto mode on │ ctx ▰▰▰▰▰▱▱▱ 62% │ 5h ▰▱▱▱▱▱▱▱ 18% ↻14:30 │ 7d ▰▰▰▱▱▱▱▱ 41% ↻sex │ opus5.5·xh │ $1.23 │ tok 1.2M │ cmp ≈18k │ 🔥 →36%
MedidorO que mostra
ctxQuanto da janela de contexto o último request usou.
5hUso da janela de rate limit de 5 horas e quando ela reseta.
7dUso da janela semanal e o dia em que ela reseta.
opus5.5·xhModelo e effort (lo, md, hi, xh, max) do request atual.
$1.23Custo da sessão, como o /cost soma.
tok 1.2MTokens gastos na sessão, conversa principal e subagents juntos, desde que o mod carregou.
cmp ≈18kTokens que faltam até o auto-compact.
🔥 →36%Uso que a janela de 5h terá no reset, se o ritmo atual continuar.

Configurações:

OpçãoValores
Ativoliga ou desliga os medidores
Corverde-neon, ciano, magenta, laranja, azul, roxo, branco
Barra▰▱, █░, ■□, ●○, ⣿⣀, ━┄
Resetoculto (18%), valor (18% 9:00), ícone (18% ↻9:00), texto (18% reset às 9:00)
Tokenstotal, sem-cache (sem o cache relido), in/out (↑1.2M ↓40k), oculto
ctx, 5h, 7d, Modelo, Effort, Custo, cmp, Projeção 5h, Barrasmostra ou esconde cada item
Amarelo a partir de %padrão 70
Vermelho a partir de %padrão 90
  • Em tela estreita os itens somem nesta ordem: barras, extras (custo, tokens, cmp, projeção), horários de reset, 7d, modelo/effort. ctx e 5h nunca somem.
  • Com a janela do ◆ mods aberta ao lado, o mod conta a largura dela, porque a linha do mode passa por baixo das duas.

model-picker

Troca o modelo e o effort da sessão pela janela do ◆ mods.

OpçãoValores
Ativoliga ou desliga as trocas e a sincronização
Modelodefault, opus, sonnet, haiku, fable
Effortlow, medium, high, xhigh, max
  • Escolher um modelo troca na hora, como o /model. Escolher um effort roda /effort <nível>, que executa quando a sessão fica parada.
  • Na primeira vez que carrega, o mod adota o modelo e o effort atuais da sessão: instalar não troca nada.
  • Um /model, /effort ou troca de modelo pelo /config feita à mão passa a aparecer na janela. Um modelo como claude-opus-5-5 ou opus[1m] aparece como opus.
  • Trocar de modelo não perde a conversa, mas o primeiro request depois da troca relê o contexto sem cache: sai mais caro e mais lento.

Estrutura

claude-mods/
  .claude-plugin/marketplace.json   lista os mods para o /plugin install
  plugins/
    mods-hub/        botão ◆ mods, janela lateral, /mods
    status-inline/   medidores na linha do mode
    model-picker/    modelo e effort pela janela de mods

Cada mod tem .claude-plugin/plugin.json (nome, opções em userConfig), hooks/hooks.json e o módulo em hooks/register.ts(x), com seus testes em hooks/*.test.ts(x).

Criar um mod que aparece no ◆ mods

Um mod aparece na janela quando o plugin.json declara campos em userConfig.

  • Um campo booleano chamado enabled vira o liga/desliga do mod; o mod deve checar options.enabled e ficar inerte quando for false.
  • Um campo string com options vira uma lista de valores.
  • A ajuda do hover vem do description do campo:
Explicação do campo, uma ideia por linha.
Ex.: {#39FF14}ctx ▰▰▰▰▰▱▱▱ 62%{/}
-- <opção>
Explicação daquela opção.
Ex.: {#00F0FF}ctx ▰▰▰▰▰▱▱▱ 62%{/}

O Claude Code remove as quebras de linha do description, então o hub reencontra as linhas: uma linha começa em -- <opção> (uma das opções do campo), em Ex.: e depois de ., ! ou ? quando a frase seguinte começa com letra maiúscula. Comece toda linha assim e termine cada frase com ponto. Uma linha Ex.: é desenhada como exemplo, e {#RRGGBB}texto{/} pinta aquele trecho na cor.

No /config do Claude Code a mesma descrição aparece como texto puro, com as marcações cruas.

Desenvolver

claude plugin validate plugins/<mod>
claude plugin test plugins/<mod>
Source 2 files
hooks/register.tsx 371 lines
1import { atom, read, update } from 'claude-code'
2import type { ConfigRow, ConfigValue, EngineInterface, Register } from 'claude-code'
3
4const PANE = 'mods-hub'
5const TITLE = '◆ mods'
6const COLORS: Record<string, string> = {
7  'verde-neon': '#39FF14',
8  ciano: '#00F0FF',
9  magenta: '#FF2BD6',
10  laranja: '#FF9F1C',
11  azul: '#3D8BFF',
12  roxo: '#B266FF',
13  branco: '#F2F2F2',
14}
15const NUMBER_STEP = 5
16const HOVER_BACKGROUND = '#1E2A1E'
17
18const expanded = atom({ plugin: 'mods-hub', key: 'expanded' } as const, null)
19const dock = atom({ plugin: 'mods-hub', key: 'dock' } as const, 0)
20
21/** The docked pane's frame and divider, around its body's columns. */
22const DOCK_FRAME_COLUMNS = 3
23
24/** The `userConfig` field a mod declares to be switched on and off as a whole. */
25const ENABLED_FIELD = 'enabled'
26
27type ModGroup = { plugin: string; enabledRow?: ConfigRow; rows: ConfigRow[] }
28
29function groupByPlugin(rows: readonly ConfigRow[]): ModGroup[] {
30  const groups = new Map<string, ModGroup>()
31  for (const row of rows) {
32    const { plugin, tier } = row.provider
33    if (tier === 'core' || tier === 'builtin' || plugin === 'engine') continue
34
35    const group = groups.get(plugin) ?? { plugin, rows: [] }
36    if (row.key === `${plugin}.${ENABLED_FIELD}`) {
37      group.enabledRow = row
38    } else {
39      group.rows.push(row)
40    }
41    groups.set(plugin, group)
42  }
43
44  return [...groups.values()].sort((a, b) => a.plugin.localeCompare(b.plugin))
45}
46
47async function setRow($: EngineInterface, row: ConfigRow, value: ConfigValue): Promise<void> {
48  try {
49    const result = await $.config.set({ key: row.key, value })
50    if ('deny' in result && result.deny !== undefined) {
51      $.ui.toast(`${row.label}: ${result.deny}`)
52    }
53  } catch (error) {
54    $.ui.toast(`${row.label}: ${error instanceof Error ? error.message : String(error)}`)
55  }
56  $.ui.invalidate('ui.render')
57}
58
59type Help = { row: string[]; options: Record<string, string[]> }
60
61type Segment = { text: string; color?: string }
62
63/** Starts a line that shows an example of the row or option. */
64const EXAMPLE_PREFIX = 'Ex.: '
65
66function escapeRegExp(text: string): string {
67  return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
68}
69
70/**
71 * Splits a description into its lines. The engine hands a plugin field's
72 * description with its line breaks dropped, so a line is found again at each
73 * `-- <option>` (an option of the row, longest first), at each `Ex.: ` and
74 * after each sentence.
75 */
76export function descriptionLines(description: string, options: readonly string[] = []): string[] {
77  let text = description
78  if (!text.includes('\n')) {
79    const names = [...options].sort((a, b) => b.length - a.length).map(escapeRegExp)
80    if (names.length > 0) {
81      text = text.replace(new RegExp(`\\s*--\\s*(${names.join('|')})\\s*`, 'gu'), '\n-- $1\n')
82    }
83    text = text.replace(/\s*Ex\.:\s*/g, '\nEx.: ').replace(/([.!?])\s*(?=\p{Lu})/gu, '$1\n')
84  }
85
86  return text
87    .split('\n')
88    .map(line => line.trim())
89    .filter(line => line !== '')
90}
91
92/** A plugin's name without the marketplace it came from (`status-inline@inline`). */
93function displayName(plugin: string): string {
94  return plugin.replace(/@.*$/, '')
95}
96
97/**
98 * Reads a row's help from its description: the lines before the first
99 * `-- <option>` line explain the row, and each `-- <option>` line starts that
100 * option's own lines. A line led by `Ex.: ` is an example.
101 */
102function parseHelp(row: ConfigRow): Help {
103  const help: Help = { row: [], options: {} }
104  let lines = help.row
105  for (const line of descriptionLines(row.description ?? '', row.options)) {
106    const option = /^--\s+(.+)$/.exec(line)?.[1]?.trim()
107    if (option === undefined) {
108      if (line.trim() !== '') lines.push(line)
109    } else {
110      lines = help.options[option] = []
111    }
112  }
113
114  return help
115}
116
117/** Splits an example into its parts: `{#RRGGBB}text{/}` draws `text` in that color. */
118function exampleSegments(example: string): Segment[] {
119  const segments: Segment[] = []
120  let at = 0
121  for (const match of example.matchAll(/\{(#[0-9A-Fa-f]{6})\}(.*?)\{\/\}/g)) {
122    if (match.index > at) segments.push({ text: example.slice(at, match.index) })
123    segments.push({ text: match[2] ?? '', color: match[1] })
124    at = match.index + match[0].length
125  }
126  if (at < example.length) segments.push({ text: example.slice(at) })
127
128  return segments
129}
130
131function helpScope(key: string, option?: string): string {
132  return (option === undefined ? `h:${key}` : `h:${key}=${option}`).slice(0, 64)
133}
134
135async function choose($: EngineInterface, row: ConfigRow, value: string): Promise<void> {
136  await update($, expanded, () => null)
137  if (value !== row.value) await setRow($, row, value)
138}
139
140async function openPane($: EngineInterface): Promise<void> {
141  try {
142    await $.ui.open({ id: PANE, title: TITLE })
143  } catch (error) {
144    $.ui.toast(`mods: ${error instanceof Error ? error.message : String(error)}`)
145  }
146}
147
148export const register: Register = (on, options) => {
149  const NEON = COLORS[String(options['color'])] ?? '#39FF14'
150
151  on('session.start', async ($, e, next) => {
152    await $.command.register({
153      name: 'mods',
154      description: 'Open the mods pane: toggle mods and edit their settings',
155    })
156    const isShown = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown)
157    if (!isShown) await update($, dock, () => 0)
158
159    return next(e)
160  })
161
162  on('ui.close', async ($, e, next) => {
163    const closed = await next(e)
164    if (e.id === PANE) await update($, dock, () => 0)
165
166    return closed
167  })
168
169  on('command.run', { command: 'mods' }, async $ => {
170    await openPane($)
171
172    return { text: 'Painel de mods aberto.' }
173  })
174
175  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
176    const drawn = await next(e)
177    const { Box, Button, Text } = $.ui.resolve(e)
178
179    return (
180      <Box flexDirection="row">
181        <Button key="mods-hub-open" plain onPress={() => void openPane($)}>
182          <Text color={NEON}>◆ mods</Text>
183        </Button>
184        <Text> </Text>
185        {drawn}
186      </Box>
187    )
188  })
189
190  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
191    const { Box, Button, Text } = $.ui.resolve(e)
192    const groups = groupByPlugin(await $.config.list())
193    const openKey = await read($, expanded)
194
195    // A render never writes state: the docked width is published right after.
196    const docked = e.props.placement === 'dock' ? e.props.bodyColumns + DOCK_FRAME_COLUMNS : 0
197    if ((await read($, dock)) !== docked) {
198      $.clock.after(0, () => void update($, dock, () => docked))
199    }
200
201    if (groups.length === 0) {
202      return <Text dimColor>Nenhum mod com configurações carregado.</Text>
203    }
204
205    const helps: { scope: string; title: string; lines: string[] }[] = []
206
207    return (
208      <Box flexDirection="column">
209        {groups.map(group => {
210          const isOn = group.enabledRow === undefined || group.enabledRow.value !== false
211          if (group.enabledRow !== undefined) {
212            helps.push({
213              scope: helpScope(group.enabledRow.key),
214              title: displayName(group.plugin),
215              lines: parseHelp(group.enabledRow).row,
216            })
217          }
218
219          return (
220            <Box flexDirection="column" marginBottom={1}>
221              {group.enabledRow === undefined ? (
222                <Text color={NEON} bold>
223                  ● {displayName(group.plugin)}
224                </Text>
225              ) : (
226                <Box hover={{ scope: helpScope(group.enabledRow.key), backgroundColor: HOVER_BACKGROUND }}>
227                  <Button
228                    key={`toggle:${group.plugin}`}
229                    plain
230                    onPress={() => {
231                      const row = group.enabledRow
232                      if (row !== undefined) void setRow($, row, !isOn)
233                    }}
234                  >
235                    <Text color={isOn ? NEON : undefined} dimColor={!isOn} bold>
236                      {isOn ? '●' : '○'} {displayName(group.plugin)}
237                    </Text>
238                  </Button>
239                </Box>
240              )}
241              {group.rows.map(row => {
242                const help = parseHelp(row)
243                const scope = helpScope(row.key)
244                helps.push({ scope, title: row.label, lines: help.row })
245
246                if (row.kind === 'boolean') {
247                  const isChecked = row.value === true
248
249                  return (
250                    <Box hover={{ scope, backgroundColor: HOVER_BACKGROUND }}>
251                      <Button key={`row:${row.key}`} plain onPress={() => void setRow($, row, !isChecked)}>
252                        <Text color={isChecked && isOn ? NEON : undefined} dimColor={!isOn || !isChecked}>
253                          {'  '}
254                          {isChecked ? '■' : '□'} {row.label}
255                        </Text>
256                      </Button>
257                    </Box>
258                  )
259                }
260
261                if (row.kind === 'number') {
262                  const value = typeof row.value === 'number' ? row.value : 0
263
264                  return (
265                    <Box flexDirection="row" hover={{ scope, backgroundColor: HOVER_BACKGROUND }}>
266                      <Text dimColor={!isOn}>
267                        {'  '}
268                        {row.label}{' '}
269                      </Text>
270                      <Button key={`dec:${row.key}`} plain onPress={() => void setRow($, row, value - NUMBER_STEP)}>
271                        <Text color={NEON}>−</Text>
272                      </Button>
273                      <Text color={NEON}> {value} </Text>
274                      <Button key={`inc:${row.key}`} plain onPress={() => void setRow($, row, value + NUMBER_STEP)}>
275                        <Text color={NEON}>+</Text>
276                      </Button>
277                    </Box>
278                  )
279                }
280
281                if (row.kind === 'choice') {
282                  const isOpen = openKey === row.key
283
284                  return (
285                    <Box flexDirection="column">
286                      <Box hover={{ scope, backgroundColor: HOVER_BACKGROUND }}>
287                        <Button
288                          key={`row:${row.key}`}
289                          plain
290                          onPress={() => void update($, expanded, current => (current === row.key ? null : row.key))}
291                        >
292                          <Text dimColor={!isOn}>
293                            {'  '}
294                            {isOpen ? '▾' : '▸'} {row.label}: <Text color={NEON}>{String(row.value)}</Text>
295                          </Text>
296                        </Button>
297                      </Box>
298                      {isOpen &&
299                        (row.options ?? []).map(option => {
300                          const isSelected = option === row.value
301                          const optionScope = helpScope(row.key, option)
302                          helps.push({
303                            scope: optionScope,
304                            title: `${row.label}: ${option}`,
305                            lines: help.options[option] ?? help.row,
306                          })
307
308                          return (
309                            <Box hover={{ scope: optionScope, backgroundColor: HOVER_BACKGROUND }}>
310                              <Button key={`opt:${row.key}=${option}`} plain onPress={() => void choose($, row, option)}>
311                                <Text color={isSelected ? NEON : undefined} dimColor={!isSelected}>
312                                  {'      '}
313                                  {isSelected ? '●' : '○'} {option}
314                                </Text>
315                              </Button>
316                            </Box>
317                          )
318                        })}
319                    </Box>
320                  )
321                }
322
323                return (
324                  <Box hover={{ scope, backgroundColor: HOVER_BACKGROUND }}>
325                    <Text dimColor>
326                      {'  '}
327                      {row.label}: {String(row.value)}
328                    </Text>
329                  </Box>
330                )
331              })}
332            </Box>
333          )
334        })}
335        <Text dimColor>clique ou Tab + Enter · passe o mouse para ajuda · Esc fecha</Text>
336        {helps
337          .filter(help => help.lines.length > 0)
338          .map(help => (
339            <Box
340              flexDirection="column"
341              marginTop={1}
342              borderStyle="round"
343              borderColor={NEON}
344              paddingX={1}
345              display="none"
346              hover={{ scope: help.scope, display: 'flex' }}
347            >
348              <Text color={NEON} bold>
349                {help.title}
350              </Text>
351              {help.lines.map(line =>
352                line.startsWith(EXAMPLE_PREFIX) ? (
353                  <Box flexDirection="row">
354                    <Text dimColor>exemplo </Text>
355                    <Text>
356                      {exampleSegments(line.slice(EXAMPLE_PREFIX.length)).map(segment => (
357                        <Text color={segment.color}>{segment.text}</Text>
358                      ))}
359                    </Text>
360                  </Box>
361                ) : (
362                  <Text>{line}</Text>
363                ),
364              )}
365            </Box>
366          ))}
367      </Box>
368    )
369  })
370}
371
types/index.d.ts 16 lines
1/** The `/config` key of the choice row whose options are listed open, if any. */
2export type ModsHubExpanded = string | null
3
4/**
5 * Columns the mods pane takes from the transcript while docked beside it; 0
6 * while it is closed or inline. The mode line spans both, so a mod drawing
7 * there adds this to its viewport's columns.
8 */
9export type ModsHubDock = number
10
11declare module 'claude-code' {
12  interface PluginState {
13    'mods-hub': { expanded: ModsHubExpanded; dock: ModsHubDock }
14  }
15}
16