From ddb9deb6aebe439db136173248a2aba0302a3bbc Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 21 May 2026 19:37:10 +0000 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20add=20compact=20bash?= =?UTF-8?q?=20summary=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `3738256{MUX_COSTS_USD:-unknown}`_ --- .../Settings/Sections/GeneralSection.test.tsx | 36 + .../Settings/Sections/GeneralSection.tsx | 50 ++ .../Tools/Bash/BashToolCall.stories.tsx | 185 +++-- src/browser/features/Tools/BashToolCall.tsx | 23 +- .../Tools/bashCollapsedSummary.test.ts | 145 +++- .../features/Tools/bashCollapsedSummary.ts | 673 +++++++++++++++++- src/browser/stores/GitStatusStore.test.ts | 3 + src/common/constants/storage.ts | 13 + 8 files changed, 1060 insertions(+), 68 deletions(-) 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,99 @@ 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", + ], + [ + "keeps pipeline commands from heredoc declaration lines", + "cat <<-EOF | sed -n '1p'\n\thello\n\tEOF\ngh pr view 1", + "cat, sed, gh", + ], + [ + "ignores arithmetic bit shifts when scanning for heredocs", + "echo $((1<<2))\ngit status\n((x<<=1))\ngh pr view 1", + "echo, git, gh", + ], + [ + "does not split arithmetic for loop headers into command names", + 'for ((i=0; i<10; i++)); do echo "$i"; done && git status', + "echo, git", + ], + [ + "skips case arm labels before command names", + 'case "$target" in\n foo|bar) gh pr view 1 ;;\n baz) make test ;;\nesac', + "gh, make", + ], + [ + "skips spaced case arm alternatives before command names", + 'case "$target" in\n foo | bar | baz ) gh pr view 1 ;;\nesac', + "gh", + ], + [ + "skips variable-backed case arm labels before command names", + 'case "$target" in\n $pattern) gh pr view 1 ;;\nesac', + "gh", + ], + [ + "does not split control operators inside quoted text", + "printf 'a && b' && git status", + "printf, git", + ], + ])("%s", (_caseName, command, expected) => { + 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..aea24c8e1d 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,66 @@ 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 +98,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 +135,610 @@ 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 === "(" && 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..0c4ee5371a 100644 --- a/src/browser/stores/GitStatusStore.test.ts +++ b/src/browser/stores/GitStatusStore.test.ts @@ -209,6 +209,9 @@ describe("GitStatusStore", () => { getProjectGitStatuses: mockGetProjectGitStatuses, }, }, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => true, } as unknown as Window & typeof globalThis; store = createStore(); 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" From e1a9c0b27cf23f6b0f23343264f04834f2689253 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 21 May 2026 20:39:22 +0000 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=A4=96=20tests:=20restore=20window=20?= =?UTF-8?q?mocks=20after=20keybind=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep browser global overrides scoped to each keybind test so later unit tests keep their expected DOM and event APIs. --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$223.75`_ --- src/browser/utils/ui/keybinds.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/browser/utils/ui/keybinds.test.ts b/src/browser/utils/ui/keybinds.test.ts index 571f8044c7..46be7ff561 100644 --- a/src/browser/utils/ui/keybinds.test.ts +++ b/src/browser/utils/ui/keybinds.test.ts @@ -1,7 +1,15 @@ -import { describe, it, expect, test } from "bun:test"; +import { afterEach, describe, it, expect, test } from "bun:test"; import { isMac, matchesKeybind, KEYBINDS } from "./keybinds"; import type { Keybind } from "@/common/types/keybind"; +const originalWindow = globalThis.window; +const originalNavigator = globalThis.navigator; + +afterEach(() => { + globalThis.window = originalWindow; + globalThis.navigator = originalNavigator; +}); + // Helper to create a minimal keyboard event function createEvent(overrides: Partial = {}): KeyboardEvent { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions From 106098881e0be9b7076745a2627099b325773082 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 21 May 2026 20:49:05 +0000 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20skip=20shell=20negati?= =?UTF-8?q?on=20in=20compact=20bash=20summaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Treat the shell negation operator as prefix syntax so compact command extraction shows the command that runs instead of `!`. --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$223.75`_ --- src/browser/features/Tools/bashCollapsedSummary.test.ts | 5 +++++ src/browser/features/Tools/bashCollapsedSummary.ts | 1 + 2 files changed, 6 insertions(+) diff --git a/src/browser/features/Tools/bashCollapsedSummary.test.ts b/src/browser/features/Tools/bashCollapsedSummary.test.ts index fa8a2ff2dc..6f8670f466 100644 --- a/src/browser/features/Tools/bashCollapsedSummary.test.ts +++ b/src/browser/features/Tools/bashCollapsedSummary.test.ts @@ -221,6 +221,11 @@ describe("summarizeBashCommands", () => { 'case "$target" in\n $pattern) gh pr view 1 ;;\nesac', "gh", ], + [ + "skips shell negation before command names", + "if ! git diff --quiet; then echo changed; fi && ! gh pr view 1", + "git, echo, gh", + ], [ "does not split control operators inside quoted text", "printf 'a && b' && git status", diff --git a/src/browser/features/Tools/bashCollapsedSummary.ts b/src/browser/features/Tools/bashCollapsedSummary.ts index aea24c8e1d..a53596a811 100644 --- a/src/browser/features/Tools/bashCollapsedSummary.ts +++ b/src/browser/features/Tools/bashCollapsedSummary.ts @@ -37,6 +37,7 @@ const SKIP_WHOLE_FRAGMENT_KEYWORDS = new Set([ "select", ]); const STRIP_PREFIX_KEYWORDS = new Set([ + "!", "(", ")", "{", From 73eecf4122d9bdc2b4d8a951464d6b28fe9c6f95 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 21 May 2026 21:17:24 +0000 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=A4=96=20tests:=20isolate=20global=20?= =?UTF-8?q?mock=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep test-only window and runtime factory overrides from leaking into later unit files during full suite runs. --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$223.75`_ --- src/browser/stores/GitStatusStore.test.ts | 99 ++++++++++++++++---- src/browser/utils/ui/keybinds.test.ts | 104 ++++++++++++++++------ 2 files changed, 163 insertions(+), 40 deletions(-) diff --git a/src/browser/stores/GitStatusStore.test.ts b/src/browser/stores/GitStatusStore.test.ts index 0c4ee5371a..d458738054 100644 --- a/src/browser/stores/GitStatusStore.test.ts +++ b/src/browser/stores/GitStatusStore.test.ts @@ -183,6 +183,88 @@ function createStore( return store; } +type MockableWindow = Window & typeof globalThis & { api?: unknown }; +type WindowEventMethod = "addEventListener" | "removeEventListener" | "dispatchEvent"; + +let mockedWindow: MockableWindow | undefined; +let createdMockWindow = false; +let previousApiDescriptor: PropertyDescriptor | undefined; +let previousEventMethodDescriptors: Partial< + Record +> = {}; + +function installMockWindowAPI() { + const existingWindow = globalThis.window as MockableWindow | undefined; + const targetWindow = existingWindow ?? (Object.create(null) as MockableWindow); + + mockedWindow = targetWindow; + createdMockWindow = existingWindow == null; + previousApiDescriptor = Object.getOwnPropertyDescriptor(targetWindow, "api"); + previousEventMethodDescriptors = {}; + + if (createdMockWindow) { + globalThis.window = targetWindow; + } + + Object.defineProperty(targetWindow, "api", { + configurable: true, + value: { + workspace: { + executeBash: mockExecuteBash, + getProjectGitStatuses: mockGetProjectGitStatuses, + }, + }, + }); + + ensureWindowEventMethod(targetWindow, "addEventListener", () => undefined); + ensureWindowEventMethod(targetWindow, "removeEventListener", () => undefined); + ensureWindowEventMethod(targetWindow, "dispatchEvent", () => true); +} + +function ensureWindowEventMethod( + targetWindow: MockableWindow, + method: WindowEventMethod, + replacement: EventTarget[WindowEventMethod] +) { + if (typeof targetWindow[method] === "function") { + return; + } + + previousEventMethodDescriptors[method] = Object.getOwnPropertyDescriptor(targetWindow, method); + Object.defineProperty(targetWindow, method, { configurable: true, value: replacement }); +} + +function restoreMockWindowAPI() { + const targetWindow = mockedWindow; + if (!targetWindow) { + return; + } + + if (previousApiDescriptor) { + Object.defineProperty(targetWindow, "api", previousApiDescriptor); + } else { + delete targetWindow.api; + } + + for (const method of Object.keys(previousEventMethodDescriptors) as WindowEventMethod[]) { + const descriptor = previousEventMethodDescriptors[method]; + if (descriptor) { + Object.defineProperty(targetWindow, method, descriptor); + } else { + delete (targetWindow as Partial>)[method]; + } + } + + if (createdMockWindow && globalThis.window === targetWindow) { + delete (globalThis as { window?: unknown }).window; + } + + mockedWindow = undefined; + createdMockWindow = false; + previousApiDescriptor = undefined; + previousEventMethodDescriptors = {}; +} + describe("GitStatusStore", () => { let store: GitStatusStore; @@ -200,27 +282,14 @@ describe("GitStatusStore", () => { } as Result); mockGetProjectGitStatuses.mockResolvedValue([]); - (globalThis as unknown as { window: unknown }).window = { - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - api: { - workspace: { - executeBash: mockExecuteBash, - getProjectGitStatuses: mockGetProjectGitStatuses, - }, - }, - addEventListener: () => undefined, - removeEventListener: () => undefined, - dispatchEvent: () => true, - } as unknown as Window & typeof globalThis; + installMockWindowAPI(); store = createStore(); }); afterEach(() => { store.dispose(); - // Cleanup mocked window to avoid leaking between tests - delete (globalThis as { window?: unknown }).window; + restoreMockWindowAPI(); }); test("subscribe and unsubscribe", () => { diff --git a/src/browser/utils/ui/keybinds.test.ts b/src/browser/utils/ui/keybinds.test.ts index 46be7ff561..2d09fae391 100644 --- a/src/browser/utils/ui/keybinds.test.ts +++ b/src/browser/utils/ui/keybinds.test.ts @@ -2,12 +2,76 @@ import { afterEach, describe, it, expect, test } from "bun:test"; import { isMac, matchesKeybind, KEYBINDS } from "./keybinds"; import type { Keybind } from "@/common/types/keybind"; -const originalWindow = globalThis.window; -const originalNavigator = globalThis.navigator; +type PlatformWindow = Window & typeof globalThis & { api?: unknown }; + +let mockedWindow: PlatformWindow | undefined; +let createdMockWindow = false; +let previousApiDescriptor: PropertyDescriptor | undefined; +let previousNavigator: Navigator | undefined; +let mockedNavigator: Navigator | undefined; + +function setPlatform(platform: "darwin" | "linux") { + const targetWindow = ensureWindow(); + Object.defineProperty(targetWindow, "api", { + configurable: true, + value: { platform }, + }); +} + +function clearWindowAPI() { + const targetWindow = ensureWindow(); + delete targetWindow.api; +} + +function ensureWindow(): PlatformWindow { + if (mockedWindow) { + return mockedWindow; + } + + const existingWindow = globalThis.window as PlatformWindow | undefined; + mockedWindow = existingWindow ?? (Object.create(null) as PlatformWindow); + createdMockWindow = existingWindow == null; + previousApiDescriptor = Object.getOwnPropertyDescriptor(mockedWindow, "api"); + + if (createdMockWindow) { + globalThis.window = mockedWindow; + } + + return mockedWindow; +} + +function setNavigatorPlatform(platform: string) { + previousNavigator = globalThis.navigator; + mockedNavigator = { platform, userAgent: "Mozilla/5.0" } as unknown as Navigator; + globalThis.navigator = mockedNavigator; +} afterEach(() => { - globalThis.window = originalWindow; - globalThis.navigator = originalNavigator; + if (mockedWindow) { + if (previousApiDescriptor) { + Object.defineProperty(mockedWindow, "api", previousApiDescriptor); + } else { + delete mockedWindow.api; + } + + if (createdMockWindow && globalThis.window === mockedWindow) { + delete (globalThis as { window?: unknown }).window; + } + } + + if (mockedNavigator && globalThis.navigator === mockedNavigator) { + if (previousNavigator) { + globalThis.navigator = previousNavigator; + } else { + delete (globalThis as { navigator?: unknown }).navigator; + } + } + + mockedWindow = undefined; + createdMockWindow = false; + previousApiDescriptor = undefined; + previousNavigator = undefined; + mockedNavigator = undefined; }); // Helper to create a minimal keyboard event @@ -25,45 +89,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); }); @@ -81,13 +135,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); }); @@ -100,25 +154,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); }); From bd406deb1966b4900d363b6f4659bd8ea061ecd4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 21 May 2026 21:23:48 +0000 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=A4=96=20tests:=20share=20browser=20g?= =?UTF-8?q?lobal=20test=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract shared browser test helpers for window and navigator mocking, then use them in GitStatusStore and keybind tests to remove duplicate descriptor cleanup logic. --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$223.75`_ --- src/browser/stores/GitStatusStore.test.ts | 98 +++----------------- src/browser/testUtils.ts | 105 ++++++++++++++++++++++ src/browser/utils/ui/keybinds.test.ts | 77 ++++++---------- 3 files changed, 144 insertions(+), 136 deletions(-) diff --git a/src/browser/stores/GitStatusStore.test.ts b/src/browser/stores/GitStatusStore.test.ts index d458738054..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. @@ -183,91 +184,11 @@ function createStore( return store; } -type MockableWindow = Window & typeof globalThis & { api?: unknown }; -type WindowEventMethod = "addEventListener" | "removeEventListener" | "dispatchEvent"; - -let mockedWindow: MockableWindow | undefined; -let createdMockWindow = false; -let previousApiDescriptor: PropertyDescriptor | undefined; -let previousEventMethodDescriptors: Partial< - Record -> = {}; - -function installMockWindowAPI() { - const existingWindow = globalThis.window as MockableWindow | undefined; - const targetWindow = existingWindow ?? (Object.create(null) as MockableWindow); - - mockedWindow = targetWindow; - createdMockWindow = existingWindow == null; - previousApiDescriptor = Object.getOwnPropertyDescriptor(targetWindow, "api"); - previousEventMethodDescriptors = {}; - - if (createdMockWindow) { - globalThis.window = targetWindow; - } - - Object.defineProperty(targetWindow, "api", { - configurable: true, - value: { - workspace: { - executeBash: mockExecuteBash, - getProjectGitStatuses: mockGetProjectGitStatuses, - }, - }, - }); - - ensureWindowEventMethod(targetWindow, "addEventListener", () => undefined); - ensureWindowEventMethod(targetWindow, "removeEventListener", () => undefined); - ensureWindowEventMethod(targetWindow, "dispatchEvent", () => true); -} - -function ensureWindowEventMethod( - targetWindow: MockableWindow, - method: WindowEventMethod, - replacement: EventTarget[WindowEventMethod] -) { - if (typeof targetWindow[method] === "function") { - return; - } - - previousEventMethodDescriptors[method] = Object.getOwnPropertyDescriptor(targetWindow, method); - Object.defineProperty(targetWindow, method, { configurable: true, value: replacement }); -} - -function restoreMockWindowAPI() { - const targetWindow = mockedWindow; - if (!targetWindow) { - return; - } - - if (previousApiDescriptor) { - Object.defineProperty(targetWindow, "api", previousApiDescriptor); - } else { - delete targetWindow.api; - } - - for (const method of Object.keys(previousEventMethodDescriptors) as WindowEventMethod[]) { - const descriptor = previousEventMethodDescriptors[method]; - if (descriptor) { - Object.defineProperty(targetWindow, method, descriptor); - } else { - delete (targetWindow as Partial>)[method]; - } - } - - if (createdMockWindow && globalThis.window === targetWindow) { - delete (globalThis as { window?: unknown }).window; - } - - mockedWindow = undefined; - createdMockWindow = false; - previousApiDescriptor = undefined; - previousEventMethodDescriptors = {}; -} - describe("GitStatusStore", () => { let store: GitStatusStore; + let restoreTestWindow: (() => void) | undefined; + beforeEach(() => { mockExecuteBash.mockReset(); mockGetProjectGitStatuses.mockReset(); @@ -282,14 +203,23 @@ describe("GitStatusStore", () => { } as Result); mockGetProjectGitStatuses.mockResolvedValue([]); - installMockWindowAPI(); + restoreTestWindow = installTestWindow({ + api: { + workspace: { + executeBash: mockExecuteBash, + getProjectGitStatuses: mockGetProjectGitStatuses, + }, + }, + ensureEventTargetMethods: true, + }).restore; store = createStore(); }); afterEach(() => { store.dispose(); - restoreMockWindowAPI(); + 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 2d09fae391..e0702da691 100644 --- a/src/browser/utils/ui/keybinds.test.ts +++ b/src/browser/utils/ui/keybinds.test.ts @@ -1,77 +1,50 @@ 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"; -type PlatformWindow = Window & typeof globalThis & { api?: unknown }; - -let mockedWindow: PlatformWindow | undefined; -let createdMockWindow = false; -let previousApiDescriptor: PropertyDescriptor | undefined; -let previousNavigator: Navigator | undefined; -let mockedNavigator: Navigator | undefined; +let testWindow: TestWindowWithApi | undefined; +let restoreTestWindow: (() => void) | undefined; +let restoreTestNavigator: (() => void) | undefined; function setPlatform(platform: "darwin" | "linux") { - const targetWindow = ensureWindow(); - Object.defineProperty(targetWindow, "api", { + Object.defineProperty(ensureWindow(), "api", { configurable: true, value: { platform }, }); } function clearWindowAPI() { - const targetWindow = ensureWindow(); - delete targetWindow.api; + delete ensureWindow().api; } -function ensureWindow(): PlatformWindow { - if (mockedWindow) { - return mockedWindow; - } - - const existingWindow = globalThis.window as PlatformWindow | undefined; - mockedWindow = existingWindow ?? (Object.create(null) as PlatformWindow); - createdMockWindow = existingWindow == null; - previousApiDescriptor = Object.getOwnPropertyDescriptor(mockedWindow, "api"); - - if (createdMockWindow) { - globalThis.window = mockedWindow; +function ensureWindow(): TestWindowWithApi { + if (!testWindow) { + const installedWindow = installTestWindow(); + testWindow = installedWindow.window; + restoreTestWindow = installedWindow.restore; } - return mockedWindow; + return testWindow; } function setNavigatorPlatform(platform: string) { - previousNavigator = globalThis.navigator; - mockedNavigator = { platform, userAgent: "Mozilla/5.0" } as unknown as Navigator; - globalThis.navigator = mockedNavigator; + restoreTestNavigator = installTestNavigator({ + platform, + userAgent: "Mozilla/5.0", + } as unknown as Navigator); } afterEach(() => { - if (mockedWindow) { - if (previousApiDescriptor) { - Object.defineProperty(mockedWindow, "api", previousApiDescriptor); - } else { - delete mockedWindow.api; - } - - if (createdMockWindow && globalThis.window === mockedWindow) { - delete (globalThis as { window?: unknown }).window; - } - } - - if (mockedNavigator && globalThis.navigator === mockedNavigator) { - if (previousNavigator) { - globalThis.navigator = previousNavigator; - } else { - delete (globalThis as { navigator?: unknown }).navigator; - } - } - - mockedWindow = undefined; - createdMockWindow = false; - previousApiDescriptor = undefined; - previousNavigator = undefined; - mockedNavigator = undefined; + restoreTestNavigator?.(); + restoreTestWindow?.(); + testWindow = undefined; + restoreTestWindow = undefined; + restoreTestNavigator = undefined; }); // Helper to create a minimal keyboard event From c406046e0bdfa8d2ac4f25b43438dffc7589a71c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 21 May 2026 21:48:47 +0000 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20ignore=20commented=20?= =?UTF-8?q?heredoc=20markers=20in=20compact=20summaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop heredoc detection at unquoted shell comments so compact command extraction does not hide real commands after commented heredoc syntax. --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `$223.75`_ --- src/browser/features/Tools/bashCollapsedSummary.test.ts | 1 + src/browser/features/Tools/bashCollapsedSummary.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/browser/features/Tools/bashCollapsedSummary.test.ts b/src/browser/features/Tools/bashCollapsedSummary.test.ts index 6f8670f466..1599ea9994 100644 --- a/src/browser/features/Tools/bashCollapsedSummary.test.ts +++ b/src/browser/features/Tools/bashCollapsedSummary.test.ts @@ -191,6 +191,7 @@ describe("summarizeBashCommands", () => { "cat <<'EOF'\nhello from heredoc\nEOF\ngit status", "cat, git", ], + ["ignores heredoc markers inside shell comments", "# <