SLOPSHOPPER

run-command

Pick a reply's `! <command>` suggestion by click or digit to fill it into the shell prompt

newbandspinnerrowsprompt
★ 17v?MITupdated 2026-10-07bendrucker/claude/plugins/run-command
A shopper browsing a rack in a slop shop
README

Run Command

Fill a reply's ! <command> suggestions into the shell prompt instead of copying them.

Contents

  • Mod: Once you type !, register.tsx offers a reply's ! <command> lines as a button under the reply and a numbered list above the prompt. A digit or a click fills the command. Commands separated only by blank lines or code fences form a block, and each block of two or more gets a "run all" entry that fills them joined with &&, so the run stops at the first failure. 0 fills the first such entry. A block whose commands would misbehave when chained gets no entry, and commands.ts lists the cases. Picks are recorded through mod-events. Requires CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.

Tests

bun scripts/mod-test.ts run-command runs the mod's UI tests. bun test plugins/run-command/ runs the property tests of the command parser in mod/commands.ts, which run outside the mod harness so they can use Hegel and a real bash.

Source 2 files
mod/register.tsx 148 lines
1import type { ButtonProps, On, RenderElement } from "claude-code";
2import { blocks, LIMIT, pick, runAlls, type RunAll } from "./commands.ts";
3
4const MOD = "run-command";
5const KEY = "run-command:";
6const RUN_ALL = /^run-command:all(\d+):/;
7/** The command a button's key carries, read back when it is pressed. */
8export function pressed(element: string): string | undefined {
9  if (!element.startsWith(KEY)) return undefined;
10  const rest = element.slice(KEY.length);
11  return rest.slice(rest.indexOf(":") + 1);
12}
13
14// onPress is a no-op. The ui.press hook does the fill, since only a hook has `$`.
15function commandButtons(
16  Button: (props: ButtonProps) => RenderElement,
17  found: readonly string[],
18  props: (command: string, index: number) => Partial<ButtonProps>,
19): RenderElement[] {
20  return found.map((command, index) => (
21    <Button
22      key={`${KEY}${index}:${command}`}
23      plain
24      label={command}
25      onPress={() => undefined}
26      {...props(command, index)}
27    />
28  ));
29}
30
31/**
32 * Offers the `! <command>` lines of a reply once the person types `!`: a
33 * button under the reply and a numbered list above the prompt, each filling
34 * the command into the shell prompt. A digit in the empty shell prompt picks
35 * from the list.
36 */
37export function register(on: On): void {
38  // prompt.read and prompt.edit never show bash mode, so it is read off the
39  // hint line, which says "! for shell mode" while the empty prompt is in it.
40  let shell = false;
41  // A redraw repaints older replies too, so only a reply drawn for the first
42  // time, or the latest one again, moves the list.
43  const seen = new Set<string>();
44  let latest = { requestId: "", found: [] as string[], runs: [] as RunAll[] };
45  let listed = false;
46  // The band redraws often, so each reply's list counts as shown once.
47  let shownFor: string | undefined;
48
49  on("session.start", async ($, e, next) => {
50    const started = await next(e);
51    void $.modEvents.emit({ mod: MOD, event: "session.start" });
52    return started;
53  });
54
55  on("ui.render", { component: "PromptHint" }, ($, e, next) => {
56    const now = e.props.hint.includes("shell mode") && !e.props.isDraft;
57    if (now !== shell) {
58      shell = now;
59      $.ui.invalidate("ui.render");
60    }
61    return next(e);
62  });
63
64  on("ui.render", { component: "AssistantMessage" }, async ($, e, next) => {
65    const drawing = await next(e);
66    const grouped = blocks(e.props.text);
67    const found = grouped.flat();
68    // A reply with no commands still clears the list, but a later block of the
69    // latest reply without any keeps the ones an earlier block offered.
70    const first = !seen.has(e.requestId);
71    if (first || (e.requestId === latest.requestId && found.length > 0)) {
72      seen.add(e.requestId);
73      latest = { requestId: e.requestId, found, runs: runAlls(grouped) };
74    }
75    if (!shell || found.length === 0) return drawing;
76    const { Box, Button } = $.ui.resolve(e);
77    return (
78      <Box flexDirection="column">
79        {drawing}
80        <Box flexDirection="column" marginLeft={2}>
81          {commandButtons(Button, found, (command) => ({ dimColor: true, label: `▸ ${command}` }))}
82        </Box>
83      </Box>
84    );
85  });
86
87  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
88    const drawing = await next(e);
89    const found = latest.found.slice(0, LIMIT);
90    listed = shell && !e.props.hasSurvey && found.length > 0;
91    if (!listed) {
92      shownFor = undefined;
93      return drawing;
94    }
95    if (shownFor !== latest.requestId) {
96      shownFor = latest.requestId;
97      void $.modEvents.emit({ mod: MOD, event: "list.shown", detail: { count: found.length } });
98    }
99    const { Box, Button } = $.ui.resolve(e);
100    return (
101      <Box flexDirection="column">
102        {drawing}
103        {commandButtons(Button, found, (command, index) => ({ hotkey: String(index + 1) }))}
104        {latest.runs.map((run, index) => (
105          <Button
106            key={`${KEY}all${index}:${run.command}`}
107            plain
108            label={`run all ${run.first}–${run.first + run.count - 1}`}
109            onPress={() => undefined}
110            {...(index === 0 ? { hotkey: "0" } : {})}
111          />
112        ))}
113      </Box>
114    );
115  });
116
117  // The list's hotkeys stay inert while the shell prompt holds the keys.
118  on("prompt.edit", ($, e, next) => {
119    if (!listed || e.text !== "") return next(e);
120    const run = e.inputText === "0" ? latest.runs[0] : undefined;
121    if (run !== undefined) {
122      const detail = { count: run.count, source: "digit" };
123      void $.modEvents.emit({ mod: MOD, event: "pick.all", detail });
124      return next({ ...e, inputText: run.command });
125    }
126    const command = pick(e.inputText, latest.found);
127    if (command === undefined) return next(e);
128    void $.modEvents.emit({ mod: MOD, event: "pick", detail: { command, source: "digit" } });
129    return next({ ...e, inputText: command });
130  });
131
132  on("ui.press", { plugin: "run-command" }, async ($, e, next) => {
133    const command = pressed(e.element);
134    if (command === undefined) return next(e);
135    const index = RUN_ALL.exec(e.element)?.[1];
136    if (index !== undefined) {
137      // The list may have moved on since the button drew, so only a matching run gives the count.
138      const run = latest.runs[Number(index)];
139      const count = run?.command === command ? run.count : undefined;
140      void $.modEvents.emit({ mod: MOD, event: "pick.all", detail: { count, source: "click" } });
141    } else {
142      void $.modEvents.emit({ mod: MOD, event: "pick", detail: { command, source: "click" } });
143    }
144    await $.prompt.fill({ text: command, mode: "insert" });
145    return { element: e.element };
146  });
147}
148
mod/commands.ts 112 lines
1const DIGIT = /^[1-9]$/;
2export const LIMIT = 9;
3
4// A line that is only `! <command>`, optionally bulleted or in backticks.
5const COMMAND = /^\s*(?:[-*]\s+)?`?!\s+([^`\s][^`]*?)\s*`?\s*$/;
6// Operators holding an `&` that still chain: `&&` and fd redirects.
7const CHAINING_AMPERSANDS = /&&|[<>]&|&>/g;
8// Code sourced or eval'd runs in this shell, where a chain switches off its `set -e`.
9const IN_SHELL = /(?:^|&&|\|)\s*(?:source|\.|eval)(?:\s|$)/;
10
11/** Blank lines and code fences sit between commands without ending their block. */
12function keepsBlock(line: string): boolean {
13  const trimmed = line.trim();
14  return trimmed === "" || trimmed.startsWith("```");
15}
16
17/**
18 * `;` would mask an earlier failure, `||` would run after one, `#` comments
19 * out the rest of the chain, and a lone `&` backgrounds.
20 */
21function chainable(command: string): boolean {
22  const bare = unquoted(command);
23  return (
24    bare !== undefined &&
25    !/(?:\||&&)$/.test(bare) &&
26    !/(?<!<)<<(?!<)/.test(bare) &&
27    !bare.includes("||") &&
28    !IN_SHELL.test(bare) &&
29    !/[;#&]/.test(bare.replaceAll(CHAINING_AMPERSANDS, ""))
30  );
31}
32
33/**
34 * The command with each quoted or escaped span replaced by `_`, or undefined
35 * when a quote, escape, or paren is left open and would swallow the ` && `
36 * after it. A substitution inside double quotes is refused, since tracking
37 * its own quotes would take a full parser.
38 */
39function unquoted(command: string): string | undefined {
40  let bare = "";
41  let quote: string | undefined;
42  let depth = 0;
43  for (let i = 0; i < command.length; i++) {
44    const char = command[i];
45    if (quote === "'") {
46      if (char === "'") [quote, bare] = [undefined, `${bare}_`];
47    } else if (char === "\\") {
48      if (++i === command.length) return undefined;
49      if (quote === undefined) bare += "_";
50    } else if (quote !== undefined) {
51      if (char === quote.at(-1)) [quote, bare] = [undefined, `${bare}_`];
52      else if (quote === '"' && (char === "`" || (char === "$" && command[i + 1] === "(")))
53        return undefined;
54    } else if (char === "$" && command[i + 1] === "'") {
55      [quote, i] = ["$'", i + 1];
56    } else if (char === "'" || char === '"') {
57      quote = char;
58    } else {
59      if (char === "(") depth++;
60      if (char === ")" && --depth < 0) return undefined;
61      bare += char;
62    }
63  }
64  return quote === undefined && depth === 0 ? bare : undefined;
65}
66
67/** A reply's commands, grouped into runs that only blank lines and fences separate. */
68export function blocks(text: string): string[][] {
69  const found: string[][] = [];
70  let open = false;
71  for (const line of text.split("\n")) {
72    const command = COMMAND.exec(line)?.[1];
73    if (command !== undefined) {
74      if (open) found.at(-1)?.push(command);
75      else found.push([command]);
76      open = true;
77    } else if (!keepsBlock(line)) {
78      open = false;
79    }
80  }
81  return found;
82}
83
84/** Joins a block with `&&` so it stops at the first failure, if each command chains safely. */
85export function chain(block: readonly string[]): string | undefined {
86  if (block.length < 2 || !block.every(chainable)) return undefined;
87  return block.join(" && ");
88}
89
90export interface RunAll {
91  first: number;
92  count: number;
93  command: string;
94}
95
96/** Each chainable block among the listed commands, by its 1-based list positions. */
97export function runAlls(found: readonly string[][]): RunAll[] {
98  const runs: RunAll[] = [];
99  let first = 1;
100  for (const block of found) {
101    const listed = block.slice(0, Math.max(0, LIMIT - first + 1));
102    const command = chain(listed);
103    if (command !== undefined) runs.push({ first, count: listed.length, command });
104    first += block.length;
105  }
106  return runs;
107}
108
109export function pick(inputText: string, found: readonly string[]): string | undefined {
110  return DIGIT.test(inputText) ? found[Number(inputText) - 1] : undefined;
111}
112