SLOPSHOPPER

gauravs-panel

Gaurav's Tools: a footer button that opens a model row and an effort row of clickable buttons, so switching takes one click instead of /model and /effort

newpanespinnercommandtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gauravs-panel
│ ┃ panel ✕ › fix the failing auth test and add an audit log call │ ┃ ⟨Claude Code's own drawing⟩ │ ┃ MODEL Haiku 4.5 Sonnet 5.5 O ⏺ Read(src/auth.ts) │ ┃ EFFORT Low Medium High XHi ⎿ 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 │ │ › /panel │ ⎿ gauravs-panel: Gaurav's Tools toggled. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts auto-accept edits ◆ Gaurav's Tools ▲

Draws

Pane · panel
⟨Claude Code's own drawing⟩ MODEL Haiku 4.5 Sonnet 5.5 Opus 5.5 EFFORT Low Medium High XHigh Max
README

claude-mods

Mods for Claude Code by Gaurav Lahoti. Each one is a plugin, and this repo is the marketplace that lists them.

ModWhat it does
token-weatherA band above the prompt with a forecast of the context window: a weather word, percent used, tokens, a 12-turn chart and how much the last turn added.
cache-meterA band above the prompt showing how long the prompt cache stays warm, the last turn's hit rate, and a Compact button.
gauravs-panelA footer button that opens a row of models and a row of effort levels. One click to switch, no /model or /effort.

Install

In Claude Code:

/plugin marketplace add gauravlahoti/claude-mods
/plugin install token-weather@gauravlahoti
/reload-plugins

Swap token-weather for cache-meter or gauravs-panel to install the others.

Update

/plugin marketplace update gauravlahoti

Layout

.claude-plugin/marketplace.json   the list of mods and where each one lives
plugins/<mod>/                    one folder per mod, each a normal plugin
Source 3 files
hooks/register.tsx 183 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { BUBBLE_TOP, CARD, EFFORTS, MODELS, isActiveModel, isEffort } from './panel'
5
6const model = atom({ plugin: 'gauravs-panel', key: 'model' } as const, null)
7// Effort has no getter in the API: it is seeded from the last model request
8// (turn.step carries it) and set optimistically when a button is pressed.
9const effort = atom({ plugin: 'gauravs-panel', key: 'effort' } as const, null)
10// Whether the card is showing.
11const isOpen = atom({ plugin: 'gauravs-panel', key: 'isOpen' } as const, false)
12// Panes on screen (any plugin's). While one is up it paints over the rows
13// above the footer, so the card moves into that pane and draws on top there.
14const panes = atom({ plugin: 'gauravs-panel', key: 'panes' } as const, [] as string[])
15
16async function refreshModel($: EngineInterface) {
17  const current = await $.session.model()
18  await update($, model, () => current)
19}
20
21// A pick closes the card first, then runs the real command.
22async function pick($: EngineInterface, command: 'model' | 'effort', arg: string) {
23  await update($, isOpen, () => false)
24  try {
25    // Show what the command said, so a refused switch is never silent.
26    const { text } = await $.command.run({ command, args: arg })
27    if (text) $.ui.toast(text)
28    if (command === 'model') await refreshModel($)
29    else await update($, effort, () => (isEffort(arg) ? arg : null))
30  } catch {
31    $.ui.toast(`Could not set ${command} to ${arg}`)
32  }
33}
34
35// The two-row card, absolutely placed. Callers put it LAST among their
36// children: there is no z-index, and an absolute Box paints only over the
37// siblings before it.
38async function card($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0], top: number) {
39  const current = await read($, model)
40  const level = await read($, effort)
41  const { Box, Text, Button } = $.ui.resolve(e)
42
43  const row = (
44    label: string,
45    items: Array<{ key: string; label: string; isActive: boolean; onPress: () => void }>,
46  ) => (
47    <Box flexDirection="row">
48      <Box width={12}>
49        <Text color={CARD.label}>{label}</Text>
50      </Box>
51      {items.map(item => (
52        <Box
53          key={`box-${item.key}`}
54          paddingX={2}
55          backgroundColor={item.isActive ? CARD.lit : undefined}
56        >
57          <Button key={item.key} label={item.label} plain onPress={item.onPress} />
58        </Box>
59      ))}
60    </Box>
61  )
62
63  return (
64    <Box
65      position="absolute"
66      top={top}
67      right={0}
68      flexDirection="column"
69      paddingX={2}
70      backgroundColor={CARD.bubble}
71    >
72      {row(
73        'MODEL',
74        MODELS.map(c => ({
75          key: c.key,
76          label: c.label,
77          isActive: isActiveModel(current, c),
78          onPress: () => pick($, 'model', c.arg),
79        })),
80      )}
81      {row(
82        'EFFORT',
83        EFFORTS.map(c => ({
84          key: c.level,
85          label: c.label,
86          isActive: level === c.level,
87          onPress: () => pick($, 'effort', c.level),
88        })),
89      )}
90    </Box>
91  )
92}
93
94export const register: Register = on => {
95  on('session.start', async ($, e, next) => {
96    const result = await next(e)
97    // An earlier version pinned a text readout under the prompt; clear it.
98    $.ui.status(undefined)
99    await $.command.register({
100      name: 'panel',
101      description: "Gaurav's Tools: pick the model and effort in one click",
102      immediate: true,
103    })
104    await refreshModel($)
105    return result
106  })
107
108  on('command.run', { command: 'panel' }, async $ => {
109    await update($, isOpen, open => !open)
110    return { text: "Gaurav's Tools toggled." }
111  })
112
113  on('turn.complete', async ($, e, next) => {
114    await refreshModel($)
115    return next(e)
116  })
117
118  on('turn.step', async function* ($, e, next) {
119    if (isEffort(e.effort)) {
120      const seen = e.effort
121      await update($, effort, () => seen)
122    }
123    return yield* next(e)
124  })
125
126  // A pane that opens takes the card over from the footer.
127  on('ui.open', async ($, e, next) => {
128    const result = await next(e)
129    const id = e.id
130    if ('value' in result && result.value?.isPlaced) await update($, panes, ids => (ids.includes(id) ? ids : [...ids, id]))
131    return result
132  })
133
134  // A pane that closes hands the card back to the footer.
135  on('ui.close', async ($, e, next) => {
136    const result = await next(e)
137    const id = e.id
138    await update($, panes, ids => ids.filter(p => p !== id))
139    return result
140  })
141
142  // Every pane: keep what its owner drew and lay the card over its last two
143  // visible rows. This needs gauravs-panel ABOVE the pane's owner in the chain
144  // (first in CLAUDE_CODE_PLUGIN_DIRS), since an owner's hook doesn't call next.
145  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
146    const below = await next(e)
147    if (!(await read($, isOpen))) return below
148    const { Box } = $.ui.resolve(e)
149    const { offset, bodyRows } = e.props.scroll
150    return (
151      <Box flexDirection="column">
152        {below}
153        {await card($, e, offset + Math.max(0, bodyRows - 2))}
154      </Box>
155    )
156  })
157
158  // The right end of the prompt footer: keep the engine's mode labels, add the
159  // button, and draw the card over the rows above it while no pane is up.
160  on('ui.render', { component: 'SessionMode' }, async ($, e) => {
161    const open = await read($, isOpen)
162    const paneUp = (await read($, panes)).length > 0
163    const { Box, Text, Button } = $.ui.resolve(e)
164    const modes = e.props.modes
165
166    return (
167      <Box flexDirection="row">
168        {modes.length > 0 ? <Text dimColor>{modes.join(' & ')} </Text> : null}
169        <Box key="open-box" paddingX={1} backgroundColor={CARD.button}>
170          <Button
171            key="open"
172            label={open ? "◆ Gaurav's Tools ▲" : "◆ Gaurav's Tools ▼"}
173            plain
174            hover={{ color: '#ffffff', backgroundColor: CARD.hover, bold: true }}
175            onPress={() => update($, isOpen, o => !o)}
176          />
177        </Box>
178        {open && !paneUp ? await card($, e, BUBBLE_TOP) : null}
179      </Box>
180    )
181  })
182}
183
hooks/panel.ts 45 lines
1import type { Effort } from '../types'
2
3export type ModelChoice = { key: string; label: string; arg: string; match: string }
4export type EffortChoice = { level: Effort; label: string }
5
6// `arg` is what /model is given; `match` finds the choice in the resolved id
7// that $.session.model() returns (e.g. claude-opus-5-5).
8export const MODELS: ModelChoice[] = [
9  { key: 'haiku', label: 'Haiku 4.5', arg: 'haiku', match: 'haiku' },
10  { key: 'sonnet', label: 'Sonnet 5.5', arg: 'sonnet', match: 'sonnet' },
11  { key: 'opus', label: 'Opus 5.5', arg: 'opus', match: 'opus' },
12  { key: 'fable', label: 'Fable 5.1', arg: 'claude-fable-5-1', match: 'fable' },
13]
14
15export const EFFORTS: EffortChoice[] = [
16  { level: 'low', label: 'Low' },
17  { level: 'medium', label: 'Medium' },
18  { level: 'high', label: 'High' },
19  { level: 'xhigh', label: 'XHigh' },
20  { level: 'max', label: 'Max' },
21]
22
23export const isActiveModel = (current: string | null, choice: ModelChoice) =>
24  current !== null && current.toLowerCase().includes(choice.match)
25
26export const isEffort = (value: unknown): value is Effort =>
27  typeof value === 'string' && EFFORTS.some(c => c.level === value)
28
29// Claude's own colour only: its terracotta orange lights the chosen option on
30// both rows, everything else stays neutral.
31export const CARD = {
32  // Match the terminal's own background so the card reads as transparent.
33  bubble: '#282c34',
34  label: '#8a8678',
35  lit: '#d97757',
36  // The footer button wears the same Claude terracotta, a shade lighter under the pointer.
37  button: '#d97757',
38  hover: '#e8906f',
39}
40
41// The footer slot paints only rows 4, 3 and 2 above the footer line; anything
42// higher is cut off (confirmed on screen, and again at -14). The card is two
43// rows, so it starts at the very top of that window.
44export const BUBBLE_TOP = -4
45
types/index.d.ts 8 lines
1export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
2
3declare module 'claude-code' {
4  interface PluginState {
5    'gauravs-panel': { model: string | null; effort: Effort | null; isOpen: boolean; panes: string[] }
6  }
7}
8