diff --git a/src/browser/features/Settings/Sections/GeneralSection.test.tsx b/src/browser/features/Settings/Sections/GeneralSection.test.tsx index a30c9d4c45..46dbae4e73 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.test.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.test.tsx @@ -8,6 +8,7 @@ import { DEFAULT_CODER_ARCHIVE_BEHAVIOR, type CoderWorkspaceArchiveBehavior, } from "@/common/config/coderArchiveBehavior"; +import { TOOL_COLLAPSED_DISPLAY_MODE_KEY } from "@/common/constants/storage"; import { DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR, type WorktreeArchiveBehavior, @@ -302,6 +303,41 @@ describe("GeneralSection", () => { }); } + async function waitForArchiveSettingsLoad(view: ReturnType): Promise { + // Storage-only tests still need the async backend config load to settle before cleanup. + await waitFor(() => { + expect(getSelectTrigger(view, "Worktree archive behavior").textContent).toContain( + "Keep checkout" + ); + }); + } + + test("updates the collapsed bash summary mode selection", async () => { + const { view } = renderGeneralSection(); + + expect(getSelectTrigger(view, "Collapsed bash summaries").textContent).toContain( + "Intent and command" + ); + + await chooseSelectOption(view, "Collapsed bash summaries", "Compact"); + + expect(window.localStorage.getItem(TOOL_COLLAPSED_DISPLAY_MODE_KEY)).toBe( + JSON.stringify("compact") + ); + await waitForArchiveSettingsLoad(view); + }); + + test("falls back to the default collapsed bash summary mode for invalid storage", async () => { + window.localStorage.setItem(TOOL_COLLAPSED_DISPLAY_MODE_KEY, JSON.stringify("invalid")); + + const { view } = renderGeneralSection(); + + expect(getSelectTrigger(view, "Collapsed bash summaries").textContent).toContain( + "Intent and command" + ); + await waitForArchiveSettingsLoad(view); + }); + test("renders the worktree archive behavior copy and loads the saved value", async () => { const { view } = renderGeneralSection({ coderWorkspaceArchiveBehavior: "delete", diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index 89f9d5fdfe..95a6272f2c 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -18,10 +18,14 @@ import { TERMINAL_FONT_CONFIG_KEY, DEFAULT_TERMINAL_FONT_CONFIG, LAUNCH_BEHAVIOR_KEY, + DEFAULT_TOOL_COLLAPSED_DISPLAY_MODE, + TOOL_COLLAPSED_DISPLAY_MODE_KEY, + isToolCollapsedDisplayMode, type EditorConfig, type EditorType, type LaunchBehavior, type TerminalFontConfig, + type ToolCollapsedDisplayMode, } from "@/common/constants/storage"; import { appendTerminalIconFallback, @@ -143,6 +147,14 @@ const LAUNCH_BEHAVIOR_OPTIONS = [ { value: "new-chat", label: "New chat on recent project" }, { value: "last-workspace", label: "Last visited workspace" }, ] as const; +const TOOL_COLLAPSED_DISPLAY_MODE_OPTIONS: Array<{ + value: ToolCollapsedDisplayMode; + label: string; +}> = [ + { value: "intent-command", label: "Intent and command" }, + { value: "compact", label: "Compact" }, + { value: "command", label: "Command" }, +]; const ARCHIVE_BEHAVIOR_OPTIONS = [ { value: "keep", label: "Keep running" }, { value: "stop", label: "Stop workspace" }, @@ -167,6 +179,13 @@ export function GeneralSection() { LAUNCH_BEHAVIOR_KEY, "dashboard" ); + const [rawToolCollapsedDisplayMode, setToolCollapsedDisplayMode] = usePersistedState( + TOOL_COLLAPSED_DISPLAY_MODE_KEY, + DEFAULT_TOOL_COLLAPSED_DISPLAY_MODE + ); + const toolCollapsedDisplayMode = isToolCollapsedDisplayMode(rawToolCollapsedDisplayMode) + ? rawToolCollapsedDisplayMode + : DEFAULT_TOOL_COLLAPSED_DISPLAY_MODE; const [rawTerminalFontConfig, setTerminalFontConfig] = usePersistedState( TERMINAL_FONT_CONFIG_KEY, DEFAULT_TERMINAL_FONT_CONFIG @@ -403,6 +422,12 @@ export function GeneralSection() { setEditorConfig((prev) => ({ ...normalizeEditorConfig(prev), editor })); }; + const handleToolCollapsedDisplayModeChange = (value: string) => { + if (isToolCollapsedDisplayMode(value)) { + setToolCollapsedDisplayMode(value); + } + }; + const handleTerminalFontFamilyChange = (fontFamily: string) => { setTerminalFontConfig((prev) => ({ ...normalizeTerminalFontConfig(prev), fontFamily })); }; @@ -502,6 +527,31 @@ export function GeneralSection() { +
+
+
Collapsed bash summaries
+
+ Choose whether collapsed bash tools show intent with the command, compact command + names, or the raw command. +
+
+ +
+
Terminal Font
diff --git a/src/browser/features/Tools/Bash/BashToolCall.stories.tsx b/src/browser/features/Tools/Bash/BashToolCall.stories.tsx index 72b3a12a26..08da4904ce 100644 --- a/src/browser/features/Tools/Bash/BashToolCall.stories.tsx +++ b/src/browser/features/Tools/Bash/BashToolCall.stories.tsx @@ -6,6 +6,11 @@ import { BashBackgroundListToolCall } from "@/browser/features/Tools/BashBackgro import { BashBackgroundTerminateToolCall } from "@/browser/features/Tools/BashBackgroundTerminateToolCall"; import { BashOutputToolCall } from "@/browser/features/Tools/BashOutputToolCall"; import { BashToolCall } from "@/browser/features/Tools/BashToolCall"; +import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { + TOOL_COLLAPSED_DISPLAY_MODE_KEY, + type ToolCollapsedDisplayMode, +} from "@/common/constants/storage"; import { lightweightMeta } from "@/browser/stories/meta.js"; const meta = { @@ -20,6 +25,10 @@ type Story = StoryObj; const STORYBOOK_WORKSPACE_ID = "storybook-bash"; +function setCollapsedDisplayMode(mode: ToolCollapsedDisplayMode) { + updatePersistedState(TOOL_COLLAPSED_DISPLAY_MODE_KEY, mode); +} + function ToolStoryShell(props: { children: ReactNode }) { return ( @@ -88,29 +97,32 @@ export const WithTerminal: Story = { /** collapsed bash header showing intent on top and command on the second line */ export const IntentCollapsedSummary: Story = { - render: () => ( - - - - ), + render: () => { + setCollapsedDisplayMode("intent-command"); + return ( + + + + ); + }, play: async ({ canvasElement }) => { await waitFor(() => { const textContent = canvasElement.textContent ?? ""; @@ -131,23 +143,26 @@ export const IntentCollapsedSummary: Story = { }; export const IntentExecutingSummary: Story = { - render: () => ( - - - - ), + render: () => { + setCollapsedDisplayMode("intent-command"); + return ( + + + + ); + }, play: async ({ canvasElement }) => { await waitFor(() => { const textContent = canvasElement.textContent ?? ""; @@ -164,29 +179,77 @@ export const IntentExecutingSummary: Story = { }, }; +export const CompactCollapsedSummary: Story = { + render: () => { + setCollapsedDisplayMode("compact"); + return ( + + + + ); + }, + play: async ({ canvasElement }) => { + await waitFor(() => { + const textContent = canvasElement.textContent ?? ""; + if (!textContent.includes("sleep") || !textContent.includes("tail")) { + throw new Error("Compact mode should show command names"); + } + if (textContent.includes("Waiting for the dev instance to start")) { + throw new Error("Compact mode should not show model intent text"); + } + if (textContent.includes("sleep 30 && tail -30 /tmp/develop.log")) { + throw new Error("Compact mode should not echo the full script"); + } + if (textContent.includes("timeout: 120s") || textContent.includes("took 30s")) { + throw new Error("Compact mode should not show command metadata"); + } + }); + }, +}; + /** when the model omits `model_intent`, the collapsed row falls back to the bare command */ export const NoIntentSummary: Story = { - render: () => ( - - - - ), + render: () => { + setCollapsedDisplayMode("intent-command"); + return ( + + + + ); + }, play: async ({ canvasElement }) => { await waitFor(() => { const textContent = canvasElement.textContent ?? ""; diff --git a/src/browser/features/Tools/BashToolCall.tsx b/src/browser/features/Tools/BashToolCall.tsx index c021fd521e..a45e41d5d0 100644 --- a/src/browser/features/Tools/BashToolCall.tsx +++ b/src/browser/features/Tools/BashToolCall.tsx @@ -24,6 +24,11 @@ import { useBashToolLiveOutput, useLatestStreamingBashId } from "@/browser/store import { useForegroundBashToolCallIds } from "@/browser/stores/BackgroundBashStore"; import { useBackgroundBashActions } from "@/browser/contexts/BackgroundBashContext"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/browser/components/Tooltip/Tooltip"; +import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import { + DEFAULT_TOOL_COLLAPSED_DISPLAY_MODE, + TOOL_COLLAPSED_DISPLAY_MODE_KEY, +} from "@/common/constants/storage"; import { buildBashCollapsedSummary } from "./bashCollapsedSummary"; import { BackgroundBashOutputDialog } from "@/browser/components/BackgroundBashOutputDialog/BackgroundBashOutputDialog"; @@ -108,12 +113,17 @@ export const BashToolCall: React.FC = ({ result && "backgroundProcessId" in result ? result.backgroundProcessId : null; const isBackground = args.run_in_background ?? Boolean(backgroundProcessId); + const [rawToolCollapsedDisplayMode] = usePersistedState( + TOOL_COLLAPSED_DISPLAY_MODE_KEY, + DEFAULT_TOOL_COLLAPSED_DISPLAY_MODE, + { listener: true } + ); const bashCollapsedSummary = buildBashCollapsedSummary({ args, result, isBackground, + displayMode: rawToolCollapsedDisplayMode, }); - const isIntentCommandSummary = bashCollapsedSummary.kind === "intent-command"; // Override status for backgrounded processes: the aggregator sees success=true and marks "completed", // but for a foreground→background migration we want to show "backgrounded" @@ -191,6 +201,13 @@ export const BashToolCall: React.FC = ({ )} + ) : bashCollapsedSummary.kind === "compact-command" ? ( + + {bashCollapsedSummary.commandSummary} + ) : ( {bashCollapsedSummary.command} @@ -220,8 +237,8 @@ export const BashToolCall: React.FC = ({ {args.display_name} )} - {!isBackground && !isIntentCommandSummary && ( - // Normal mode: show timeout and duration + {!isBackground && bashCollapsedSummary.kind === "command" && ( + // Command mode keeps the legacy timeout and duration metadata. { - test("returns intent, command, and completed duration when intent is present", () => { + test("returns the legacy command summary in command mode", () => { + expect( + buildBashCollapsedSummary({ + args: createArgs({ model_intent: "Waiting for the dev instance to start" }), + result: completedResult, + isBackground: false, + displayMode: "command", + }) + ).toEqual({ kind: "command", command }); + }); + + test("falls back to the default summary mode for invalid display mode values", () => { + expect( + buildBashCollapsedSummary({ + args: createArgs({ model_intent: "Waiting for the dev instance to start" }), + result: completedResult, + isBackground: false, + displayMode: "invalid", + }) + ).toEqual({ + kind: "intent-command", + intent: "Waiting for the dev instance to start", + command, + durationLabel: "30.1s", + }); + }); + + test("returns intent, command, and completed duration in intent-command mode", () => { expect( buildBashCollapsedSummary({ args: createArgs({ model_intent: "waiting for the dev instance to start" }), result: completedResult, isBackground: false, + displayMode: "intent-command", }) ).toEqual({ kind: "intent-command", @@ -38,11 +70,23 @@ describe("buildBashCollapsedSummary", () => { }); }); + test("returns command names only in compact mode", () => { + expect( + buildBashCollapsedSummary({ + args: createArgs({ model_intent: "Waiting for the dev instance to start" }), + result: completedResult, + isBackground: false, + displayMode: "compact", + }) + ).toEqual({ kind: "compact-command", command, commandSummary: "sleep, tail" }); + }); + test("falls back to the command when intent is missing, blank, or repeats the command", () => { expect( buildBashCollapsedSummary({ args: createArgs(), isBackground: false, + displayMode: "intent-command", }) ).toEqual({ kind: "command", command }); @@ -50,6 +94,7 @@ describe("buildBashCollapsedSummary", () => { buildBashCollapsedSummary({ args: createArgs({ model_intent: " " }), isBackground: false, + displayMode: "intent-command", }) ).toEqual({ kind: "command", command }); @@ -57,6 +102,7 @@ describe("buildBashCollapsedSummary", () => { buildBashCollapsedSummary({ args: createArgs({ model_intent: null }), isBackground: false, + displayMode: "intent-command", }) ).toEqual({ kind: "command", command }); @@ -64,6 +110,7 @@ describe("buildBashCollapsedSummary", () => { buildBashCollapsedSummary({ args: createArgs({ model_intent: command.toUpperCase() }), isBackground: false, + displayMode: "intent-command", }) ).toEqual({ kind: "command", command }); }); @@ -81,6 +128,7 @@ describe("buildBashCollapsedSummary", () => { backgroundProcessId: "proc-1", }, isBackground: true, + displayMode: "intent-command", }) ).toEqual({ kind: "intent-command", @@ -90,6 +138,105 @@ describe("buildBashCollapsedSummary", () => { }); }); +describe("summarizeBashCommands", () => { + test.each([ + [ + "deduplicates command names across shell control operators", + "cd /repo && git push && git status", + "cd, git", + ], + [ + "handles wrappers, assignments, paths, and pipes", + "env MUX_ESLINT_CONCURRENCY=2 make static-check && ./scripts/wait_pr_ready.sh 3337 | sed -n '1,2p'", + "make, wait_pr_ready.sh, sed", + ], + [ + "skips env options with required arguments before command names", + "env -C /repo -u FOO git status && env --chdir=/repo --unset=BAR gh pr view 1 && env -iu BAZ make test", + "git, gh, make", + ], + [ + "skips simple wrapper options before command names", + "time -p git status && exec -ca newname bash -lc 'echo hi' && command -p make test", + "git, bash, make", + ], + ["skips redirection-only wrapper fragments", "exec >/tmp/mux.log && git status", "git"], + [ + "ignores shell keywords while keeping commands inside blocks", + 'set -euo pipefail\nfor pr in 1 2; do\n gh pr view "$pr"\ndone', + "set, gh", + ], + [ + "skips attached leading redirections before command names", + ">/tmp/mux.log make test && 2>/dev/null git status && 2>&1 gh pr view 1", + "make, git, gh", + ], + [ + "does not split noclobber redirection targets into command names", + "printf hi >|/tmp/out && git status", + "printf, git", + ], + [ + "skips brace group reserved words before command names", + "{ git status; } && gh pr view 1", + "git, gh", + ], + [ + "skips subshell group delimiters before command names", + "(git status) && ( gh pr view 1 )", + "git, gh", + ], + [ + "skips heredoc bodies before command extraction", + "cat <<'EOF'\nhello from heredoc\nEOF\ngit status", + "cat, git", + ], + ["ignores heredoc markers inside shell comments", "# < { + expect(summarizeBashCommands(command)).toBe(expected); + }); +}); + describe("sanitizeModelIntent", () => { test("strips redundant command and duration suffixes before display", () => { expect( diff --git a/src/browser/features/Tools/bashCollapsedSummary.ts b/src/browser/features/Tools/bashCollapsedSummary.ts index 34a1c88691..c42a03f769 100644 --- a/src/browser/features/Tools/bashCollapsedSummary.ts +++ b/src/browser/features/Tools/bashCollapsedSummary.ts @@ -1,15 +1,21 @@ import type { BashToolArgs, BashToolResult } from "@/common/types/tools"; +import { + DEFAULT_TOOL_COLLAPSED_DISPLAY_MODE, + isToolCollapsedDisplayMode, +} from "@/common/constants/storage"; import { capitalize } from "@/common/utils/capitalize"; import { formatDuration } from "@/common/utils/formatDuration"; export type BashCollapsedSummary = | { kind: "command"; command: string } + | { kind: "compact-command"; command: string; commandSummary: string } | { kind: "intent-command"; intent: string; command: string; durationLabel?: string }; interface BuildBashCollapsedSummaryOptions { args: BashToolArgs; result?: BashToolResult; isBackground: boolean; + displayMode: unknown; } const DURATION_TOKEN_PATTERN = String.raw`\d+(?:\.\d+)?\s*(?:ms|milliseconds?|s|secs?|seconds?|m|mins?|minutes?|h|hrs?|hours?)`; @@ -21,11 +27,67 @@ const TRAILING_USING_PATTERN = /^(?:(.*)\s+)?using\s+(.+?)\.?$/isu; /** Two passes catch nested patterns, for example "doing work using cmd for 5s for 3m". */ const MAX_SANITIZE_PASSES = 2; +const SKIP_WHOLE_FRAGMENT_KEYWORDS = new Set([ + "case", + "done", + "esac", + "fi", + "for", + "function", + "select", +]); +const STRIP_PREFIX_KEYWORDS = new Set([ + "!", + "(", + ")", + "{", + "}", + "do", + "elif", + "else", + "if", + "then", + "until", + "while", +]); +const SIMPLE_COMMAND_WRAPPERS = new Set(["builtin", "command", "exec", "time"]); +const ENV_OPTIONS_WITH_ARGUMENT = new Set([ + "-C", + "-S", + "-u", + "--chdir", + "--split-string", + "--unset", +]); +const ENV_SHORT_OPTIONS_WITH_ARGUMENT = new Set(["C", "S", "u"]); +const REDIRECTION_OPERATOR_PATTERN = String.raw`(?:&>>|&>|<>|>>|>\||>|<<<|<<-?|>&|<&)`; +const REDIRECTION_TOKEN_PATTERN = new RegExp( + String.raw`^(?:\d*)?${REDIRECTION_OPERATOR_PATTERN}$`, + "u" +); +const ATTACHED_REDIRECTION_TOKEN_PATTERN = new RegExp( + String.raw`^(?:\d*)?${REDIRECTION_OPERATOR_PATTERN}.+`, + "u" +); +const CASE_ARM_LABEL_TOKEN_PATTERN = /^.+\)$/u; +const ASSIGNMENT_TOKEN_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*=.*$/u; + /** Intent improves scanability, command lets users verify what ran. */ export function buildBashCollapsedSummary( options: BuildBashCollapsedSummaryOptions ): BashCollapsedSummary { const command = typeof options.args.script === "string" ? options.args.script : ""; + const displayMode = isToolCollapsedDisplayMode(options.displayMode) + ? options.displayMode + : DEFAULT_TOOL_COLLAPSED_DISPLAY_MODE; + + if (displayMode === "command") { + return { kind: "command", command }; + } + + if (displayMode === "compact") { + return { kind: "compact-command", command, commandSummary: summarizeBashCommands(command) }; + } const intent = sanitizeModelIntent(options.args.model_intent, command); if (!intent || normalizeForComparison(intent) === normalizeForComparison(command)) { @@ -37,8 +99,12 @@ export function buildBashCollapsedSummary( ? formatDuration(options.result.wall_duration_ms, "decimal") : undefined; - // So users can verify what ran. - return { kind: "intent-command", intent, command, durationLabel }; + return { + kind: "intent-command", + intent, + command, + ...(durationLabel !== undefined ? { durationLabel } : {}), + }; } /** Models may echo `using ` and `for ` despite schema guidance, so strip those since Mux appends them. */ @@ -70,6 +136,614 @@ export function sanitizeModelIntent(rawIntent: unknown, command: string): string return capitalize(intent); } +let lastSummarizedCommand: string | undefined; +let lastCommandSummary: string | undefined; + +export function summarizeBashCommands(command: string): string { + // Compact summaries are computed during BashToolCall renders, so avoid re-parsing + // the same script while a command is streaming or its elapsed time updates. + if (command === lastSummarizedCommand && lastCommandSummary !== undefined) { + return lastCommandSummary; + } + + const summary = summarizeBashCommandsUncached(command); + lastSummarizedCommand = command; + lastCommandSummary = summary; + return summary; +} + +function summarizeBashCommandsUncached(command: string): string { + const commandNames: string[] = []; + const seenCommandNames = new Set(); + + for (const fragment of splitShellCommandFragments(stripHeredocBodies(command))) { + const commandName = extractCommandNameFromFragment(fragment); + if (!commandName) { + continue; + } + + const normalizedCommandName = commandName.toLowerCase(); + if (seenCommandNames.has(normalizedCommandName)) { + continue; + } + + seenCommandNames.add(normalizedCommandName); + commandNames.push(commandName); + } + + return commandNames.length > 0 ? commandNames.join(", ") : command; +} + +interface HeredocDelimiter { + delimiter: string; + allowLeadingTabs: boolean; +} + +function stripHeredocBodies(command: string): string { + const outputLines: string[] = []; + const pendingDelimiters: HeredocDelimiter[] = []; + + for (const line of command.split("\n")) { + const pendingDelimiter = pendingDelimiters[0]; + if (pendingDelimiter) { + const terminatorLine = pendingDelimiter.allowLeadingTabs ? line.replace(/^\t+/u, "") : line; + if (terminatorLine === pendingDelimiter.delimiter) { + pendingDelimiters.shift(); + } + continue; + } + + outputLines.push(line); + pendingDelimiters.push(...findHeredocDelimiters(line)); + } + + return outputLines.join("\n"); +} + +function findHeredocDelimiters(line: string): HeredocDelimiter[] { + const delimiters: HeredocDelimiter[] = []; + let quote: "'" | '"' | "`" | null = null; + let escaped = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + + if (escaped) { + escaped = false; + continue; + } + + if (char === "\\" && quote !== "'") { + escaped = true; + continue; + } + + if (quote) { + if (char === quote) { + quote = null; + } + continue; + } + + if (char === "'" || char === '"' || char === "`") { + quote = char; + continue; + } + + if (char === "#" && (i === 0 || /\s/u.test(line[i - 1] ?? ""))) { + break; + } + + if (char === "(" && line[i + 1] === "(") { + i = skipArithmeticExpression(line, i + 2); + continue; + } + + if (char === "$" && line[i + 1] === "(" && line[i + 2] === "(") { + i = skipArithmeticExpression(line, i + 3); + continue; + } + + if (char !== "<" || line[i + 1] !== "<" || line[i + 2] === "<") { + continue; + } + + const parsed = parseHeredocDelimiter(line, i + 2); + if (!parsed) { + continue; + } + + delimiters.push({ delimiter: parsed.delimiter, allowLeadingTabs: parsed.allowLeadingTabs }); + i = parsed.endIndex - 1; + } + + return delimiters; +} + +function skipArithmeticExpression(line: string, startIndex: number): number { + let parenDepth = 0; + let quote: "'" | '"' | "`" | null = null; + let escaped = false; + + for (let index = startIndex; index < line.length; index++) { + const char = line[index]; + + if (escaped) { + escaped = false; + continue; + } + + if (char === "\\" && quote !== "'") { + escaped = true; + continue; + } + + if (quote) { + if (char === quote) { + quote = null; + } + continue; + } + + if (char === "'" || char === '"' || char === "`") { + quote = char; + continue; + } + + if (char === "(") { + parenDepth++; + continue; + } + + if (char !== ")") { + continue; + } + + if (parenDepth > 0) { + parenDepth--; + continue; + } + + if (line[index + 1] === ")") { + return index + 1; + } + } + + return line.length - 1; +} + +function parseHeredocDelimiter( + line: string, + startIndex: number +): { delimiter: string; allowLeadingTabs: boolean; endIndex: number } | undefined { + let index = startIndex; + let allowLeadingTabs = false; + + if (line[index] === "-") { + allowLeadingTabs = true; + index++; + } + + while (line[index] === " " || line[index] === "\t") { + index++; + } + + const quote = line[index]; + if (quote === "'" || quote === '"') { + const endIndex = line.indexOf(quote, index + 1); + if (endIndex === -1) { + return undefined; + } + + const delimiter = line.slice(index + 1, endIndex); + return delimiter ? { delimiter, allowLeadingTabs, endIndex: endIndex + 1 } : undefined; + } + + const delimiterStart = index; + while (index < line.length && !/[\s;|&<>]/u.test(line[index])) { + index++; + } + + const delimiter = line.slice(delimiterStart, index).replace(/\\(.)/gu, "$1"); + return delimiter ? { delimiter, allowLeadingTabs, endIndex: index } : undefined; +} + +// Case patterns use `|` before the closing label marker, not a pipeline command boundary. +function isCasePatternAlternativePipe( + command: string, + operatorIndex: number, + fragment: string +): boolean { + const currentPattern = fragment.slice(fragment.lastIndexOf("|") + 1).trim(); + if (!currentPattern || /\s/u.test(currentPattern)) { + return false; + } + + let sawPatternToken = false; + let sawWhitespaceAfterPatternToken = false; + for (let index = operatorIndex + 1; index < command.length; index++) { + const char = command[index]; + + if (char === ")") { + return sawPatternToken; + } + + if (char === "|") { + if (!sawPatternToken) { + return false; + } + sawPatternToken = false; + sawWhitespaceAfterPatternToken = false; + continue; + } + + if (char === "\n" || char === ";" || char === "&") { + return false; + } + + if (/\s/u.test(char)) { + sawWhitespaceAfterPatternToken ||= sawPatternToken; + continue; + } + + if (sawWhitespaceAfterPatternToken) { + return false; + } + + sawPatternToken = true; + if (char === "\\") { + index++; + } + } + + return false; +} + +function splitShellCommandFragments(command: string): string[] { + const fragments: string[] = []; + let fragment = ""; + let quote: "'" | '"' | "`" | null = null; + let escaped = false; + + const flushFragment = () => { + const trimmedFragment = fragment.trim(); + if (trimmedFragment) { + fragments.push(trimmedFragment); + } + fragment = ""; + }; + + for (let i = 0; i < command.length; i++) { + const char = command[i]; + const nextChar = command[i + 1]; + const previousChar = command[i - 1]; + + if (escaped) { + fragment += char; + escaped = false; + continue; + } + + if (char === "\\" && quote !== "'") { + fragment += char; + escaped = true; + continue; + } + + if (quote) { + fragment += char; + if (char === quote) { + quote = null; + } + continue; + } + + if (char === "'" || char === '"' || char === "`") { + quote = char; + fragment += char; + continue; + } + + // Arithmetic for-loop headers use semicolons as expression separators, not command boundaries. + if (char === "(" && nextChar === "(") { + const endIndex = skipArithmeticExpression(command, i + 2); + fragment += command.slice(i, endIndex + 1); + i = endIndex; + continue; + } + + if (char === "$" && nextChar === "(" && command[i + 2] === "(") { + const endIndex = skipArithmeticExpression(command, i + 3); + fragment += command.slice(i, endIndex + 1); + i = endIndex; + continue; + } + + if (char === "#" && (fragment.length === 0 || /\s/u.test(fragment.at(-1) ?? ""))) { + flushFragment(); + while (i + 1 < command.length && command[i + 1] !== "\n") { + i++; + } + continue; + } + + if (char === ";" || char === "\n") { + flushFragment(); + continue; + } + + if (char === "|" || char === "&") { + const isDoubleOperator = nextChar === char; + const isRedirection = + (char === "|" && previousChar === ">") || + (char === "&" && !isDoubleOperator && (previousChar === ">" || nextChar === ">")); + const isCaseAlternative = + char === "|" && !isDoubleOperator && isCasePatternAlternativePipe(command, i, fragment); + if (!isRedirection && !isCaseAlternative) { + flushFragment(); + if (isDoubleOperator) { + i++; + } + continue; + } + } + + fragment += char; + } + + flushFragment(); + return fragments; +} + +function extractCommandNameFromFragment(fragment: string): string | undefined { + const tokens = tokenizeShellFragment(fragment); + let index = 0; + + while (index < tokens.length) { + const token = tokens[index]; + + if (SKIP_WHOLE_FRAGMENT_KEYWORDS.has(token) || token.startsWith("((")) { + return undefined; + } + + if (STRIP_PREFIX_KEYWORDS.has(token) || ASSIGNMENT_TOKEN_PATTERN.test(token)) { + index++; + continue; + } + + const nextCaseArmIndex = skipCaseArmLabelTokens(tokens, index); + if (nextCaseArmIndex !== undefined) { + index = nextCaseArmIndex; + continue; + } + + const nextIndex = skipLeadingRedirection(tokens, index); + if (nextIndex !== undefined) { + index = nextIndex; + continue; + } + + break; + } + + const commandToken = unwrapCommandWrapper(tokens, index); + return commandToken ? normalizeCommandName(commandToken) : undefined; +} + +function skipCaseArmLabelTokens(tokens: string[], startIndex: number): number | undefined { + const firstToken = tokens[startIndex]; + if (!firstToken) { + return undefined; + } + + if (CASE_ARM_LABEL_TOKEN_PATTERN.test(firstToken)) { + return startIndex + 1; + } + + let sawAlternativeSeparator = false; + for (let index = startIndex + 1; index < tokens.length; index++) { + const token = tokens[index]; + + if (token === "|") { + sawAlternativeSeparator = true; + continue; + } + + if (token === ")" || CASE_ARM_LABEL_TOKEN_PATTERN.test(token)) { + return sawAlternativeSeparator ? index + 1 : undefined; + } + + if (!sawAlternativeSeparator) { + return undefined; + } + } + + return undefined; +} + +function skipLeadingRedirection(tokens: string[], index: number): number | undefined { + const token = tokens[index]; + if (REDIRECTION_TOKEN_PATTERN.test(token)) { + return index + 2; + } + + if (ATTACHED_REDIRECTION_TOKEN_PATTERN.test(token)) { + return index + 1; + } + + return undefined; +} + +function tokenizeShellFragment(fragment: string): string[] { + const tokens: string[] = []; + let token = ""; + let quote: "'" | '"' | "`" | null = null; + let escaped = false; + + const pushToken = () => { + if (token) { + tokens.push(token); + token = ""; + } + }; + + for (const char of fragment) { + if (escaped) { + token += char; + escaped = false; + continue; + } + + if (char === "\\" && quote !== "'") { + escaped = true; + continue; + } + + if (quote) { + if (char === quote) { + quote = null; + } else { + token += char; + } + continue; + } + + if (char === "'" || char === '"' || char === "`") { + quote = char; + continue; + } + + if (/\s/u.test(char)) { + pushToken(); + continue; + } + + token += char; + } + + pushToken(); + return tokens; +} + +function envShortOptionConsumesNext(token: string): boolean { + if (!token.startsWith("-") || token.startsWith("--")) { + return false; + } + + const shortOptions = token.slice(1); + const optionWithArgumentIndex = [...shortOptions].findIndex((option) => + ENV_SHORT_OPTIONS_WITH_ARGUMENT.has(option) + ); + + return optionWithArgumentIndex !== -1 && optionWithArgumentIndex === shortOptions.length - 1; +} + +function execOptionConsumesNext(token: string): boolean { + if (!token.startsWith("-") || token.startsWith("--")) { + return false; + } + + return token.endsWith("a"); +} + +function skipEnvWrapperTokens(tokens: string[], startIndex: number): number { + let index = startIndex; + + while (index < tokens.length) { + const token = tokens[index]; + + if (ASSIGNMENT_TOKEN_PATTERN.test(token)) { + index++; + continue; + } + + if (token === "--") { + return index + 1; + } + + if (ENV_OPTIONS_WITH_ARGUMENT.has(token)) { + index += 2; + continue; + } + + if ( + token.startsWith("--chdir=") || + token.startsWith("--split-string=") || + token.startsWith("--unset=") + ) { + index++; + continue; + } + + if (token.startsWith("-")) { + index += envShortOptionConsumesNext(token) ? 2 : 1; + continue; + } + + break; + } + + return index; +} + +function skipSimpleWrapperOptions(tokens: string[], startIndex: number, wrapper: string): number { + let index = startIndex; + + while (index < tokens.length && tokens[index].startsWith("-")) { + if (wrapper === "exec" && execOptionConsumesNext(tokens[index])) { + index += 2; + continue; + } + + index++; + } + + return index; +} + +function unwrapCommandWrapper(tokens: string[], startIndex: number): string | undefined { + let index = startIndex; + + while (index < tokens.length) { + const token = tokens[index]; + + if (SIMPLE_COMMAND_WRAPPERS.has(token)) { + index = skipSimpleWrapperOptions(tokens, index + 1, token); + continue; + } + + if (token === "env") { + index = skipEnvWrapperTokens(tokens, index + 1); + continue; + } + + if (token === "run_and_report") { + index += 2; + continue; + } + + const nextIndex = skipLeadingRedirection(tokens, index); + if (nextIndex !== undefined) { + index = nextIndex; + continue; + } + + return token; + } + + return undefined; +} + +function normalizeCommandName(commandToken: string): string | undefined { + const trimmedToken = commandToken.trim().replace(/^\(+/u, "").replace(/\)+$/u, ""); + if (!trimmedToken) { + return undefined; + } + + const pathParts = trimmedToken.split("/").filter(Boolean); + return pathParts.at(-1) ?? trimmedToken; +} + function stripTrailingDuration(intent: string): string { return intent.replace(TRAILING_DURATION_PATTERN, "").trim(); } diff --git a/src/browser/stores/GitStatusStore.test.ts b/src/browser/stores/GitStatusStore.test.ts index 32f505aae5..9179f66fcc 100644 --- a/src/browser/stores/GitStatusStore.test.ts +++ b/src/browser/stores/GitStatusStore.test.ts @@ -11,6 +11,7 @@ import { import type { RuntimeStatus, RuntimeStatusStore } from "./RuntimeStatusStore"; import type { FrontendWorkspaceMetadata, GitStatus } from "@/common/types/workspace"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; +import { installTestWindow } from "@/browser/testUtils"; /** * Unit tests for GitStatusStore. @@ -186,6 +187,8 @@ function createStore( describe("GitStatusStore", () => { let store: GitStatusStore; + let restoreTestWindow: (() => void) | undefined; + beforeEach(() => { mockExecuteBash.mockReset(); mockGetProjectGitStatuses.mockReset(); @@ -200,24 +203,23 @@ describe("GitStatusStore", () => { } as Result); mockGetProjectGitStatuses.mockResolvedValue([]); - (globalThis as unknown as { window: unknown }).window = { - addEventListener: jest.fn(), - removeEventListener: jest.fn(), + restoreTestWindow = installTestWindow({ api: { workspace: { executeBash: mockExecuteBash, getProjectGitStatuses: mockGetProjectGitStatuses, }, }, - } as unknown as Window & typeof globalThis; + ensureEventTargetMethods: true, + }).restore; store = createStore(); }); afterEach(() => { store.dispose(); - // Cleanup mocked window to avoid leaking between tests - delete (globalThis as { window?: unknown }).window; + restoreTestWindow?.(); + restoreTestWindow = undefined; }); test("subscribe and unsubscribe", () => { diff --git a/src/browser/testUtils.ts b/src/browser/testUtils.ts index f309093bcf..9ac33305f4 100644 --- a/src/browser/testUtils.ts +++ b/src/browser/testUtils.ts @@ -14,6 +14,111 @@ export function requireTestModule(modulePath: string): T { return requireForTest(modulePath) as T; } +type TestWindowEventMethod = "addEventListener" | "removeEventListener" | "dispatchEvent"; + +export type TestWindowWithApi = Window & typeof globalThis & { api?: unknown }; + +interface InstallTestWindowOptions { + api?: unknown; + ensureEventTargetMethods?: boolean; +} + +interface InstalledTestWindow { + window: TestWindowWithApi; + restore: () => void; +} + +const TEST_WINDOW_EVENT_METHODS: Record = + { + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => true, + }; + +export function installTestWindow(options: InstallTestWindowOptions = {}): InstalledTestWindow { + const existingWindow = globalThis.window as TestWindowWithApi | undefined; + const targetWindow = existingWindow ?? (Object.create(null) as TestWindowWithApi); + const createdWindow = existingWindow == null; + const previousApiDescriptor = Object.getOwnPropertyDescriptor(targetWindow, "api"); + const previousEventMethodDescriptors = new Map< + TestWindowEventMethod, + PropertyDescriptor | undefined + >(); + + if (createdWindow) { + globalThis.window = targetWindow; + } + + if ("api" in options) { + Object.defineProperty(targetWindow, "api", { + configurable: true, + value: options.api, + }); + } + + if (options.ensureEventTargetMethods) { + for (const [method, replacement] of Object.entries(TEST_WINDOW_EVENT_METHODS) as Array< + [TestWindowEventMethod, EventTarget[TestWindowEventMethod]] + >) { + if (typeof targetWindow[method] === "function") { + continue; + } + + previousEventMethodDescriptors.set( + method, + Object.getOwnPropertyDescriptor(targetWindow, method) + ); + Object.defineProperty(targetWindow, method, { configurable: true, value: replacement }); + } + } + + let restored = false; + return { + window: targetWindow, + restore() { + if (restored) { + return; + } + restored = true; + + if (previousApiDescriptor) { + Object.defineProperty(targetWindow, "api", previousApiDescriptor); + } else { + delete targetWindow.api; + } + + for (const [method, descriptor] of previousEventMethodDescriptors) { + if (descriptor) { + Object.defineProperty(targetWindow, method, descriptor); + } else { + delete (targetWindow as Partial>)[method]; + } + } + + if (createdWindow && globalThis.window === targetWindow) { + delete (globalThis as { window?: unknown }).window; + } + }, + }; +} + +export function installTestNavigator(navigator: Navigator): () => void { + const previousNavigator = globalThis.navigator; + globalThis.navigator = navigator; + + return () => { + if (globalThis.navigator !== navigator) { + return; + } + + if (previousNavigator) { + globalThis.navigator = previousNavigator; + } else { + delete (globalThis as { navigator?: unknown }).navigator; + } + }; +} + /** * Helper type for recursive partial mocks. * Allows partial mocking of nested objects and async functions. diff --git a/src/browser/utils/ui/keybinds.test.ts b/src/browser/utils/ui/keybinds.test.ts index 571f8044c7..e0702da691 100644 --- a/src/browser/utils/ui/keybinds.test.ts +++ b/src/browser/utils/ui/keybinds.test.ts @@ -1,7 +1,52 @@ -import { describe, it, expect, test } from "bun:test"; +import { afterEach, describe, it, expect, test } from "bun:test"; +import { + installTestNavigator, + installTestWindow, + type TestWindowWithApi, +} from "@/browser/testUtils"; import { isMac, matchesKeybind, KEYBINDS } from "./keybinds"; import type { Keybind } from "@/common/types/keybind"; +let testWindow: TestWindowWithApi | undefined; +let restoreTestWindow: (() => void) | undefined; +let restoreTestNavigator: (() => void) | undefined; + +function setPlatform(platform: "darwin" | "linux") { + Object.defineProperty(ensureWindow(), "api", { + configurable: true, + value: { platform }, + }); +} + +function clearWindowAPI() { + delete ensureWindow().api; +} + +function ensureWindow(): TestWindowWithApi { + if (!testWindow) { + const installedWindow = installTestWindow(); + testWindow = installedWindow.window; + restoreTestWindow = installedWindow.restore; + } + + return testWindow; +} + +function setNavigatorPlatform(platform: string) { + restoreTestNavigator = installTestNavigator({ + platform, + userAgent: "Mozilla/5.0", + } as unknown as Navigator); +} + +afterEach(() => { + restoreTestNavigator?.(); + restoreTestWindow?.(); + testWindow = undefined; + restoreTestWindow = undefined; + restoreTestNavigator = undefined; +}); + // Helper to create a minimal keyboard event function createEvent(overrides: Partial = {}): KeyboardEvent { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions @@ -17,45 +62,35 @@ function createEvent(overrides: Partial = {}): KeyboardEvent { describe("isMac", () => { it("falls back to navigator.platform when Electron API is missing", () => { - const originalWindow = globalThis.window; - const originalNavigator = globalThis.navigator; - - // Simulate browser mode on macOS (no Electron preload API) - globalThis.window = {} as unknown as Window & typeof globalThis; - globalThis.navigator = { - platform: "MacIntel", - userAgent: "Mozilla/5.0", - } as unknown as Navigator; + clearWindowAPI(); + setNavigatorPlatform("MacIntel"); expect(isMac()).toBe(true); // Ctrl-style keybinds should match Cmd (Meta) on macOS const event = createEvent({ key: "P", metaKey: true, shiftKey: true }); expect(matchesKeybind(event, KEYBINDS.OPEN_COMMAND_PALETTE)).toBe(true); - - globalThis.window = originalWindow; - globalThis.navigator = originalNavigator; }); }); describe("CYCLE_MODEL keybind (Ctrl+/)", () => { it("matches Ctrl+/ on Linux/Windows", () => { // Mock non-Mac platform - globalThis.window = { api: { platform: "linux" } } as unknown as Window & typeof globalThis; + setPlatform("linux"); const event = createEvent({ key: "/", ctrlKey: true }); expect(matchesKeybind(event, { key: "/", ctrl: true })).toBe(true); }); it("matches Cmd+/ on macOS", () => { // Mock Mac platform - globalThis.window = { api: { platform: "darwin" } } as unknown as Window & typeof globalThis; + setPlatform("darwin"); const event = createEvent({ key: "/", metaKey: true }); expect(matchesKeybind(event, { key: "/", ctrl: true })).toBe(true); }); it("matches Ctrl+/ on macOS (either behavior)", () => { // Mock Mac platform - globalThis.window = { api: { platform: "darwin" } } as unknown as Window & typeof globalThis; + setPlatform("darwin"); const event = createEvent({ key: "/", ctrlKey: true }); expect(matchesKeybind(event, { key: "/", ctrl: true })).toBe(true); }); @@ -73,13 +108,13 @@ describe("CYCLE_MODEL keybind (Ctrl+/)", () => { describe("CYCLE_AGENT keybind (Ctrl/Cmd+.)", () => { it("matches Cmd+. on macOS via the Period key code", () => { - globalThis.window = { api: { platform: "darwin" } } as unknown as Window & typeof globalThis; + setPlatform("darwin"); const event = createEvent({ key: ".", code: "Period", metaKey: true }); expect(matchesKeybind(event, KEYBINDS.CYCLE_AGENT)).toBe(true); }); it("matches Cmd+Shift+Period on layouts where Period requires Shift", () => { - globalThis.window = { api: { platform: "darwin" } } as unknown as Window & typeof globalThis; + setPlatform("darwin"); const event = createEvent({ key: ">", code: "Period", metaKey: true, shiftKey: true }); expect(matchesKeybind(event, KEYBINDS.CYCLE_AGENT)).toBe(true); }); @@ -92,25 +127,25 @@ test("removed auto agent toggle keybind", () => { describe("SEND_MESSAGE_AFTER_TURN keybind (Ctrl/Cmd+Enter)", () => { it("matches Ctrl+Enter", () => { - globalThis.window = { api: { platform: "linux" } } as unknown as Window & typeof globalThis; + setPlatform("linux"); const event = createEvent({ key: "Enter", ctrlKey: true, metaKey: false }); expect(matchesKeybind(event, KEYBINDS.SEND_MESSAGE_AFTER_TURN)).toBe(true); }); it("matches Cmd+Enter on macOS", () => { - globalThis.window = { api: { platform: "darwin" } } as unknown as Window & typeof globalThis; + setPlatform("darwin"); const event = createEvent({ key: "Enter", metaKey: true, ctrlKey: false }); expect(matchesKeybind(event, KEYBINDS.SEND_MESSAGE_AFTER_TURN)).toBe(true); }); it("does not match plain Enter", () => { - globalThis.window = { api: { platform: "linux" } } as unknown as Window & typeof globalThis; + setPlatform("linux"); const event = createEvent({ key: "Enter" }); expect(matchesKeybind(event, KEYBINDS.SEND_MESSAGE_AFTER_TURN)).toBe(false); }); it("SEND_MESSAGE does not match Ctrl+Enter", () => { - globalThis.window = { api: { platform: "linux" } } as unknown as Window & typeof globalThis; + setPlatform("linux"); const event = createEvent({ key: "Enter", ctrlKey: true }); expect(matchesKeybind(event, KEYBINDS.SEND_MESSAGE)).toBe(false); }); diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 61c7383894..16b54183a7 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -82,6 +82,19 @@ export const LAUNCH_BEHAVIOR_KEY = "launchBehavior"; export type LaunchBehavior = "dashboard" | "new-chat" | "last-workspace"; +/** + * User preference for collapsed bash tool summaries (global). + */ +export const TOOL_COLLAPSED_DISPLAY_MODE_KEY = "toolCollapsedDisplayMode"; + +export type ToolCollapsedDisplayMode = "intent-command" | "compact" | "command"; + +export const DEFAULT_TOOL_COLLAPSED_DISPLAY_MODE: ToolCollapsedDisplayMode = "intent-command"; + +export function isToolCollapsedDisplayMode(value: unknown): value is ToolCollapsedDisplayMode { + return value === "intent-command" || value === "compact" || value === "command"; +} + /** * Get the localStorage key for expanded projects in sidebar (global) * Format: "expandedProjects"