SLOPSHOPPER

quickbar

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

newbandcommandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · quickbar
› fix the failing auth test and add an audit log call ⏺ 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 › /quickbar ⎿ quickbar: big buttons above the prompt, from quickbar.json ⎿ quickbar: /quickbar init copy the example config to ~/.claude/quickbar.json ⎿ quickbar: /quickbar init project copy it to this project (.claude/quickbar.json) ⎿ quickbar: /quickbar where show which config is active and its errors ⎿ quickbar: /quickbar reload read the config again (it also reloads on save) ⎿ quickbar: /quickbar hide | show hide or show the bar ⟨Claude Code's own drawing⟩ quickbar: 1 config error, press for details ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ quickbar: 1 config error, press for details
README

Claude Quickbar

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.

CI License: MIT Claude Code

<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">

Why

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.

  • Buttons write a prompt, or send it right away.
  • Selects open their options above the bar. Options can open more options, so a few clicks compose a precise prompt. Opt in to "navigation": "hover" to walk the levels with the mouse, like a desktop menu.
  • One file describes everything: labels, colors, text, where the text goes, and whether it is sent.
  • Live reload: save the file and the bar updates. A broken file never breaks your session; the bar tells you what is wrong.

Install

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.

Updates

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.

Configure

{
  "$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.

Navigation

ValueBehavior
"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": [ ... ] }

Where the config lives

The first file found wins:

OrderFileUse it for
1<project>/.claude/quickbar.jsonButtons for one repository, shareable with your team
2~/.claude/quickbar.jsonYour personal buttons, everywhere
3bundled exampleWhat you see right after installing

Buttons and selects

FieldTypeDefaultDescription
labelstringrequiredWhat the button shows.
textstringA button: the text it writes. A select: a prefix before the chosen texts.
optionsoption[]Makes the button a select.
colorcolorstyle.colorBackground color.
hotkeya–z, 0–9Presses the button while the bar has focus.
modeinsert \append \replaceinsertInsert at the cursor, append to the end, or replace the prompt.
sendbooleanfalsetrue submits the prompt; false only writes it so you can edit first.
separatorstring" "Joins the prefix and the chosen texts of a select.

Options

FieldTypeDescription
labelstringWhat the option shows. Required.
textstringText this choice adds. A final choice without text adds its label.
optionsoption[]Opens another level of choices (up to 6 levels).
colorcolorBackground color.
mode, sendOverride 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.

Style

FieldDefaultDescription
sizelgHow wide a button is: sm, md or lg. Buttons are one row tall (see below).
paddingXfrom sizeColumns of color left and right of the label (0–8).
paddingYfrom sizeRows above and below the label, only with "navigation": "hover" (0–3).
gap1Columns between buttons.
color#3b4252Default background.
activeColor#2e7d4fThe open select and the chosen options.
hoverColor#5e6a82Under the pointer, with "navigation": "hover".

Colors accept hex (#2e7d4f) or terminal color names (red, blueBright).

Commands

CommandWhat it does
/quickbar initCopy the example to ~/.claude/quickbar.json (never overwrites).
/quickbar init projectCopy it to .claude/quickbar.json in the current project.
/quickbar whereShow which config is active and list its errors.
/quickbar reloadRead the config again (saving the file also does it).
/quickbar hide, /quickbar showHide or show the bar for this session.
/quickbar demo, /quickbar demo offShow the bundled example in this session only, for recording a demo.

Using the bar

  • Click anywhere on a button. Clicking an open select or an open option again folds it; ✕ closes the select.
  • ⏎ after a label marks a button or option that sends the prompt right away.
  • Under the pointer, Claude Code highlights a button by inverting its colors. It does that cleanly only for one-row buttons, which is why buttons are one row tall in click and peek navigation.
  • Keyboard: press 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.)
  • Hover (opt-in): pointing at a select opens it, pointing at an option with › 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.

Validate a config in CI

node scripts/check-config.ts path/to/quickbar.json

Runs the same checks as the plugin (Node 22.18 or later).

Limitations

  • Mods are an early-access Claude Code feature; the API may change between releases.
  • The band above the prompt has a maximum height. Many open levels at size lg scroll inside it.
  • Button label colors follow the terminal theme; only backgrounds are configurable.
  • Buttons are one row tall in 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.

Development

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.

License

MIT © Hugo Amadio

Source 6 files
hooks/register.tsx 299 lines
1import { 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}
299
hooks/bar.tsx 104 lines
1import 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
104
hooks/compose.ts 55 lines
1import 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}
55
hooks/config.ts 129 lines
1import 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 }
129
hooks/layout.ts 136 lines
1import 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])
136
types/index.d.ts 86 lines
1// 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