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

<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" />
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.
▶ runs them.Claude Code 2.1.287 or later, where mods load by default.
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.
Type /quick-actions to open the pane, and again to close it. The pane has two lists.
Actions lists every saved action:
| Control | What it does |
|---|---|
| label | Opens the action's form |
☆ / ★ | Pins the action to the band, or takes it off |
▶ | Runs the action now |
✕ | Deletes the action (press twice) |
+ Add action | Opens 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.
| Type | What a press does |
|---|---|
| Call Skill | Runs /name args at once, as if typed |
| Fill Prompt | Puts the text in the prompt box, to edit before sending |
| Send Prompt | Sends the text to Claude at once |
| Run Shell | Runs 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.
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:
| Type | What the mod does | Engine call |
|---|---|---|
| Call Skill | Runs the slash command you saved, for example /commit, with the arguments you saved | $.command.run |
| Fill Prompt | Puts the text you saved in the prompt box; nothing is sent until you send it | $.prompt.fill |
| Send Prompt | Sends the text you saved to Claude, unchanged, as your own prompt | $.prompt.submit |
| Run Shell | Runs 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).
These come from the mod API:
1: commit).! text stays a normal prompt; the prompt box cannot be put into shell mode. Use Run Shell instead.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/.
hooks/register.tsx 327 lines1import { 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}
327hooks/actions.ts 223 lines1import 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}
223hooks/limits.ts 9 lines1/** 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
9hooks/names.ts 21 lines1/** 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'
21hooks/views/band-view.tsx 62 lines1import 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}
62hooks/views/kit.ts 36 lines1import 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}
36hooks/views/pane/pane-view.tsx 26 lines1import 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}
26hooks/views/label.ts 12 lines1import { 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}
12hooks/views/pane/form-view.tsx 212 lines1import 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}
212hooks/views/pane/list-view.tsx 175 lines1import 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}
175hooks/views/pane/buttons.tsx 50 lines1import 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}
50hooks/views/pane/header-view.tsx 21 lines1import 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