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

Fill a reply's ! <command> suggestions into the shell prompt instead of copying them.
!, 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.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.
mod/register.tsx 148 lines1import 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}
148mod/commands.ts 112 lines1const 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