authorgravatar for scratchywon@gmail.comscratchyone <scratchywon@gmail.com> 2026-10-05 01:46:13-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 00:46:13-05:00
log2a778f7a6e7b5f9403c5dbff2c2f4a9ce26c0ade
tree9fdec90ee181535069377078752bdec6e6e9f836
parentc40873762863abe34e2e784c518d81cbc08f6662
signature Signed by PGP key B5690EEEBB952194

feat(web): shell commands in the timeline are syntax highlighted (#15037)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Yash Singh <saiansh2525@gmail.com>

12 files changed, 1266 insertions(+), 61 deletions(-)

apps/web/package.json+1
...@@ -60,6 +60,7 @@...@@ -60,6 +60,7 @@
60 "remark-breaks": "^4.0.0",60 "remark-breaks": "^4.0.0",
61 "remark-gfm": "^4.0.1",61 "remark-gfm": "^4.0.1",
62 "tailwind-merge": "^3.4.0",62 "tailwind-merge": "^3.4.0",
63 "unbash": "^5.0.0",
63 "zustand": "^5.0.11"64 "zustand": "^5.0.11"
64 },65 },
65 "devDependencies": {66 "devDependencies": {
apps/web/src/components/chat/HighlightedTokens.test.ts created+117
...@@ -0,0 +1,117 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { embeddedScripts } from "../../lib/embeddedScripts";
4import { keyedLines, withEmbeddedScripts, wordsOf } from "./HighlightedTokens";
5
6function tokensOf(...contents: string[]) {
7 let offset = 0;
8 return contents.map((content) => {
9 const token = { content, offset };
10 offset += content.length;
11 return token;
12 });
13}
14
15function shape(parts: ReturnType<typeof wordsOf>) {
16 return parts.map((part) =>
17 typeof part === "string" ? part : part.pieces.map((piece) => piece.text).join(""),
18 );
19}
20
21describe("wordsOf", () => {
22 it.each([
23 // Shiki splits these shell words into several tokens.
24 [["echo", " ", '"', "$HOME", "/${", "PATH", "%%:*", '}"'], 'echo "$HOME/${PATH%%:*}"'],
25 [["rsync", " ", "-a", " ", "--exclude", " ", "build"], "rsync -a --exclude build"],
26 // A token can carry whitespace on either side or inside a string.
27 [["git", " && ", "echo", " ", '"two words"', "; "], 'git && echo "two words"; '],
28 [[" ", "indented"], " indented"],
29 [[], ""],
30 ])("wraps the same words as the plain-text fallback: %j", (contents, line) => {
31 expect(shape(wordsOf(tokensOf(...contents)))).toEqual(
32 line.split(/(\s+)/u).filter((part) => part !== ""),
33 );
34 });
35
36 it("keeps each piece's own token so colors survive regrouping", () => {
37 const [word] = wordsOf(tokensOf('"', "$HOME", '"'));
38 expect(
39 typeof word === "string" ? null : word?.pieces.map((piece) => piece.token.content),
40 ).toEqual(['"', "$HOME", '"']);
41 });
42});
43
44describe("keyedLines", () => {
45 it.each([
46 "git status\ngit diff",
47 "Get-ChildItem\r\nWrite-Output done\r\n",
48 "mixed\r\nendings\n\nlone \r stays",
49 ])("renders the same text as the source: %j", (code) => {
50 // Shiki splits lines on \r?\n and drops the ending from the tokens.
51 const lines = code.split(/\r?\n/u).map((line) => tokensOf(line));
52 const rendered = keyedLines(code, lines)
53 .map(({ tokens, ending }) => tokens.map((token) => token.content).join("") + ending)
54 .join("");
55 expect(rendered).toBe(code);
56 });
57
58 it("keys empty lines apart", () => {
59 const code = "a\r\n\r\n\r\nb";
60 const keys = keyedLines(
61 code,
62 code.split(/\r?\n/u).map((line) => tokensOf(line)),
63 ).map((line) => line.key);
64 expect(new Set(keys).size).toBe(keys.length);
65 });
66});
67
68describe("withEmbeddedScripts", () => {
69 // A stand-in grammar: each word is one token colored with the language name,
70 // and lines split the way Shiki splits them.
71 function tokenize(text: string, language: string) {
72 if (language === "unknown") throw new Error("Language not found");
73 let lineStart = 0;
74 return text.split(/\r?\n/u).map((line) => {
75 const tokens = [...line.matchAll(/\S+|\s+/gu)].map((match) => ({
76 content: match[0],
77 offset: lineStart + match.index,
78 color: language,
79 }));
80 lineStart +=
81 line.length + (text.slice(lineStart + line.length).match(/^\r?\n/u)?.[0].length ?? 0);
82 return tokens;
83 });
84 }
85
86 function colorsOf(code: string, scripts = embeddedScripts(code)) {
87 const lines = withEmbeddedScripts(code, tokenize(code, "shellscript"), scripts, tokenize);
88 expect(lines.map((line) => line.map((token) => token.content).join("")).join("\n")).toBe(
89 code.replaceAll("\r\n", "\n"),
90 );
91 return lines.flat().map((token) => [token.content, token.color]);
92 }
93
94 it("colors each nested script with its own grammar, keeping the text", () => {
95 expect(colorsOf(`bash -lc "python3 -c 'print(1)'"`)).toEqual([
96 [`bash -lc "python3 -c '`, "shellscript"],
97 ["print(1)", "python"],
98 [`'"`, "shellscript"],
99 ]);
100 });
101
102 it("colors an escape sequence like the character it spells", () => {
103 expect(colorsOf(`bash -c "python3 -c 'print(\\"x\\")'"`)).toContainEqual([
104 `print(\\"x\\")`,
105 "python",
106 ]);
107 });
108
109 it("keeps the surrounding colors where a grammar is missing", () => {
110 const code = "cat > x.ts <<'EOF'\r\nbody\r\nEOF";
111 const [script] = embeddedScripts(code);
112 // Compare per character: untouched lines keep their grammar's token boundaries.
113 const characterColors = (scripts: ReturnType<typeof embeddedScripts>) =>
114 colorsOf(code, scripts).flatMap(([content, color]) => [...content!].map(() => color));
115 expect(characterColors([{ ...script!, language: "unknown" }])).toEqual(characterColors([]));
116 });
117});
apps/web/src/components/chat/HighlightedTokens.tsx created+204
...@@ -0,0 +1,204 @@
1import { use, useMemo, type CSSProperties } from "react";
2
3import { resolveDiffThemeName } from "../../lib/diffRendering";
4import type { EmbeddedScript } from "../../lib/embeddedScripts";
5import { getSyntaxHighlighterPromise } from "../../lib/syntaxHighlighting";
6
7interface SyntaxToken {
8 readonly content: string;
9 readonly offset: number;
10 readonly color?: string;
11 readonly fontStyle?: number;
12}
13
14type TokenLines = ReadonlyArray<ReadonlyArray<SyntaxToken>>;
15
16const NO_EMBEDDED_SCRIPTS: ReadonlyArray<EmbeddedScript> = [];
17
18function syntaxTokenStyle(token: SyntaxToken): CSSProperties {
19 const fontStyle = token.fontStyle ?? 0;
20 return {
21 ...(token.color ? { color: token.color } : {}),
22 ...(fontStyle & 1 ? { fontStyle: "italic" } : {}),
23 ...(fontStyle & 2 ? { fontWeight: 700 } : {}),
24 ...(fontStyle & 4 ? { textDecoration: "underline" } : {}),
25 };
26}
27
28/**
29 * Colors `code` inside the caller's `<pre>` without changing its text, so
30 * selection and copy match the plain version. Suspends while the grammar
31 * loads; wrap it in Suspense with the plain text as the fallback.
32 * `wordClassName` goes on each whitespace-separated word. `embedded`
33 * scripts are colored with their own grammar inside `code`.
34 */
35export function HighlightedTokens({
36 code,
37 language,
38 embedded = NO_EMBEDDED_SCRIPTS,
39 theme,
40 wordClassName,
41}: {
42 code: string;
43 language: string;
44 embedded?: ReadonlyArray<EmbeddedScript>;
45 theme: "light" | "dark";
46 wordClassName?: string;
47}) {
48 const highlighter = use(getSyntaxHighlighterPromise(language));
49 // Every grammar loads into the same shared highlighter.
50 for (const script of embedded) use(getSyntaxHighlighterPromise(script.language));
51 const lines = useMemo(() => {
52 const themeName = resolveDiffThemeName(theme);
53 const tokenize = (text: string, lang: string) =>
54 highlighter.codeToTokens(text, { lang, theme: themeName }).tokens;
55 return keyedLines(
56 code,
57 withEmbeddedScripts(code, tokenize(code, language), embedded, tokenize),
58 );
59 }, [code, embedded, highlighter, language, theme]);
60
61 return lines.map(({ key, tokens, ending }) => (
62 <span key={key}>
63 {wordClassName
64 ? wordsOf(tokens).map((part) =>
65 typeof part === "string" ? (
66 part
67 ) : (
68 <span key={part.key} className={wordClassName}>
69 {part.pieces.map((piece) => (
70 <span key={piece.key} style={syntaxTokenStyle(piece.token)}>
71 {piece.text}
72 </span>
73 ))}
74 </span>
75 ),
76 )
77 : tokens.map((token) => (
78 <span key={`${token.offset}:${token.content}`} style={syntaxTokenStyle(token)}>
79 {token.content}
80 </span>
81 ))}
82 {ending}
83 </span>
84 ));
85}
86
87/**
88 * Recolors each embedded script's span of `code` with its own grammar,
89 * outermost first so nested scripts win. A script whose grammar is missing
90 * keeps the colors around it.
91 */
92export function withEmbeddedScripts(
93 code: string,
94 lines: TokenLines,
95 embedded: ReadonlyArray<EmbeddedScript>,
96 tokenize: (text: string, language: string) => TokenLines,
97): TokenLines {
98 if (embedded.length === 0) return lines;
99 const styles = Array.from<SyntaxToken | undefined>({ length: code.length });
100 for (const token of lines.flat()) {
101 styles.fill(token, token.offset, token.offset + token.content.length);
102 }
103 for (const script of embedded) {
104 let scriptLines: TokenLines;
105 try {
106 scriptLines = tokenize(script.text, script.language);
107 } catch {
108 continue;
109 }
110 for (const token of scriptLines.flat()) {
111 if (token.content === "") continue;
112 const last = token.offset + token.content.length - 1;
113 styles.fill(token, script.starts[token.offset], script.ends[last]);
114 }
115 }
116 return restyledLines(code, styles);
117}
118
119/** Splits `code` into lines of tokens, one per run of characters sharing a style. */
120function restyledLines(
121 code: string,
122 styles: ReadonlyArray<SyntaxToken | undefined>,
123): SyntaxToken[][] {
124 const lines: SyntaxToken[][] = [];
125 let start = 0;
126 for (const lineBreak of [...code.matchAll(/\r?\n/gu), undefined]) {
127 const end = lineBreak?.index ?? code.length;
128 const tokens: SyntaxToken[] = [];
129 for (let index = start; index < end;) {
130 const style = styles[index];
131 let next = index + 1;
132 while (
133 next < end &&
134 styles[next]?.color === style?.color &&
135 styles[next]?.fontStyle === style?.fontStyle
136 ) {
137 next += 1;
138 }
139 tokens.push({
140 content: code.slice(index, next),
141 offset: index,
142 ...(style?.color ? { color: style.color } : {}),
143 ...(style?.fontStyle ? { fontStyle: style.fontStyle } : {}),
144 });
145 index = next;
146 }
147 lines.push(tokens);
148 if (lineBreak) start = lineBreak.index + lineBreak[0].length;
149 }
150 return lines;
151}
152
153/**
154 * Keys each line by its start offset, which stays unique when several lines
155 * are empty, and pairs it with the line ending `code` has after it. Shiki drops
156 * `\n` and `\r\n` from tokens alike, so CRLF has to come back from the source.
157 */
158export function keyedLines<Token extends SyntaxToken>(
159 code: string,
160 lines: ReadonlyArray<ReadonlyArray<Token>>,
161) {
162 const endings = code.match(/\r?\n/gu) ?? [];
163 let lineStart = 0;
164 return lines.map((tokens, index) => {
165 const text = tokens.map((token) => token.content).join("");
166 const key = `${lineStart}:${text}`;
167 const ending = endings[index] ?? "";
168 lineStart += text.length + ending.length;
169 return { key, tokens, ending };
170 });
171}
172
173interface Word {
174 readonly key: string;
175 readonly pieces: Array<{ key: string; text: string; token: SyntaxToken }>;
176}
177
178/**
179 * Regroups a line's tokens into whitespace runs and whole words, matching
180 * `text.split(/(\s+)/)`, so a word made of several tokens (`"$HOME/x"`) wraps
181 * exactly like its plain-text fallback.
182 */
183export function wordsOf(tokens: ReadonlyArray<SyntaxToken>): Array<string | Word> {
184 const parts: Array<string | Word> = [];
185 let word: Word | null = null;
186 for (const token of tokens) {
187 let offset = token.offset;
188 for (const text of token.content.split(/(\s+)/u)) {
189 if (text === "") continue;
190 if (/^\s/u.test(text)) {
191 parts.push(text);
192 word = null;
193 } else {
194 if (!word) {
195 word = { key: String(offset), pieces: [] };
196 parts.push(word);
197 }
198 word.pieces.push({ key: String(offset), text, token });
199 }
200 offset += text.length;
201 }
202 }
203 return parts;
204}
apps/web/src/components/chat/MessagesTimeline.logic.ts+1-1
...@@ -121,7 +121,7 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string...@@ -121,7 +121,7 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string
121 }121 }
122 const toolPresentation = resolveWorkEntryToolPresentation(entry);122 const toolPresentation = resolveWorkEntryToolPresentation(entry);
123 if (toolPresentation) return toolPresentation.displayName;123 if (toolPresentation) return toolPresentation.displayName;
124 if (entry.command) return commandDisplayText(entry.command);124 if (entry.command?.trim()) return commandDisplayText(entry.command);
125 const action = toolGroupAction(entry);125 const action = toolGroupAction(entry);
126 if (action === "code-search" || action === "search") {126 if (action === "code-search" || action === "search") {
127 // Adapters title file searches with their target; the item keeps only the pattern.127 // Adapters title file searches with their target; the item keeps only the pattern.
apps/web/src/components/chat/ShellCommandBlock.tsx created+76
...@@ -0,0 +1,76 @@
1import {
2 commandHighlightLanguage,
3 withVisibleControlCharacters,
4} from "@t3tools/client-runtime/work-log/command-label";
5import { Suspense, use, useMemo } from "react";
6
7import { useTheme } from "../../hooks/useTheme";
8import { RenderErrorBoundary } from "../RenderErrorBoundary";
9import { HighlightedTokens } from "./HighlightedTokens";
10
11// Shell words wrap as a unit, so `--exclude` or a quoted string is not split
12// at a hyphen; a word longer than the line still breaks anywhere.
13const WORD_CLASS_NAME = "inline-block max-w-full [overflow-wrap:anywhere]";
14
15// The shell parser loads with the first expanded command, not with the timeline.
16let embeddedScriptsModule: Promise<typeof import("../../lib/embeddedScripts")> | undefined;
17function loadEmbeddedScripts() {
18 embeddedScriptsModule ??= import("../../lib/embeddedScripts");
19 return embeddedScriptsModule;
20}
21
22/** Same layout as the highlighted version, so the grammar arriving never reflows the block. */
23function PlainWords({ code }: { code: string }) {
24 // split with a capture group alternates words (even) and whitespace (odd).
25 return code.split(/(\s+)/u).map((part, index) =>
26 index % 2 === 1 || part === "" ? (
27 part
28 ) : (
29 // Positions are stable for a given string, which is all this renders.
30 // oxlint-disable-next-line react/no-array-index-key
31 <span key={index} className={WORD_CLASS_NAME}>
32 {part}
33 </span>
34 ),
35 );
36}
37
38function HighlightedCommand({ code, theme }: { code: string; theme: "light" | "dark" }) {
39 const { embeddedScripts } = use(loadEmbeddedScripts());
40 const language = commandHighlightLanguage(code);
41 // Only shell syntax nests scripts; PowerShell's own grammar colors its strings.
42 const embedded = useMemo(
43 () => (language === "shellscript" ? embeddedScripts(code) : []),
44 [code, embeddedScripts, language],
45 );
46 return (
47 <HighlightedTokens
48 code={code}
49 language={language}
50 embedded={embedded}
51 theme={theme}
52 wordClassName={WORD_CLASS_NAME}
53 />
54 );
55}
56
57/**
58 * The command a command_execution item ran, syntax highlighted. Scripts inside
59 * it, such as a `bash -lc` script or a Python heredoc, get their own grammar.
60 */
61export function ShellCommandBlock({ command }: { command: string }) {
62 const { resolvedTheme } = useTheme();
63 const code = withVisibleControlCharacters(command.trim());
64 if (!code) return null;
65 const plain = <PlainWords code={code} />;
66 return (
67 // The tool body sets the monospace, pre-wrapped text this sits in.
68 <div className="text-foreground/85">
69 <RenderErrorBoundary fallback={plain} resetKeys={[code]}>
70 <Suspense fallback={plain}>
71 <HighlightedCommand code={code} theme={resolvedTheme} />
72 </Suspense>
73 </RenderErrorBoundary>
74 </div>
75 );
76}
apps/web/src/components/chat/SnapShotAttachmentDetails.tsx+3-57
...@@ -1,15 +1,14 @@...@@ -1,15 +1,14 @@
1import type { SnapShotSource } from "@t3tools/contracts";1import type { SnapShotSource } from "@t3tools/contracts";
2import { ImageIcon, TextIcon } from "lucide-react";2import { ImageIcon, TextIcon } from "lucide-react";
3import { Suspense, use, useMemo, type CSSProperties } from "react";3import { Suspense } from "react";
44
5import { useTheme } from "../../hooks/useTheme";5import { useTheme } from "../../hooks/useTheme";
6import { resolveDiffThemeName } from "../../lib/diffRendering";
7import { getSyntaxHighlighterPromise } from "../../lib/syntaxHighlighting";
8import { cn } from "../../lib/utils";6import { cn } from "../../lib/utils";
9import { RenderErrorBoundary } from "../RenderErrorBoundary";7import { RenderErrorBoundary } from "../RenderErrorBoundary";
10import { Button } from "../ui/button";8import { Button } from "../ui/button";
11import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover";9import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "../ui/popover";
12import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";10import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
11import { HighlightedTokens } from "./HighlightedTokens";
1312
14export const SNAP_SHOT_ATTACHMENT_FRAME_CLASS =13export const SNAP_SHOT_ATTACHMENT_FRAME_CLASS =
15 "relative h-28 w-52 max-w-full overflow-hidden rounded-lg border border-border/80";14 "relative h-28 w-52 max-w-full overflow-hidden rounded-lg border border-border/80";
...@@ -19,59 +18,6 @@ export interface SnapShotAccessibilityDetails {...@@ -19,59 +18,6 @@ export interface SnapShotAccessibilityDetails {
19 format: "json" | "text";18 format: "json" | "text";
20}19}
2120
22interface SyntaxToken {
23 readonly content: string;
24 readonly offset: number;
25 readonly color?: string;
26 readonly fontStyle?: number;
27}
28
29function syntaxTokenStyle(token: SyntaxToken): CSSProperties {
30 const fontStyle = token.fontStyle ?? 0;
31 return {
32 ...(token.color ? { color: token.color } : {}),
33 ...(fontStyle & 1 ? { fontStyle: "italic" } : {}),
34 ...(fontStyle & 2 ? { fontWeight: 700 } : {}),
35 ...(fontStyle & 4 ? { textDecoration: "underline" } : {}),
36 };
37}
38
39function HighlightedAccessibilityJson({
40 content,
41 theme,
42}: {
43 content: string;
44 theme: "light" | "dark";
45}) {
46 const highlighter = use(getSyntaxHighlighterPromise("json"));
47 const lines = useMemo(
48 () =>
49 highlighter.codeToTokens(content, {
50 lang: "json",
51 theme: resolveDiffThemeName(theme),
52 }).tokens,
53 [content, highlighter, theme],
54 );
55
56 let lineOffset = 0;
57 return lines.map((line) => {
58 const lineContent = line.map((token) => token.content).join("");
59 const lineKey = `${lineOffset}:${lineContent}`;
60 const hasNextLine = lineOffset + lineContent.length < content.length;
61 lineOffset += lineContent.length + 1;
62 return (
63 <span key={lineKey}>
64 {line.map((token) => (
65 <span key={`${token.offset}:${token.content}`} style={syntaxTokenStyle(token)}>
66 {token.content}
67 </span>
68 ))}
69 {hasNextLine ? "\n" : null}
70 </span>
71 );
72 });
73}
74
75export function SnapShotAccessibilityData({21export function SnapShotAccessibilityData({
76 details,22 details,
77 className,23 className,
...@@ -84,7 +30,7 @@ export function SnapShotAccessibilityData({...@@ -84,7 +30,7 @@ export function SnapShotAccessibilityData({
84 details.format === "json" ? (30 details.format === "json" ? (
85 <RenderErrorBoundary fallback={details.content}>31 <RenderErrorBoundary fallback={details.content}>
86 <Suspense fallback={details.content}>32 <Suspense fallback={details.content}>
87 <HighlightedAccessibilityJson content={details.content} theme={resolvedTheme} />33 <HighlightedTokens code={details.content} language="json" theme={resolvedTheme} />
88 </Suspense>34 </Suspense>
89 </RenderErrorBoundary>35 </RenderErrorBoundary>
90 ) : (36 ) : (
apps/web/src/components/chat/V2ItemInspector.tsx+2-1
...@@ -24,6 +24,7 @@ import { Button } from "../ui/button";...@@ -24,6 +24,7 @@ import { Button } from "../ui/button";
24import ChatMarkdown from "../ChatMarkdown";24import ChatMarkdown from "../ChatMarkdown";
25import { RenderErrorBoundary } from "../RenderErrorBoundary";25import { RenderErrorBoundary } from "../RenderErrorBoundary";
26import { resolveExternalWebLinkHref } from "./externalLinkContextMenu";26import { resolveExternalWebLinkHref } from "./externalLinkContextMenu";
27import { ShellCommandBlock } from "./ShellCommandBlock";
2728
28interface V2ItemInspectorProps {29interface V2ItemInspectorProps {
29 readonly projectedItem: OrchestrationV2ProjectedTurnItem;30 readonly projectedItem: OrchestrationV2ProjectedTurnItem;
...@@ -173,7 +174,7 @@ function ToolCallBody(...@@ -173,7 +174,7 @@ function ToolCallBody(
173 const call = toolCallLines({ command: props.command, args: props.args });174 const call = toolCallLines({ command: props.command, args: props.args });
174 return (175 return (
175 <div className={cn("space-y-1.5", monoClassName)}>176 <div className={cn("space-y-1.5", monoClassName)}>
176 {call.command ? <div className="text-foreground/85">{call.command}</div> : null}177 {call.command ? <ShellCommandBlock command={call.command} /> : null}
177 {call.args ? (178 {call.args ? (
178 <div className="text-foreground/85">179 <div className="text-foreground/85">
179 {call.args.map(([key, value]) => (180 {call.args.map(([key, value]) => (
apps/web/src/lib/embeddedScripts.test.ts created+124
...@@ -0,0 +1,124 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { embeddedScripts } from "./embeddedScripts";
4
5/** Each script's language, its text, and the part of the command that spells it. */
6function scriptsOf(command: string) {
7 return embeddedScripts(command).map((script) => {
8 // Every character maps inside the command, in order.
9 script.starts.forEach((start, index) => {
10 expect(start).toBeLessThan(script.ends[index]!);
11 if (index > 0) expect(start).toBeGreaterThanOrEqual(script.ends[index - 1]!);
12 });
13 return [
14 script.language,
15 script.text,
16 command.slice(script.starts[0], script.ends.at(-1)),
17 ] as const;
18 });
19}
20
21const pythonScript = `from pathlib import Path
22import json
23
24# print every package.json name and version
25for p in sorted(Path('.').glob('**/package.json')):
26 data = json.loads(p.read_text())
27 print(f"{data['name']:16} {data.get('version', '?')}")
28`;
29
30describe("embeddedScripts", () => {
31 it("finds the script inside a Codex login-shell wrapper and the script inside that", () => {
32 // Recorded in the turn_interrupt_mid_tool Codex transcript
33 // (apps/server/src/orchestration-v2/testkit/fixtures).
34 const inner = `console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)`;
35 const command = `/bin/bash -lc "node -e \\"${inner}\\""`;
36 expect(scriptsOf(command)).toEqual([
37 ["shellscript", `node -e "${inner}"`, `node -e \\"${inner}\\"`],
38 ["javascript", inner, inner],
39 ]);
40 });
41
42 it("highlights a heredoc fed to an interpreter in that interpreter's language", () => {
43 const command = `python3 - <<'PY'\n${pythonScript}PY`;
44 expect(scriptsOf(command)).toEqual([["python", pythonScript, pythonScript]]);
45 });
46
47 it("maps escaped characters back to both source characters", () => {
48 const command = `/bin/zsh -lc "python3 - <<'PY'\nprint(\\"\\$HOME\\")\nPY"`;
49 const [shell, python] = scriptsOf(command);
50 expect(shell?.[1]).toBe(`python3 - <<'PY'\nprint("$HOME")\nPY`);
51 expect(python).toEqual(["python", `print("$HOME")\n`, `print(\\"\\$HOME\\")\n`]);
52 });
53
54 it.each([
55 [`python3 -c 'print(1)'`, "python", "print(1)"],
56 [`python3 -X dev -c 'print(1)'`, "python", "print(1)"],
57 [`py -3 -c 'print(1)'`, "python", "print(1)"],
58 [`C:\\Python312\\python.exe -c 'print(1)'`, "python", "print(1)"],
59 [`"C:\\Program Files\\Python312\\python.exe" -c 'print(1)'`, "python", "print(1)"],
60 [`node --eval "process.exit(0)"`, "javascript", "process.exit(0)"],
61 [`bun -e 'console.log(Bun.version)'`, "typescript", "console.log(Bun.version)"],
62 [`deno eval 'console.log(1)'`, "typescript", "console.log(1)"],
63 [`deno eval --config deno.json 'console.log(1)'`, "typescript", "console.log(1)"],
64 [`deno eval --env-file 'console.log(1)'`, "typescript", "console.log(1)"],
65 [`psql -f schema.sql -c 'select 1'`, "sql", "select 1"],
66 [`node -C development -e 'console.log(1)'`, "javascript", "console.log(1)"],
67 [`ruby -e 'puts 1'`, "ruby", "puts 1"],
68 [`ruby -I lib -e 'puts 1'`, "ruby", "puts 1"],
69 [`perl -E 'say 1'`, "perl", "say 1"],
70 [`sqlite3 app.db "select * from users"`, "sql", "select * from users"],
71 [`psql -d app -c 'select 1'`, "sql", "select 1"],
72 [`pwsh -NoProfile -Command "Get-ChildItem"`, "powershell", "Get-ChildItem"],
73 [`env FOO=1 sudo -u bob python3 -c 'print(1)'`, "python", "print(1)"],
74 [`env -- FOO=1 python3 -c 'print(1)'`, "python", "print(1)"],
75 [`node --eval="process.exit(0)"`, "javascript", "process.exit(0)"],
76 [`psql --command='select 1'`, "sql", "select 1"],
77 [`mysql -c -e 'select 1'`, "sql", "select 1"],
78 [`mysql -f app <<'EOF'\nselect 1;\nEOF`, "sql", "select 1;\n"],
79 [`psql app <<'EOF'\nselect 1;\nEOF`, "sql", "select 1;\n"],
80 [`echo "$(node -e 'console.log(1)')"`, "javascript", "console.log(1)"],
81 [`python3 <<< 'print(1)'`, "python", "print(1)"],
82 ])("finds the inline script in %s", (command, language, script) => {
83 expect(scriptsOf(command)).toEqual([[language, script, expect.any(String)]]);
84 });
85
86 it.each([[`psql -c 'select 1' -c 'select 2'`], [`sqlite3 app.db "select 1" "select 2"`]])(
87 "finds every statement a SQL shell runs: %s",
88 (command) => {
89 expect(scriptsOf(command).map(([language, text]) => [language, text])).toEqual([
90 ["sql", "select 1"],
91 ["sql", "select 2"],
92 ]);
93 },
94 );
95
96 it.each([
97 ["the file `cat` writes", `cat > src/app.ts <<'EOF'\nconst a = 1;\nEOF`, "typescript"],
98 ["the file `tee` writes", `tee -a config.json <<EOF\n{}\nEOF`, "json"],
99 ["its delimiter", `kubectl apply -f - <<'YAML'\nkind: Pod\nYAML`, "yaml"],
100 ["the shell reading it", `bash <<'EOF'\necho hi\nEOF`, "shellscript"],
101 ])("takes a heredoc's language from %s", (_source, command, language) => {
102 expect(scriptsOf(command).map(([lang]) => lang)).toEqual([language]);
103 });
104
105 it.each([
106 ["a script file", `python3 build.py <<EOF\ndata\nEOF`],
107 ["a module", `python3 -m json.tool <<EOF\n{}\nEOF`],
108 ["a SQL file", `psql -f migrate.sql <<EOF\ny\nEOF`],
109 ["a SQL file given with =", `psql --file=migrate.sql <<EOF\ny\nEOF`],
110 ["plain commands", `rg -n "foo" src && git status`],
111 ["an unnamed heredoc into a plain command", `git commit -F - <<'EOF'\nfix: x\nEOF`],
112 ["an empty script", `bash -lc ' '`],
113 ["a heredoc `cat` sends to stdout while stderr goes to a file", `cat 2>log.ts <<'EOF'\nx\nEOF`],
114 ])("finds nothing for %s", (_case, command) => {
115 expect(scriptsOf(command)).toEqual([]);
116 });
117
118 it("trusts nothing in a command whose quoting does not parse", () => {
119 // Codex's secret redaction dropped the backslash of an escaped quote, so
120 // the -lc argument ends early and the rest no longer balances.
121 const command = `/bin/zsh -lc "python3 -c 'token=[REDACTED_SECRET]"apiToken\\"]; print(token)'"`;
122 expect(scriptsOf(command)).toEqual([]);
123 });
124});
apps/web/src/lib/embeddedScripts.ts created+544
...@@ -0,0 +1,544 @@
1import { getFiletypeFromFileName } from "@pierre/diffs";
2import {
3 parse,
4 type Command,
5 type DoubleQuotedChild,
6 type HereDoc,
7 type Word,
8 type WordPart,
9} from "unbash";
10
11/**
12 * Code in another language inside a shell command: a `bash -lc "…"` script, a
13 * `python3 -c '…'` argument, or a heredoc fed to an interpreter or written to
14 * a file. `starts[i]` and `ends[i]` are the span of the original command that
15 * spells `text[i]`, so a quoted `\"` maps back to both of its characters.
16 */
17export interface EmbeddedScript {
18 readonly language: string;
19 readonly text: string;
20 readonly starts: ReadonlyArray<number>;
21 readonly ends: ReadonlyArray<number>;
22}
23
24interface MappedText {
25 readonly text: string;
26 readonly starts: ReadonlyArray<number>;
27 readonly ends: ReadonlyArray<number>;
28}
29
30interface Interpreter {
31 readonly language: string;
32 /** Flags whose next argument is the script. */
33 readonly inline: (flag: string) => boolean;
34 /** Options that consume the next argument. */
35 readonly valueOptions?: ReadonlySet<string>;
36 /** Options after which the script comes from a file or module instead. */
37 readonly scriptFileOptions?: ReadonlySet<string>;
38 /** "always" for SQL shells, which read statements from stdin next to their database argument. */
39 readonly stdin: "always" | "without-script-file" | "never";
40 /** The first positional argument holding a script, as in `sqlite3 app.db "select 1"`. */
41 readonly positionalScript?: number;
42 /** SQL shells run every `-c` and every statement argument, in order. */
43 readonly repeatable?: boolean;
44 readonly subcommand?: string;
45 /** PowerShell matches parameters case-insensitively; the lists above are lowercase. */
46 readonly ignoreCase?: boolean;
47}
48
49const MAX_DEPTH = 4;
50
51const flagIn =
52 (...flags: string[]) =>
53 (flag: string) =>
54 flags.includes(flag);
55
56const SHELL: Interpreter = {
57 language: "shellscript",
58 inline: (flag) => /^-[a-z]*c[a-z]*$/u.test(flag),
59 valueOptions: new Set(["-o", "-O", "--rcfile", "--init-file"]),
60 stdin: "without-script-file",
61};
62
63const PYTHON: Interpreter = {
64 language: "python",
65 inline: flagIn("-c"),
66 valueOptions: new Set(["-W", "-X"]),
67 scriptFileOptions: new Set(["-m"]),
68 stdin: "without-script-file",
69};
70
71// Shared by SQL clients; each names its own script flags, since `-c` and `-f`
72// mean different things to psql and mysql.
73const SQL_SHELL = {
74 language: "sql",
75 valueOptions: new Set([
76 "-d",
77 "-h",
78 "-u",
79 "-U",
80 "--dbname",
81 "--host",
82 "--user",
83 "--username",
84 "-separator",
85 "-nullvalue",
86 "-init",
87 ]),
88 stdin: "always",
89 repeatable: true,
90} satisfies Omit<Interpreter, "inline">;
91
92const INTERPRETERS: Record<string, Interpreter> = {
93 sh: SHELL,
94 bash: SHELL,
95 zsh: SHELL,
96 dash: SHELL,
97 ash: SHELL,
98 ksh: SHELL,
99 fish: { language: "fish", inline: flagIn("-c", "--command"), stdin: "without-script-file" },
100 node: {
101 language: "javascript",
102 inline: flagIn("-e", "--eval", "-p", "--print"),
103 valueOptions: new Set(["-r", "--require", "--import", "--loader", "-C", "--conditions"]),
104 stdin: "without-script-file",
105 },
106 bun: { language: "typescript", inline: flagIn("-e", "--eval", "-p", "--print"), stdin: "never" },
107 deno: {
108 language: "typescript",
109 inline: () => false,
110 // `--env-file` takes its optional path only as `--env-file=path`.
111 valueOptions: new Set(["-c", "--config", "--import-map", "--ext"]),
112 stdin: "never",
113 subcommand: "eval",
114 },
115 ruby: {
116 language: "ruby",
117 inline: flagIn("-e"),
118 valueOptions: new Set(["-r", "-I", "-C"]),
119 stdin: "without-script-file",
120 },
121 perl: { language: "perl", inline: flagIn("-e", "-E"), stdin: "without-script-file" },
122 osascript: {
123 language: "applescript",
124 inline: flagIn("-e"),
125 valueOptions: new Set(["-l", "-s"]),
126 stdin: "without-script-file",
127 },
128 pwsh: {
129 language: "powershell",
130 inline: flagIn("-c", "-command"),
131 valueOptions: new Set(["-executionpolicy", "-workingdirectory", "-windowstyle"]),
132 scriptFileOptions: new Set(["-f", "-file", "-encodedcommand"]),
133 stdin: "never",
134 ignoreCase: true,
135 },
136 psql: {
137 ...SQL_SHELL,
138 inline: flagIn("-c", "--command"),
139 scriptFileOptions: new Set(["-f", "--file"]),
140 },
141 mysql: { ...SQL_SHELL, inline: flagIn("-e", "--execute") },
142 sqlite3: { ...SQL_SHELL, inline: flagIn("-cmd"), positionalScript: 1 },
143 duckdb: {
144 ...SQL_SHELL,
145 inline: flagIn("-c", "-cmd"),
146 scriptFileOptions: new Set(["-f"]),
147 positionalScript: 1,
148 },
149};
150INTERPRETERS.powershell = INTERPRETERS.pwsh!;
151INTERPRETERS.mariadb = INTERPRETERS.mysql!;
152
153// Commands that run the next word as the program, with the options that consume a value.
154const PROGRAM_WRAPPERS: Record<string, ReadonlySet<string>> = {
155 env: new Set(["-u", "--unset", "-C", "--chdir"]),
156 sudo: new Set(["-u", "--user", "-g", "--group", "-C", "-D", "--chdir"]),
157 nice: new Set(["-n", "--adjustment"]),
158 nohup: new Set(),
159 exec: new Set(["-a"]),
160 command: new Set(),
161 builtin: new Set(),
162};
163
164// Heredoc delimiters that name their language, as in `<<'SQL'`.
165const DELIMITER_LANGUAGES: Record<string, string> = {
166 py: "python",
167 python: "python",
168 js: "javascript",
169 javascript: "javascript",
170 ts: "typescript",
171 typescript: "typescript",
172 sql: "sql",
173 json: "json",
174 yaml: "yaml",
175 yml: "yaml",
176 toml: "toml",
177 sh: "shellscript",
178 bash: "shellscript",
179 shell: "shellscript",
180 rb: "ruby",
181 ruby: "ruby",
182 html: "html",
183 css: "css",
184 md: "markdown",
185 markdown: "markdown",
186 xml: "xml",
187 diff: "diff",
188 patch: "diff",
189};
190
191/**
192 * The scripts embedded in a shell command, outermost first, so a highlighter
193 * painting them in order lets a nested script override the one containing it.
194 * Returns nothing for a command that does not parse cleanly; where a broken
195 * quote ends cannot be trusted.
196 */
197export function embeddedScripts(command: string): EmbeddedScript[] {
198 const identity = Array.from({ length: command.length }, (_, index) => index);
199 return scriptsIn(
200 { text: command, starts: identity, ends: identity.map((index) => index + 1) },
201 0,
202 );
203}
204
205function scriptsIn(source: MappedText, depth: number): EmbeddedScript[] {
206 const script = parse(source.text);
207 if (script.errors?.length) return [];
208 const found: EmbeddedScript[] = [];
209 forEachCommand(script.commands, (command) => {
210 for (const { language, mapped } of commandScripts(command, source.text)) {
211 if (mapped.text.trim() === "") continue;
212 const embedded: EmbeddedScript = {
213 language,
214 text: mapped.text,
215 starts: mapped.starts.map((start) => source.starts[start]!),
216 ends: mapped.ends.map((end) => source.ends[end - 1]!),
217 };
218 found.push(embedded);
219 if (language === "shellscript" && depth + 1 < MAX_DEPTH) {
220 found.push(...scriptsIn(embedded, depth + 1));
221 }
222 }
223 });
224 return found;
225}
226
227function isNode(value: unknown): value is { readonly type: string } {
228 return typeof value === "object" && value !== null && "type" in value;
229}
230
231/** Visits every simple command, including those inside `$(…)`, loops and subshells. */
232function forEachCommand(
233 node: unknown,
234 visit: (command: Command) => void,
235 seen = new WeakSet<object>(),
236): void {
237 if (Array.isArray(node)) {
238 for (const child of node) forEachCommand(child, visit, seen);
239 return;
240 }
241 if (!isNode(node) || seen.has(node)) return;
242 seen.add(node);
243 switch (node.type) {
244 case "Command":
245 visit(node as Command);
246 break;
247 case "CommandExpansion":
248 case "ProcessSubstitution": {
249 const { script } = node as Extract<WordPart, { type: "CommandExpansion" }>;
250 // Escaped-backtick scripts own a decoded source their positions index.
251 if (script && !script.errors?.length && script.source === undefined) {
252 forEachCommand(script.commands, visit, seen);
253 }
254 return;
255 }
256 }
257 // Word parts are lazy getters, which Object.values skips.
258 if ("parts" in node) forEachCommand(node.parts, visit, seen);
259 for (const value of Object.values(node)) {
260 if (typeof value === "object") forEachCommand(value, visit, seen);
261 }
262}
263
264function programName(word: Word): string {
265 // Bash reads `C:\Python312\python.exe` unquoted as escapes; Windows meant a path.
266 const path = /\\/u.test(word.text) ? word.text.replace(/^["']|["']$/gu, "") : word.value;
267 return (path.split(/[\\/]/u).at(-1) ?? "").toLowerCase().replace(/\.exe$/u, "");
268}
269
270function interpreterFor(name: string): Interpreter | undefined {
271 // `py` is the Windows Python launcher, as in `py -3 -c '…'`.
272 if (/^python(?:\d+(?:\.\d+)?)?$|^pypy3?$|^py$/u.test(name)) return PYTHON;
273 return Object.hasOwn(INTERPRETERS, name) ? INTERPRETERS[name] : undefined;
274}
275
276/** Skips `env FOO=1`, `sudo -u bob` and similar to the word naming the real program. */
277function programIndex(words: ReadonlyArray<Word>): number {
278 let index = 0;
279 while (index < words.length) {
280 const name = programName(words[index]!);
281 if (!Object.hasOwn(PROGRAM_WRAPPERS, name)) return index;
282 const valueOptions = PROGRAM_WRAPPERS[name]!;
283 index += 1;
284 let optionsEnded = false;
285 while (index < words.length) {
286 const value = words[index]!.value;
287 const isOption = !optionsEnded && value.startsWith("-");
288 if (isOption && value === "--") optionsEnded = true;
289 // `env -- FOO=1 cmd` still takes assignments after the options end.
290 if (isOption || /^[A-Za-z_]\w*=/u.test(value)) {
291 index += isOption && valueOptions.has(value) ? 2 : 1;
292 } else {
293 break;
294 }
295 }
296 }
297 return index;
298}
299
300function commandScripts(
301 command: Command,
302 source: string,
303): Array<{ language: string; mapped: MappedText }> {
304 if (!command.name) return [];
305 const words = [command.name, ...command.args.filter((arg): arg is Word => arg.type === "Word")];
306 const index = programIndex(words);
307 const program = words[index];
308 if (!program) return [];
309 const name = programName(program);
310 const interpreter = interpreterFor(name);
311 const args = words.slice(index + 1);
312 const scripts: Array<{ language: string; mapped: MappedText }> = [];
313
314 let readsStdinScript = false;
315 if (interpreter) {
316 const found = interpreterArgs(interpreter, args);
317 for (const script of found.scripts) {
318 const mapped = wordText(script.word, source);
319 if (!mapped) continue;
320 const start = script.inValue ? mapped.text.indexOf("=") + 1 : 0;
321 scripts.push({ language: interpreter.language, mapped: sliceMapped(mapped, start) });
322 }
323 readsStdinScript =
324 interpreter.stdin !== "never" && found.scripts.length === 0 && !found.scriptFromFile;
325 }
326
327 const stdinLanguage = readsStdinScript ? interpreter?.language : undefined;
328 const writtenLanguage = writtenFileLanguage(name, command, args);
329 for (const redirect of command.redirects) {
330 const toStdin = isDescriptor(redirect.descriptor, 0);
331 if (redirect.type === "HereString" && toStdin && stdinLanguage && redirect.target) {
332 const mapped = wordText(redirect.target, source);
333 if (mapped) scripts.push({ language: stdinLanguage, mapped });
334 }
335 if (redirect.type === "HereDoc") {
336 const language =
337 (toStdin ? (stdinLanguage ?? writtenLanguage) : undefined) ?? delimiterLanguage(redirect);
338 if (language) scripts.push({ language, mapped: hereDocText(redirect, source) });
339 }
340 }
341 return scripts;
342}
343
344/** Whether a redirection without a descriptor, or with `fd`, targets file descriptor `fd`. */
345function isDescriptor(descriptor: HereDoc["descriptor"], fd: number): boolean {
346 return (
347 descriptor === undefined || (descriptor.type === "FileDescriptor" && descriptor.value === fd)
348 );
349}
350
351interface ScriptArgument {
352 readonly word: Word;
353 /** The script follows the `=` of `--eval=…` rather than filling the word. */
354 readonly inValue: boolean;
355}
356
357/** The script arguments, and whether the script comes from a file instead. */
358function interpreterArgs(
359 interpreter: Interpreter,
360 args: ReadonlyArray<Word>,
361): { scripts: ScriptArgument[]; scriptFromFile: boolean } {
362 const positionals: Word[] = [];
363 const scripts: ScriptArgument[] = [];
364 const whole = (word: Word): ScriptArgument => ({ word, inValue: false });
365 let optionsEnded = false;
366 let scriptFromFile = false;
367 for (let index = 0; index < args.length; index += 1) {
368 const arg = args[index]!;
369 const flag = interpreter.ignoreCase ? arg.value.toLowerCase() : arg.value;
370 if (!optionsEnded && flag === "--") {
371 optionsEnded = true;
372 continue;
373 }
374 if (!optionsEnded && flag.startsWith("-") && flag !== "-") {
375 // `--eval=code` and `--file=path` carry their value in the same word.
376 const [name = flag, value] = flag.startsWith("--") ? flag.split(/=(.*)/su) : [flag];
377 if (interpreter.scriptFileOptions?.has(name)) {
378 if (!interpreter.repeatable) return { scripts, scriptFromFile: true };
379 // SQL shells run `-f` files and `-c` statements alike, in order.
380 scriptFromFile = true;
381 if (value === undefined) index += 1;
382 continue;
383 }
384 if (interpreter.inline(name)) {
385 const next = args[index + 1];
386 if (value !== undefined) scripts.push({ word: arg, inValue: true });
387 else if (next) scripts.push(whole(next));
388 if (!interpreter.repeatable) return { scripts, scriptFromFile: false };
389 if (value === undefined) index += 1;
390 continue;
391 }
392 if (value === undefined && interpreter.valueOptions?.has(flag)) index += 1;
393 continue;
394 }
395 positionals.push(arg);
396 // An interpreter stops reading its own options at the script file.
397 if (interpreter.stdin === "without-script-file" && !interpreter.subcommand) break;
398 }
399
400 if (interpreter.subcommand) {
401 if (positionals[0]?.value !== interpreter.subcommand) return { scripts, scriptFromFile: true };
402 return { scripts: positionals.slice(1, 2).map(whole), scriptFromFile: false };
403 }
404 if (interpreter.positionalScript !== undefined) {
405 scripts.push(...positionals.slice(interpreter.positionalScript).map(whole));
406 }
407 // SQL shells take databases as positional arguments, not script files.
408 if (interpreter.stdin === "always") return { scripts, scriptFromFile };
409 const first = positionals[0];
410 return { scripts, scriptFromFile: first !== undefined && first.value !== "-" };
411}
412
413function sliceMapped(mapped: MappedText, start: number): MappedText {
414 if (start === 0) return mapped;
415 return {
416 text: mapped.text.slice(start),
417 starts: mapped.starts.slice(start),
418 ends: mapped.ends.slice(start),
419 };
420}
421
422/** The language of the file `cat > file` or `tee file` writes its stdin to. */
423function writtenFileLanguage(
424 name: string,
425 command: Command,
426 args: ReadonlyArray<Word>,
427): string | undefined {
428 let path: string | undefined;
429 if (name === "cat") {
430 for (const redirect of command.redirects) {
431 if (
432 redirect.type === "Redirect" &&
433 [">", ">>", ">|", "&>", "&>>"].includes(redirect.operator) &&
434 isDescriptor(redirect.descriptor, 1)
435 ) {
436 path = redirect.target?.value;
437 }
438 }
439 } else if (name === "tee") {
440 path = args.find((arg) => !arg.value.startsWith("-"))?.value;
441 }
442 if (!path) return undefined;
443 const language = getFiletypeFromFileName(path);
444 return language === "text" ? undefined : language;
445}
446
447function delimiterLanguage(hereDoc: HereDoc): string | undefined {
448 const name = hereDoc.delimiter?.value.toLowerCase();
449 return name && Object.hasOwn(DELIMITER_LANGUAGES, name) ? DELIMITER_LANGUAGES[name] : undefined;
450}
451
452/** A heredoc body as written; the interpreter reads its characters one to one. */
453function hereDocText(hereDoc: HereDoc, source: string): MappedText {
454 const { pos, end } = hereDoc.body;
455 const starts = Array.from({ length: end - pos }, (_, index) => pos + index);
456 return {
457 text: source.slice(pos, end),
458 starts,
459 ends: starts.map((start) => start + 1),
460 };
461}
462
463/**
464 * A word's value with the source span of each character: quotes removed and
465 * escapes resolved, the way the program receives it. Expansions such as
466 * `$HOME` stay as written. Returns undefined for spellings it cannot map.
467 */
468function wordText(word: Word, source: string): MappedText | undefined {
469 const builder = new MappedTextBuilder();
470 const parts: ReadonlyArray<WordPart> = word.parts ?? [
471 { type: "Literal", pos: word.pos, end: word.end, text: word.text, value: word.value },
472 ];
473 for (const part of parts) {
474 switch (part.type) {
475 case "Literal":
476 if (!builder.addEscaped(part, false)) return undefined;
477 break;
478 case "SingleQuoted":
479 builder.addRaw(part.value, part.pos + 1);
480 break;
481 case "DoubleQuoted":
482 case "LocaleString":
483 for (const child of part.parts) {
484 if (!builder.addDoubleQuotedChild(child)) return undefined;
485 }
486 break;
487 case "AnsiCQuoted":
488 // $'…' escapes such as \x41 do not map character for character.
489 if (part.value !== part.text.slice(2, -1)) return undefined;
490 builder.addRaw(part.value, part.pos + 2);
491 break;
492 default:
493 builder.addRaw(source.slice(part.pos, part.end), part.pos);
494 }
495 }
496 return builder.build();
497}
498
499class MappedTextBuilder {
500 private text = "";
501 private readonly starts: number[] = [];
502 private readonly ends: number[] = [];
503
504 addRaw(text: string, pos: number) {
505 for (let index = 0; index < text.length; index += 1) this.push(text[index]!, pos + index, 1);
506 }
507
508 addDoubleQuotedChild(child: DoubleQuotedChild): boolean {
509 if (child.type === "Literal") return this.addEscaped(child, true);
510 this.addRaw(child.text, child.pos);
511 return true;
512 }
513
514 /** Adds a literal, resolving backslash escapes; false when the result disagrees with the parser. */
515 addEscaped(literal: { pos: number; text: string; value: string }, doubleQuoted: boolean) {
516 const before = this.text.length;
517 const { text, pos } = literal;
518 for (let index = 0; index < text.length; index += 1) {
519 const next = text[index + 1];
520 if (
521 text[index] === "\\" &&
522 next !== undefined &&
523 (!doubleQuoted || '$`"\\\n'.includes(next))
524 ) {
525 // A backslash-newline joins lines and spells nothing.
526 if (next !== "\n") this.push(next, pos + index, 2);
527 index += 1;
528 } else {
529 this.push(text[index]!, pos + index, 1);
530 }
531 }
532 return this.text.slice(before) === literal.value;
533 }
534
535 build(): MappedText {
536 return { text: this.text, starts: this.starts, ends: this.ends };
537 }
538
539 private push(character: string, start: number, length: number) {
540 this.text += character;
541 this.starts.push(start);
542 this.ends.push(start + length);
543 }
544}
packages/client-runtime/src/work-log/commandLabel.test.ts+109-1
...@@ -1,6 +1,10 @@...@@ -1,6 +1,10 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import { commandDisplayText, commandProgramName } from "./commandLabel.ts";3import {
4 commandDisplayText,
5 commandHighlightLanguage,
6 commandProgramName,
7} from "./commandLabel.ts";
48
5describe("commandProgramName", () => {9describe("commandProgramName", () => {
6 it.each([10 it.each([
...@@ -493,3 +497,107 @@ describe("commandDisplayText", () => {...@@ -493,3 +497,107 @@ describe("commandDisplayText", () => {
493 expect(commandDisplayText(input)).toBe(expected);497 expect(commandDisplayText(input)).toBe(expected);
494 });498 });
495});499});
500
501describe("commands recorded from each provider", () => {
502 // Inputs are copied from the recorded replay transcripts in
503 // apps/server/src/orchestration-v2/testkit/fixtures (turn_interrupt_mid_tool,
504 // todo_list, grok_background_bash and
505 // opencode_running_child_approval). In turn_interrupt_mid_tool every provider
506 // ran the same script, and only Codex wraps it in a login shell.
507 const interruptScript = `node -e "console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)"`;
508 it.each([
509 ["Claude", interruptScript, interruptScript],
510 ["Cursor", interruptScript, interruptScript],
511 ["Pi", interruptScript, interruptScript],
512 [
513 "Codex",
514 `/bin/bash -lc "node -e \\"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\\""`,
515 interruptScript,
516 ],
517 [
518 "Codex",
519 "/bin/bash -lc 'cat package.json && cat tsconfig.json'",
520 "cat package.json && cat tsconfig.json",
521 ],
522 [
523 "Grok",
524 "for i in 1 2 3; do sleep 8; echo tock $i; done",
525 "for i in 1 2 3; do sleep 8; echo tock $i; done",
526 ],
527 [
528 "OpenCode",
529 "printf 'running child approval' > running-child.txt",
530 "printf 'running child approval' > running-child.txt",
531 ],
532 ])("%s shows the script it ran: %s", (_provider, input, script) => {
533 expect(commandDisplayText(input)).toBe(script);
534 expect(commandHighlightLanguage(commandDisplayText(input))).toBe("shellscript");
535 });
536});
537
538describe("commandDisplayText with redacted Codex commands", () => {
539 // Codex replaces secrets in command text before sending it
540 // (codex-rs/secrets/src/sanitizer.rs). Usually the quoting survives.
541 it("unwraps a redacted command whose quoting is intact", () => {
542 expect(
543 commandDisplayText(
544 `/bin/zsh -lc 'curl -H "Authorization: Bearer [REDACTED_SECRET]" https://example.com'`,
545 ),
546 ).toBe('curl -H "Authorization: Bearer [REDACTED_SECRET]" https://example.com');
547 });
548
549 // Its secret-assignment pattern can also swallow the backslash of an escaped
550 // quote: `token=config[\"apiToken\"]` becomes `token=[REDACTED_SECRET]"apiToken\"]`.
551 // The bare quote ends the -lc argument early, so where the script ends is unknown.
552 it("keeps the wrapper when redaction broke the quoting", () => {
553 const input = `/bin/zsh -lc "python3 -c 'token=[REDACTED_SECRET]"apiToken\\"]; print(token)'"`;
554 expect(commandDisplayText(input)).toBe(input);
555 });
556});
557
558describe("commandDisplayText control characters", () => {
559 it.each([
560 ["printf '\u001b[31mred\u001b[0m'", "printf '␛[31mred␛[0m'"],
561 ["zsh -lc 'printf \"\u001b[1m\"'", 'printf "␛[1m"'],
562 ["echo bell\u0007 delete\u007f", "echo bell␇ delete␡"],
563 ["printf 'a\\tb'\r\necho done", "printf 'a\\tb'\r\necho done"],
564 ["cat <<'EOF'\n\tindented\nEOF", "cat <<'EOF'\n\tindented\nEOF"],
565 ])("shows %j as %j", (input, expected) => {
566 expect(commandDisplayText(input)).toBe(expected);
567 });
568});
569
570describe("commandHighlightLanguage", () => {
571 it.each([
572 [
573 '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoProfile -Command "Get-ChildItem -Recurse"',
574 "powershell",
575 ],
576 [
577 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\\work\\scripts\\doctor.ps1"',
578 "powershell",
579 ],
580 ["& pwsh -c 'Get-Date'", "powershell"],
581 ["PWSH.EXE -Command Get-Date", "powershell"],
582 ["pwsh-preview -c 'Get-Date'", "shellscript"],
583 ["git status; pwsh -c 'Get-Date'", "shellscript"],
584 ["cat <<'EOF' > notes.txt\npwsh\nEOF", "shellscript"],
585 ["", "shellscript"],
586 // Windows agents also run PowerShell directly, without a pwsh wrapper.
587 ["Get-Content package.json | Select-String version", "powershell"],
588 ["$env:CI='1'; npm test", "powershell"],
589 ["$tmp = Join-Path $env:TEMP repo; git clone example", "powershell"],
590 ['"=== CHECK FILE ==="; Get-Content file.txt', "powershell"],
591 ["& 'C:\\Python312\\python.exe' -c 'print(1)'", "powershell"],
592 ["Install-Module Pester -Scope CurrentUser", "powershell"],
593 ["ConvertTo-Json @{ a = 1 }", "powershell"],
594 ["update-alternatives --list java", "shellscript"],
595 ["install-info --version", "shellscript"],
596 ["Make-Thing now", "shellscript"],
597 ["echo Get-Content; git status", "shellscript"],
598 ["FOO=1 npm test", "shellscript"],
599 ["echo $HOME && ls", "shellscript"],
600 ])("%s", (command, language) => {
601 expect(commandHighlightLanguage(command)).toBe(language);
602 });
603});
packages/client-runtime/src/work-log/commandLabel.ts+76-1
...@@ -1368,8 +1368,31 @@ export function commandProgramName(command: string, depth = 0): string | null {...@@ -1368,8 +1368,31 @@ export function commandProgramName(command: string, depth = 0): string | null {
1368 return commandProgramNameInternal(command, depth, "shell", MAX_COMMAND_SEGMENTS);1368 return commandProgramNameInternal(command, depth, "shell", MAX_COMMAND_SEGMENTS);
1369}1369}
13701370
1371/** Removes a plain shell -c wrapper for display; callers retain the original for details. */1371// Escape bytes and other control characters render as invisible gaps; display
1372// shows them as Unicode control pictures (ESC becomes ␛) instead. Tab, newline
1373// and carriage return lay out as whitespace, so CRLF scripts stay unmarked.
1374// oxlint-disable-next-line no-control-regex
1375const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu;
1376
1377/**
1378 * The command as rows show it: a plain shell -c wrapper removed and control
1379 * characters made visible. Callers retain the original for details.
1380 */
1372export function commandDisplayText(command: string): string {1381export function commandDisplayText(command: string): string {
1382 return withVisibleControlCharacters(withoutShellWrapper(command));
1383}
1384
1385/** Replaces each control character with its control picture; the length does not change. */
1386export function withVisibleControlCharacters(text: string): string {
1387 return text.replace(CONTROL_CHARACTERS, (character) =>
1388 character === "\u007f" ? "␡" : String.fromCharCode(0x2400 + character.charCodeAt(0)),
1389 );
1390}
1391
1392// A wrapper whose quoting does not balance is kept: the script's end cannot be
1393// known, which happens when Codex's secret redaction removes the backslash of
1394// an escaped quote inside `-lc "…"`.
1395function withoutShellWrapper(command: string): string {
1373 const trimmed = command.trim();1396 const trimmed = command.trim();
1374 const split = splitFirstShellCommand(trimmed);1397 const split = splitFirstShellCommand(trimmed);
1375 if (split.remainingCommand !== null) return trimmed;1398 if (split.remainingCommand !== null) return trimmed;
...@@ -1384,3 +1407,55 @@ export function commandDisplayText(command: string): string {...@@ -1384,3 +1407,55 @@ export function commandDisplayText(command: string): string {
1384 if (scriptIndex === null || scriptIndex !== tokens.length - 1) return trimmed;1407 if (scriptIndex === null || scriptIndex !== tokens.length - 1) return trimmed;
1385 return tokens[scriptIndex]?.trim() || trimmed;1408 return tokens[scriptIndex]?.trim() || trimmed;
1386}1409}
1410
1411const POWERSHELL_PROGRAMS = new Set(["powershell", "pwsh"]);
1412
1413// PowerShell's approved verbs (`Get-Verb`), plus the ForEach, Where, Sort and
1414// Tee of its built-in *-Object cmdlets. Cmdlets match only capitalized, as
1415// agents write them: lowercase `update-grub` or `install-info` are Linux tools.
1416const POWERSHELL_VERBS = new Set(
1417 `Add Approve Assert Backup Block Build Checkpoint Clear Close Compare Complete Compress Confirm Connect Convert ConvertFrom ConvertTo Copy Debug Deny Deploy Disable Disconnect Dismount Edit Enable Enter Exit Expand Export Find ForEach Format Get Grant Group Hide Import Initialize Install Invoke Join Limit Lock Measure Merge Mount Move New Open Optimize Out Ping Pop Protect Publish Push Read Receive Redo Register Remove Rename Repair Request Reset Resize Resolve Restart Restore Resume Revoke Save Search Select Send Set Show Skip Sort Split Start Step Stop Submit Suspend Switch Sync Tee Test Trace Unblock Undo Uninstall Unlock Unprotect Unpublish Unregister Update Use Wait Watch Where Write`.split(
1418 " ",
1419 ),
1420);
1421
1422function isPowerShellCmdlet(word: string): boolean {
1423 const cmdlet = /^([A-Z][a-z]+(?:[A-Z][a-z]+)?)-[A-Z][A-Za-z]*$/u.exec(word);
1424 return cmdlet !== null && POWERSHELL_VERBS.has(cmdlet[1]!);
1425}
1426
1427/**
1428 * The grammar to highlight a command with: PowerShell when pwsh or powershell
1429 * runs it, or when it is written in PowerShell, as Windows agents run it.
1430 */
1431export function commandHighlightLanguage(command: string): "powershell" | "shellscript" {
1432 const program = /^(?:&\s*)?(?:"([^"]*)"|'([^']*)'|(\S+))/u.exec(command.trim());
1433 const name = (program?.[1] ?? program?.[2] ?? program?.[3] ?? "")
1434 .split(/[\\/]/u)
1435 .at(-1)
1436 ?.toLowerCase()
1437 .replace(/\.exe$/u, "");
1438 if (name && POWERSHELL_PROGRAMS.has(name)) return "powershell";
1439 return isPowerShellScript(command) ? "powershell" : "shellscript";
1440}
1441
1442/** Whether any statement starts like PowerShell and never like POSIX shell. */
1443function isPowerShellScript(command: string): boolean {
1444 let rest: string | null = command.trim();
1445 // A leading call operator; POSIX shell cannot start a command with `&`.
1446 if (/^&\s*\S/u.test(rest)) return true;
1447 for (let segment = 0; rest && segment < MAX_COMMAND_SEGMENTS; segment += 1) {
1448 const { firstCommand, remainingCommand } = splitFirstShellCommand(rest);
1449 const statement = firstCommand.trim();
1450 if (
1451 // `$env:NAME` or `$name = value`; neither parses as POSIX shell.
1452 /^\$(?:env|global|local|script):/iu.test(statement) ||
1453 /^\$[A-Za-z_]\w*\s+=/u.test(statement) ||
1454 isPowerShellCmdlet(statement.match(/^\S+/u)?.[0] ?? "")
1455 ) {
1456 return true;
1457 }
1458 rest = remainingCommand;
1459 }
1460 return false;
1461}
pnpm-lock.yaml+9
...@@ -755,6 +755,9 @@ importers:...@@ -755,6 +755,9 @@ importers:
755 tailwind-merge:755 tailwind-merge:
756 specifier: ^3.4.0756 specifier: ^3.4.0
757 version: 3.6.0757 version: 3.6.0
758 unbash:
759 specifier: ^5.0.0
760 version: 5.0.0
758 zustand:761 zustand:
759 specifier: ^5.0.11762 specifier: ^5.0.11
760 version: 5.0.14(@types/react@19.3.0)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6))763 version: 5.0.14(@types/react@19.3.0)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6))
...@@ -10825,6 +10828,10 @@ packages:...@@ -10825,6 +10828,10 @@ packages:
10825 resolution: {integrity: sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ==}10828 resolution: {integrity: sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ==}
10826 engines: {node: '>=14'}10829 engines: {node: '>=14'}
1082710830
10831 unbash@5.0.0:
10832 resolution: {integrity: sha512-RYNm/ISFtflWpIcXTPHHLJZ0mqzInav+L9ke9PcL+hHsSf9+Fd/syPb614Mlqs/O3AYgjnvxDOFl0a+zVqi1/w==}
10833 engines: {node: '>=14'}
10834
10828 uncrypto@0.1.3:10835 uncrypto@0.1.3:
10829 resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}10836 resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
1083010837
...@@ -21998,6 +22005,8 @@ snapshots:...@@ -21998,6 +22005,8 @@ snapshots:
2199822005
21999 unbash@4.0.11: {}22006 unbash@4.0.11: {}
2200022007
22008 unbash@5.0.0: {}
22009
22001 uncrypto@0.1.3: {}22010 uncrypto@0.1.3: {}
2200222011
22003 undici-types@7.16.0: {}22012 undici-types@7.16.0: {}