SLOPSHOPPER

quick-actions

A band above the prompt with buttons that run saved prompts, skills and shell commands

newpanebandcommandtoastprocess
v0.1.2MITupdated 2026-10-08zirkelc/claude-quick-actions
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · quick-actions
│ ┃ Quick actions ✕ › fix the failing auth test and add an audit log call │ ┃ QUICK ACTIONS ✕ Close │ ┃ ⏺ Read(src/auth.ts) │ ┃ Actions ★ on the band · ▶ run now ⎿ Read 6 lines │ ┃ + Add action ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ ──────────── ⎿ 3 pass, 1 fail │ ┃ │ ┃ Band ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Nothing on the band yet: press ☆ on an │ ┃ action. ✻ Worked for 42s · done 4:20 PM │ │ › /quick-actions │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Quick actions
QUICK ACTIONS ✕ Close Actions ★ on the band · ▶ run now + Add action ────────────────────────────────────────────────────── Band Nothing on the band yet: press ☆ on an action.
README

<h1 align="center">Quick Actions </h1>

<img src="assets/quick-actions.png" alt="A Claude Code session with the quick actions band above the prompt: git pull, review diff, /commit and /release as colored buttons" width="800" />

Why

git pull, "review the diff", /commit: things you type ten times a day become one click. Save an action once, pin it to the band, and press it. Actions you use less often stay in a list, one click away from the band.

quick-actions is a Claude Code mod: a plugin of function hooks that draws its own UI inside Claude Code.

Features

  • Four types of action: call a skill or slash command, send a prompt, fill the prompt box to edit before sending, or run a shell command.
  • A band you arrange: pin the actions you want, set their order, and split them over several rows.
  • Colors and hotkeys: give an action a color, and a digit that presses it from an empty prompt box.
  • More actions than the band shows: unpinned actions stay in the list, where ▶ runs them.
  • Plays well with other mods: the band draws above the bands of other mods, and draws nothing when no action is pinned.
  • Same actions everywhere: actions are kept in the mod's store, so every project and session shows them.

Requirements

Claude Code 2.1.287 or later, where mods load by default.

Install

claude plugin marketplace add zirkelc/claude-quick-actions
claude plugin install quick-actions@claude-quick-actions --scope user

To try it for one session without installing:

git clone https://github.com/zirkelc/claude-quick-actions.git
claude --plugin-dir ./claude-quick-actions

To uninstall: claude plugin uninstall quick-actions@claude-quick-actions.

Usage

Type /quick-actions to open the pane, and again to close it. The pane has two lists.

Actions lists every saved action:

ControlWhat it does
labelOpens the action's form
☆ / ★Pins the action to the band, or takes it off
▶Runs the action now
✕Deletes the action (press twice)
+ Add actionOpens the form for a new action. A new action is not on the band until you press its ☆

Band lists what the band shows, in order. Each entry has ↑ ↓ to move it and - to take it off the band. Once an action is on the band, + Add new line adds a ↵ new line entry: the entries after it go on the next row of the band.

The form sets the action's type (Call Skill, Fill Prompt, Send Prompt or Run Shell), its color, its hotkey, its label and its text. The preview shows the button as the band draws it. ← Back and Esc go back one step; ✕ Close closes the pane.

TypeWhat a press does
Call SkillRuns /name args at once, as if typed
Fill PromptPuts the text in the prompt box, to edit before sending
Send PromptSends the text to Claude at once
Run ShellRuns the command with sh -c in the session's directory, without Claude; the output goes to the transcript

Skills and prompts wait until Claude is idle. Shell commands run at once.

What it can access

The mod does only what you set up in the pane. It builds no command, prompt or slash command of its own, and it adds nothing to the text you save.

What it runs, and when. An action runs only when you press its button or its hotkey (a digit, typed while the prompt box is empty), or press ▶ in the pane:

TypeWhat the mod doesEngine call
Call SkillRuns the slash command you saved, for example /commit, with the arguments you saved$.command.run
Fill PromptPuts the text you saved in the prompt box; nothing is sent until you send it$.prompt.fill
Send PromptSends the text you saved to Claude, unchanged, as your own prompt$.prompt.submit
Run ShellRuns the command you saved as sh -c "<your command>" in the session's directory, with a 2 minute time limit. The output goes to the transcript and nowhere else$.process.run

The mod runs no other program, no other slash command and no other prompt. The command text in the shell call comes from your saved action, which is why the directory cannot read it as fixed text.

What it reads. Only its own saved data: your actions and the band order, kept with $.store under the mod's own keys. The text of an action is the only stored data that goes into a command or a prompt, and only into the one you pressed. It reads no files, no conversation, no environment variables and no credentials.

What it sends. The mod itself makes no network calls. A Send Prompt action sends its text to Claude, as if you typed it. A Run Shell command can do anything the command you wrote does, so save only commands you trust.

Other calls. It draws the band, the pane, toasts and transcript lines ($.ui), keeps what the pane shows in session state ($.state), adds the /quick-actions command ($.command.register), and reads the saved actions again every 15 seconds so that actions saved in another session show up ($.clock).

Limits

These come from the mod API:

  • A button label is one plain string, so a colored button keeps the terminal's text color. Light colors (yellow, white) read best with a light terminal theme.
  • A digit hotkey only works for an action on the band, and the band shows it before the label (1: commit).
  • Text inputs are single-line. In the text field, Enter adds a line break, and Save saves.
  • A filled ! text stays a normal prompt; the prompt box cannot be put into shell mode. Use Run Shell instead.

Development

claude plugin validate .
claude plugin test .

To load your checkout in every session, add it to the env block of ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "~/Developer/claude-quick-actions"
  }
}

Claude Code writes the API types to .claude-plugin/types/ when it loads the mod, and tsconfig.json extends them, so tsc -p . type-checks the mod after the first load.

hooks/register.tsx holds the hooks, the state and every call on the engine, because the mod API follows the engine interface only into functions of the same file. The rest is pure: hooks/actions.ts (the action model and the band order) and hooks/views/ (the band, the list and the form). Each file under tests/ covers the file of the same name under hooks/.

License

MIT

Source 14 files
hooks/register.tsx 327 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { CustomAction, Draft } from '../types'
5import {
6  type Action,
7  arrangeOrder,
8  checkDraft,
9  moveEntry,
10  newlineEntry,
11  newDraft,
12  normalizeText,
13  rowsOf,
14  toAction,
15  togglePin,
16} from './actions'
17import { POLL_MS, SHELL_TIMEOUT_MS } from './limits'
18import { COMMAND, LEGACY_PANES, PANE, PANE_IN_BAND_TEXT, PANE_OPTIONS, STEP_BACK_REASON, STORE_ACTIONS, STORE_ORDER } from './names'
19import { bandView } from './views/band-view'
20import type { BandHandlers, PaneHandlers } from './views/kit'
21import { type PaneModel, paneView } from './views/pane/pane-view'
22
23const customState = atom({ plugin: 'quick-actions', key: 'custom' } as const, [])
24const orderState = atom({ plugin: 'quick-actions', key: 'order' } as const, [])
25const draftState = atom({ plugin: 'quick-actions', key: 'draft' } as const, null)
26const pendingDeleteState = atom({ plugin: 'quick-actions', key: 'pendingDelete' } as const, null)
27const paneOpenState = atom({ plugin: 'quick-actions', key: 'isPaneOpen' } as const, false)
28const paneInBandState = atom({ plugin: 'quick-actions', key: 'isPaneInBand' } as const, false)
29
30/** Reads the saved actions and their order from the store, so changes from other sessions show up. */
31export async function loadStore($: EngineInterface) {
32  const stored = await $.store.get(STORE_ACTIONS)
33  const custom = Array.isArray(stored) ? (stored as Array<CustomAction>) : []
34  await update($, customState, () => custom)
35  const order = arrangeOrder(await $.store.get(STORE_ORDER), custom.map(action => action.id))
36  await update($, orderState, () => order)
37}
38
39/** Saves the actions; a deleted one leaves the band too. */
40async function writeCustom($: EngineInterface, custom: Array<CustomAction>) {
41  await $.store.set(STORE_ACTIONS, custom)
42  await update($, customState, () => custom)
43  await writeOrder($, arrangeOrder(await read($, orderState), custom.map(action => action.id)))
44}
45
46/** Saves the band: its actions and new lines, in order. */
47async function writeOrder($: EngineInterface, order: Array<string>) {
48  await $.store.set(STORE_ORDER, order)
49  await update($, orderState, () => order)
50}
51
52/**
53 * Opens the pane. Called before any other await of a press, so the engine
54 * counts the open as the person's. A pane without room to draw would wait
55 * undrawn, so it is closed again and the band shows its views instead.
56 */
57async function openPane($: EngineInterface): Promise<boolean> {
58  const opened = await $.ui.open(PANE_OPTIONS)
59  if (!opened.isPlaced) {
60    await $.ui.close({ id: PANE }).catch(() => undefined)
61  }
62  await update($, paneOpenState, () => opened.isPlaced)
63  await update($, paneInBandState, () => !opened.isPlaced)
64  return opened.isPlaced
65}
66
67/**
68 * Closes the pane, or its views in the band. A close the mod makes itself
69 * does not always reach its own close hook, so the flags are cleared here.
70 */
71async function closePane($: EngineInterface) {
72  await $.ui.close({ id: PANE })
73  await update($, paneOpenState, () => false)
74  await update($, paneInBandState, () => false)
75}
76
77/** Runs a press's work, and says in the transcript when it fails; a press has no caller to report to. */
78function report($: EngineInterface, what: string, work: Promise<unknown>) {
79  work.catch(error => $.ui.log(`Quick actions: ${what} failed: ${error instanceof Error ? error.message : String(error)}`))
80}
81
82/** Opens the pane on the list of actions. */
83async function openList($: EngineInterface) {
84  const opened = openPane($)
85  await update($, draftState, () => null)
86  return opened
87}
88
89/** Opens the list, or closes the pane when it is open in any view, in the pane or in the band. */
90async function toggleList($: EngineInterface) {
91  const isOpen = (await read($, paneOpenState)) || (await read($, paneInBandState))
92  return isOpen ? closePane($).then(() => true) : openList($)
93}
94
95/** Opens the form for a new action in place of the list. */
96async function startAdd($: EngineInterface) {
97  await update($, draftState, () => newDraft())
98}
99
100async function startEdit($: EngineInterface, action: CustomAction) {
101  /** Actions saved before colors existed have no color. A command shows with its slash, as typed. */
102  await update($, draftState, () => ({
103    ...action,
104    text: action.kind === 'command' ? `/${action.text}` : action.text,
105    color: action.color ?? '',
106    error: null,
107    picking: null,
108  }))
109}
110
111/** Leaves the form, back to the list. */
112async function leaveForm($: EngineInterface) {
113  await update($, draftState, () => null)
114}
115
116/** Applies a change to the form; any change clears the last save error. */
117async function editDraft($: EngineInterface, change: Partial<Draft>) {
118  await update($, draftState, draft => (draft === null ? null : { ...draft, error: null, ...change }))
119}
120
121async function saveDraft($: EngineInterface) {
122  const draft = await read($, draftState)
123  if (draft === null) {
124    return
125  }
126  const custom = await read($, customState)
127  const usedHotkeys = custom.filter(action => action.id !== draft.id && action.hotkey !== '').map(action => action.hotkey)
128
129  /** Shown in the form: the pane holds toasts back while it is open, so a toast would stay unseen. */
130  const problem = checkDraft(draft, usedHotkeys)
131  if (problem !== null) {
132    await update($, draftState, current => (current === null ? null : { ...current, error: problem }))
133    return
134  }
135
136  const saved: CustomAction = {
137    id: draft.id ?? `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
138    label: draft.label.trim(),
139    hotkey: draft.hotkey,
140    color: draft.color,
141    kind: draft.kind,
142    text: normalizeText(draft.kind, draft.text),
143  }
144  await writeCustom($, draft.id === null ? [...custom, saved] : custom.map(action => (action.id === draft.id ? saved : action)))
145  await leaveForm($)
146  $.ui.toast(`Saved "${saved.label}"`)
147}
148
149/** Deletes on the second press, so one stray click cannot remove an action. */
150async function pressDelete($: EngineInterface, action: CustomAction) {
151  if ((await read($, pendingDeleteState)) !== action.id) {
152    await update($, pendingDeleteState, () => action.id)
153    return
154  }
155  await update($, pendingDeleteState, () => null)
156  await writeCustom($, (await read($, customState)).filter(one => one.id !== action.id))
157  $.ui.toast(`Deleted "${action.label}"`)
158}
159
160async function runShell($: EngineInterface, argv: Array<string>) {
161  const line = argv[0] === 'sh' && argv[1] === '-c' ? (argv[2] ?? '') : argv.join(' ')
162  $.ui.toast(`${line} …`)
163  try {
164    const { exitCode, stdout, stderr } = await $.process.run(argv, { timeoutMs: SHELL_TIMEOUT_MS })
165    const output = `${stdout}${stderr}`.trim()
166    $.ui.log(`$ ${line}${output === '' ? '' : `\n${output}`}${exitCode === 0 ? '' : `\nexit ${exitCode}`}`)
167    $.ui.toast(exitCode === 0 ? `${line}: done` : `${line}: failed (exit ${exitCode})`)
168  } catch (error) {
169    $.ui.log(`$ ${line}\n${error instanceof Error ? error.message : String(error)}`)
170    $.ui.toast(`${line}: did not run`)
171  }
172}
173
174async function run($: EngineInterface, action: Action) {
175  if (action.kind === 'command') {
176    await $.command.run({ command: action.command, args: action.args })
177  } else if (action.kind === 'submit') {
178    await $.prompt.submit({ text: action.text, asUser: true })
179  } else if (action.kind === 'shell') {
180    await runShell($, action.argv)
181  } else {
182    await $.prompt.fill({ text: action.text })
183  }
184}
185
186/** The pane's handlers, bound to one engine interface. */
187function paneHandlersOf($: EngineInterface, order: Array<string>): PaneHandlers {
188  return {
189    close: () => report($, 'closing the pane', closePane($)),
190    startAdd: () => report($, 'opening the form', startAdd($)),
191    startEdit: action => report($, 'opening the form', startEdit($, action)),
192    pressDelete: action => report($, 'deleting the action', pressDelete($, action)),
193    moveEntry: (entry, step) => report($, 'moving the entry', writeOrder($, moveEntry(order, entry, step))),
194    removeFromBand: entry => report($, 'removing from the band', writeOrder($, order.filter(one => one !== entry))),
195    togglePin: id => report($, 'changing the band', writeOrder($, togglePin(order, id))),
196    run: action => report($, `running "${action.label}"`, run($, toAction(action))),
197    addNewline: () => report($, 'adding a new line', writeOrder($, [...order, newlineEntry()])),
198    editDraft: change => editDraft($, change),
199    saveDraft: () => report($, 'saving the action', saveDraft($)),
200    leaveForm: () => report($, 'leaving the form', leaveForm($)),
201  }
202}
203
204async function paneModelOf($: EngineInterface, columns: number, surface: string): Promise<PaneModel> {
205  return {
206    draft: await read($, draftState),
207    custom: await read($, customState),
208    order: await read($, orderState),
209    pendingDelete: await read($, pendingDeleteState),
210    columns,
211    surface,
212  }
213}
214
215/** The poll that reads the saved actions again; a new session start replaces it. */
216let poll: Timer | undefined
217
218/** Reads the saved actions again, for changes from other sessions. Started from a timer without a caller, so a failure goes to the debug log. */
219async function reload($: EngineInterface) {
220  try {
221    await loadStore($)
222  } catch (error) {
223    $.ui.log(`reading the saved actions failed: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
224  }
225}
226
227export const register: Register = on => {
228  on('session.start', async ($, e, next) => {
229    const started = await next(e)
230    /**
231     * Earlier versions opened separate panes under these ids; nothing draws them
232     * any more, so they would stay empty. A failed close must not stop the start.
233     */
234    await Promise.all(LEGACY_PANES.map(id => $.ui.close({ id }).catch(() => undefined)))
235    /** A hot reload can find the pane still open. A failed read must not stop the start either. */
236    const panes = await $.ui.panes().catch(() => [])
237    await update($, paneOpenState, () => panes.some(pane => pane.id === PANE && pane.isPlaced))
238    await update($, paneInBandState, () => false)
239    try {
240      await $.command.register(COMMAND)
241    } catch (error) {
242      $.ui.log(`Quick actions: /${COMMAND.name} is not available: ${error instanceof Error ? error.message : String(error)}`)
243    }
244    await reload($)
245    /** A new start (a clear, a resume) replaces the poll of the one before. */
246    poll?.cancel()
247    poll = $.clock.every(POLL_MS, () => void reload($))
248    return started
249  })
250
251  on('command.run', { command: COMMAND.name }, async $ => {
252    const isShown = await toggleList($)
253    /** No text when the pane opened or closed, so the command leaves no line in the transcript. */
254    return isShown || !(await read($, paneInBandState)) ? {} : { text: PANE_IN_BAND_TEXT }
255  })
256
257  /**
258   * Esc steps back as the config menu does: from an option list to the form,
259   * from a form opened in the list back to the list; only then it closes.
260   */
261  on('ui.close', { id: PANE }, async ($, e, next) => {
262    /** Lets the close through and clears the open flag, for closes the mod did not make itself (Esc, the engine's mark). */
263    const close = async () => {
264      const closed = await next(e)
265      await update($, paneOpenState, () => false)
266      return closed
267    }
268    const draft = await read($, draftState)
269    if (e.origin.kind !== 'person' || draft === null) {
270      return close()
271    }
272    if (draft.picking !== null) {
273      await editDraft($, { picking: null })
274      return { deny: STEP_BACK_REASON }
275    }
276    await leaveForm($)
277    return { deny: STEP_BACK_REASON }
278  })
279
280  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
281    /** The engine raises the band on the terminal and the desktop only; the mobile app has no Input for the form. */
282    if (e.props.hasSurvey || e.surface === 'mobile') {
283      return next(e)
284    }
285    /**
286     * The band of the plugins beneath, drawn under this one. The engine's own
287     * drawing holds only the surveys, so without a survey it adds nothing.
288     */
289    const below = await next(e)
290    const ui = $.ui.resolve(e)
291
292    let band
293    if (await read($, paneInBandState)) {
294      band = paneView(ui, await paneModelOf($, e.props.bodyColumns, e.surface), paneHandlersOf($, await read($, orderState)))
295    } else {
296      const byId = new Map((await read($, customState)).map(action => [action.id, toAction(action)]))
297      const rows = rowsOf(await read($, orderState))
298        .map(row => row.flatMap(id => byId.get(id) ?? []))
299        .filter(row => row.length > 0)
300      /** With nothing on the band, the band is not drawn; the plugins beneath still draw theirs. */
301      if (rows.length === 0) {
302        return below
303      }
304      band = bandView(ui, { rows, surface: e.surface }, { run: action => report($, `running "${action.label}"`, run($, action)) })
305    }
306
307    if (below === null || below === undefined || below.type === 'engine') {
308      return band
309    }
310    const { Box } = ui
311    return (
312      <Box flexDirection="column">
313        {band}
314        {below}
315      </Box>
316    )
317  })
318
319  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
320    if (e.surface === 'mobile') {
321      const { Text } = $.ui.resolve(e)
322      return <Text dimColor>Manage quick actions in the terminal or the desktop app.</Text>
323    }
324    return paneView($.ui.resolve(e), await paneModelOf($, e.props.bodyColumns, e.surface), paneHandlersOf($, await read($, orderState)))
325  })
326}
327
hooks/actions.ts 223 lines
1import type { CustomAction, CustomKind, Draft } from '../types'
2
3type BaseAction = {
4  key: string
5  label: string
6  /** One digit that presses the button from an empty prompt box, or while the band has the focus. */
7  hotkey?: string
8  /** Color name of the button. */
9  color?: string
10}
11
12/**
13 * One button of the band.
14 *
15 * - `command` runs a slash command or skill at once, as if typed (`/commit`).
16 * - `submit` sends a prompt to Claude at once.
17 * - `fill` puts text in the prompt box to check before sending. The box stays
18 *   in prompt mode, so a leading `!` does not make it a shell command.
19 * - `shell` runs a command on the host in the session's directory, without
20 *   Claude, and shows its output in the transcript (Claude does not read it).
21 *
22 * Commands and prompts queue until the session is idle; shell commands run at once.
23 */
24export type Action =
25  | (BaseAction & { kind: 'command'; command: string; args: string })
26  | (BaseAction & { kind: 'submit' | 'fill'; text: string })
27  | (BaseAction & { kind: 'shell'; argv: Array<string> })
28
29/** Turns a saved action into a button. */
30export function toAction(custom: CustomAction): Action {
31  const base = {
32    key: `custom-${custom.id}`,
33    label: custom.label,
34    /** Only digits work from the prompt box; a letter saved by an older version is dropped. */
35    hotkey: /^[0-9]$/.test(custom.hotkey) ? custom.hotkey : undefined,
36    /** Actions saved before colors existed have no color. */
37    color: custom.color || undefined,
38  }
39  if (custom.kind === 'command') {
40    const [command = '', ...args] = custom.text.split(' ')
41    return { ...base, kind: 'command', command, args: args.join(' ') }
42  }
43  if (custom.kind === 'shell') {
44    return { ...base, kind: 'shell', argv: ['sh', '-c', custom.text] }
45  }
46  return { ...base, kind: custom.kind, text: custom.text }
47}
48
49/** An empty form for a new action: a prompt that is sent at once. */
50export function newDraft(): Draft {
51  return { id: null, error: null, picking: null, label: '', hotkey: '', color: '', kind: 'submit', text: '' }
52}
53
54/**
55 * The text as it is saved: trimmed, and without the prefix a person may type
56 * out of habit, `/` before a command and `!` before a shell command.
57 */
58export function normalizeText(kind: CustomKind, text: string): string {
59  const trimmed = text.trim()
60  if (kind === 'command') {
61    return trimmed.replace(/^\/+/, '')
62  }
63  if (kind === 'shell') {
64    return trimmed.replace(/^!\s*/, '')
65  }
66  return trimmed
67}
68
69/**
70 * Checks a draft before it is saved, and returns the reason it cannot be, or
71 * null. `usedHotkeys` are those of every other shown action.
72 */
73export function checkDraft(draft: Draft, usedHotkeys: Array<string>): string | null {
74  const text = normalizeText(draft.kind, draft.text)
75  if (draft.label.trim() === '') {
76    return 'The label is empty.'
77  }
78  if (text === '') {
79    return 'The action text is empty.'
80  }
81  if (draft.kind === 'command' && !/^[\w:.-]+/.test(text)) {
82    return 'A command starts with its name, e.g. "commit" or "pr --draft".'
83  }
84  if (draft.hotkey !== '' && !/^[0-9]$/.test(draft.hotkey)) {
85    return 'The hotkey must be one digit.'
86  }
87  if (draft.hotkey !== '' && usedHotkeys.includes(draft.hotkey)) {
88    return `The hotkey "${draft.hotkey}" is already in use.`
89  }
90  return null
91}
92
93/** Entries of the list that start a new row on the band carry this prefix before their own id. */
94export const NEWLINE_PREFIX = 'newline:'
95
96export function isNewline(entry: string): boolean {
97  return entry.startsWith(NEWLINE_PREFIX)
98}
99
100/** A new entry that starts a new row on the band. */
101export function newlineEntry(): string {
102  return `${NEWLINE_PREFIX}${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
103}
104
105/**
106 * Makes the stored band order fit the saved actions: ids of deleted actions
107 * go, and an entry that shows twice keeps its first place. Actions in no
108 * place are not on the band, so with no order stored yet the band is empty.
109 */
110export function arrangeOrder(stored: unknown, ids: Array<string>): Array<string> {
111  if (!Array.isArray(stored)) {
112    return []
113  }
114  const known = new Set(ids)
115  const seen = new Set<string>()
116  return stored.filter((entry): entry is string => {
117    if (typeof entry !== 'string' || seen.has(entry) || (!known.has(entry) && !isNewline(entry))) {
118      return false
119    }
120    seen.add(entry)
121    return true
122  })
123}
124
125/** Puts an action on the band, at the end, or takes it off. */
126export function togglePin(order: Array<string>, id: string): Array<string> {
127  return order.includes(id) ? order.filter(entry => entry !== id) : [...order, id]
128}
129
130/** Moves an entry one place up (-1) or down (+1); unchanged at either end. */
131export function moveEntry(order: Array<string>, entry: string, step: -1 | 1): Array<string> {
132  const from = order.indexOf(entry)
133  const to = from + step
134  if (from === -1 || to < 0 || to >= order.length) {
135    return order
136  }
137  const next = [...order]
138  next[from] = order[to] as string
139  next[to] = entry
140  return next
141}
142
143/** The rows of the band: the order split at each new line entry, empty rows left out. */
144export function rowsOf(order: Array<string>): Array<Array<string>> {
145  const rows: Array<Array<string>> = [[]]
146  for (const entry of order) {
147    if (isNewline(entry)) {
148      rows.push([])
149    } else {
150      rows[rows.length - 1]?.push(entry)
151    }
152  }
153  return rows.filter(row => row.length > 0)
154}
155
156export type Section = 'commands' | 'prompts' | 'shell'
157
158/** The manage pane's section of a kind: commands run directly, prompts go to Claude, shell runs on the host. */
159export function sectionOf(kind: CustomKind): Section {
160  if (kind === 'command') {
161    return 'commands'
162  }
163  return kind === 'shell' ? 'shell' : 'prompts'
164}
165
166/** The colors the form offers. */
167export const COLORS = ['red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white']
168
169/** The RGB values of the named colors, as common terminal themes draw them. */
170const NAMED_RGB: Record<string, [number, number, number]> = {
171  black: [0, 0, 0],
172  red: [205, 49, 49],
173  green: [13, 188, 121],
174  yellow: [229, 229, 16],
175  blue: [36, 114, 200],
176  magenta: [188, 63, 188],
177  cyan: [17, 168, 205],
178  white: [229, 229, 229],
179  gray: [102, 102, 102],
180  grey: [102, 102, 102],
181}
182
183/** Reads a named color, `#rrggbb` or `rgb(r,g,b)`; null for anything else. */
184function rgbOf(color: string): [number, number, number] | null {
185  const name = color.toLowerCase().replace(/bright$/, '')
186  if (name in NAMED_RGB) {
187    return NAMED_RGB[name] ?? null
188  }
189  const hex = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(color)
190  if (hex !== null) {
191    return [parseInt(hex[1] ?? '', 16), parseInt(hex[2] ?? '', 16), parseInt(hex[3] ?? '', 16)]
192  }
193  const rgb = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i.exec(color)
194  if (rgb !== null) {
195    return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])]
196  }
197  return null
198}
199
200/** The relative luminance of WCAG 2: 0 for black, 1 for white. */
201function luminanceOf([red, green, blue]: [number, number, number]): number {
202  const linear = (channel: number) => {
203    const value = channel / 255
204    return value <= 0.039_28 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
205  }
206  return 0.2126 * linear(red) + 0.7152 * linear(green) + 0.0722 * linear(blue)
207}
208
209/**
210 * Black or white, whichever has the higher WCAG contrast ratio on the given
211 * background color; white for a color it cannot read.
212 */
213export function textColorOn(background: string): 'black' | 'white' {
214  const rgb = rgbOf(background)
215  if (rgb === null) {
216    return 'white'
217  }
218  const luminance = luminanceOf(rgb)
219  const onBlack = (luminance + 0.05) / 0.05
220  const onWhite = 1.05 / (luminance + 0.05)
221  return onBlack > onWhite ? 'black' : 'white'
222}
223
hooks/limits.ts 9 lines
1/** How often the saved actions are read again, for changes made in other sessions. */
2export const POLL_MS = 15_000
3
4/** How long a shell action may run before it is killed. */
5export const SHELL_TIMEOUT_MS = 120_000
6
7/** The longest label the band draws whole; a longer one is cut with an ellipsis. */
8export const LABEL_MAX_CHARS = 24
9
hooks/names.ts 21 lines
1/** One pane for the list of actions and the form that adds or edits one. */
2export const PANE = 'quick-actions'
3
4/** Panes that earlier versions opened; nothing draws them any more. */
5export const LEGACY_PANES = ['quick-actions-edit', 'quick-actions-manage']
6
7export const PANE_OPTIONS = { id: PANE, title: 'Quick actions', focus: true, closeOnEscape: true, holdToasts: true, rows: 20 } as const
8
9/** Keys in the store, which keeps the person's actions and their order across sessions. */
10export const STORE_ACTIONS = 'actions'
11export const STORE_ORDER = 'order'
12
13/** The slash command that opens or closes the pane, also when the band is hidden. */
14export const COMMAND = { name: 'quick-actions', description: 'Open or close the quick actions pane' } as const
15
16/** What a press or a command answers when the pane is open but not drawn; the band draws it instead. */
17export const PANE_IN_BAND_TEXT = 'The quick actions pane has no room here, so the band above the prompt shows it.'
18
19/** Why Esc did not close the pane: it stepped back one view instead. */
20export const STEP_BACK_REASON = 'back one view'
21
hooks/views/band-view.tsx 62 lines
1import type { RenderElement, RenderSurface } from 'claude-code'
2
3import type { Action } from '../actions'
4import type { BandHandlers, Ui } from './kit'
5import { shownLabel } from './label'
6
7export type BandModel = {
8  /** The band's rows of actions, top to bottom. */
9  rows: Array<Array<Action>>
10  surface: RenderSurface
11}
12
13/** The band above the prompt: the actions on it, in their rows. */
14export function bandView(ui: Ui, model: BandModel, handlers: BandHandlers): RenderElement {
15  const { Box, Button } = ui
16
17  /**
18   * One action. On the terminal a block in the action's color, or its plain
19   * label, underlined under the pointer, without one; both take one cell of
20   * room on each side, so colored and plain actions line up. Other surfaces
21   * draw their own button.
22   */
23  const item = (action: Action) => {
24    const label = shownLabel(action.label)
25    if (model.surface !== 'terminal') {
26      return <Button key={action.key} label={label} hotkey={action.hotkey} onPress={() => handlers.run(action)} />
27    }
28    return (
29      <Box
30        key={`block-${action.key}`}
31        flexDirection="row"
32        flexShrink={0}
33        paddingX={1}
34        {...(action.color !== undefined ? { backgroundColor: action.color } : {})}
35      >
36        <Button
37          key={action.key}
38          plain
39          label={label}
40          hotkey={action.hotkey}
41          {...(action.color === undefined ? { hover: { underline: true } } : {})}
42          onPress={() => handlers.run(action)}
43        />
44      </Box>
45    )
46  }
47
48  /** One row of the band. No wrapping: a wrapping row that may shrink reserves a second, empty line; room past the band's width is cut instead. */
49  const row = (key: string, actions: Array<Action>) => (
50    <Box key={key} flexDirection="row" columnGap={2} flexShrink={1} overflow="hidden">
51      {actions.map(item)}
52    </Box>
53  )
54
55  return (
56    <Box key="quick-actions" flexDirection="column" paddingLeft={1} paddingRight={3} marginTop={1}>
57      {/* The right padding keeps the last button off the band's own collapse control. */}
58      {model.rows.map((actions, index) => row(`row-${index}`, actions))}
59    </Box>
60  )
61}
62
hooks/views/kit.ts 36 lines
1import type { ElementTable } from 'claude-code'
2
3import type { CustomAction, Draft } from '../../types'
4import type { Action } from '../actions'
5
6/**
7 * The element constructors the views draw with, from the table the render
8 * hook resolves. The terminal and the desktop app have all of them; the
9 * mobile app has no Input, so the pane does not draw there.
10 */
11export type Ui = Pick<ElementTable<'terminal' | 'desktop'>, 'Box' | 'Text' | 'Button' | 'Input'>
12
13/** What the band's buttons do. */
14export type BandHandlers = {
15  run: (action: Action) => void
16}
17
18/** What the pane's buttons and inputs do. */
19export type PaneHandlers = {
20  close: () => void
21  startAdd: () => void
22  startEdit: (action: CustomAction) => void
23  pressDelete: (action: CustomAction) => void
24  moveEntry: (entry: string, step: -1 | 1) => void
25  /** Takes an action or a new line off the band; an action stays in the list of all actions. */
26  removeFromBand: (entry: string) => void
27  /** Puts an action on the band, or takes it off. */
28  togglePin: (id: string) => void
29  run: (action: CustomAction) => void
30  addNewline: () => void
31  /** Resolves once the change is in the form's state, so a save after it sees it. */
32  editDraft: (change: Partial<Draft>) => Promise<void>
33  saveDraft: () => void
34  leaveForm: () => void
35}
36
hooks/views/pane/pane-view.tsx 26 lines
1import type { RenderElement } from 'claude-code'
2
3import type { CustomAction, Draft } from '../../../types'
4import type { PaneHandlers, Ui } from '../kit'
5import { formView } from './form-view'
6import { listView } from './list-view'
7
8export type PaneModel = {
9  /** The form being filled in, or null for the list. */
10  draft: Draft | null
11  custom: Array<CustomAction>
12  order: Array<string>
13  pendingDelete: string | null
14  /** The pane's width in columns. */
15  columns: number
16  surface: string
17}
18
19/** The pane's current view: the form while one is filled in, the list otherwise. */
20export function paneView(ui: Ui, model: PaneModel, handlers: PaneHandlers): RenderElement {
21  if (model.draft !== null) {
22    return formView(ui, { draft: model.draft, custom: model.custom, columns: model.columns, surface: model.surface }, handlers)
23  }
24  return listView(ui, model, handlers)
25}
26
hooks/views/label.ts 12 lines
1import { LABEL_MAX_CHARS } from '../limits'
2
3/** Characters a label cannot draw: control characters, which would break the row. */
4const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g
5
6/** A label as the band and the list draw it: on one line, without control characters, and cut with an ellipsis past the limit. */
7export function shownLabel(label: string, maxChars = LABEL_MAX_CHARS): string {
8  const clean = label.replace(/\s+/g, ' ').replace(CONTROL_CHARACTERS, '').trim()
9  const chars = [...clean]
10  return chars.length > maxChars ? `${chars.slice(0, maxChars - 1).join('')}…` : clean
11}
12
hooks/views/pane/form-view.tsx 212 lines
1import type { RenderElement } from 'claude-code'
2
3import type { CustomAction, Draft } from '../../../types'
4import { textColorOn } from '../../actions'
5import type { PaneHandlers, Ui } from '../kit'
6import { shownLabel } from '../label'
7import { actionButton, ruleView } from './buttons'
8import { headerView } from './header-view'
9import { COLOR_OPTIONS, HOTKEYS, type Option, TEXT_FIELDS, TYPES } from './options'
10
11export type FormModel = {
12  draft: Draft
13  /** The saved actions, so a hotkey another action uses is not offered. */
14  custom: Array<CustomAction>
15  /** The pane's width in columns. */
16  columns: number
17  surface: string
18}
19
20type Setting = { id: 'color' | 'hotkey'; title: string; question: string; options: ReadonlyArray<Option> }
21
22/** The width of a setting's name, so the values of all settings start in one column. */
23const TITLE_WIDTH = 8
24
25/** The cells one option takes as a button: its label, or a one-cell swatch for a color, the ground's padding, and the gap before the next. */
26const optionCells = (setting: Setting, option: Option) => (setting.id === 'color' ? 1 : [...option.label].length) + 2 + 1
27
28/**
29 * The form that adds or edits an action. A setting shows its options as a row
30 * of buttons when they fit the pane; otherwise as its value, which opens the
31 * setting's option list in place of the form.
32 */
33export function formView(ui: Ui, model: FormModel, handlers: PaneHandlers): RenderElement {
34  const { Box, Button, Input, Text } = ui
35  const { draft, surface } = model
36  const field = TEXT_FIELDS[draft.kind]
37
38  /** Digits another action already uses are left out, so a clash cannot be picked. */
39  const used = new Set(model.custom.filter(action => action.id !== draft.id).map(action => action.hotkey))
40  const hotkeyOptions = [{ value: '', label: '-' }, ...HOTKEYS.filter(digit => !used.has(digit)).map(digit => ({ value: digit, label: digit }))]
41
42  const settings: Array<Setting> = [
43    { id: 'color', title: 'Color', question: 'The color of the button', options: COLOR_OPTIONS },
44    { id: 'hotkey', title: 'Hotkey', question: 'The digit that presses the button from an empty prompt box', options: hotkeyOptions },
45  ]
46
47  /**
48   * One option as a button: blue when chosen, dim when not. On the terminal a
49   * color is a swatch in its own color without text, ticked when chosen, so
50   * the colors fit a narrow pane; the default color is a dash.
51   */
52  const optionButton = (setting: Setting, option: Option) => {
53    const key = `${setting.id}-${option.value || 'none'}`
54    const isChosen = draft[setting.id] === option.value
55    const pick = () => void handlers.editDraft({ [setting.id]: option.value, picking: null })
56    if (setting.id === 'color' && surface === 'terminal') {
57      return (
58        <Box
59          key={`box-${key}`}
60          flexDirection="row"
61          flexShrink={0}
62          paddingX={1}
63          {...(option.value !== '' ? { backgroundColor: option.value } : {})}
64        >
65          <Button key={key} label={isChosen ? '✔' : option.value === '' ? '-' : ' '} plain dimColor={option.value === '' && !isChosen} onPress={pick} />
66        </Box>
67      )
68    }
69    return actionButton(ui, surface, key, option.label, pick, isChosen)
70  }
71
72  const picking = settings.find(setting => setting.id === draft.picking)
73  if (picking !== undefined) {
74    return pickerView(ui, model, picking, handlers)
75  }
76
77  /** The type as one button for each kind. */
78  const typeRow = (
79    <Box key="setting-row-kind" flexDirection="row" columnGap={1}>
80      <Text>{'Type'.padEnd(TITLE_WIDTH)}</Text>
81      {TYPES.map(type =>
82        actionButton(ui, surface, `kind-${type.kind}`, type.label, () => void handlers.editDraft({ kind: type.kind }), draft.kind === type.kind),
83      )}
84    </Box>
85  )
86
87  /** A setting as a row of option buttons when they fit, or as its value that opens the option list. */
88  const settingRow = (setting: Setting) => {
89    const cells = TITLE_WIDTH + 1 + setting.options.reduce((sum, option) => sum + optionCells(setting, option), 0)
90    if (cells <= model.columns - 2) {
91      return (
92        <Box key={`setting-row-${setting.id}`} flexDirection="row" columnGap={1}>
93          <Text>{setting.title.padEnd(TITLE_WIDTH)}</Text>
94          {setting.options.map(option => optionButton(setting, option))}
95        </Box>
96      )
97    }
98    const option = setting.options.find(one => one.value === draft[setting.id]) ?? setting.options[0]
99    return (
100      <Box key={`setting-row-${setting.id}`} flexDirection="row" columnGap={1}>
101        <Button
102          key={`setting-${setting.id}`}
103          label={`${setting.title.padEnd(TITLE_WIDTH)}${option?.label ?? ''} ›`}
104          plain
105          onPress={() => void handlers.editDraft({ picking: setting.id })}
106        />
107        {setting.id === 'color' && <Text color={draft.color || undefined}>■■■</Text>}
108      </Box>
109    )
110  }
111
112  /**
113   * The button as the band draws it: a block in the action's color with black or white text,
114   * whichever reads better on it, or the plain label without a color.
115   */
116  const label = shownLabel(draft.label) || '…'
117  const preview =
118    draft.color !== '' ? (
119      <Text backgroundColor={draft.color} color={textColorOn(draft.color)}>{` ${label} `}</Text>
120    ) : (
121      <Text>{` ${label} `}</Text>
122    )
123
124  return (
125    <Box flexDirection="column" paddingX={1}>
126      {headerView(ui, surface, handlers, handlers.leaveForm)}
127      <Text bold>{draft.id === null ? 'New action' : 'Edit action'}</Text>
128      <Box flexDirection="column" rowGap={1} marginTop={1}>
129        {typeRow}
130        {settings.map(settingRow)}
131      </Box>
132      <Box flexDirection="column" marginTop={1}>
133        <Text dimColor>Label</Text>
134        {ruleView(ui, 'rule-label-top', model.columns - 2)}
135        <Box flexDirection="row">
136          <Text bold>❯ </Text>
137          <Input
138            key="label"
139            placeholder="review diff"
140            value={draft.label}
141            submitLabel="save"
142            onInput={value => void handlers.editDraft({ label: value })}
143            onSubmit={value => void handlers.editDraft({ label: value }).then(handlers.saveDraft)}
144          />
145        </Box>
146        {ruleView(ui, 'rule-label-bottom', model.columns - 2)}
147      </Box>
148      <Box flexDirection="column" marginTop={1}>
149        <Box flexDirection="row" justifyContent="space-between">
150          <Text dimColor>{field.title}</Text>
151          <Text dimColor>Enter adds a line</Text>
152        </Box>
153        {ruleView(ui, 'rule-text-top', model.columns - 2)}
154        {/* The input is one line and Enter submits it, so Enter appends a line break and Save saves. */}
155        <Box flexDirection="row" minHeight={3}>
156          <Text bold>❯ </Text>
157          <Input
158            key="text"
159            placeholder={field.placeholder}
160            value={draft.text}
161            autoFocus
162            submitLabel="new line"
163            onInput={value => void handlers.editDraft({ text: value })}
164            onSubmit={value => void handlers.editDraft({ text: `${value}\n` })}
165          />
166        </Box>
167        {ruleView(ui, 'rule-text-bottom', model.columns - 2)}
168      </Box>
169      {draft.error !== null && <Text color="red">{draft.error}</Text>}
170      <Box flexDirection="row" justifyContent="space-between" marginTop={1}>
171        {actionButton(ui, surface, 'save', 'Save', handlers.saveDraft)}
172        <Box flexDirection="row" columnGap={1}>
173          <Text dimColor>Preview:</Text>
174          {preview}
175        </Box>
176      </Box>
177    </Box>
178  )
179}
180
181/**
182 * The option list of one setting, for a pane too narrow for its row of
183 * buttons: the current option ticked and focused, a press picks it and returns
184 * to the form, Back or Esc returns without a change.
185 */
186function pickerView(ui: Ui, model: FormModel, setting: Setting, handlers: PaneHandlers): RenderElement {
187  const { Box, Button, Text } = ui
188  const current = model.draft[setting.id]
189  /** Labels padded to the longest, so the swatches start in one column. */
190  const width = Math.max(...setting.options.map(option => option.label.length))
191  return (
192    <Box flexDirection="column" paddingX={1}>
193      {headerView(ui, model.surface, handlers, () => void handlers.editDraft({ picking: null }))}
194      <Text bold>{setting.question}</Text>
195      <Box flexDirection="column" marginTop={1}>
196        {setting.options.map(option => (
197          <Box key={`option-row-${option.value || 'none'}`} flexDirection="row" columnGap={2}>
198            <Button
199              key={`${setting.id}-${option.value || 'none'}`}
200              label={`${option.value === current ? '✔' : ' '} ${option.label.padEnd(width)}`}
201              plain
202              autoFocus={option.value === current ? true : undefined}
203              onPress={() => void handlers.editDraft({ [setting.id]: option.value, picking: null })}
204            />
205            {setting.id === 'color' && <Text color={option.value || undefined}>■■■</Text>}
206          </Box>
207        ))}
208      </Box>
209    </Box>
210  )
211}
212
hooks/views/pane/list-view.tsx 175 lines
1import type { RenderElement } from 'claude-code'
2
3import type { CustomAction } from '../../../types'
4import { isNewline, sectionOf } from '../../actions'
5import type { PaneHandlers, Ui } from '../kit'
6import { shownLabel } from '../label'
7import { ruleView } from './buttons'
8import { headerView } from './header-view'
9import { SECTION_MARKS } from './options'
10
11export type ListModel = {
12  /** Every saved action, in the order they were saved. */
13  custom: Array<CustomAction>
14  /** The band: the actions on it and its new lines, in order. */
15  order: Array<string>
16  /** The action whose delete button was pressed once and waits for the second press. */
17  pendingDelete: string | null
18  /** The pane's width in columns. */
19  columns: number
20  surface: string
21}
22
23/** The widest the label column gets; a longer label is cut. */
24const LABEL_WIDTH = 20
25
26/** What an action does, on one line: the command with its slash, the shell command, or the prompt. */
27function whatOf(action: CustomAction): string {
28  const text = action.text.replace(/\s+/g, ' ').trim()
29  return action.kind === 'command' ? `/${text}` : text
30}
31
32/**
33 * Two lists, one action or new line per line: every action, each with a star
34 * that puts it on the band or takes it off, a button that runs it and a delete
35 * button; then the band in its order, with buttons that move an entry or take
36 * it off the band.
37 */
38export function listView(ui: Ui, model: ListModel, handlers: PaneHandlers): RenderElement {
39  const { Box, Button, Text } = ui
40  const { order, pendingDelete } = model
41  const onBand = new Set(order)
42  const width = Math.min(Math.max(1, ...model.custom.map(action => [...shownLabel(action.label)].length)), LABEL_WIDTH)
43
44  /**
45   * The section's symbol in its color, then the label in a column of one
46   * width; in its color the label is a block, as on the band.
47   */
48  const labelOf = (action: CustomAction, button: RenderElement) => {
49    const mark = SECTION_MARKS[sectionOf(action.kind)]
50    return [
51      <Box key={`mark-${action.id}`} flexShrink={0}>
52        <Text bold color={mark.color}>
53          {mark.symbol}
54        </Text>
55      </Box>,
56      <Box
57        key={`block-${action.id}`}
58        flexDirection="row"
59        flexShrink={0}
60        paddingX={1}
61        {...(action.color ? { backgroundColor: action.color } : {})}
62      >
63        {button}
64      </Box>,
65    ]
66  }
67
68  const columnLabel = (action: CustomAction) => shownLabel(action.label, width).padEnd(width)
69
70  /**
71   * A control at the end of a row. The spaces are part of the label, so the
72   * hover and the click cover them too and the target is three cells wide.
73   */
74  const control = (key: string, label: string, isDim: boolean, onPress: () => void) => (
75    <Button key={key} label={` ${label} `} plain dimColor={isDim} onPress={onPress} />
76  )
77
78  /** One action: its label opens the form, then its hotkey and what it does, dim and cut at the pane's edge. */
79  const actionRow = (action: CustomAction) => {
80    const isOnBand = onBand.has(action.id)
81    const edit = <Button key={`edit-${action.id}`} label={columnLabel(action)} plain onPress={() => handlers.startEdit(action)} />
82    return (
83      <Box key={`row-${action.id}`} flexDirection="row" columnGap={1}>
84        {labelOf(action, edit)}
85        <Box width={3} flexShrink={0}>
86          <Text dimColor>{action.hotkey !== '' ? `[${action.hotkey}]` : ''}</Text>
87        </Box>
88        {/** Width 0 and grow: only this cell takes the room left, so a long text cannot squeeze the cells beside it. */}
89        <Box width={0} flexGrow={1} overflow="hidden">
90          <Text dimColor wrap="truncate-end">
91            {whatOf(action)}
92          </Text>
93        </Box>
94        <Box flexDirection="row" flexShrink={0}>
95          {control(`pin-${action.id}`, isOnBand ? '★' : '☆', !isOnBand, () => handlers.togglePin(action.id))}
96          {control(`run-${action.id}`, '▶', true, () => handlers.run(action))}
97          {control(
98            `delete-${action.id}`,
99            pendingDelete === action.id ? 'delete? ✕' : '✕',
100            pendingDelete !== action.id,
101            () => handlers.pressDelete(action),
102          )}
103        </Box>
104      </Box>
105    )
106  }
107
108  /** Moves an entry of the band one place; across a new line it moves to the next or previous row. */
109  const bandControls = (entry: string, index: number) => (
110    <Box flexDirection="row" flexShrink={0}>
111      {control(`up-${entry}`, '↑', index > 0, () => handlers.moveEntry(entry, -1))}
112      {control(`down-${entry}`, '↓', index < order.length - 1, () => handlers.moveEntry(entry, 1))}
113      {control(`remove-${entry}`, '-', true, () => handlers.removeFromBand(entry))}
114    </Box>
115  )
116
117  /** A dim line at the end of a list that adds to it: a sign in the symbol column, the text in the label column. */
118  const addRow = (key: string, sign: string, label: string, onPress: () => void) => (
119    <Box key={`row-${key}`} flexDirection="row" columnGap={1}>
120      <Text dimColor>{sign}</Text>
121      <Box flexDirection="row" flexShrink={0} paddingX={1}>
122        <Button key={key} label={label} plain dimColor onPress={onPress} />
123      </Box>
124    </Box>
125  )
126
127  const byId = new Map(model.custom.map(action => [action.id, action]))
128  /** A new line splits rows of actions, so it is offered only once an action is on the band. */
129  const hasActionOnBand = order.some(entry => byId.has(entry))
130  const bandRows = order.flatMap((entry, index) => {
131    if (isNewline(entry)) {
132      return [
133        <Box key={`band-row-${entry}`} flexDirection="row" justifyContent="space-between" columnGap={1}>
134          <Box flexDirection="row" columnGap={1}>
135            <Text dimColor>↵</Text>
136            <Box flexDirection="row" paddingX={1}>
137              <Text dimColor>new line</Text>
138            </Box>
139          </Box>
140          {bandControls(entry, index)}
141        </Box>,
142      ]
143    }
144    const action = byId.get(entry)
145    if (action === undefined) {
146      return []
147    }
148    return [
149      <Box key={`band-row-${entry}`} flexDirection="row" justifyContent="space-between" columnGap={1}>
150        <Box flexDirection="row" columnGap={1} flexShrink={1}>
151          {labelOf(action, <Text key={`band-label-${entry}`}>{columnLabel(action)}</Text>)}
152        </Box>
153        {bandControls(entry, index)}
154      </Box>,
155    ]
156  })
157
158  return (
159    <Box flexDirection="column" paddingX={1}>
160      {headerView(ui, model.surface, handlers, null)}
161      <Box flexDirection="row" justifyContent="space-between">
162        <Text bold>Actions</Text>
163        <Text dimColor>★ on the band · ▶ run now</Text>
164      </Box>
165      {model.custom.map(actionRow)}
166      {addRow('new', '+', 'Add action', handlers.startAdd)}
167      <Box marginY={1}>{ruleView(ui, 'rule-band', model.columns - 2)}</Box>
168      <Text bold>Band</Text>
169      {bandRows}
170      {!hasActionOnBand && <Text dimColor>Nothing on the band yet: press ☆ on an action.</Text>}
171      {hasActionOnBand && addRow('newline', '+', 'Add new line', handlers.addNewline)}
172    </Box>
173  )
174}
175
hooks/views/pane/buttons.tsx 50 lines
1import type { RenderElement } from 'claude-code'
2
3import type { Ui } from '../kit'
4
5/** The background of an action button in the terminal: the blue of a VS Code button, readable in both themes. */
6export const BUTTON_COLOR = '#0e639c'
7
8/**
9 * A button as an action: in the terminal a label on a colored ground, or the
10 * same label dimmed and without ground for an option not chosen; elsewhere the
11 * surface's own button, primary or not.
12 */
13export function actionButton(
14  ui: Ui,
15  surface: string,
16  id: string,
17  label: string,
18  onPress: () => void,
19  isChosen = true,
20): RenderElement {
21  const { Box, Button } = ui
22  if (surface !== 'terminal') {
23    return <Button key={id} label={label} variant={isChosen ? 'primary' : 'secondary'} onPress={onPress} />
24  }
25  return (
26    <Box key={`box-${id}`} flexDirection="row" flexShrink={0} paddingX={1} {...(isChosen ? { backgroundColor: BUTTON_COLOR } : {})}>
27      <Button key={id} label={label} plain {...(isChosen ? {} : { dimColor: true })} onPress={onPress} />
28    </Box>
29  )
30}
31
32/** A button that moves between views, as plain text in the terminal; elsewhere the surface's own button. */
33export function navButton(ui: Ui, surface: string, id: string, label: string, onPress: () => void): RenderElement {
34  const { Button } = ui
35  if (surface !== 'terminal') {
36    return <Button key={id} label={label} variant="secondary" onPress={onPress} />
37  }
38  return <Button key={id} label={label} plain onPress={onPress} />
39}
40
41/** A dim rule across the pane, to set one part of a view off from the next. */
42export function ruleView(ui: Ui, id: string, columns: number): RenderElement {
43  const { Text } = ui
44  return (
45    <Text key={id} dimColor>
46      {'─'.repeat(Math.max(columns, 1))}
47    </Text>
48  )
49}
50
hooks/views/pane/header-view.tsx 21 lines
1import type { RenderElement } from 'claude-code'
2
3import type { PaneHandlers, Ui } from '../kit'
4import { navButton } from './buttons'
5
6/** The pane's first line: its name, and the back and close buttons right. Back is left out on the list, which has nothing to go back to. */
7export function headerView(ui: Ui, surface: string, handlers: PaneHandlers, back: (() => void) | null): RenderElement {
8  const { Box, Text } = ui
9  return (
10    <Box flexDirection="row" justifyContent="space-between" marginBottom={1}>
11      <Text bold color="cyan">
12        QUICK ACTIONS
13      </Text>
14      <Box flexDirection="row" columnGap={2}>
15        {back !== null && navButton(ui, surface, 'back', '← Back', back)}
16        {navButton(ui, surface, 'close', '✕ Close', handlers.close)}
17      </Box>
18    </Box>
19  )
20}
21