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

Mods for Claude Code by Gaurav Lahoti. Each one is a plugin, and this repo is the marketplace that lists them.
| Mod | What it does |
|---|---|
token-weather | A 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-meter | A band above the prompt showing how long the prompt cache stays warm, the last turn's hit rate, and a Compact button. |
gauravs-panel | A footer button that opens a row of models and a row of effort levels. One click to switch, no /model or /effort. |
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.
/plugin marketplace update gauravlahoti
.claude-plugin/marketplace.json the list of mods and where each one lives
plugins/<mod>/ one folder per mod, each a normal pluginhooks/register.tsx 183 lines1import { 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}
183hooks/panel.ts 45 lines1import 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
45types/index.d.ts 8 lines1export 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