SLOPSHOPPER

command-buttons

Pone botones [Ejecutar] y [Copiar] al lado de cada comando de shell que Claude escribe en un bloque de código, para correrlo o copiarlo sin seleccionar texto a…

newbandrowstoastprocess
★ 1v0.1.0MITupdated 2026-09-17Faridmurzone/command-buttons-claude-code
A shopper browsing a rack in a slop shop
README

command-buttons

A Claude Code plugin that adds instant Run and Copy buttons to every shell command in Claude's responses, plus a quick-access hotkey band above the prompt.

version license claude-code

Why command-buttons?

Claude often suggests shell commands, but copying and running them involves:

  • Selecting text carefully (especially with line continuations)
  • Opening a terminal or new window
  • Finding the command again if you need to run a variant

command-buttons eliminates all that friction. With one click—or one keystroke—you run the exact command Claude proposed, see the output right there in the transcript, and copy it just as easily.

Features

  • Inline buttons on every shell block ([ ▶ Run ] [ ⧉ Copy ]), framed to stand out from prose
  • Output inline under each command, up to 6 lines, in red if it fails
  • Hotkey band above the prompt: 1–9 to run, a–i to copy the latest reply's commands, no mouse required
  • Confirmation gate on destructive commands (kubectl delete, terraform destroy, --force, etc.)
  • State shared between the message box and the band: run from either place, see it update in both
  • Permission-aware: runs through Claude's own permission system; no secret extraction or bypasses
  • Typed, tested, and built as a Claude Code function hook

Installation

Prerequisites

  • Claude Code 2.1.273 or later
  • Function hooks enabled: set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in ~/.claude/settings.json

Quick Start

# Clone or download this repo
git clone https://github.com/faridmurzone/command-buttons-claude-code.git ~/.claude/skills/command-buttons

# Verify the plugin loads
claude plugin validate ~/.claude/skills/command-buttons

The plugin auto-loads as command-buttons@skills-dir on your next Claude Code session.

Manual Install

  1. Copy the repo into ~/.claude/skills/command-buttons
  2. Add to ~/.claude/settings.json: ``json { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } } ``
  3. Restart Claude Code

Usage

Message Box (Mouse)

Every shell fence in Claude's reply gets a bracketed button row:

  gcloud projects add-iam-policy-binding example-project \
    --role="roles/editor"

  [ ▶ Run ]  [ ⧉ Copy ]  gcloud projects add-iam-policy-binding…
  Updated IAM policy for project [example-project].
  • Run calls tool.call({ tool: 'Bash' }) — same as the model's own calls
  • Copy puts the command (with continuations intact) on your clipboard
  • Output shows below, in red if it failed

Quick Band (Keyboard)

Above the prompt, the latest finished reply's commands appear as hotkeys:

  ╭─ Quick run — last reply: ─────────────────────────────────────╮
  │ 1: Run   a: Copy   gcloud projects add-iam-policy-binding exam…│
  ╰─────────────────────────────────────────────────────────────────╯
  > _
  • 1–9 run the command (works with an empty prompt)
  • a–i copy the command (need focus on the band first: click it or ctrl+x tab)
  • No mouse needed once you memorize the keys

Safety Gates

Commands matching the confirm pattern (destructive by default) need two presses:

  First press:   [ ▶ Run ]  → [ ⚠ Confirm ]
  "Sensitive command: press again to confirm"
  Second press:  [ ⚠ Confirm ]  → runs it

Default destructive patterns: rm -rf, kubectl delete, terraform destroy, --force, remove-iam-policy-binding, set-iam-policy, firestore import|databases, and any command with uma-v2 (customize in /config → confirmPattern).

Configuration

In Claude Code's /config menu, or in ~/.claude/settings.json under pluginConfigs["command-buttons@skills-dir"].options:

OptionDefaultWhat it does
runInBackgroundfalseRun with run_in_background, keeping the session alive while the command runs.
confirmPatternRegex covering destructive opsCommands matching this pattern need a second press to run. Leave empty to disable.
maxButtons8Maximum commands per message to get buttons (others appear in the message box only).

Limitations

  • Permission mode: In bypass permissions mode, the click is the only gate. That's the point (human-in-the-loop), but one extra click runs the command. Use confirmPattern as your guardrail.
  • Background runs (runInBackground: true): The inline output shows the initial response (which comes back fast with a task ID), not the final result — watch the transcript for long-running commands.
  • Live turns (isWorking): The band only shows finished replies, never mid-turn, so it doesn't offer stale commands while Claude is typing.
  • Scrolling: If the band is tall and scrolls, the engine disarms digit hotkeys. The plugin respects maxRows and won't force a scroll.

Development

Run Tests

claude plugin test ~/.claude/skills/command-buttons   # 31 tests

Validate

claude plugin validate ~/.claude/skills/command-buttons

Regenerate Types

After updating Claude Code:

mkdir /tmp/modwork && cd /tmp/modwork
claude -p "/plugin-types"
cp .claude/types/claude-code.d.ts ~/.claude/skills/command-buttons/.claude/types/

Local Development

claude --plugin-dir ~/.claude/skills/command-buttons

The plugin reloads on file save.

Architecture

  • hooks/commands-of.ts: Parser for shell blocks in Markdown — finds closed fences, deduplicates, handles continuations, heredocs, loops
  • hooks/register.tsx: Two render hooks:
  • AssistantMessage: Draws the inline button row and output
  • AbovePrompt: Mirrors the latest reply as a hotkey band
  • tests/: 31 tests covering parsing, button state, shared state between hooks, streaming re-renders, and more

The core trick: state shared by command digest (digestOf), not by location. Run from the message box → it updates in the band. Run from the band → it updates in the message box.

Roadmap

  • ☐ Custom hotkey bindings (instead of fixed 1–9, a–i)
  • ☐ Favorites: star commands to pin to the band
  • ☐ History: re-run a recent command without typing
  • ☐ Syntax highlighting for command output
  • ☐ Per-command timeout override

Contributing

Issues, PRs, and forks welcome. The codebase is TypeScript + React (JSX for render trees), tested with claude plugin test, and linted with tsc.

How It Works Behind the Scenes

command-buttons is a Claude Code function hook plugin — a TypeScript module that decorates Claude's render pipeline. It:

  1. Hooks into the ui.render event for assistant messages
  2. Parses shell blocks using a hand-rolled Markdown fence parser
  3. Wraps the engine's own render tree with button rows and output
  4. Shares state (armed/running/output) across the message box and the quick band using a content digest as the key
  5. Routes presses through $.tool.call (so they go through the permission system and land in the transcript)

No monkey-patching, no secrets, no bypass — just one more layer of UI on top of Claude's own execution stack.

License

MIT. See LICENSE.

Feedback

Found a bug? Want a feature? Open an issue. Want to build on top of this? MIT license makes it easy.


Made with ❤️ for people who type faster than they click.

This is an early-access plugin for Claude Code function hooks. The API may change between releases — regenerate types after updating Claude Code.

Source 2 files
hooks/register.tsx 509 lines
1import type { EngineInterface, On, RenderElement } from 'claude-code'
2
3import { blocksOf } from './commands-of'
4
5/**
6 * A short stable digest of `text` (djb2): the identity a command's state
7 * (armed, running, its last output) is kept under, shared by every place the
8 * same command is drawn — the message's own box and the quick band above the
9 * prompt both key off it, so running it from either shows in both.
10 */
11function digestOf(text: string): string {
12  let hash = 5381
13
14  for (let index = 0; index < text.length; index += 1) {
15    hash = ((hash << 5) + hash + text.charCodeAt(index)) | 0
16  }
17
18  return (hash >>> 0).toString(36)
19}
20
21/** How much of a command a message-box row shows beside its buttons. */
22const PREVIEW_MIN = 24
23
24/** Room the two bracket buttons and their gaps take on a message-box row. */
25const BUTTONS_WIDTH = 30
26
27/** What the message-box frame itself takes: its border, padding and margin. */
28const FRAME_WIDTH = 6
29
30/** Room the two plain, hotkeyed buttons take on a quick-band row. */
31const ABOVE_BUTTONS_WIDTH = 22
32
33/** How many of the latest reply's commands the band offers: one digit each. */
34const MAX_ABOVE_PROMPT_COMMANDS = 9
35
36/** How many lines of a command's output are drawn before "+N more". */
37const OUTPUT_MAX_LINES = 6
38
39/** Clipboard commands to try, in order, until one is on the machine. */
40const CLIPBOARDS = [
41  ['pbcopy'],
42  ['wl-copy'],
43  ['xclip', '-selection', 'clipboard'],
44  ['xsel', '--clipboard', '--input'],
45]
46
47type Engine = EngineInterface
48
49type Options = {
50  runInBackground?: boolean
51  confirmPattern?: string
52  maxButtons?: string
53}
54
55/** What a run left behind, kept until the next press replaces it. */
56type Output = {
57  text: string
58  isError: boolean
59}
60
61/** The state a press reads and updates, shared by every row of one command. */
62type Ctx = {
63  options: Options
64  confirms: RegExp | null
65  armed: Set<string>
66  running: Set<string>
67  outputs: Map<string, Output>
68}
69
70type Handlers = {
71  run: () => void
72  take: () => void
73}
74
75/**
76 * Puts `text` on the system clipboard, answering which tool took it.
77 *
78 * @param $ the engine interface
79 * @param text the command to copy
80 * @returns the tool that took it, or null when the machine has none
81 */
82async function copy($: Engine, text: string): Promise<string | null> {
83  for (const argv of CLIPBOARDS) {
84    try {
85      const { exitCode } = await $.process.run(argv, {
86        stdin: text,
87        timeoutMs: 5000,
88      })
89
90      if (exitCode === 0) {
91        return argv[0] ?? null
92      }
93    } catch {
94      // Not on this machine, or it refused the text: try the next one.
95    }
96  }
97
98  return null
99}
100
101/**
102 * Runs `command` the way the model's own Bash calls do, and answers what it
103 * left behind. Toasts on a denial or a thrown error; a plain result is left
104 * for the caller to draw.
105 *
106 * @param $ the engine interface
107 * @param command the shell command to run
108 * @param options the manifest's `userConfig`
109 */
110async function runCommand(
111  $: Engine,
112  command: string,
113  options: Options,
114): Promise<Output> {
115  try {
116    const response = await $.tool.call({
117      tool: 'Bash',
118      command,
119      description: 'Run the command the assistant proposed',
120      ...(options.runInBackground === true ? { run_in_background: true } : {}),
121    })
122
123    if (response.deny !== undefined) {
124      $.ui.toast(`Denied: ${response.deny}`, { timeoutMs: 8000 })
125
126      return { text: response.deny, isError: true }
127    }
128
129    return { text: response.text ?? '', isError: response.isError === true }
130  } catch (error) {
131    const reason = error instanceof Error ? error.message : String(error)
132
133    $.ui.toast(`Did not run: ${reason}`, { timeoutMs: 8000 })
134
135    return { text: reason, isError: true }
136  }
137}
138
139/**
140 * The `onPress` closures one row needs, built once per draw: pressing Run
141 * arms a confirm-gated command on its first press and runs it on its second
142 * (or its only press, when nothing gates it); pressing Copy never runs
143 * anything. Both read and write `ctx`'s Sets and Map by `id`, so a press from
144 * the quick band and a press from the message box agree on one command's
145 * state.
146 *
147 * @param $ the engine interface
148 * @param id the command's identity (`digestOf`), not the row's own key
149 * @param command the shell command this row offers
150 * @param ctx the state shared by every row of this plugin
151 */
152function handlersFor(
153  $: Engine,
154  id: string,
155  command: string,
156  ctx: Ctx,
157): Handlers {
158  const needsConfirm = ctx.confirms !== null && ctx.confirms.test(command)
159
160  const run = () => {
161    if (ctx.running.has(id)) {
162      return
163    }
164
165    if (needsConfirm && !ctx.armed.has(id)) {
166      ctx.armed.add(id)
167      $.ui.invalidate('ui.render')
168      $.ui.toast('Sensitive command: press again to confirm', {
169        timeoutMs: 6000,
170      })
171
172      return
173    }
174
175    ctx.armed.delete(id)
176    ctx.running.add(id)
177    ctx.outputs.delete(id)
178    $.ui.invalidate('ui.render')
179
180    void runCommand($, command, ctx.options)
181      .then(output => {
182        ctx.outputs.set(id, output)
183      })
184      .finally(() => {
185        ctx.running.delete(id)
186        $.ui.invalidate('ui.render')
187      })
188  }
189
190  const take = () => {
191    void copy($, command).then(tool => {
192      $.ui.toast(
193        tool === null
194          ? 'No clipboard tool found (pbcopy or equivalent)'
195          : 'Command copied',
196        { timeoutMs: 2500 },
197      )
198    })
199  }
200
201  return { run, take }
202}
203
204/**
205 * `output.text`, cut to `width` columns and `OUTPUT_MAX_LINES` lines, with
206 * trailing blank lines dropped so a command that prints one line doesn't
207 * leave empty rows under it.
208 *
209 * @param output what the run resolved with
210 * @param width how wide one line of it may draw
211 * @returns the lines to draw, and how many more there were past the cap
212 */
213function previewOf(
214  output: Output,
215  width: number,
216): { lines: string[]; more: number } {
217  const all = output.text.split('\n')
218
219  while (all.length > 0 && all[all.length - 1]!.trim() === '') {
220    all.pop()
221  }
222
223  if (all.length === 0) {
224    return { lines: ['(no output)'], more: 0 }
225  }
226
227  const shown = all.slice(0, OUTPUT_MAX_LINES).map(line =>
228    line.length > width ? `${line.slice(0, width - 1)}…` : line,
229  )
230
231  return { lines: shown, more: Math.max(0, all.length - OUTPUT_MAX_LINES) }
232}
233
234/** A command on one line, its `\` continuations folded away. */
235function singleLineOf(command: string): string {
236  return command.replace(/\\\s*\n\s*/g, ' ').replace(/\s+/g, ' ')
237}
238
239/** `text`, cut to `width` columns with an ellipsis. */
240function clip(text: string, width: number): string {
241  return text.length > width ? `${text.slice(0, width - 1)}…` : text
242}
243
244/**
245 * Draws `[ Run ]` and `[ Copy ]` under every shell block of an assistant
246 * message, framed apart from its prose, with the run's output under its row;
247 * and mirrors the latest finished reply's commands as a hotkeyed band above
248 * the prompt (`1`, `2`, … to run; `a`, `b`, … to copy), so the last one
249 * never needs the mouse.
250 *
251 * Running goes through `$.tool.call`, so the command takes the session's
252 * permission path and lands in the transcript as its own `Bash` row too.
253 * Nothing runs without a press, and a command the confirm pattern names
254 * takes two.
255 *
256 * @param on the engine's registrar
257 * @param options the manifest's `userConfig`, as the person set it
258 */
259export function register(on: On, options: Options = {}) {
260  const ctx: Ctx = {
261    options,
262    confirms: (() => {
263      const source = options.confirmPattern ?? ''
264
265      if (source.trim() === '') {
266        return null
267      }
268
269      try {
270        return new RegExp(source, 'i')
271      } catch {
272        return null
273      }
274    })(),
275    armed: new Set<string>(),
276    running: new Set<string>(),
277    outputs: new Map<string, Output>(),
278  }
279
280  const maxButtons = (() => {
281    const parsed = Number.parseInt(options.maxButtons ?? '8', 10)
282
283    return Number.isFinite(parsed) && parsed > 0 ? parsed : 8
284  })()
285
286  /**
287   * The latest reply's text blocks, by `requestId`: a re-render (a resize,
288   * a streamed chunk) replaces its own entry rather than appending to it, so
289   * a block still growing never leaves a stale prefix behind. `isFirstOfReply`
290   * starts a new reply and drops every entry from the one before it.
291   */
292  const replyBlocks = new Map<string, string>()
293
294  /** `replyBlocks`, in the order its entries were last touched. */
295  const latestReplyText = () => [...replyBlocks.values()].join('\n')
296
297  on(
298    'ui.render',
299    { component: 'AssistantMessage' },
300    async ($: any, e: any, next: any): Promise<RenderElement> => {
301      const base = await next(e)
302      const text: string = e.props?.text ?? ''
303
304      if (e.props?.isFirstOfReply === true) {
305        replyBlocks.clear()
306      }
307
308      if (text !== '') {
309        replyBlocks.set(e.requestId, text)
310      }
311
312      if (text === '' || (!text.includes('```') && !text.includes('~~~'))) {
313        return base
314      }
315
316      const commands = blocksOf(text)
317        .flatMap(block => block.commands)
318        .slice(0, maxButtons)
319
320      if (commands.length === 0) {
321        return base
322      }
323
324      const { Box, Text, Button } = await $.ui.resolve(e)
325      const columns: number = e.viewport?.columns ?? 80
326      const preview = Math.max(
327        PREVIEW_MIN,
328        columns - BUTTONS_WIDTH - FRAME_WIDTH - 4,
329      )
330      const outputWidth = Math.max(PREVIEW_MIN, columns - FRAME_WIDTH - 2)
331
332      const rows = commands.map((command, index) => {
333        const id = digestOf(command)
334        const elementKey = `${e.requestId}:${index}:${id}`
335        const { run, take } = handlersFor($ as Engine, id, command, ctx)
336
337        const isArmed = ctx.armed.has(id)
338        const isRunning = ctx.running.has(id)
339        const output = ctx.outputs.get(id)
340        const outputLines =
341          output === undefined ? null : previewOf(output, outputWidth)
342
343        const label = isRunning
344          ? '⋯ Running'
345          : isArmed
346            ? '⚠ Confirm'
347            : '▶ Run'
348
349        return (
350          <Box key={elementKey} flexDirection="column">
351            <Box flexDirection="row" gap={1}>
352              <Button
353                key={`run:${elementKey}`}
354                label={label}
355                dimColor={isRunning}
356                onPress={run}
357              />
358              <Button
359                key={`copy:${elementKey}`}
360                label="⧉ Copy"
361                onPress={take}
362              />
363              <Text dimColor wrap="truncate-end">
364                {clip(singleLineOf(command), preview)}
365              </Text>
366            </Box>
367            {output === undefined ? null : (
368              <Box flexDirection="column" marginLeft={2}>
369                {outputLines!.lines.map((line, lineIndex) => (
370                  <Text
371                    key={`out:${elementKey}:${lineIndex}`}
372                    dimColor={!output.isError}
373                    color={output.isError ? 'red' : undefined}
374                    wrap="truncate-end"
375                  >
376                    {line}
377                  </Text>
378                ))}
379                {outputLines!.more > 0 ? (
380                  <Text dimColor>… (+{outputLines!.more} more lines)</Text>
381                ) : null}
382              </Box>
383            )}
384          </Box>
385        )
386      })
387
388      return (
389        <Box flexDirection="column">
390          {base}
391          <Box
392            flexDirection="column"
393            borderStyle="round"
394            borderDimColor
395            paddingX={1}
396            marginLeft={2}
397            marginTop={1}
398            alignSelf="flex-start"
399          >
400            {rows}
401          </Box>
402        </Box>
403      )
404    },
405  )
406
407  on(
408    'ui.render',
409    { component: 'AbovePrompt' },
410    async ($: any, e: any, next: any): Promise<RenderElement> => {
411      // A survey (a permission ask, a question) owns the band while it runs.
412      if (e.props?.hasSurvey === true) {
413        return next(e)
414      }
415
416      const below = await next(e)
417
418      // Mid-turn the latest reply's fences aren't closed yet, so this would
419      // otherwise show the reply before it — stale commands for a live turn.
420      if (e.props?.isWorking === true) {
421        return below
422      }
423
424      const text = latestReplyText()
425
426      if (text === '') {
427        return below
428      }
429
430      // A digest per command: two identical commands in one reply would
431      // otherwise draw two Buttons under the same key, which the engine
432      // refuses to draw (a real key, not just an address, must be unique).
433      const commands = [
434        ...new Map(
435          blocksOf(text)
436            .flatMap(block => block.commands)
437            .map(command => [digestOf(command), command] as const),
438        ).values(),
439      ]
440
441      if (commands.length === 0) {
442        return below
443      }
444
445      const maxRows: number = e.props?.maxRows ?? 0
446      const withHeader = maxRows >= 2
447      const capacity = Math.max(0, withHeader ? maxRows - 1 : maxRows)
448      const shown = commands.slice(
449        0,
450        Math.min(MAX_ABOVE_PROMPT_COMMANDS, capacity),
451      )
452
453      // Below the fold (no room even for one row): leave the band as is
454      // rather than force a scroll, which would drop the digit hotkeys.
455      if (shown.length === 0) {
456        return below
457      }
458
459      const { Box, Text, Button } = await $.ui.resolve(e)
460      const bodyColumns: number = e.props?.bodyColumns ?? 80
461      const preview = Math.max(PREVIEW_MIN, bodyColumns - ABOVE_BUTTONS_WIDTH)
462
463      const rows = shown.map((command, index) => {
464        const id = digestOf(command)
465        const { run, take } = handlersFor($ as Engine, id, command, ctx)
466        const digit = String(index + 1)
467        const letter = String.fromCharCode(97 + index)
468
469        const isArmed = ctx.armed.has(id)
470        const isRunning = ctx.running.has(id)
471        const runLabel = isRunning ? 'Running' : isArmed ? 'Confirm' : 'Run'
472
473        return (
474          <Box key={`above:${id}`} flexDirection="row" gap={1}>
475            <Button
476              key={`above:run:${id}`}
477              hotkey={digit}
478              plain
479              label={runLabel}
480              dimColor={isRunning}
481              onPress={run}
482            />
483            <Button
484              key={`above:copy:${id}`}
485              hotkey={letter}
486              plain
487              label="Copy"
488              onPress={take}
489            />
490            <Text dimColor wrap="truncate-end">
491              {clip(singleLineOf(command), preview)}
492            </Text>
493          </Box>
494        )
495      })
496
497      return (
498        <Box flexDirection="column">
499          {below}
500          {withHeader ? (
501            <Text dimColor>Quick run — last reply:</Text>
502          ) : null}
503          {rows}
504        </Box>
505      )
506    },
507  )
508}
509
hooks/commands-of.ts 186 lines
1/**
2 * Finds the shell commands a markdown message offers to run.
3 *
4 * Only closed fences count: a fence still streaming has no terminator yet, so
5 * a half-written command never grows a button it would run truncated.
6 */
7
8/** Info strings whose fence holds shell, plus the unlabelled fence. */
9const SHELL_LANGUAGES = new Set([
10  '',
11  'bash',
12  'sh',
13  'shell',
14  'shell-session',
15  'zsh',
16  'console',
17  'terminal',
18  'cmd',
19  'gcloud',
20])
21
22/**
23 * Openers that make the lines beneath them one statement. A block holding any
24 * of them is offered whole: splitting `for`/`if`/`case` at its newlines would
25 * hand `done` to the shell on its own.
26 */
27const BLOCK_OPENERS =
28  /^(for|while|until|if|elif|else|then|fi|do|done|case|esac|function|\}|\{)\b/
29
30/** A line that opens a heredoc, capturing the word that closes it. */
31const HEREDOC = /<<-?\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?/
32
33/** A line whose statement continues on the next one. */
34const CONTINUES = /(\\|&&|\|\||\||;)\s*$/
35
36/** What a fence holds when it is output pasted back, not a command. */
37const NOT_A_COMMAND = /^\s*[[{<]|^\s*(\d{4}-\d{2}-\d{2}|[A-Z]+:|\||[+-]{3}\s)/
38
39export type Block = {
40  /** The fence's whole body, as the message drew it. */
41  body: string
42  /** The commands it offers, in order; one entry for a fence kept whole. */
43  commands: string[]
44}
45
46/** True when `text` reads as a command rather than as pasted output. */
47function isCommand(text: string): boolean {
48  const first = text.split('\n').find(line => line.trim() !== '')
49
50  if (first === undefined) {
51    return false
52  }
53
54  return !NOT_A_COMMAND.test(first)
55}
56
57/**
58 * Splits a fence body into the commands it holds, or keeps it whole when its
59 * lines depend on each other.
60 *
61 * A comment rides with the command beneath it, so copying one carries what it
62 * is for.
63 */
64export function splitCommands(body: string): string[] {
65  const lines = body.split('\n')
66  const commands: string[] = []
67
68  let current: string[] = []
69  let heredoc: string | null = null
70
71  for (const line of lines) {
72    const trimmed = line.trim()
73
74    if (heredoc !== null) {
75      current.push(line)
76
77      if (trimmed === heredoc) {
78        heredoc = null
79        commands.push(current.join('\n').trim())
80        current = []
81      }
82
83      continue
84    }
85
86    if (BLOCK_OPENERS.test(trimmed)) {
87      return [body.trim()]
88    }
89
90    if (trimmed === '') {
91      if (current.length > 0) {
92        commands.push(current.join('\n').trim())
93        current = []
94      }
95
96      continue
97    }
98
99    current.push(line)
100
101    const opener = HEREDOC.exec(line)
102
103    if (opener) {
104      heredoc = opener[1] ?? null
105      continue
106    }
107
108    if (CONTINUES.test(trimmed) || trimmed.startsWith('#')) {
109      continue
110    }
111
112    commands.push(current.join('\n').trim())
113    current = []
114  }
115
116  if (current.length > 0) {
117    commands.push(current.join('\n').trim())
118  }
119
120  const kept = commands.filter(command => {
121    const bare = command
122      .split('\n')
123      .filter(line => !line.trim().startsWith('#'))
124      .join('\n')
125      .trim()
126
127    return bare !== ''
128  })
129
130  return kept.length === 0 ? [] : kept
131}
132
133/**
134 * The shell blocks of a markdown message, each with the commands it offers.
135 *
136 * @param text the message's markdown, as the transcript draws it
137 */
138export function blocksOf(text: string): Block[] {
139  const lines = text.split('\n')
140  const blocks: Block[] = []
141
142  let fence: { marker: string; language: string; body: string[] } | null = null
143
144  for (const line of lines) {
145    const opener = /^\s*(```+|~~~+)\s*([A-Za-z0-9_+-]*)\s*$/.exec(line)
146
147    if (fence === null) {
148      if (opener) {
149        fence = {
150          marker: opener[1]!.slice(0, 3),
151          language: (opener[2] ?? '').toLowerCase(),
152          body: [],
153        }
154      }
155
156      continue
157    }
158
159    const closes = /^\s*(```+|~~~+)\s*$/.exec(line)
160
161    if (closes && closes[1]!.startsWith(fence.marker)) {
162      const body = fence.body.join('\n')
163
164      if (SHELL_LANGUAGES.has(fence.language) && isCommand(body)) {
165        const commands = splitCommands(body)
166
167        if (commands.length > 0) {
168          blocks.push({ body, commands })
169        }
170      }
171
172      fence = null
173      continue
174    }
175
176    fence.body.push(line)
177  }
178
179  return blocks
180}
181
182/** Every command a message offers, in the order it drew them. */
183export function commandsOf(text: string): string[] {
184  return blocksOf(text).flatMap(block => block.commands)
185}
186