SLOPSHOPPER

launchpad

A menu of one-click actions under the header, below the prompt or in a pane: each button runs an installed command, skill or agent. Pick and order up to 8 with…

newpanespinnerrowscommandtoast
★ 2v0.4.1MITupdated 2026-10-09ice-lfernandes/claude-code-mods/launchpad
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · launchpad
│ ┃ launchpad-menu ✕ › fix the failing auth test and add an audit log call │ ┃ ✻ What do you want to do? │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭─────────────────╮ ⎿ Read 6 lines │ ┃ │ 🔍 Explore code │ ⏺ Update(src/auth.ts) │ ┃ ╰─────────────────╯ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ /pad configuration · list · add · remove · ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pad │ ⎿ launchpad: Launchpad shortcuts menu. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · launchpad-menu
✻ What do you want to do? ╭─────────────────╮ │ 🔍 Explore code │ ╰─────────────────╯ /pad configuration · list · add · remove · reset · place ·
Pane · launchpad
Only commands, skills and agents installed in this session show. Up to 8 in the menu. In the menu (1/8) – 🗜️ Compact chat /compact (not install… ↓ re – 📊 See context /context (not install… ↑ ↓ – ⏱️ See limits /limits (not installe… ↑ ↓ – ↩️ Resume a chat /resume (not installe… ↑ ↓ – 🧠 Edit memory /memory (not installe… ↑ ↓ – 🎛️ Switch model /model (not installed) ↑ ↓ 1 🔍 Explore code @Explore ↑ ↓ – ❓ Help /help (not installed) ↑ re Add Filter: command, skill or agent name ⏎ + add + add @general-purpose agent + add @Plan agent [ Restore defaults ] [ Close ]
README

launchpad

A menu of one-click actions under the Claude Code header, below the prompt or in a pane of its own, so nobody has to know a /command before they can get something done. Each button runs a command, a skill or an agent that this session has installed.

launchpad: the welcome menu under the header, and an agent request waiting in the prompt with its blank marked

  • Shows when a session starts with an empty conversation, and after /clear: a framed card under the header, each button an icon and an action in a bordered tile, in columns that fit the window. The card is the output of /pad, which the mod runs for you, so it sits in the conversation and scrolls up as the conversation grows. /pad draws a fresh one at any time.
  • Two kinds of button, told from the text:
  • /name runs that command or skill: 🗜️ Compactar conversa runs /compact.
  • /name [blank] puts the command in the prompt with the blank marked, for you to fill in and send: /code-review [level] waits for the level. Arguments with no brackets (/code-review high) run as written. A command whose arguments are all optional runs bare: /clear [name] runs /clear.
  • @name calls that agent: 🔍 Explorar código puts Use o agente Explore para [tarefa] in the prompt with the blank marked, for you to say what to explore.
  • Only what is installed. A button shows, and can be added, only when this session has its command, skill or agent: the commands and skills Claude Code lists, the built-in agents (general-purpose, Explore, Plan) and the agent files in .claude/agents/ of the project and of your home folder. A button whose command another session has (/limits comes with limits-meter) stays in your list and shows where it works.
  • A row of /pad's own arguments under the buttons: configuration · list · add · remove · reset · place · off · help. configuration opens the pane, list and help print as a dim line in the conversation (Claude does not read it), off turns the menu off. add, remove and reset wait in the prompt (/pad add [nome] | [/comando ou @agente]), so a stray click changes nothing.
  • Where it shows is your choice (/pad place, or the placement option):
  • header, the default: the card under the header described above.
  • prompt: the buttons in a row below the prompt that stays there, under Claude Code's own hint line (? for shortcuts), with ⋯ configure to open the pane.
  • pane: a pane of its own, a tab like those of /limits and /watch, with the card's bordered tiles. It opens when a session starts and after /clear, and on /pad. Claude Code opens a pane only with the keyboard on it: Esc closes it and gives the prompt back.
  • Up to 8 buttons that work here. The defaults: compact the chat, see context, see limits, resume a chat, edit memory, switch model, explore the code, help.

Commands

/pad                                  show the menu again
/pad configuration                    pane to order, remove and add buttons
/pad list                             every button, numbered, with what it runs
/pad add 🔎 Revisão | /code-review    a button that runs a command or skill
/pad add Revisor | @revisor           a button that calls an agent
/pad remove 3                         drop button 3
/pad reset                            back to the default buttons
/pad place header | prompt | pane     where the menu shows: under the header, below the prompt, or in a pane
/pad off                              turn the menu off: no card at the start, after /clear or on /pad
/pad on                               turn it back on

/pad configuration (or /pad config) opens a pane with your buttons, each with ↑, ↓ and remover, and below them every installed command, skill and agent not yet in the menu, with a filter to type into and + adicionar on each. Enter in the filter adds the first match. A command that takes an argument comes in with it as a blank (/code-review [level]), from the hint Claude Code shows for it in the / menu. The mod learns the hints as that menu lists the commands, so one you have not yet seen there in this session comes in bare: add the blank with /pad add. A hint of optional arguments only ([name], [auto|<tokens>]) gives no blank: the button runs the command bare. A button saved with such blanks by an earlier version (/clear [name]) is saved back bare once Claude Code lists the command. Once 8 buttons work, the pane says the menu is full until you remove one.

/pad off is kept across sessions and hides the cards already in the conversation too. The other commands and the pane go on working, so you can set the menu up before /pad on. The showOnStart option below only stops the card at the start and after /clear.

The icon in /pad add is optional. It can be any emoji or symbol, or a built-in name between colons (/pad add :chart: Vendas | /cost): folder, doc, pen, search, compress, gauge, chart, table, mail, undo, spark, brain, sliders, help, agent, tool, plug. A plain first word stays in the label. An agent button can carry its task (@revisor revise [arquivo]); without one, the prompt waits with [tarefa]. Your list is kept across sessions.

Buttons for a whole team

A project can ship its own buttons in .claude/launchpad.json:

{
  "buttons": [
    { "icon": "🧾", "label": "Fechamento do mês", "text": "/fechamento" },
    { "icon": "chart", "label": "Revisor", "text": "@revisor" }
  ]
}

Each text is a /command or an @agent, and the button shows only when the session has it. They show after your own, up to 8 in the menu. Their text comes from the repository, so a project button never runs a command on its own: a press only puts its text in the prompt, and you read it before pressing Enter. To remove one, edit the file.

Options

Set them in /config or under pluginConfigs in ~/.claude/settings.json, keyed by the installed name (launchpad@lfernandes-mods).

OptionValuesDefault
languageauto, pt-BR, en: the default buttons and the messages. auto follows the system's LANG: Portuguese for pt_*, English otherwise and when LANG is unsetauto
iconsauto, emoji, or symbol (⇲ ▥ ◔) for terminals that draw emoji at odd widths. auto uses symbols in a JetBrains IDE's terminal (IntelliJ, PyCharm, ...: TERMINAL_EMULATOR=JetBrains-JediTerm), which gives many emoji one column, and emoji elsewhereauto
showOnStartfalse shows the menu only on /padtrue
placementheader, prompt or pane: where the menu shows. /pad place overrides it and is kept across sessionsheader

In the desktop app the buttons are native buttons and always use emoji.

An option already saved in your settings wins over a new default. Installing the plugin can save every option with the value it had then, so after an update that adds auto to an option, set it to auto yourself to get the new behaviour.

Known limits

  • Hover needs a terminal that reports mouse motion. Over a tile, the border and label turn the accent color and the tile tints. VS Code's terminal reports the pointer as it moves; a JetBrains IDE's terminal reports clicks but not motion, so there the tiles do not light up. Clicks work in both.
  • The border rows of a tile do not take a click. Only a button takes a press, and a terminal inverts the button under the pointer, which turns a border into a solid bar. The whole row inside the border is the button.
  • Emoji widths depend on the terminal. Claude Code counts an emoji as two columns. A terminal that gives it one (a JetBrains IDE, VS Code with terminal.integrated.gpuAcceleration set to off) shifts the rest of the row and bends the borders. icons: auto covers JetBrains; in VS Code, turn GPU acceleration on, or set icons to symbol.
  • Agents: the built-in ones and your files. The catalog holds general-purpose, Explore, Plan and the .md files directly in .claude/agents/ of the project and of your home folder. An agent a plugin brings, or one in a subfolder, counts as not installed: Claude Code gives a mod no list of agent types.
  • Argument hints come from the / menu. The pane gives a command its argument as a blank from the hint Claude Code shows in the / menu. The mod learns the hints as that menu lists the commands, and forgets them when it reloads.
  • The menu is a row of the conversation. It scrolls away with the conversation, and /pad draws a new one. Claude reads /pad's one-line output (Menu de atalhos do launchpad.), not the buttons.

Install

/plugin install launchpad --marketplace ice-lfernandes/claude-code-mods

Or for one session: claude --plugin-dir ./launchpad

What it reaches

ModNetworkRuns processesFilesCalls a modelSends data anywhere
launchpadNoNoReads .claude/launchpad.json, and the agent files in .claude/agents/ of the session's folder and of $HOMENoNo

A pressed button does what typing would do: it runs a slash command or fills the prompt box. Those go through Claude Code and every other mod's hooks as usual, so permission dialogs and other guards still apply. The mod keeps your list of buttons in its plugin store, on this machine.

Source 3 files
hooks/register.tsx 691 lines
1// launchpad: a welcome menu of one-click actions, so nobody has to know a /command first.
2//
3//   menu     a card in the transcript, under the header: an icon and an action per button, in
4//            bordered tiles. It is /pad's output row drawn as buttons, so the mod runs /pad when
5//            a session starts with an empty conversation and after /clear, and the card scrolls
6//            up with the conversation. Typing /pad draws a new one.
7//   buttons  each one runs a command or skill (`/compact`) or calls an agent (`@Explore`, which
8//            puts "use the Explore agent to [task]" in the prompt). Only what this session has
9//            installed shows or can be added: the catalog is $.command.list() (commands and
10//            skills), the built-in agents and the files in .claude/agents/ (project and user).
11//   /pad configuration  a pane to order, remove and add buttons from the catalog, up to
12//            MAX_SHOWN. /pad add, remove, list and reset do the same from the prompt. The
13//            person's list is kept across sessions in the plugin's store.
14//   /pad off | on  turns the menu off (no card at the start, after /clear or on /pad) and back
15//            on, kept across sessions.
16//   /pad place header | prompt | pane  where the menu shows: the card under the header, a row
17//            under the prompt that stays (below the engine's hint line), or a pane of its own, a
18//            tab like other mods' panes, with the card's tiles.
19//            Kept across sessions; the `placement` option is the default.
20//   project  .claude/launchpad.json adds the repository's buttons. They come from the repo, so
21//            a press only puts the text in the prompt: the person reads it before pressing Enter.
22//
23// Reads .claude/launchpad.json and the agent files in .claude/agents/ (the session's folder and
24// the home folder). Runs no process, calls no model.
25
26import { atom, read, update } from 'claude-code'
27import type { Elements, EngineInterface, Register } from 'claude-code'
28
29import type { IconStyle, Lang, Pad, Placement, Target } from '../types'
30import {
31  agentName,
32  agentOf,
33  agentTask,
34  asPads,
35  available,
36  BUILTIN_AGENTS,
37  blankIn,
38  blanksOf,
39  buttonLabel,
40  commandOf,
41  defaults,
42  keyOf,
43  langOf,
44  layout,
45  listText,
46  localize,
47  matches,
48  MAX_SHOWN,
49  moveId,
50  windowOf,
51  padFor,
52  padKey,
53  parseAdd,
54  parseProject,
55  placementOf,
56  sameText,
57  shownOf,
58  spell,
59  styleOf,
60  tileLabel,
61  unblank,
62  WORDS,
63} from './pad'
64
65const PANE = 'launchpad'
66/** The menu's own pane, under `/pad place pane`. */
67const MENU_PANE = 'launchpad-menu'
68const PROJECT_FILE = '.claude/launchpad.json'
69const AGENTS_DIR = '.claude/agents'
70const KEY_MENU = 'menu'
71const KEY_OFF = 'off'
72const KEY_PLACE = 'placement'
73
74const menu = atom({ plugin: 'launchpad', key: 'menu' } as const, [] as Pad[])
75const project = atom({ plugin: 'launchpad', key: 'project' } as const, [] as Pad[])
76const catalog = atom({ plugin: 'launchpad', key: 'catalog' } as const, [] as Target[])
77const filter = atom({ plugin: 'launchpad', key: 'filter' } as const, '')
78const offset = atom({ plugin: 'launchpad', key: 'offset' } as const, 0)
79const isOff = atom({ plugin: 'launchpad', key: 'isOff' } as const, false)
80const placement = atom({ plugin: 'launchpad', key: 'placement' } as const, 'header' as Placement)
81
82/**
83 * Argument hints by command name, as the engine lists the commands for the typeahead and /help
84 * (command.describe). $.command.list() carries none, so a command the person has not yet seen
85 * listed in this session has no hint here.
86 */
87const hints = new Map<string, string>()
88
89/** The last first row the pane's catalog list can start at, as last drawn: where the wheel stops. */
90let lastStart = 0
91
92// Set by register from the options, and by session.start for the folder.
93let lang: Lang = 'pt-BR'
94let style: IconStyle = 'emoji'
95let cwd = ''
96/** The `placement` option: where the menu shows until /pad place picks a place. */
97let placeOption: Placement = 'header'
98
99/** The agent types in one `.claude/agents` folder; none where it cannot be read. */
100async function agentsIn($: EngineInterface, dir: string): Promise<Target[]> {
101  const entries = (await $.fs.list(dir).catch(() => [])).filter(f => f.kind === 'file' && f.name.endsWith('.md'))
102  const names = await Promise.all(
103    entries.map(async f => {
104      const raw = await $.fs.read(`${dir}/${f.name}`).catch(() => null)
105      return agentName(f.name, typeof raw === 'string' ? raw : null)
106    }),
107  )
108  return names.filter(Boolean).map(name => ({ kind: 'agent' as const, name, description: '', source: 'agent' }))
109}
110
111/** Every command, skill and agent this session has, less /pad itself. */
112async function readCatalog($: EngineInterface): Promise<Target[]> {
113  const commands = await $.command.list().catch(() => [])
114  const home = await $.env.get('HOME').catch(() => undefined)
115  const [here, mine] = await Promise.all([cwd ? agentsIn($, `${cwd}/${AGENTS_DIR}`) : [], home ? agentsIn($, `${home}/${AGENTS_DIR}`) : []])
116  const files = [...here, ...mine]
117  const seen = new Set<string>()
118  const out: Target[] = []
119  const push = (t: Target) => {
120    const key = keyOf(t.kind, t.name)
121    if (seen.has(key)) return
122    seen.add(key)
123    out.push(t)
124  }
125  for (const c of commands) {
126    const name = c.name.replace(/^\//, '')
127    if (name && name !== 'pad') push({ kind: 'command', name, description: c.description, source: c.source })
128  }
129  for (const name of BUILTIN_AGENTS) push({ kind: 'agent', name, description: '', source: 'agent' })
130  for (const a of files) push(a)
131  return out
132}
133
134/**
135 * Reads the person's list, the project's buttons and the on/off switch. The catalog is read once
136 * a session (and again when the pane opens, or an add names something it lacks): `fresh` reads
137 * it now.
138 */
139async function load($: EngineInterface, fresh = false) {
140  const stored = asPads(await $.store.get(KEY_MENU).catch(() => undefined))
141  const raw = cwd ? await $.fs.read(`${cwd}/${PROJECT_FILE}`).catch(() => null) : null
142  await update($, menu, () => (stored ? localize(stored, lang) : defaults(lang)))
143  const off = (await $.store.get(KEY_OFF).catch(() => undefined)) === true
144  await update($, isOff, () => off)
145  const place = await $.store.get(KEY_PLACE).catch(() => undefined)
146  await update($, placement, () => (typeof place === 'string' ? placementOf(place) : placeOption))
147  await update($, project, () => parseProject(typeof raw === 'string' ? raw : null))
148  if (fresh || (await read($, catalog)).length === 0) {
149    const list = await readCatalog($)
150    await update($, catalog, () => list)
151  }
152}
153
154/** Applies a change to the list as it stands now, not as a drawing last saw it. */
155async function editMenu($: EngineInterface, change: (list: Pad[]) => Pad[]) {
156  await saveMenu($, change(await read($, menu)))
157}
158
159async function saveMenu($: EngineInterface, list: Pad[]) {
160  await $.store.set(KEY_MENU, list)
161  await update($, menu, () => list)
162}
163
164/** The buttons as shown and numbered: the person's that work here, then the project's. */
165async function shown($: EngineInterface) {
166  return shownOf(await read($, menu), await read($, project), await read($, catalog))
167}
168
169/** Puts a text in the prompt, its `[blank]` marked for the person to replace. */
170async function fill($: EngineInterface, text: string) {
171  const blank = blankIn(text)
172  await $.prompt.fill(blank ? { text, decorations: [{ ...blank, bold: true, underline: true }] } : { text })
173}
174
175async function press($: EngineInterface, p: Pad) {
176  try {
177    if (p.kind === 'agent') return await fill($, WORDS[lang].useAgent(agentOf(p.text), agentTask(p.text)))
178    // A command with a [blank] waits in the prompt for the person to fill in; so does any
179    // project button, whose text comes from the repository.
180    // A command whose blanks came from an optional hint (`/clear [name]`) runs bare.
181    const text = p.origin === 'project' ? p.text : unblank(p.text, hints.get(commandOf(p.text).command))
182    if (p.origin === 'project' || blankIn(text)) return await fill($, text)
183    const { command, args } = commandOf(text)
184    await $.command.run({ command, args })
185  } catch {
186    $.ui.toast(WORDS[lang].failed(p.label))
187  }
188}
189
190/** Adds a button to the person's list. Returns why not, or null when it was added. */
191async function add($: EngineInterface, p: Pad): Promise<string | null> {
192  const w = WORDS[lang]
193  const list = await read($, menu)
194  const key = padKey(p)
195  const known = async () => (await read($, catalog)).some(t => keyOf(t.kind, t.name) === key)
196  // Installed since the catalog was read? Read it again before saying no.
197  if (!(await known())) await load($, true)
198  if (!(await known())) return w.missing(p.text.split(/\s+/)[0] ?? p.text)
199  if ([...list, ...(await read($, project))].some(x => sameText(x.text, p.text))) return w.already(p.text)
200  // The limit counts the buttons that work here: one whose command another session has stays
201  // in the list, marked in the pane, and takes no place in this menu.
202  if (available(list, await read($, catalog)).length >= MAX_SHOWN) return w.full
203  await saveMenu($, [...list, p])
204  return null
205}
206
207/** The tile's background under the pointer: the theme's subtle gray, so it reads on dark and light. */
208const TILE_HOVER = 'subtle'
209
210/** /pad's arguments in the row under the menu; `fill` is the text the prompt waits with. */
211const PAD_ACTIONS: { verb: string; fill?: (w: (typeof WORDS)[Lang]) => string }[] = [
212  { verb: 'configuration' },
213  { verb: 'list' },
214  { verb: 'add', fill: w => w.addTemplate },
215  { verb: 'remove', fill: w => w.removeTemplate },
216  { verb: 'reset', fill: () => '/pad reset' },
217  { verb: 'place', fill: w => w.placeTemplate },
218  { verb: 'off' },
219  { verb: 'help' },
220]
221
222/** An id no button in the list has: the time, and the list's length for two in one millisecond. */
223async function newId($: EngineInterface) {
224  return `user:${await $.clock.now()}:${(await read($, menu)).length}`
225}
226
227/** Opens the menu's own pane. The host opens a pane only with the keyboard on it; Esc closes it. */
228function openMenuPane($: EngineInterface) {
229  return $.ui.open({ id: MENU_PANE, title: WORDS[lang].menuPane, focus: true, closeOnEscape: true }).catch(() => null)
230}
231
232/**
233 * Shows the menu at the start and after /clear, where it lives: under the header, /pad's row
234 * drawn as the card (not awaited: the row lands once the hook has returned); in its pane, opened
235 * with the keyboard on it, as the host opens every pane. The band above the prompt is there
236 * already.
237 */
238async function showMenu($: EngineInterface) {
239  const place = await read($, placement)
240  if (place === 'header') $.command.run({ command: 'pad' }).catch(() => undefined)
241  else if (place === 'pane') await openMenuPane($)
242}
243
244/** Runs one of /pad's arguments from the row: its answer, if any, as dim lines in the transcript. */
245async function pressVerb($: EngineInterface, a: (typeof PAD_ACTIONS)[number]) {
246  const w = WORDS[lang]
247  try {
248    if (a.fill) return await fill($, a.fill(w))
249    const { text } = await runPad($, a.verb)
250    // A log line is drawn as one row: a list or the help goes out a line at a time.
251    for (const line of text?.split('\n') ?? []) if (line.trim()) $.ui.log(line)
252  } catch {
253    $.ui.toast(w.failed(`/pad ${a.verb}`))
254  }
255}
256
257/**
258 * The row under the buttons: /pad's own arguments, one press each. Those that run at once (the
259 * pane, the list, off, help) run; those that take an argument or undo the person's list (add,
260 * remove, reset) wait in the prompt, so nothing is lost on a stray click.
261 */
262function padRow($: EngineInterface, ui: Pick<Elements[keyof Elements], 'Box' | 'Text' | 'Button'>, more: number) {
263  const { Box, Text, Button } = ui
264  const w = WORDS[lang]
265  return (
266    <Box flexDirection="row" flexWrap="wrap">
267      {more > 0 && <Text dimColor>{`${w.more(more)} · `}</Text>}
268      <Text dimColor>/pad </Text>
269      {PAD_ACTIONS.map((a, i) => (
270        <Box key={`padrow:${a.verb}`} flexDirection="row">
271          {i > 0 && <Text dimColor> · </Text>}
272          <Button key={`cmd:${a.verb}`} plain dimColor label={a.verb} onPress={() => pressVerb($, a)} />
273        </Box>
274      ))}
275    </Box>
276  )
277}
278
279/**
280 * The terminal's grid of bordered tiles, in columns that fit `columns` cells. Each tile adds 5
281 * cells to its label (border and padding on both sides, one cell of gap).
282 */
283function tiles($: EngineInterface, ui: Elements['terminal'], visible: Pad[], columns: number) {
284  const { Box, Button } = ui
285  const { width, rows } = layout(visible, style, Math.max(1, columns), 5)
286  return (
287    <Box flexDirection="column">
288      {rows.map((row, r) => (
289        <Box key={`row:${r}`} flexDirection="row">
290          {row.map(p => (
291            <Box key={`cell:${p.id}`} width={width} paddingRight={1}>
292              {/* The border is the Box's: a Button there would show inverted under the pointer.
293                  The label fills the row inside it, so a press anywhere on that row counts.
294                  Over the tile, it tints and its border and label turn the accent color. */}
295              <Box
296                key={`tile:${p.id}`}
297                flexGrow={1}
298                borderStyle="round"
299                borderDimColor
300                hover={{ borderColor: 'claude', borderDimColor: false, backgroundColor: TILE_HOVER }}
301              >
302                <Button
303                  key={`pad:${p.id}`}
304                  plain
305                  label={tileLabel(buttonLabel(p, style), width - 3)}
306                  hover={{ color: 'claude', bold: true }}
307                  onPress={() => press($, p)}
308                />
309              </Box>
310            </Box>
311          ))}
312        </Box>
313      ))}
314    </Box>
315  )
316}
317
318/** The terminal's card: a frame in the accent color (4 cells), the question, the tiles and /pad's row. */
319function terminalCard($: EngineInterface, ui: Elements['terminal'], list: Pad[], columns: number) {
320  const { Box, Text } = ui
321  const w = WORDS[lang]
322  const visible = list.slice(0, MAX_SHOWN)
323  const more = list.length - visible.length
324  return (
325    <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
326      <Text color="claude" bold>
327        ✻ {w.ask}
328      </Text>
329      {tiles($, ui, visible, columns - 4)}
330      {padRow($, ui, more)}
331    </Box>
332  )
333}
334
335/** /pad and its arguments: what the command answers, and what the row under the menu runs. */
336async function runPad($: EngineInterface, args: string): Promise<{ text?: string }> {
337  const w = WORDS[lang]
338  const [verb = '', ...rest] = args.trim().split(/\s+/)
339  const arg = rest.join(' ')
340
341  switch (verb.toLowerCase()) {
342    case '':
343    case 'show': {
344      await load($)
345      if (await read($, isOff)) return { text: w.isOff }
346      if (!(await shown($)).length) return { text: w.empty }
347      const place = await read($, placement)
348      if (place === 'prompt') return { text: w.inBand }
349      if (place === 'pane') {
350        const opened = await openMenuPane($)
351        if (opened?.isPlaced) return {}
352        return { text: listText(await shown($), lang, style) }
353      }
354      return { text: w.shown }
355    }
356
357    case 'place': {
358      const where = arg.toLowerCase()
359      if (where !== 'header' && where !== 'prompt' && where !== 'pane') return { text: w.badPlace }
360      await $.store.set(KEY_PLACE, where)
361      await update($, placement, () => where)
362      if (where === 'pane') await openMenuPane($)
363      else await $.ui.close({ id: MENU_PANE }).catch(() => undefined)
364      return { text: w.placed[where] }
365    }
366
367    // Kept across sessions. Off hides every card, those already in the transcript too; the
368    // commands and the pane go on working, to set the menu up before turning it back on.
369    case 'off':
370    case 'on': {
371      const off = verb.toLowerCase() === 'off'
372      await $.store.set(KEY_OFF, off)
373      await update($, isOff, () => off)
374      return { text: off ? w.off : w.on }
375    }
376
377    case 'configuration':
378    case 'config':
379    case 'configure': {
380      await load($, true)
381      await update($, filter, () => '')
382      await update($, offset, () => 0)
383      const opened = await $.ui.open({ id: PANE, title: w.pane.title, focus: true, closeOnEscape: true }).catch(() => null)
384      if (opened?.isPlaced) return {}
385      return { text: listText(await shown($), lang, style) }
386    }
387
388    case 'list':
389      await load($)
390      return { text: listText(await shown($), lang, style) }
391
392    case 'add': {
393      await load($)
394      const p = parseAdd(args.trim().slice(3), await newId($))
395      if (!p) return { text: w.badAdd }
396      const why = await add($, p)
397      if (why) return { text: why }
398      const n = (await shown($)).findIndex(x => x.id === p.id) + 1
399      return { text: w.added(n, buttonLabel(p, style)) }
400    }
401
402    case 'remove': {
403      await load($)
404      const p = (await shown($))[Number(arg) - 1]
405      if (!/^\d+$/.test(arg) || !p) return { text: w.noSuch(arg || '?') }
406      if (p.origin === 'project') return { text: w.projectRemove }
407      await saveMenu($, (await read($, menu)).filter(x => x.id !== p.id))
408      return { text: w.removed(p.label) }
409    }
410
411    case 'reset':
412      await saveMenu($, defaults(lang))
413      return { text: w.reset }
414
415    default:
416      return { text: w.help }
417  }
418}
419
420export const register: Register = (on, options) => {
421  // The options alone until session.start can read the system's LANG and which terminal this is.
422  lang = langOf(options.language)
423  style = styleOf(options.icons)
424  const showOnStart = options.showOnStart !== false
425  placeOption = placementOf(options.placement)
426  let w = WORDS[lang]
427
428  on('session.start', async ($, e, next) => {
429    const result = await next(e)
430    cwd = e.cwd
431    lang = langOf(options.language, await $.env.get('LANG').catch(() => undefined))
432    style = styleOf(options.icons, await $.env.get('TERMINAL_EMULATOR').catch(() => undefined))
433    w = WORDS[lang]
434    await $.command.register({
435      name: 'pad',
436      description:
437        lang === 'en'
438          ? 'Shortcuts: /pad shows them; configuration, list, add, remove, reset, place, off, on'
439          : 'Atalhos: /pad mostra; configuration, list, add, remove, reset, place, off, on',
440      argumentHint: '[configuration|list|add|remove|reset|place|off|on]',
441    })
442    await load($, true)
443    if (showOnStart && e.isInteractive) {
444      const messages = await $.session.messages().catch(() => [])
445      if (messages.length === 0 && !(await read($, isOff))) await showMenu($)
446    }
447    return result
448  })
449
450  // Watches the engine list the commands, to learn which take an argument.
451  on('command.describe', async ($, e, next) => {
452    const result = await next(e)
453    const name = e.command.replace(/^\//, '')
454    const hint = result.argumentHint?.trim()
455    if (hint) hints.set(name, hint)
456    else hints.delete(name)
457    // A button saved with blanks from an optional hint (`/clear [name]`) is saved back bare.
458    const list = await read($, menu)
459    const fixed = list.map(p => (p.kind === 'command' && p.origin === 'user' && commandOf(p.text).command === name ? { ...p, text: unblank(p.text, hint) } : p))
460    if (fixed.some((p, i) => p !== list[i] && p.text !== list[i]!.text)) await saveMenu($, fixed)
461    return result
462  }).catch(($, e, next) => next(e))
463
464  // A /clear starts the conversation over with no session.start; the classic SessionStart says so.
465  on('classic.SessionStart', async ($, e, next) => {
466    const result = await next(e)
467    if (e.source === 'clear' && showOnStart && !(await read($, isOff))) await showMenu($)
468    return result
469  }).catch(($, e, next) => next(e))
470
471  on('command.run', { command: 'pad' }, ($, e) => runPad($, e.args))
472
473  // /pad's own row, drawn as the menu. Any other /pad output (list, add, ...) stays text.
474  on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
475    const verb = e.props.args.trim().toLowerCase()
476    if (e.props.command !== 'pad' || e.props.isErrored || (verb !== '' && verb !== 'show')) return next(e)
477    if ((await read($, isOff)) || (await read($, placement)) !== 'header') return next(e)
478    const list = await shown($)
479    if (list.length === 0) return next(e)
480
481    if (e.surface === 'terminal') {
482      return terminalCard($, $.ui.resolve({ ...e, surface: 'terminal' }), list, e.viewport?.columns ?? 80)
483    }
484
485    const { Box, Text, Button } = $.ui.resolve(e)
486    const visible = list.slice(0, MAX_SHOWN)
487    const more = list.length - visible.length
488    return (
489      <Box flexDirection="column" rowGap={1}>
490        <Text bold>{w.ask}</Text>
491        <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1}>
492          {visible.map(p => (
493            <Button key={`pad:${p.id}`} label={buttonLabel(p, 'emoji')} onPress={() => press($, p)} />
494          ))}
495        </Box>
496        {padRow($, { Box, Text, Button }, more)}
497      </Box>
498    )
499  })
500
501  // `/pad place prompt`: the buttons in a row under the prompt, below the engine's hint line,
502  // which stays as the engine draws it.
503  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
504    const theirs = await next(e)
505    if ((await read($, placement)) !== 'prompt' || (await read($, isOff))) return theirs
506    const list = await shown($)
507    if (list.length === 0) return theirs
508    const { Box, Text, Button } = $.ui.resolve(e)
509    const icons = e.surface === 'terminal' ? style : 'emoji'
510    return (
511      <Box flexDirection="column">
512        {theirs}
513        <Box key="launchpad" flexDirection="row" flexWrap="wrap" gap={2} paddingX={2}>
514          <Text color="claude">✻</Text>
515          {list.slice(0, MAX_SHOWN).map(p => (
516            <Button key={`pad:${p.id}`} plain label={buttonLabel(p, icons)} hover={{ color: 'claude', bold: true }} onPress={() => press($, p)} />
517          ))}
518          <Button key="band:settings" plain dimColor label={w.settings} onPress={() => pressVerb($, PAD_ACTIONS[0]!)} />
519        </Box>
520      </Box>
521    )
522  })
523
524  // `/pad place pane`: the menu in a pane of its own, a tab beside the other mods' panes, with
525  // the card's bordered tiles on the terminal and its native buttons elsewhere.
526  on('ui.render', { component: 'Pane', requestId: MENU_PANE }, async ($, e) => {
527    const ui = $.ui.resolve(e)
528    const { Box, Text, Button } = ui
529    const list = await shown($)
530    const visible = list.slice(0, MAX_SHOWN)
531    const more = list.length - visible.length
532    const columns = Math.max(20, (e.props.bodyColumns || e.viewport?.columns || 80) - 2)
533    const body =
534      e.surface === 'terminal' ? (
535        tiles($, $.ui.resolve({ ...e, surface: 'terminal' }), visible, columns)
536      ) : (
537        <Box flexDirection="row" flexWrap="wrap" columnGap={1} rowGap={1}>
538          {visible.map(p => (
539            <Button key={`pad:${p.id}`} label={buttonLabel(p, 'emoji')} onPress={() => press($, p)} />
540          ))}
541        </Box>
542      )
543    return (
544      <Box flexDirection="column" paddingX={1} gap={1}>
545        <Text color="claude" bold>{`✻ ${w.ask}`}</Text>
546        {(await read($, isOff)) ? <Text dimColor>{w.isOff}</Text> : visible.length === 0 ? <Text dimColor>{w.empty}</Text> : body}
547        <Box flexDirection="row" flexWrap="wrap" gap={2}>
548          {padRow($, { Box, Text, Button }, more)}
549          <Button key="close" role="dismiss" label={w.pane.close} onPress={() => $.ui.close({ id: MENU_PANE })} />
550        </Box>
551      </Box>
552    )
553  })
554
555  // The wheel over the pane moves the catalog list, which the pane keeps whole in view.
556  on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
557    await update($, offset, o => Math.max(0, Math.min(lastStart, o + e.by)))
558    return {}
559  }).catch(($, e, next) => next(e))
560
561  // /pad configuration: the person's buttons to order and remove, the project's, and the catalog
562  // to add from, filtered as they type.
563  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
564    const els = $.ui.resolve(e)
565    const { Box, Text, Button } = els
566    // The mobile app draws no field yet: there the catalog lists unfiltered.
567    const Input = 'Input' in els ? els.Input : null
568    const icons = e.surface === 'terminal' ? style : 'emoji'
569    const all = await read($, catalog)
570    const mine = await read($, menu)
571    const theirs = available(await read($, project), all)
572    const working = available(mine, all)
573    const query = await read($, filter)
574    const found = matches(all, mine, query)
575    // The catalog list takes the rows the rest leaves: hint, menu, project, headings, filter,
576    // the scroll row, the footer and the gaps between them. The pane keeps it in view whole.
577    const fixed = 10 + mine.length + (theirs.length ? theirs.length + 2 : 0)
578    const listRows = Math.max(5, e.props.scroll.bodyRows - fixed)
579    const view = windowOf(found.length, await read($, offset), listRows)
580    const nameWidth = Math.max(24, Math.min(44, Math.floor(e.props.bodyColumns * 0.4)))
581    lastStart = Math.max(0, found.length - listRows)
582    const scrollList = (by: number) => update($, offset, o => windowOf(found.length, o + by, listRows).start)
583    const addTarget = async (t: Target) => {
584      const why = await add($, padFor(t, await newId($), hints.get(t.name)))
585      if (why) $.ui.toast(why)
586    }
587
588    return (
589      <Box flexDirection="column" paddingX={1} gap={1}>
590        <Text dimColor>{w.pane.hint}</Text>
591
592        <Box flexDirection="column">
593          <Text bold>{w.pane.menu(working.length)}</Text>
594          {mine.map((p, i) => {
595            // Numbered as /pad list and /pad remove number them: the buttons that work here.
596            const n = working.indexOf(p) + 1
597            return (
598              <Box key={`menu:${p.id}`} flexDirection="row" gap={1}>
599                <Text dimColor>{n > 0 ? String(n).padStart(2) : ' –'}</Text>
600                <Box width={26}>
601                  <Text wrap="truncate-end">{buttonLabel(p, icons)}</Text>
602                </Box>
603                <Box width={22}>
604                  <Text dimColor={n > 0} color={n > 0 ? undefined : 'warning'} wrap="truncate-end">
605                    {n > 0 ? p.text : `${p.text} ${w.pane.gone}`}
606                  </Text>
607                </Box>
608                <Box width={2}>{i > 0 && <Button key={`up:${p.id}`} plain label="↑" onPress={() => editMenu($, list => moveId(list, p.id, -1))} />}</Box>
609                <Box width={2}>
610                  {i < mine.length - 1 && <Button key={`down:${p.id}`} plain label="↓" onPress={() => editMenu($, list => moveId(list, p.id, 1))} />}
611                </Box>
612                <Button
613                  key={`remove:${p.id}`}
614                  plain
615                  dimColor
616                  label={w.pane.remove}
617                  onPress={() => editMenu($, list => list.filter(x => x.id !== p.id))}
618                />
619              </Box>
620            )
621          })}
622        </Box>
623
624        {theirs.length > 0 && (
625          <Box flexDirection="column">
626            <Text bold>{w.pane.project}</Text>
627            {theirs.map((p, i) => (
628              <Text key={`project:${p.id}`} dimColor wrap="truncate-end">{`${String(working.length + i + 1).padStart(2)} ${buttonLabel(p, icons)}  ${p.text}`}</Text>
629            ))}
630          </Box>
631        )}
632
633        <Box flexDirection="column">
634          <Text bold>{w.pane.add}</Text>
635          {working.length >= MAX_SHOWN ? (
636            <Text color="warning">{w.pane.full}</Text>
637          ) : (
638            <Box flexDirection="column">
639              {Input && (
640                <Input
641                  key="filter"
642                  label={w.pane.filter}
643                  placeholder={w.pane.placeholder}
644                  value={query}
645                  autoFocus
646                  submitLabel={w.pane.plus}
647                  onInput={async (value: string) => {
648                    await update($, filter, () => value)
649                    await update($, offset, () => 0)
650                  }}
651                  onSubmit={(value: string) => {
652                    const first = matches(all, mine, value)[0]
653                    if (first) addTarget(first)
654                  }}
655                />
656              )}
657              {found.length === 0 && <Text dimColor>{w.pane.none}</Text>}
658              {found.slice(view.start, view.end).map(t => (
659                <Box key={`find:${keyOf(t.kind, t.name)}`} flexDirection="row" gap={1}>
660                  <Button key={`add:${keyOf(t.kind, t.name)}`} plain label={w.pane.plus} onPress={() => addTarget(t)} />
661                  <Box width={nameWidth}>
662                    <Text wrap="truncate-end">
663                      {spell(t)}
664                      {t.kind === 'command' && hints.has(t.name) && <Text dimColor>{` ${blanksOf(hints.get(t.name))}`}</Text>}
665                    </Text>
666                  </Box>
667                  <Text dimColor wrap="truncate-end">
668                    {t.description || w.kinds[t.kind]}
669                  </Text>
670                </Box>
671              ))}
672              {found.length > listRows && (
673                <Box flexDirection="row" gap={2}>
674                  <Button key="list:up" plain dimColor={view.start === 0} label={w.pane.up} onPress={() => scrollList(-listRows + 1)} />
675                  <Button key="list:down" plain dimColor={view.end === found.length} label={w.pane.down} onPress={() => scrollList(listRows - 1)} />
676                  <Text dimColor>{w.pane.range(view.start + 1, view.end, found.length)}</Text>
677                </Box>
678              )}
679            </Box>
680          )}
681        </Box>
682
683        <Box flexDirection="row" gap={2}>
684          <Button key="reset" label={w.pane.reset} onPress={() => saveMenu($, defaults(lang))} />
685          <Button key="close" role="dismiss" label={w.pane.close} onPress={() => $.ui.close({ id: PANE })} />
686        </Box>
687      </Box>
688    )
689  })
690}
691
hooks/pad.ts 530 lines
1// Pure logic for launchpad: the default buttons, icons, parsing /pad add and the project file,
2// checking buttons against what the session has installed, and laying them out in columns.
3// No `$` here, so it tests without an engine.
4
5import type { IconStyle, Kind, Lang, Origin, Pad, Placement, Target } from '../types'
6
7/** Buttons the menu shows, and the most the person's own list may hold. */
8export const MAX_SHOWN = 8
9export const MAX_LABEL = 24
10export const MAX_TEXT = 2000
11export const MAX_PROJECT = 8
12
13/** Agent types every session has, besides the ones in `.claude/agents/`. */
14export const BUILTIN_AGENTS = ['general-purpose', 'Explore', 'Plan']
15
16/** Built-in icons: an emoji for most terminals, a one-cell symbol for those that draw emoji badly. */
17export const ICONS: Record<string, { emoji: string; symbol: string }> = {
18  folder: { emoji: '📁', symbol: '▤' },
19  doc: { emoji: '📄', symbol: '¶' },
20  pen: { emoji: '✏️', symbol: '✎' },
21  search: { emoji: '🔍', symbol: 'Δ' },
22  compress: { emoji: '🗜️', symbol: '⇲' },
23  gauge: { emoji: '⏱️', symbol: '◔' },
24  chart: { emoji: '📊', symbol: '▥' },
25  table: { emoji: '📋', symbol: '▦' },
26  mail: { emoji: '✉️', symbol: '✉' },
27  undo: { emoji: '↩️', symbol: '↺' },
28  spark: { emoji: '✨', symbol: '✦' },
29  brain: { emoji: '🧠', symbol: '◎' },
30  sliders: { emoji: '🎛️', symbol: '≡' },
31  help: { emoji: '❓', symbol: '?' },
32  agent: { emoji: '🤖', symbol: '◉' },
33  tool: { emoji: '🔧', symbol: '⚙' },
34  plug: { emoji: '🔌', symbol: '⌁' },
35}
36
37type Words = {
38  ask: string
39  defaults: { id: string; icon: string; label: string; text: string }[]
40  kinds: Record<Kind, string>
41  origins: Record<Origin, string>
42  shown: string
43  empty: string
44  off: string
45  on: string
46  isOff: string
47  added: (n: number, label: string) => string
48  removed: (label: string) => string
49  reset: string
50  projectRemove: string
51  noSuch: (n: string) => string
52  help: string
53  badAdd: string
54  addTemplate: string
55  removeTemplate: string
56  missing: (text: string) => string
57  full: string
58  already: (text: string) => string
59  failed: (what: string) => string
60  more: (n: number) => string
61  /** Where the menu shows: the answer to /pad place, and what /pad says when the menu is not a card. */
62  placed: Record<Placement, string>
63  badPlace: string
64  placeTemplate: string
65  inBand: string
66  /** The menu's own pane, when it shows as one. */
67  menuPane: string
68  /** The band's button that opens /pad configuration. */
69  settings: string
70  /** What an agent button puts in the prompt, its `[blank]` for the task. */
71  useAgent: (name: string, task?: string) => string
72  pane: {
73    title: string
74    hint: string
75    menu: (n: number) => string
76    project: string
77    add: string
78    filter: string
79    placeholder: string
80    plus: string
81    remove: string
82    gone: string
83    full: string
84    none: string
85    range: (from: number, to: number, of: number) => string
86    up: string
87    down: string
88    reset: string
89    close: string
90  }
91}
92
93export const WORDS: Record<Lang, Words> = {
94  'pt-BR': {
95    ask: 'O que você quer fazer?',
96    defaults: [
97      { id: 'compact', icon: 'compress', label: 'Compactar conversa', text: '/compact' },
98      { id: 'context', icon: 'chart', label: 'Ver contexto', text: '/context' },
99      { id: 'limits', icon: 'gauge', label: 'Ver limites', text: '/limits' },
100      { id: 'resume', icon: 'undo', label: 'Retomar conversa', text: '/resume' },
101      { id: 'memory', icon: 'brain', label: 'Editar memória', text: '/memory' },
102      { id: 'model', icon: 'sliders', label: 'Trocar modelo', text: '/model' },
103      { id: 'explore', icon: 'search', label: 'Explorar código', text: '@Explore' },
104      { id: 'help', icon: 'help', label: 'Ajuda', text: '/help' },
105    ],
106    kinds: { command: 'comando', agent: 'agente' },
107    origins: { default: 'padrão', user: 'seu', project: 'projeto' },
108    shown: 'Menu de atalhos do launchpad.',
109    empty: 'Nenhum atalho disponível. /pad configuration escolhe os do menu.',
110    off: 'Launchpad desligado: o menu não aparece mais ao abrir a sessão nem com /pad. /pad on liga de novo.',
111    on: 'Launchpad ligado: o menu aparece ao abrir a sessão, depois do /clear e com /pad.',
112    isOff: 'O launchpad está desligado. /pad on liga de novo.',
113    added: (n, label) => `Atalho ${n} criado: ${label}.`,
114    removed: label => `Atalho removido: ${label}.`,
115    reset: 'Atalhos de volta aos padrões.',
116    projectRemove: 'Esse atalho vem do projeto. Edite .claude/launchpad.json para tirá-lo.',
117    noSuch: n => `Não existe atalho ${n}. /pad list mostra os números.`,
118    help: [
119      '/pad                   mostra o menu',
120      '/pad configuration     abre o painel para escolher e ordenar os atalhos',
121      '/pad list              lista os atalhos',
122      '/pad add 📊 Nome | /comando   um atalho que roda um comando ou skill',
123      '/pad add Nome | @agente       um atalho que chama um agente',
124      '/pad remove 3          tira o atalho 3',
125      '/pad reset             volta aos atalhos padrão',
126      '/pad off | on          desliga ou liga o menu',
127      '/pad place header      o menu como cartão sob o cabeçalho (padrão)',
128      '/pad place prompt      o menu numa linha abaixo do prompt, sempre à mão',
129      '/pad place pane        o menu num painel, uma aba como as de /limits e /watch',
130      `Só entram comandos, skills e agentes instalados, até ${MAX_SHOWN} atalhos.`,
131    ].join('\n'),
132    badAdd: 'Use: /pad add 📊 Nome | /comando  ou  /pad add Nome | @agente',
133    addTemplate: '/pad add [nome] | [/comando ou @agente]',
134    removeTemplate: '/pad remove [número]',
135    missing: text => `${text} não está instalado nesta sessão. /pad configuration mostra o que está.`,
136    full: `O menu já tem ${MAX_SHOWN} atalhos. Tire um com /pad remove ou no painel.`,
137    already: text => `${text} já está no menu.`,
138    failed: what => `Não deu para rodar: ${what}`,
139    more: n => `+${n} em /pad list`,
140    placed: {
141      header: 'O menu volta a ser um cartão sob o cabeçalho, ao abrir a sessão, depois do /clear e com /pad.',
142      prompt: 'O menu agora fica numa linha abaixo do prompt, sempre à mão.',
143      pane: 'O menu agora abre num painel, uma aba como as de /limits e /watch.',
144    },
145    badPlace: 'Use: /pad place header | prompt | pane',
146    placeTemplate: '/pad place [header|prompt|pane]',
147    inBand: 'O menu está na linha abaixo do prompt. /pad place header volta ao cartão.',
148    menuPane: 'Atalhos',
149    settings: '⋯ configurar',
150    useAgent: (name, task) => `Use o agente ${name} para ${task || '[tarefa]'}`,
151    pane: {
152      title: 'Launchpad',
153      hint: `Só aparecem comandos, skills e agentes instalados nesta sessão. Até ${MAX_SHOWN} no menu.`,
154      menu: n => `No menu (${n}/${MAX_SHOWN})`,
155      project: 'Do projeto (.claude/launchpad.json)',
156      add: 'Adicionar',
157      filter: 'Filtrar',
158      placeholder: 'nome de comando, skill ou agente',
159      plus: '+ adicionar',
160      remove: 'remover',
161      gone: '(não instalado)',
162      full: `Menu cheio. Tire um atalho para adicionar outro.`,
163      none: 'Nada com esse nome.',
164      range: (from, to, of) => `${from}–${to} de ${of}`,
165      up: '▲ acima',
166      down: '▼ abaixo',
167      reset: 'Restaurar padrões',
168      close: 'Fechar',
169    },
170  },
171  en: {
172    ask: 'What do you want to do?',
173    defaults: [
174      { id: 'compact', icon: 'compress', label: 'Compact chat', text: '/compact' },
175      { id: 'context', icon: 'chart', label: 'See context', text: '/context' },
176      { id: 'limits', icon: 'gauge', label: 'See limits', text: '/limits' },
177      { id: 'resume', icon: 'undo', label: 'Resume a chat', text: '/resume' },
178      { id: 'memory', icon: 'brain', label: 'Edit memory', text: '/memory' },
179      { id: 'model', icon: 'sliders', label: 'Switch model', text: '/model' },
180      { id: 'explore', icon: 'search', label: 'Explore code', text: '@Explore' },
181      { id: 'help', icon: 'help', label: 'Help', text: '/help' },
182    ],
183    kinds: { command: 'command', agent: 'agent' },
184    origins: { default: 'default', user: 'yours', project: 'project' },
185    shown: 'Launchpad shortcuts menu.',
186    empty: 'No shortcuts available. /pad configuration picks the menu.',
187    off: 'Launchpad off: the menu no longer shows when a session starts or on /pad. /pad on turns it back on.',
188    on: 'Launchpad on: the menu shows when a session starts, after /clear and on /pad.',
189    isOff: 'The launchpad is off. /pad on turns it back on.',
190    added: (n, label) => `Shortcut ${n} added: ${label}.`,
191    removed: label => `Shortcut removed: ${label}.`,
192    reset: 'Shortcuts back to the defaults.',
193    projectRemove: 'That shortcut comes from the project. Edit .claude/launchpad.json to drop it.',
194    noSuch: n => `There is no shortcut ${n}. /pad list shows the numbers.`,
195    help: [
196      '/pad                   show the menu',
197      '/pad configuration     open the pane to pick and order the shortcuts',
198      '/pad list              list the shortcuts',
199      '/pad add 📊 Name | /command   a shortcut that runs a command or skill',
200      '/pad add Name | @agent        a shortcut that calls an agent',
201      '/pad remove 3          drop shortcut 3',
202      '/pad reset             back to the default shortcuts',
203      '/pad off | on          turn the menu off or on',
204      '/pad place header      the menu as a card under the header (the default)',
205      '/pad place prompt      the menu in a row below the prompt, always at hand',
206      '/pad place pane        the menu in a pane, a tab like those of /limits and /watch',
207      `Only installed commands, skills and agents, up to ${MAX_SHOWN} shortcuts.`,
208    ].join('\n'),
209    badAdd: 'Use: /pad add 📊 Name | /command  or  /pad add Name | @agent',
210    addTemplate: '/pad add [name] | [/command or @agent]',
211    removeTemplate: '/pad remove [number]',
212    missing: text => `${text} is not installed in this session. /pad configuration shows what is.`,
213    full: `The menu already has ${MAX_SHOWN} shortcuts. Drop one with /pad remove or in the pane.`,
214    already: text => `${text} is already in the menu.`,
215    failed: what => `Could not run: ${what}`,
216    more: n => `+${n} in /pad list`,
217    placed: {
218      header: 'The menu is a card under the header again: when a session starts, after /clear and on /pad.',
219      prompt: 'The menu now sits in a row below the prompt, always at hand.',
220      pane: 'The menu now opens in a pane, a tab like those of /limits and /watch.',
221    },
222    badPlace: 'Use: /pad place header | prompt | pane',
223    placeTemplate: '/pad place [header|prompt|pane]',
224    inBand: 'The menu is in the row below the prompt. /pad place header brings the card back.',
225    menuPane: 'Shortcuts',
226    settings: '⋯ configure',
227    useAgent: (name, task) => `Use the ${name} agent to ${task || '[task]'}`,
228    pane: {
229      title: 'Launchpad',
230      hint: `Only commands, skills and agents installed in this session show. Up to ${MAX_SHOWN} in the menu.`,
231      menu: n => `In the menu (${n}/${MAX_SHOWN})`,
232      project: 'From the project (.claude/launchpad.json)',
233      add: 'Add',
234      filter: 'Filter',
235      placeholder: 'command, skill or agent name',
236      plus: '+ add',
237      remove: 'remove',
238      gone: '(not installed)',
239      full: 'The menu is full. Drop a shortcut to add another.',
240      none: 'Nothing by that name.',
241      range: (from, to, of) => `${from}–${to} of ${of}`,
242      up: '▲ up',
243      down: '▼ down',
244      reset: 'Restore defaults',
245      close: 'Close',
246    },
247  },
248}
249
250/**
251 * The language: the `language` option when it names one; on `auto` (or none), Portuguese when the
252 * system's LANG is Portuguese (`pt_BR.UTF-8`, `pt_PT`, `pt`), English otherwise and when unset.
253 */
254export const langOf = (option: unknown, systemLang?: string | null): Lang =>
255  option === 'en' || option === 'pt-BR' ? option : /^pt([_.@-]|$)/i.test(systemLang ?? '') ? 'pt-BR' : 'en'
256/**
257 * The icon style: the `icons` option when it names one; on `auto` (or none), symbols in a
258 * JetBrains IDE's terminal (TERMINAL_EMULATOR=JetBrains-JediTerm), which gives many emoji one
259 * column where Claude Code counts two, and emoji everywhere else.
260 */
261export const styleOf = (option: unknown, terminal?: string | null): IconStyle =>
262  option === 'emoji' || option === 'symbol' ? option : /^JetBrains/i.test(terminal ?? '') ? 'symbol' : 'emoji'
263
264/** `/x` runs a command or skill, `@x` calls an agent; anything else is no button. */
265export const kindOf = (text: string): Kind | null => (/^\/[^\s/]/.test(text) ? 'command' : /^@[^\s@]/.test(text) ? 'agent' : null)
266
267const BLANK = /\[[^\]\n]+\]/
268
269/** The first `[blank]` in a text, as offsets, so the prompt can mark what to replace. */
270export const blankIn = (text: string): { start: number; end: number } | null => {
271  const m = BLANK.exec(text)
272  return m ? { start: m.index, end: m.index + m[0].length } : null
273}
274
275/** A command button's name and arguments: `/compact focus` is `compact` and `focus`. */
276export const commandOf = (text: string): { command: string; args: string } => {
277  const [name = '', ...rest] = text.replace(/^\//, '').trim().split(/\s+/)
278  return { command: name, args: rest.join(' ') }
279}
280
281/** An agent button's type: `@Explore` is `Explore`. */
282export const agentOf = (text: string): string => text.replace(/^@/, '').trim().split(/\s+/)[0] ?? ''
283
284/** What an agent button asks after the agent's name: `@revisor review [file]` is `review [file]`. */
285export const agentTask = (text: string): string => text.replace(/^@/, '').trim().split(/\s+/).slice(1).join(' ')
286
287/** What a button or a target stands for, the same spelling for both: `command:compact`, `agent:Explore`. */
288export const keyOf = (kind: Kind, name: string) => `${kind}:${name}`
289export const padKey = (p: Pad) => keyOf(p.kind, p.kind === 'command' ? commandOf(p.text).command : agentOf(p.text))
290
291/** How a target is written on a button: `/compact`, `@Explore`. */
292export const spell = (t: Target) => (t.kind === 'command' ? `/${t.name}` : `@${t.name}`)
293
294export const defaults = (lang: Lang): Pad[] =>
295  WORDS[lang].defaults.map(d => ({ ...d, kind: kindOf(d.text)!, origin: 'default' as const }))
296
297/** A saved list in the current language: a default button takes its label from `defaults(lang)`. */
298export const localize = (list: Pad[], lang: Lang): Pad[] => {
299  const labels = new Map(defaults(lang).map(d => [d.id, d.label]))
300  return list.map(p => (p.origin === 'default' && labels.has(p.id) ? { ...p, label: labels.get(p.id)! } : p))
301}
302
303/** The icon a target gets when the pane adds it: by kind, and for a command by where it comes from. */
304export const iconFor = (t: Target) => (t.kind === 'agent' ? 'agent' : t.source === 'builtin' ? 'tool' : t.source === 'mcp' ? 'plug' : 'spark')
305
306/**
307 * Whether a command's argument hint names only optional arguments: every part in square
308 * brackets, as `/clear [name]` or `/autocompact [auto|<tokens>]`. Such a command runs bare.
309 */
310export const isOptionalHint = (hint: string | undefined) => /^\s*(\[[^\]]*\]\s*)+$/.test(hint ?? '')
311
312/** Where the menu shows: the option or /pad place when it names one, else under the header. */
313export const placementOf = (v: unknown): Placement => (v === 'prompt' || v === 'pane' || v === 'header' ? v : 'header')
314
315/**
316 * A command button's text without the blanks an optional hint gave it: `/clear [name]` back to
317 * `/clear`, so it runs. Any other text, and an agent's, as it is.
318 */
319export const unblank = (text: string, hint: string | undefined) => {
320  if (!isOptionalHint(hint)) return text
321  const { command, args } = commandOf(text)
322  return sameText(args, blanksOf(hint)) ? `/${command}` : text
323}
324
325/**
326 * A command's argument hint as blanks to fill: `[level]` and `[a] [b]` stay, `<file>` becomes
327 * `[file]`, a bare `message` becomes `[message]`; empty when there is no hint.
328 */
329export const blanksOf = (hint: string | undefined): string => {
330  const h = (hint ?? '').trim()
331  if (!h) return ''
332  if (h.includes('[')) return h
333  return /<[^>]+>/.test(h) ? h.replace(/<([^>]+)>/g, '[$1]') : `[${h}]`
334}
335
336/**
337 * A button for a target the pane lists. A command whose hint asks for an argument gets it as
338 * blanks, so a press puts it in the prompt to finish rather than running it bare. A hint of
339 * optional arguments only gives none: the command runs as it is.
340 */
341export const padFor = (t: Target, id: string, hint?: string): Pad => {
342  const blanks = t.kind === 'command' && !isOptionalHint(hint) ? blanksOf(hint) : ''
343  return {
344    id,
345    icon: iconFor(t),
346    label: t.name.slice(0, MAX_LABEL),
347    text: blanks ? `${spell(t)} ${blanks}` : spell(t),
348    kind: t.kind,
349    origin: 'user',
350  }
351}
352
353/** Whether a name is one of the built-in icons; own keys only, so `constructor` is none. */
354export const isIcon = (name: string) => Object.hasOwn(ICONS, name)
355
356/**
357 * The icon a word of /pad add names, or null: a built-in name written `:chart:`, or a glyph (any
358 * token that starts with no letter or digit). A bare word is part of the label, so `search docs`
359 * is a label and not the icon `search`.
360 */
361const iconIn = (token: string): string | null => {
362  const named = /^:([\w-]+):$/.exec(token)?.[1]
363  if (named) return isIcon(named) ? named : null
364  return /^[^\p{L}\p{N}]/u.test(token) ? token : null
365}
366
367/** `/pad add 📊 Name | /command` or `| @agent` (icon optional) as a button, or null. */
368export const parseAdd = (args: string, id: string): Pad | null => {
369  const bar = args.indexOf('|')
370  if (bar < 0) return null
371  const left = args.slice(0, bar).trim()
372  const text = args.slice(bar + 1).trim().slice(0, MAX_TEXT)
373  const [first = '', ...rest] = left.split(/\s+/)
374  const icon = rest.length > 0 ? iconIn(first) : null
375  const label = (icon ? rest.join(' ') : left).trim().slice(0, MAX_LABEL)
376  const kind = kindOf(text)
377  if (!/[\p{L}\p{N}]/u.test(label) || !kind) return null
378  return { id, icon: icon ?? (kind === 'agent' ? 'agent' : 'tool'), label, text, kind, origin: 'user' }
379}
380
381/**
382 * The project's `.claude/launchpad.json`: `{ "buttons": [{ "icon", "label", "text" }] }`, each
383 * text a `/command` or an `@agent`. It comes from the repository, so a press only puts the text in
384 * the prompt and the person reads it before pressing Enter. Nothing from the file runs on a press.
385 */
386export const parseProject = (raw: string | null | undefined): Pad[] => {
387  if (!raw) return []
388  let data: unknown
389  try {
390    data = JSON.parse(raw)
391  } catch {
392    return []
393  }
394  const list = (data as { buttons?: unknown })?.buttons
395  if (!Array.isArray(list)) return []
396  const out: Pad[] = []
397  for (const b of list) {
398    if (out.length >= MAX_PROJECT) break
399    const label = typeof b?.label === 'string' ? b.label.trim().slice(0, MAX_LABEL) : ''
400    const text = typeof b?.text === 'string' ? b.text.trim().slice(0, MAX_TEXT) : ''
401    const kind = kindOf(text)
402    if (!label || !kind) continue
403    const icon = typeof b?.icon === 'string' && b.icon.trim() ? b.icon.trim().slice(0, 8) : 'table'
404    out.push({ id: `project:${out.length}`, icon, label, text, kind, origin: 'project' })
405  }
406  return out
407}
408
409/** An agent file's type: the `name:` in its frontmatter, else the file name less `.md`. */
410export const agentName = (file: string, raw: string | null | undefined): string => {
411  const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw ?? '')?.[1] ?? ''
412  const name = /^name:\s*["']?([^"'\r\n]+?)["']?\s*$/m.exec(front)?.[1]
413  return (name ?? file.replace(/\.md$/i, '')).trim()
414}
415
416/**
417 * A saved list as buttons: entries with no id, label or button text are dropped, and a missing
418 * icon or origin takes the kind's icon and `user`, so nothing malformed reaches the drawing.
419 */
420export const asPads = (v: unknown): Pad[] | null => {
421  if (!Array.isArray(v)) return null
422  const out: Pad[] = []
423  for (const p of v) {
424    if (typeof p?.id !== 'string' || typeof p?.label !== 'string' || typeof p?.text !== 'string') continue
425    const kind = kindOf(p.text)
426    if (!kind) continue
427    const icon = typeof p.icon === 'string' && p.icon ? p.icon : kind === 'agent' ? 'agent' : 'tool'
428    const origin: Origin = p.origin === 'default' || p.origin === 'project' ? p.origin : 'user'
429    out.push({ id: p.id, icon, label: p.label, text: p.text, kind, origin })
430  }
431  return out
432}
433
434/** Whether two button texts run the same thing: the same words, whatever the spaces. */
435export const sameText = (a: string, b: string) => a.trim().split(/\s+/).join(' ') === b.trim().split(/\s+/).join(' ')
436
437/** Keeps the buttons whose command, skill or agent this session has. */
438export const available = (pads: Pad[], catalog: readonly Target[]): Pad[] => {
439  const have = new Set(catalog.map(t => keyOf(t.kind, t.name)))
440  return pads.filter(p => have.has(padKey(p)))
441}
442
443/** The person's buttons that work here, then the project's: what the menu shows and numbers. */
444export const shownOf = (menu: Pad[], project: Pad[], catalog: readonly Target[]): Pad[] => available([...menu, ...project], catalog)
445
446/** Targets not yet in the menu that match the filter, commands first, then agents, by name. */
447export const matches = (catalog: readonly Target[], menu: Pad[], filter: string): Target[] => {
448  const taken = new Set(menu.map(padKey))
449  const q = filter.trim().toLowerCase().replace(/^[/@]/, '')
450  return catalog
451    .filter(t => !taken.has(keyOf(t.kind, t.name)))
452    .filter(t => !q || t.name.toLowerCase().includes(q) || t.description.toLowerCase().includes(q))
453    .sort((a, b) => (a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === 'command' ? -1 : 1))
454}
455
456/** The window of `rows` items a list of `total` shows from `offset`, kept inside the list. */
457export const windowOf = (total: number, offset: number, rows: number): { start: number; end: number } => {
458  const size = Math.max(1, rows)
459  const start = Math.max(0, Math.min(offset, total - size))
460  return { start, end: Math.min(total, start + size) }
461}
462
463/** The list with the button `id` moved by `delta` places; unchanged where it cannot move. */
464export const moveId = (list: readonly Pad[], id: string, delta: number): Pad[] => move(list, list.findIndex(p => p.id === id), delta)
465
466/** The list with item `i` moved by `delta` places; unchanged where it cannot move. */
467export const move = <T>(list: readonly T[], i: number, delta: number): T[] => {
468  const j = i + delta
469  if (i < 0 || i >= list.length || j < 0 || j >= list.length) return [...list]
470  const out = [...list]
471  ;[out[i], out[j]] = [out[j]!, out[i]!]
472  return out
473}
474
475export const glyph = (icon: string, style: IconStyle): string => {
476  return isIcon(icon) ? ICONS[icon]![style] : icon
477}
478
479const EMOJI = /\p{Emoji_Presentation}/u
480const PICTO = /\p{Extended_Pictographic}/u
481
482/**
483 * Cells a string takes in a terminal: two for an emoji drawn as one (📁, or ✏️ with its variation
484 * selector), one for a symbol drawn as text (⚙, ✉) and the rest, none for the selectors.
485 */
486export const cells = (s: string): number => {
487  const chars = [...s]
488  let n = 0
489  chars.forEach((ch, i) => {
490    if (ch === '\uFE0F' || ch === '\u200D') return
491    n += EMOJI.test(ch) || (PICTO.test(ch) && chars[i + 1] === '\uFE0F') ? 2 : 1
492  })
493  return n
494}
495
496/**
497 * A button's icon and label. With symbols, an icon of the person's own that is not one cell wide
498 * (an emoji from /pad add or the project file) gives way to the symbol of the button's kind, so
499 * the columns stay aligned.
500 */
501export const buttonLabel = (p: Pad, style: IconStyle) => {
502  const icon = style === 'symbol' && !isIcon(p.icon) && cells(p.icon) !== 1 ? (p.kind === 'agent' ? 'agent' : 'tool') : p.icon
503  return `${glyph(icon, style)} ${p.label}`
504}
505
506/**
507 * A tile's label padded to the cells inside its border, a space each side: the whole row between
508 * the side borders is the button, so a press anywhere on it counts.
509 */
510export const tileLabel = (label: string, inner: number): string => {
511  const room = Math.max(cells(label) + 2, inner)
512  return ` ${label}${' '.repeat(room - 1 - cells(label))}`
513}
514
515/** Columns of equal width that fit `columns` cells: the cell width and the rows of buttons. */
516export const layout = (pads: Pad[], style: IconStyle, columns: number, gap = 3): { width: number; rows: Pad[][] } => {
517  const width = Math.max(1, ...pads.map(p => cells(buttonLabel(p, style)))) + gap
518  const per = Math.max(1, Math.floor(Math.max(columns, width) / width))
519  const rows: Pad[][] = []
520  for (let i = 0; i < pads.length; i += per) rows.push(pads.slice(i, i + per))
521  return { width, rows }
522}
523
524/** /pad list: every button, numbered, with what it runs, its kind and where it comes from. */
525export const listText = (pads: Pad[], lang: Lang, style: IconStyle): string => {
526  const w = WORDS[lang]
527  if (pads.length === 0) return w.empty
528  return pads.map((p, i) => `${i + 1}. ${buttonLabel(p, style)}  ${p.text}  (${w.kinds[p.kind]}, ${w.origins[p.origin]})`).join('\n')
529}
530
types/index.d.ts 55 lines
1/** What a button does when pressed: run a command or skill, or call an agent. */
2export type Kind = 'command' | 'agent'
3
4/** Where a button comes from: built in, the person's own, or the project's .claude/launchpad.json. */
5export type Origin = 'default' | 'user' | 'project'
6
7export type Lang = 'pt-BR' | 'en'
8
9export type IconStyle = 'emoji' | 'symbol'
10
11/** Where the menu shows: a card under the header, a band above the prompt, or its own pane. */
12export type Placement = 'header' | 'prompt' | 'pane'
13
14export type Pad = {
15  id: string
16  /** A key of ICONS (`folder`) or a glyph of the person's own (`🧾`). */
17  icon: string
18  label: string
19  /** `/name args` for a command or skill, `@type` for an agent. */
20  text: string
21  kind: Kind
22  origin: Origin
23}
24
25/** A command, skill or agent this session has: what a button may point at. */
26export type Target = {
27  kind: Kind
28  /** Without the slash or the at sign. */
29  name: string
30  description: string
31  /** Where a command comes from (`builtin`, `plugin`, `user`, `mcp`); `agent` for an agent. */
32  source: string
33}
34
35declare module 'claude-code' {
36  interface PluginState {
37    launchpad: {
38      /** The person's buttons in order, at most MAX_SHOWN: the defaults until they change them. */
39      menu: Pad[]
40      /** The buttons of the project's .claude/launchpad.json. */
41      project: Pad[]
42      /** The commands, skills and agents this session has, read when the menu or the pane loads. */
43      catalog: Target[]
44      /** The pane's filter over the catalog. */
45      filter: string
46      /** The first catalog row the pane's list shows: moved by its arrows and the wheel. */
47      offset: number
48      /** True after /pad off: no menu at the start, after /clear or on /pad, until /pad on. */
49      isOff: boolean
50      /** Where the menu shows: /pad place, else the `placement` option. */
51      placement: Placement
52    }
53  }
54}
55