Big, colorful, configurable buttons above the Claude Code prompt. Buttons and nested selects from one JSON file write (or send) your prompts.

Big, colorful, configurable buttons above the Claude Code prompt.
One JSON file turns the prompts you type every day into one-click buttons, and into nested selects that build a prompt step by step.
<img src="docs/demo.gif" alt="Quickbar in Claude Code: one-row colored buttons; Review opens Bugs, Security and Style, Style opens Gentle and Strict; Git, Commit, Detailed writes a full commit prompt; Explain writes another" width="770">
You probably type the same handful of prompts all day: explain this, review for bugs, run the tests and fix what fails, plan first. Quickbar puts them one click away, in your own words, without leaving the prompt.
"navigation": "hover" to walk the levels with the mouse, like a desktop menu.Requires Claude Code 2.1.287 or later (mods are on by default).
/plugin marketplace add hugoamadio/claude-quickbar
/plugin install quickbar@claude-quickbar
/reload-plugins
The bar appears with an example set of five buttons. Make it yours:
/quickbar init
This writes the example to ~/.claude/quickbar.json. Edit it and save; the bar updates by itself.
Claude Code turns auto-update off for third-party marketplaces. Turn it on once to get new versions:
/plugin → Marketplaces → claude-quickbar → Enable auto-update
Claude Code then updates the plugin in the background and shows Plugin updated: quickbar · Run /reload-plugins to apply. To update by hand instead, run claude plugin update quickbar@claude-quickbar. What changed in each version is in the CHANGELOG.
{
"$schema": "https://raw.githubusercontent.com/hugoamadio/claude-quickbar/main/schema/quickbar.schema.json",
"style": { "size": "lg" },
"buttons": [
// A button: writes its text at the cursor.
{ "label": "Explain", "color": "#1f6feb", "hotkey": "e", "text": "Explain how this works, step by step: " },
// A select: picking options composes "Review the current changes for readability and flag every inconsistency."
{
"label": "Review",
"color": "#8957e5",
"text": "Review the current changes",
"options": [
{ "label": "Bugs", "text": "for correctness bugs" },
{ "label": "Style", "text": "for readability", "options": [
{ "label": "Gentle", "text": "and only flag what really matters." },
{ "label": "Strict", "text": "and flag every inconsistency." }
] }
]
},
// Sent right away instead of only written.
{ "label": "Run tests", "color": "#bf8700", "text": "Run the test suite and fix what fails.", "send": true }
]
}
The $schema line gives you autocompletion and inline errors in VS Code and any editor that reads JSON Schema.
| Value | Behavior |
|---|---|
"click" (default) | Click a select to open it, click an option to pick it. The whole button is clickable. Text selection in the terminal works as usual. |
"hover" | Hovering opens selects and their levels like a desktop menu bar; leaving the bar closes them. Claude Code then tracks the pointer, which takes over text selection in the terminal: a selection may start a little off the pointer. |
"peek" (experimental) | Hovering a select reveals its options right above it, and they stay while the pointer is on them; deeper levels open on click. It uses a hover style only, without tracking the pointer. |
{ "navigation": "hover", "buttons": [ ... ] }
The first file found wins:
| Order | File | Use it for |
|---|---|---|
| 1 | <project>/.claude/quickbar.json | Buttons for one repository, shareable with your team |
| 2 | ~/.claude/quickbar.json | Your personal buttons, everywhere |
| 3 | bundled example | What you see right after installing |
| Field | Type | Default | Description | ||
|---|---|---|---|---|---|
label | string | required | What the button shows. | ||
text | string | A button: the text it writes. A select: a prefix before the chosen texts. | |||
options | option[] | Makes the button a select. | |||
color | color | style.color | Background color. | ||
hotkey | a–z, 0–9 | Presses the button while the bar has focus. | |||
mode | insert \ | append \ | replace | insert | Insert at the cursor, append to the end, or replace the prompt. |
send | boolean | false | true submits the prompt; false only writes it so you can edit first. | ||
separator | string | " " | Joins the prefix and the chosen texts of a select. |
| Field | Type | Description |
|---|---|---|
label | string | What the option shows. Required. |
text | string | Text this choice adds. A final choice without text adds its label. |
options | option[] | Opens another level of choices (up to 6 levels). |
color | color | Background color. |
mode, send | Override the button's values when this choice is the last one. |
A select writes once, when you reach a choice with no further options. The composed text is the button's text followed by every chosen text.
| Field | Default | Description |
|---|---|---|
size | lg | How wide a button is: sm, md or lg. Buttons are one row tall (see below). |
paddingX | from size | Columns of color left and right of the label (0–8). |
paddingY | from size | Rows above and below the label, only with "navigation": "hover" (0–3). |
gap | 1 | Columns between buttons. |
color | #3b4252 | Default background. |
activeColor | #2e7d4f | The open select and the chosen options. |
hoverColor | #5e6a82 | Under the pointer, with "navigation": "hover". |
Colors accept hex (#2e7d4f) or terminal color names (red, blueBright).
| Command | What it does |
|---|---|
/quickbar init | Copy the example to ~/.claude/quickbar.json (never overwrites). |
/quickbar init project | Copy it to .claude/quickbar.json in the current project. |
/quickbar where | Show which config is active and list its errors. |
/quickbar reload | Read the config again (saving the file also does it). |
/quickbar hide, /quickbar show | Hide or show the bar for this session. |
/quickbar demo, /quickbar demo off | Show the bundled example in this session only, for recording a demo. |
✕ closes the select.click and peek navigation.ctrl+x then tab to focus the bar, then a button's hotkey; Esc returns to the prompt. (With "navigation": "hover", click the bar once instead.)› opens its level, and moving away closes the menus after a moment. It needs a terminal that reports mouse movement; VS Code always uses click navigation.node scripts/check-config.ts path/to/quickbar.json
Runs the same checks as the plugin (Node 22.18 or later).
lg scroll inside it.click and peek navigation: Claude Code's pointer highlight covers only the first row of a taller button."navigation": "hover" makes Claude Code track the pointer, which changes how text selection behaves in the terminal. That is why click is the default.git clone https://github.com/hugoamadio/claude-quickbar
cd claude-quickbar
claude --plugin-dir ./plugins/quickbar # run it
claude plugin validate ./plugins/quickbar # check the manifest and hooks
claude plugin test ./plugins/quickbar # run the tests
See CONTRIBUTING.md.
MIT © Hugo Amadio
hooks/register.tsx 299 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, Timer } from 'claude-code'
3
4import type { QuickbarButton, QuickbarConfig, QuickbarOption, QuickbarSource } from '../types'
5import type { BarProps } from './bar'
6import { applyMode, chosen, deliveryOf } from './compose'
7import { parse, resolveStyle } from './config'
8import { activate, layout } from './layout'
9import type { Pill } from './layout'
10
11// Quickbar: a band of big buttons above the prompt, read from quickbar.json.
12// A button writes its text into the prompt (or sends it); a select opens its options above the bar,
13// and each option can open another level, until a final choice writes the composed text.
14// Where the surface has a Client (terminal, desktop) the bar is ./bar.tsx: whole-pill clicks and hover
15// navigation. Elsewhere it falls back to plain Buttons driven by clicks.
16
17const config = atom({ plugin: 'quickbar', key: 'config' } as const, null as QuickbarConfig | null)
18const source = atom({ plugin: 'quickbar', key: 'source' } as const, null as QuickbarSource | null)
19const errors = atom({ plugin: 'quickbar', key: 'errors' } as const, [] as string[])
20const open = atom({ plugin: 'quickbar', key: 'open' } as const, -1)
21const path = atom({ plugin: 'quickbar', key: 'path' } as const, [] as number[])
22const hidden = atom({ plugin: 'quickbar', key: 'hidden' } as const, false)
23const demo = atom({ plugin: 'quickbar', key: 'demo' } as const, false)
24
25const FILE = 'quickbar.json'
26
27const POLL_MS = 3000
28const ERROR_COLOR = '#b42318'
29
30type Candidate = QuickbarSource & { kind: 'project' | 'user' }
31
32async function candidates($: EngineInterface): Promise<Candidate[]> {
33 const root = await $.session.root()
34 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
35 const out: Candidate[] = [{ kind: 'project', path: `${root}/.claude/${FILE}` }]
36 if (home) out.push({ kind: 'user', path: `${home}/.claude/${FILE}` })
37 return out
38}
39
40/** A fingerprint of the config files: changes when one appears, disappears or is saved. */
41async function fingerprint($: EngineInterface): Promise<string> {
42 const parts = await Promise.all(
43 (await candidates($)).map(async c => ((await $.fs.exists(c.path)) ? `${c.path}@${(await $.fs.stat(c.path)).mtimeMs}` : '-')),
44 )
45 return parts.join('|')
46}
47
48/** Loads the first config found: the project's, then the user's, then the bundled example. */
49async function load($: EngineInterface): Promise<void> {
50 let found: QuickbarSource = { kind: 'default', path: `${$.plugin.root}/defaults/${FILE}` }
51 // Demo mode (this session only): the bundled example, whatever config files exist.
52 for (const c of (await read($, demo)) ? [] : await candidates($)) {
53 if (await $.fs.exists(c.path)) {
54 found = c
55 break
56 }
57 }
58 let text = ''
59 let readError = ''
60 try {
61 text = await $.fs.read(found.path)
62 } catch (err) {
63 readError = `could not read ${found.path}: ${err instanceof Error ? err.message : String(err)}`
64 }
65 const parsed = readError ? { config: null, errors: [readError] } : parse(text)
66 await update($, config, () => parsed.config)
67 await update($, source, () => found)
68 await update($, errors, () => parsed.errors)
69 await update($, open, () => -1)
70 await update($, path, () => [])
71}
72
73/** Writes or sends a finished button or select path. */
74async function deliver($: EngineInterface, button: QuickbarButton, picked: QuickbarOption[]): Promise<void> {
75 const d = deliveryOf(button, picked)
76 if (d.send) {
77 const box = await $.prompt.read()
78 await $.prompt.fill({ text: '', mode: 'replace' })
79 await $.prompt.submit({ text: applyMode(box, d.text, d.mode), asUser: true })
80 return
81 }
82 if (d.mode === 'append') {
83 await $.prompt.fill({ text: applyMode(await $.prompt.read(), d.text, 'append'), mode: 'replace' })
84 return
85 }
86 await $.prompt.fill({ text: d.text, mode: d.mode })
87}
88
89const close = async ($: EngineInterface) => {
90 await update($, open, () => -1)
91 await update($, path, () => [])
92}
93
94const USAGE = [
95 'big buttons above the prompt, from quickbar.json',
96 ' /quickbar init copy the example config to ~/.claude/quickbar.json',
97 ' /quickbar init project copy it to this project (.claude/quickbar.json)',
98 ' /quickbar where show which config is active and its errors',
99 ' /quickbar reload read the config again (it also reloads on save)',
100 ' /quickbar hide | show hide or show the bar',
101 ' /quickbar demo [off] show the bundled example in this session only (for recording a demo)',
102].join('\n')
103
104async function runCommand($: EngineInterface, args: string): Promise<string> {
105 const [verb = '', target = ''] = args.trim().split(/\s+/)
106 if (verb === 'init') {
107 const list = await candidates($)
108 const dest = list.find(c => c.kind === (target === 'project' ? 'project' : 'user'))
109 if (!dest) return 'no home directory found for the user config.'
110 if (await $.fs.exists(dest.path)) return `${dest.path} already exists; edit it, nothing was overwritten.`
111 await $.fs.write(dest.path, await $.fs.read(`${$.plugin.root}/defaults/${FILE}`))
112 await load($)
113 return `wrote ${dest.path}. Edit it and save; the bar updates by itself.`
114 }
115 if (verb === 'reload') {
116 await load($)
117 } else if (verb === 'demo') {
118 await update($, demo, () => target !== 'off')
119 await load($)
120 return target === 'off' ? 'demo off: your config is back in this session.' : 'demo on: the bundled example in this session only. /quickbar demo off to go back.'
121 } else if (verb === 'hide' || verb === 'show') {
122 await update($, hidden, () => verb === 'hide')
123 return `bar ${verb === 'hide' ? 'hidden' : 'shown'}.`
124 } else if (verb !== 'where') {
125 return USAGE
126 }
127 const src = await read($, source)
128 const errs = await read($, errors)
129 const where = src ? `${src.kind} config: ${src.path}` : 'no config loaded'
130 return errs.length ? `${where}\n${errs.length} error(s):\n- ${errs.join('\n- ')}` : `${where} (ok)`
131}
132
133export const register: Register = on => {
134 let timer: Timer | undefined
135 let last = ''
136 let loading: Promise<void> | undefined
137
138 on('session.start', async ($, e, next) => {
139 await $.command.register({ name: 'quickbar', description: 'Quickbar: init, where, reload, hide, show' })
140 await load($)
141 last = await fingerprint($)
142 timer = $.clock.every(POLL_MS, () => {
143 void fingerprint($).then(async now => {
144 if (now === last) return
145 last = now
146 await load($)
147 }).catch(() => undefined)
148 })
149 return next(e)
150 })
151
152 on('command.run', { command: 'quickbar' }, async ($, e) => ({ text: await runCommand($, e.args) }))
153
154 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
155 // Other plugins may draw in this band too: draw ours under whatever the chain beneath returns.
156 const below = await next(e)
157 const mine = await (async (): Promise<RenderElement | null> => {
158 if (e.props.hasSurvey || (await read($, hidden))) return null
159
160 const { Box, Button, Text } = $.ui.resolve(e)
161 const cfg = await read($, config)
162 const errs = await read($, errors)
163 const st = resolveStyle(cfg?.style)
164
165 // A pill is one single-row Button on a colored Box. Claude Code highlights a Button under the pointer by
166 // inverting it, and only a single-row Button inverts as a whole (a taller one inverts only its first row,
167 // stacked Buttons row by row), so pills are one row tall and carry no hover colors of their own.
168 // A select in peek mode puts its pill in a hover scope that only reveals its options row.
169 const pill = (key: string, label: string, bg: string, onPress: () => unknown, hotkey?: string, scope?: string) => {
170 const pad = ' '.repeat(st.paddingX)
171 // A plain Button with a hotkey is drawn as "e: label": the "e:" takes two of the left padding columns,
172 // so the pill keeps its width and the label stays centered.
173 const left = hotkey ? ' '.repeat(Math.max(1, st.paddingX - 2)) : pad
174 return (
175 <Box key={`pill-${key}`} backgroundColor={bg} marginRight={st.gap} {...(scope ? { hover: { scope } } : {})}>
176 <Button key={key} plain label={`${left}${label}${pad}`} onPress={() => void onPress()} {...(hotkey ? { hotkey } : {})} />
177 </Box>
178 )
179 }
180
181 // Not loaded yet (drawn before session.start finished): draw nothing and make sure a load is on its way.
182 if (!cfg && errs.length === 0) {
183 if (!loading) loading = load($).finally(() => { loading = undefined })
184 return null
185 }
186
187 if (!cfg) {
188 return (
189 <Box key="quickbar-error">
190 {pill('error', `quickbar: ${errs.length} config error${errs.length === 1 ? '' : 's'}, press for details`, ERROR_COLOR,
191 () => $.ui.toast(`${errs.slice(0, 3).join(' · ')} (run /quickbar where)`, { timeoutMs: 10000 }))}
192 </Box>
193 )
194 }
195
196 const errLine = errs.length > 0 ? <Text dimColor>{`quickbar: ${errs[0]}`}</Text> : null
197 const table = $.ui.resolve(e)
198
199 // Hover navigation is opt-in: its Client tracks the pointer, which takes over text selection in the terminal.
200 if (cfg.navigation === 'hover' && (e.surface === 'terminal' || e.surface === 'desktop') && 'Client' in table) {
201 const { Client } = table
202 const props: BarProps = { buttons: cfg.buttons, style: st, columns: e.props.bodyColumns }
203 return (
204 <Box key="quickbar" flexDirection="column">
205 <Client key="bar" module="./bar.tsx" props={props} />
206 {errLine}
207 </Box>
208 )
209 }
210
211 // Click navigation (the default, and VS Code): the same layout drawn with Buttons.
212 const view = { open: await read($, open), path: await read($, path) }
213 const l = layout(cfg.buttons, st, view, e.props.bodyColumns)
214 const press = (p: Pill) => async () => {
215 const act = activate(view, p)
216 await update($, open, () => act.view.open)
217 await update($, path, () => [...act.view.path])
218 if (act.kind === 'deliver') {
219 const b = cfg.buttons[act.button]
220 if (b) await deliver($, b, chosen(b, act.path))
221 }
222 }
223 const isPeek = cfg.navigation === 'peek'
224 const scopeOf = (i: number) => `quickbar-select-${i}`
225 const drawLine = (line: (typeof l.lines)[number], onPress: (p: Pill) => () => Promise<void>, prefix = '') => (
226 <Box key={`${prefix}line-${line.y}`}>
227 {line.pills.map(p => pill(`${prefix}${p.id}`, p.label, p.color, onPress(p),
228 p.kind === 'button' ? cfg.buttons[p.index]?.hotkey : undefined,
229 isPeek && p.kind === 'button' && p.hasChildren && view.open !== p.index ? scopeOf(p.index) : undefined))}
230 </Box>
231 )
232 const barStart = l.lines.findIndex(line => line.pills.some(p => p.kind === 'button'))
233
234 // Peek: each closed select's first level, hidden right above the bar and revealed by hovering the select
235 // (a hover style, no pointer tracking). The revealed row shares the select's hover scope, so moving onto
236 // it keeps it open; a click there works like click navigation.
237 const peeks = isPeek
238 ? cfg.buttons.flatMap((b, i) => {
239 if (!b.options || view.open === i) return []
240 const pv = { open: i, path: [] as number[] }
241 const pl = layout(cfg.buttons, st, pv, e.props.bodyColumns)
242 const rows = pl.lines.filter(line => line.pills.every(p => p.kind !== 'button'))
243 // Right above its own select: shifted to the select's column, as far as the row still fits.
244 const selectX = l.lines.flatMap(line => line.pills).find(p => p.id === `b${i}`)?.x ?? 0
245 const rowWidth = Math.max(0, ...rows.map(line => {
246 const last = line.pills[line.pills.length - 1]
247 return last ? last.x + last.width : 0
248 }))
249 const shift = Math.max(0, Math.min(selectX, e.props.bodyColumns - rowWidth))
250 const pressPeek = (p: Pill) => async () => {
251 const act = activate(pv, p)
252 await update($, open, () => act.view.open)
253 await update($, path, () => [...act.view.path])
254 if (act.kind === 'deliver') await deliver($, b, chosen(b, act.path))
255 }
256 return [(
257 <Box key={`peek-${i}`} flexDirection="column" marginLeft={shift} display="none" hover={{ display: 'flex', scope: scopeOf(i) }}>
258 {rows.map(line => drawLine(line, pressPeek, `p${i}-`))}
259 </Box>
260 )]
261 })
262 : []
263
264 return (
265 <Box key="quickbar" flexDirection="column">
266 {l.lines.slice(0, barStart).map(line => drawLine(line, press))}
267 {peeks}
268 {l.lines.slice(barStart).map(line => drawLine(line, press))}
269 {errLine}
270 </Box>
271 )
272 })()
273 if (!mine) return below
274 const { Box } = $.ui.resolve(e)
275 return (
276 <Box key="quickbar-stack" flexDirection="column">
277 {below}
278 {mine}
279 </Box>
280 )
281 })
282
283 on('ui.message', async ($, e, next) => {
284 const data = e.data as { type?: string; button?: number; path?: number[] } | null
285 if (e.module.endsWith('bar.tsx') && data?.type === 'deliver' && typeof data.button === 'number') {
286 const cfg = await read($, config)
287 const b = cfg?.buttons[data.button]
288 if (b) await deliver($, b, chosen(b, Array.isArray(data.path) ? data.path : []))
289 return {}
290 }
291 return next(e)
292 })
293
294 on('session.end', async ($, e, next) => {
295 timer?.cancel()
296 return next(e)
297 })
298}
299hooks/bar.tsx 104 lines1import type { ClientModule, ClientPointerEvent, RenderElement } from 'claude-code'
2
3import type { QuickbarButton } from '../types'
4import type { ResolvedStyle } from './config'
5import { activate, hitTest, hoverView, layout, sameView } from './layout'
6import type { View } from './layout'
7
8// The bar as a Client: it draws every pill itself and gets the pointer, so the whole pill is clickable and
9// hovering walks the menus like a desktop menu bar. Writing to the prompt happens in the hooks module (post).
10
11export type BarProps = { buttons: QuickbarButton[]; style: ResolvedStyle; columns: number }
12
13type State = { view: View; hover: string | null; pressed: string | null; closeIn: number }
14
15const CLOSED: View = { open: -1, path: [] }
16const TICK_MS = 100
17/** Ticks after the pointer leaves before open menus close. */
18const CLOSE_TICKS = 5
19/** The running close timer of each instance (a module function runs again on every draw). */
20const timers = new WeakMap<object, () => void>()
21
22const Bar: ClientModule<BarProps, State> = (props, surface) => {
23 const { Box, Text } = surface.elements
24 const st = surface.state ?? { view: CLOSED, hover: null, pressed: null, closeIn: 0 }
25 const set = (patch: Partial<State>) => {
26 const now = surface.state ?? st
27 const next = { ...now, ...patch }
28 const same = sameView(now.view, next.view) && now.hover === next.hover && now.pressed === next.pressed && now.closeIn === next.closeIn
29 if (!same) surface.setState(next)
30 }
31
32 // A config reload can drop the open select.
33 const view = st.view.open < props.buttons.length ? st.view : CLOSED
34 const l = layout(props.buttons, props.style, view, props.columns)
35
36 if (surface.state === undefined) surface.setState(st)
37
38 // The close timer runs only while open menus wait to close after the pointer left: no idle ticking.
39 const startCloseTimer = () => {
40 timers.get(surface)?.()
41 timers.set(surface, surface.every(TICK_MS, () => {
42 const s = surface.state
43 if (!s || s.closeIn <= 0) {
44 timers.get(surface)?.()
45 timers.delete(surface)
46 return
47 }
48 set(s.closeIn === 1 ? { closeIn: 0, view: CLOSED, hover: null } : { closeIn: s.closeIn - 1 })
49 }))
50 }
51
52 surface.onPointer((e: ClientPointerEvent) => {
53 const s = surface.state ?? st
54 if (e.type === 'leave') {
55 set({ hover: null, pressed: null, closeIn: s.view.open >= 0 ? CLOSE_TICKS : 0 })
56 if (s.view.open >= 0) startCloseTimer()
57 return
58 }
59 const pill = hitTest(l, e.x, e.y)
60 if (e.type === 'move' || e.type === 'enter') {
61 set({ hover: pill?.id ?? null, closeIn: 0, view: pill && e.button === undefined ? hoverView(props.buttons, s.view, pill) : s.view })
62 return
63 }
64 if (e.type === 'down') {
65 set({ pressed: pill?.id ?? null, closeIn: 0 })
66 return
67 }
68 // up: a click is a down and an up on the same pill.
69 if (!pill || pill.id !== s.pressed) {
70 set({ pressed: null })
71 return
72 }
73 const act = activate(s.view, pill)
74 if (act.kind === 'deliver') surface.post({ type: 'deliver', button: act.button, path: act.path })
75 set({ pressed: null, view: act.view })
76 })
77
78 surface.onKey(e => {
79 const i = props.buttons.findIndex(b => b.hotkey === e.key)
80 const s = surface.state ?? st
81 const pill = l.lines.flatMap(line => line.pills).find(p => p.kind === 'button' && p.index === i)
82 if (!pill) return
83 const act = activate(s.view, pill)
84 if (act.kind === 'deliver') surface.post({ type: 'deliver', button: act.button, path: act.path })
85 set({ view: act.view })
86 })
87
88 // Children are passed spread: a surface module's `h` does not flatten arrays.
89 const pill = (p: (typeof l.lines)[number]['pills'][number]) => {
90 const isHover = st.hover === p.id
91 return (
92 <Box key={p.id} width={p.width} height={l.pillHeight} marginRight={props.style.gap}
93 paddingX={props.style.paddingX} paddingY={props.style.paddingY}
94 backgroundColor={isHover ? props.style.hoverColor : p.color}>
95 <Text bold={isHover || p.isActive} wrap="truncate-end">{p.label}</Text>
96 </Box>
97 )
98 }
99 const lines = l.lines.map(line => h(Box, { key: `line-${line.y}`, height: l.pillHeight }, ...line.pills.map(pill)))
100 return h(Box, { flexDirection: 'column' }, ...lines) as RenderElement
101}
102
103export default Bar
104hooks/compose.ts 55 lines1import type { QuickbarButton, QuickbarMode, QuickbarOption } from '../types'
2
3// What a select shows and what it writes. Pure, like config.ts.
4
5/** The option lists shown for a select, one per level: the button's own, then each chosen option's. */
6export function levels(button: QuickbarButton, path: readonly number[]): QuickbarOption[][] {
7 const out: QuickbarOption[][] = []
8 let current = button.options
9 for (let depth = 0; current && current.length; depth++) {
10 out.push(current)
11 const chosen = path[depth]
12 current = chosen === undefined ? undefined : current[chosen]?.options
13 }
14 return out
15}
16
17/** The chosen options along the path, stopping at the first index that does not exist. */
18export function chosen(button: QuickbarButton, path: readonly number[]): QuickbarOption[] {
19 const out: QuickbarOption[] = []
20 let current = button.options
21 for (const i of path) {
22 const opt = current?.[i]
23 if (!opt) break
24 out.push(opt)
25 current = opt.options
26 }
27 return out
28}
29
30/** The text a finished path writes: the button's prefix, each chosen `text`, the last choice's label if it has none. */
31export function composeText(button: QuickbarButton, picked: readonly QuickbarOption[]): string {
32 const parts = picked.map((o, i) => (o.text ?? (i === picked.length - 1 ? o.label : '')))
33 return [button.text ?? '', ...parts].filter(p => p.trim() !== '').join(button.separator ?? ' ')
34}
35
36export type Delivery = { text: string; mode: QuickbarMode; send: boolean }
37
38/** How a finished path is delivered; the last choice's `mode`/`send` win over the button's. */
39export function deliveryOf(button: QuickbarButton, picked: readonly QuickbarOption[]): Delivery {
40 const last = picked[picked.length - 1]
41 return {
42 text: picked.length ? composeText(button, picked) : (button.text ?? ''),
43 mode: last?.mode ?? button.mode ?? 'insert',
44 send: last?.send ?? button.send ?? false,
45 }
46}
47
48/** The prompt box after writing `text` by `mode` (used when sending, to send what the box would hold). */
49export function applyMode(box: { text: string; cursor: number }, text: string, mode: QuickbarMode): string {
50 if (mode === 'replace') return text
51 if (mode === 'append') return box.text ? `${box.text.replace(/\s*$/, '')} ${text}` : text
52 const at = Math.max(0, Math.min(box.cursor, box.text.length))
53 return box.text.slice(0, at) + text + box.text.slice(at)
54}
55hooks/config.ts 129 lines1import type { QuickbarButton, QuickbarConfig, QuickbarOption, QuickbarStyle } from '../types'
2
3// Reads and checks a quickbar.json. Pure: no `$`, so the tests drive it directly.
4
5const MODES = ['insert', 'append', 'replace']
6const SIZES = ['sm', 'md', 'lg']
7const MAX_BUTTONS = 20
8const MAX_DEPTH = 6
9
10export type Parsed = { config: QuickbarConfig | null; errors: string[] }
11
12const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
13
14function checkColor(v: unknown, at: string, errors: string[]) {
15 if (v !== undefined && (typeof v !== 'string' || !v.trim())) errors.push(`${at}: must be a color name or hex like "#2e7d4f"`)
16}
17
18function checkCommon(o: Record<string, unknown>, at: string, errors: string[]) {
19 if (typeof o.label !== 'string' || !o.label.trim()) errors.push(`${at}.label: required text`)
20 if (o.text !== undefined && typeof o.text !== 'string') errors.push(`${at}.text: must be text`)
21 checkColor(o.color, `${at}.color`, errors)
22 if (o.mode !== undefined && !MODES.includes(o.mode as string)) errors.push(`${at}.mode: one of ${MODES.join(', ')}`)
23 if (o.send !== undefined && typeof o.send !== 'boolean') errors.push(`${at}.send: true or false`)
24}
25
26function checkOptions(v: unknown, at: string, depth: number, errors: string[]) {
27 if (!Array.isArray(v) || v.length === 0) {
28 errors.push(`${at}: must be a non-empty list`)
29 return
30 }
31 if (depth > MAX_DEPTH) {
32 errors.push(`${at}: nested deeper than ${MAX_DEPTH} levels`)
33 return
34 }
35 v.forEach((opt: unknown, i) => {
36 const here = `${at}[${i}]`
37 if (!isObject(opt)) {
38 errors.push(`${here}: must be an object`)
39 return
40 }
41 checkCommon(opt, here, errors)
42 if (opt.options !== undefined) checkOptions(opt.options, `${here}.options`, depth + 1, errors)
43 })
44}
45
46function checkStyle(v: unknown, errors: string[]) {
47 if (v === undefined) return
48 if (!isObject(v)) {
49 errors.push('style: must be an object')
50 return
51 }
52 if (v.size !== undefined && !SIZES.includes(v.size as string)) errors.push(`style.size: one of ${SIZES.join(', ')}`)
53 for (const [k, max] of [['paddingX', 8], ['paddingY', 3], ['gap', 4]] as const) {
54 const n = v[k]
55 if (n !== undefined && (typeof n !== 'number' || !Number.isInteger(n) || n < 0 || n > max)) {
56 errors.push(`style.${k}: a whole number from 0 to ${max}`)
57 }
58 }
59 for (const k of ['color', 'activeColor', 'hoverColor']) checkColor(v[k], `style.${k}`, errors)
60}
61
62/** Checks a parsed JSON value; returns the config only when there is no error. */
63export function validate(raw: unknown): Parsed {
64 const errors: string[] = []
65 if (!isObject(raw)) return { config: null, errors: ['the file must hold a JSON object'] }
66 checkStyle(raw.style, errors)
67 if (raw.navigation !== undefined && !['click', 'hover', 'peek'].includes(raw.navigation as string)) errors.push('navigation: one of click, hover, peek')
68 const buttons = raw.buttons
69 if (!Array.isArray(buttons) || buttons.length === 0) {
70 errors.push('buttons: must be a non-empty list')
71 } else {
72 if (buttons.length > MAX_BUTTONS) errors.push(`buttons: at most ${MAX_BUTTONS}`)
73 const hotkeys = new Set<string>()
74 buttons.forEach((b: unknown, i) => {
75 const at = `buttons[${i}]`
76 if (!isObject(b)) {
77 errors.push(`${at}: must be an object`)
78 return
79 }
80 checkCommon(b, at, errors)
81 if (b.separator !== undefined && typeof b.separator !== 'string') errors.push(`${at}.separator: must be text`)
82 if (b.options === undefined && typeof b.text !== 'string') errors.push(`${at}: needs "text" (a button) or "options" (a select)`)
83 if (b.options !== undefined) checkOptions(b.options, `${at}.options`, 1, errors)
84 if (b.hotkey !== undefined) {
85 if (typeof b.hotkey !== 'string' || !/^[a-z0-9]$/.test(b.hotkey)) errors.push(`${at}.hotkey: one lowercase letter or digit`)
86 else if (hotkeys.has(b.hotkey)) errors.push(`${at}.hotkey: "${b.hotkey}" is already used`)
87 else hotkeys.add(b.hotkey)
88 }
89 })
90 }
91 return errors.length ? { config: null, errors } : { config: raw as unknown as QuickbarConfig, errors }
92}
93
94/** Parses the file's text, then validates it. */
95export function parse(text: string): Parsed {
96 let raw: unknown
97 try {
98 raw = JSON.parse(text)
99 } catch (err) {
100 return { config: null, errors: [`not valid JSON: ${err instanceof Error ? err.message : String(err)}`] }
101 }
102 return validate(raw)
103}
104
105export type ResolvedStyle = Required<Pick<QuickbarStyle, 'paddingX' | 'paddingY' | 'gap' | 'color' | 'activeColor' | 'hoverColor'>>
106
107// Buttons are one row tall in click and peek navigation (see register.tsx); paddingY only shapes the bar that
108// hover navigation draws itself.
109const SIZE: Record<string, { paddingX: number; paddingY: number }> = {
110 sm: { paddingX: 1, paddingY: 0 },
111 md: { paddingX: 2, paddingY: 0 },
112 lg: { paddingX: 3, paddingY: 1 },
113}
114
115export function resolveStyle(style: QuickbarStyle | undefined): ResolvedStyle {
116 const s = style ?? {}
117 const size = SIZE[s.size ?? 'lg'] ?? SIZE.lg!
118 return {
119 paddingX: s.paddingX ?? size.paddingX,
120 paddingY: s.paddingY ?? size.paddingY,
121 gap: s.gap ?? 1,
122 color: s.color ?? '#3b4252',
123 activeColor: s.activeColor ?? '#2e7d4f',
124 hoverColor: s.hoverColor ?? '#5e6a82',
125 }
126}
127
128export type { QuickbarButton, QuickbarOption }
129hooks/layout.ts 136 lines1import type { QuickbarButton } from '../types'
2import { levels } from './compose'
3import type { ResolvedStyle } from './config'
4
5// Where every pill sits, in cells, so the bar can draw them and find the one under the pointer. Pure.
6
7export type PillKind = 'button' | 'option' | 'close'
8
9export type Pill = {
10 /** Stable address: `b3` a bar button, `o1.2` option 2 of level 1, `x` the close pill. */
11 id: string
12 kind: PillKind
13 /** Bar index (buttons) or option index within its level. */
14 index: number
15 /** -1 for the bar, the level for options. */
16 depth: number
17 label: string
18 /** Background before hover: the button or option color, or the active color. */
19 color: string
20 isActive: boolean
21 hasChildren: boolean
22 x: number
23 width: number
24}
25
26export type Line = { y: number; pills: Pill[] }
27export type Layout = { lines: Line[]; pillHeight: number; width: number; height: number }
28
29export type View = { open: number; path: readonly number[] }
30
31const cells = (s: string) => [...s].length
32
33function wrap(pills: Omit<Pill, 'x'>[], columns: number, gap: number): Pill[][] {
34 const out: Pill[][] = []
35 let line: Pill[] = []
36 let x = 0
37 for (const p of pills) {
38 if (line.length && x + p.width > columns) {
39 out.push(line)
40 line = []
41 x = 0
42 }
43 line.push({ ...p, x })
44 x += p.width + gap
45 }
46 if (line.length) out.push(line)
47 return out
48}
49
50/** Lines top to bottom: the deepest open level first, the bar last. */
51export function layout(buttons: readonly QuickbarButton[], st: ResolvedStyle, view: View, columns: number): Layout {
52 const pillHeight = 1 + 2 * st.paddingY
53 const width = (label: string) => cells(label) + 2 * st.paddingX
54 const cols = Math.max(columns, 1)
55
56 const bar = buttons.map((b, i): Omit<Pill, 'x'> => {
57 const isSelect = Boolean(b.options)
58 const isOpen = view.open === i
59 const label = isSelect ? `${b.label} ${isOpen ? '▴' : '▾'}` : b.send ? `${b.label} ⏎` : b.label
60 return {
61 id: `b${i}`, kind: 'button', index: i, depth: -1, label, width: width(label),
62 color: isOpen ? st.activeColor : (b.color ?? st.color), isActive: isOpen, hasChildren: isSelect,
63 }
64 })
65
66 const groups: Pill[][][] = []
67 const open = view.open >= 0 ? buttons[view.open] : undefined
68 if (open?.options) {
69 levels(open, view.path).forEach((opts, depth) => {
70 const pills = opts.map((o, j): Omit<Pill, 'x'> => {
71 // ⏎ marks a final choice that sends the prompt right away.
72 const label = o.options ? `${o.label} ›` : (o.send ?? open.send) ? `${o.label} ⏎` : o.label
73 const isActive = view.path[depth] === j
74 return {
75 id: `o${depth}.${j}`, kind: 'option', index: j, depth, label, width: width(label),
76 color: isActive ? st.activeColor : (o.color ?? st.color), isActive, hasChildren: Boolean(o.options),
77 }
78 })
79 if (depth === 0) {
80 pills.push({ id: 'x', kind: 'close', index: 0, depth: 0, label: '✕', width: width('✕'), color: st.color, isActive: false, hasChildren: false })
81 }
82 groups.unshift(wrap(pills, cols, st.gap))
83 })
84 }
85 groups.push(wrap(bar, cols, st.gap))
86
87 const lines: Line[] = []
88 for (const group of groups) for (const pills of group) lines.push({ y: lines.length * pillHeight, pills })
89 const used = Math.max(0, ...lines.map(l => {
90 const last = l.pills[l.pills.length - 1]
91 return last ? last.x + last.width : 0
92 }))
93 return { lines, pillHeight, width: used, height: lines.length * pillHeight }
94}
95
96/** The pill under a region-relative cell, or undefined. */
97export function hitTest(l: Layout, x: number, y: number): Pill | undefined {
98 if (y < 0 || x < 0) return undefined
99 const line = l.lines[Math.floor(y / l.pillHeight)]
100 return line?.pills.find(p => x >= p.x && x < p.x + p.width)
101}
102
103/** What hovering a pill does to the open menus, like a desktop menu bar. */
104export function hoverView(buttons: readonly QuickbarButton[], view: View, pill: Pill): View {
105 if (pill.kind === 'button') {
106 if (!pill.hasChildren) return view.open === -1 ? view : { open: -1, path: [] }
107 return view.open === pill.index ? view : { open: pill.index, path: [] }
108 }
109 if (pill.kind === 'option') {
110 const next = pill.hasChildren ? [...view.path.slice(0, pill.depth), pill.index] : view.path.slice(0, pill.depth)
111 return sameView(view, { open: view.open, path: next }) ? view : { open: view.open, path: next }
112 }
113 return view
114}
115
116export type Activation =
117 | { kind: 'view'; view: View }
118 | { kind: 'deliver'; button: number; path: number[]; view: View }
119
120/** What clicking a pill does: write/send a button or a final choice, or open and close levels. */
121export function activate(view: View, pill: Pill): Activation {
122 const closed: View = { open: -1, path: [] }
123 if (pill.kind === 'close') return { kind: 'view', view: closed }
124 if (pill.kind === 'button') {
125 if (!pill.hasChildren) return { kind: 'deliver', button: pill.index, path: [], view: closed }
126 return { kind: 'view', view: view.open === pill.index ? closed : { open: pill.index, path: [] } }
127 }
128 const before = view.path.slice(0, pill.depth)
129 if (!pill.hasChildren) return { kind: 'deliver', button: view.open, path: [...before, pill.index], view: closed }
130 // Clicking the open option again folds what it opened.
131 const isOpen = view.path[pill.depth] === pill.index
132 return { kind: 'view', view: { open: view.open, path: isOpen ? before : [...before, pill.index] } }
133}
134
135export const sameView = (a: View, b: View) => a.open === b.open && a.path.length === b.path.length && a.path.every((v, i) => v === b.path[i])
136types/index.d.ts 86 lines1// Quickbar's config shape and its session state. Self-contained: no imports.
2
3/** How text reaches the prompt box. */
4export type QuickbarMode = 'insert' | 'append' | 'replace'
5
6/** One choice inside a select. A choice with `options` opens another level instead of writing. */
7export type QuickbarOption = {
8 label: string
9 /** Text this choice contributes. A final choice without `text` contributes its `label`. */
10 text?: string
11 color?: string
12 /** Overrides the button's `mode` when this choice ends the path. */
13 mode?: QuickbarMode
14 /** Overrides the button's `send` when this choice ends the path. */
15 send?: boolean
16 options?: QuickbarOption[]
17}
18
19/** A bar button: a plain button when it has `text`, a select when it has `options`. */
20export type QuickbarButton = {
21 label: string
22 /** Plain button: the text it writes. Select: an optional prefix before the chosen path. */
23 text?: string
24 color?: string
25 /** One lowercase letter or digit that presses the button while the bar has focus. */
26 hotkey?: string
27 /** Where the text goes. Default `insert` (at the cursor). */
28 mode?: QuickbarMode
29 /** Submit the prompt right away instead of only writing it. Default `false`. */
30 send?: boolean
31 /** Joins the prefix and the chosen texts of a select. Default a single space. */
32 separator?: string
33 options?: QuickbarOption[]
34}
35
36export type QuickbarStyle = {
37 /** How wide a button is. Buttons are one row tall, except with hover navigation, where `lg` is three rows. Default `lg`. */
38 size?: 'sm' | 'md' | 'lg'
39 paddingX?: number
40 paddingY?: number
41 /** Columns between buttons. Default 1. */
42 gap?: number
43 /** Default button background. */
44 color?: string
45 /** Background of the open select and the chosen options. */
46 activeColor?: string
47 /** Background under the pointer. */
48 hoverColor?: string
49}
50
51/**
52 * `click` (default): click to open and pick; text selection in the terminal keeps working.
53 * `hover`: hovering opens selects and levels like a desktop menu. It makes Claude Code track the pointer,
54 * which takes over text selection in the terminal (selection may start a little off the pointer).
55 * `peek` (experimental): hovering a select reveals its first level with a hover style, no pointer tracking;
56 * deeper levels open on click.
57 */
58export type QuickbarNavigation = 'click' | 'hover' | 'peek'
59
60export type QuickbarConfig = {
61 $schema?: string
62 navigation?: QuickbarNavigation
63 style?: QuickbarStyle
64 buttons: QuickbarButton[]
65}
66
67/** Where the active config came from. */
68export type QuickbarSource = { kind: 'project' | 'user' | 'default'; path: string }
69
70declare module 'claude-code' {
71 interface PluginState {
72 quickbar: {
73 config: QuickbarConfig | null
74 source: QuickbarSource | null
75 errors: string[]
76 /** Index of the open select in `buttons`, or -1. */
77 open: number
78 /** Indexes of the chosen options, one per level, of the open select. */
79 path: number[]
80 hidden: boolean
81 /** This session shows the bundled example instead of the config files (/quickbar demo). */
82 demo: boolean
83 }
84 }
85}
86