Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/types/src/__tests__/global-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,16 @@ describe("alwaysDenyUnapprovedCommands global setting", () => {
expect(() => globalSettingsSchema.parse({ alwaysDenyUnapprovedCommands: "true" })).toThrow()
})
})

describe("collapseCommandOutputByDefault global setting", () => {
it.each([true, false, undefined])("round-trips %s through the export/import schema", (value) => {
const settings = globalSettingsSchema.parse({ collapseCommandOutputByDefault: value })
expect(globalSettingsSchema.parse(JSON.parse(JSON.stringify(settings))).collapseCommandOutputByDefault).toBe(
value,
)
expect(GLOBAL_SETTINGS_KEYS).toContain("collapseCommandOutputByDefault")
})
it("rejects non-boolean values", () => {
expect(globalSettingsSchema.safeParse({ collapseCommandOutputByDefault: "true" }).success).toBe(false)
})
})
4 changes: 4 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ export const DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES = false
*/
export const DEFAULT_DIFF_FUZZY_THRESHOLD = 1.0

// Preserve the existing terminal-dependent output display until users opt in.
export const DEFAULT_COLLAPSE_COMMAND_OUTPUT_BY_DEFAULT = false

export const DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED = false

/**
Expand Down Expand Up @@ -236,6 +239,7 @@ export const globalSettingsSchema = z.object({
terminalOutputPreviewSize: z.enum(["small", "medium", "large"]).optional(),
terminalShellIntegrationTimeout: z.number().optional(),
terminalShellIntegrationDisabled: z.boolean().optional(),
collapseCommandOutputByDefault: z.boolean().optional(),
terminalCommandDelay: z.number().optional(),
terminalPowershellCounter: z.boolean().optional(),
terminalZshClearEolMark: z.boolean().optional(),
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ export type ExtensionState = Pick<
| "terminalOutputPreviewSize"
| "terminalShellIntegrationTimeout"
| "terminalShellIntegrationDisabled"
| "collapseCommandOutputByDefault"
| "terminalCommandDelay"
| "terminalPowershellCounter"
| "terminalZshClearEolMark"
Expand Down
6 changes: 6 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
DEFAULT_DIFF_FUZZY_THRESHOLD,
DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED,
DEFAULT_ALWAYS_DENY_UNAPPROVED_COMMANDS,
DEFAULT_COLLAPSE_COMMAND_OUTPUT_BY_DEFAULT,
DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES,
DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED,
DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES,
Expand Down Expand Up @@ -2625,6 +2626,7 @@ export class ClineProvider
diffFuzzyThreshold,
terminalShellIntegrationTimeout,
terminalShellIntegrationDisabled,
collapseCommandOutputByDefault,
terminalCommandDelay,
terminalPowershellCounter,
terminalZshClearEolMark,
Expand Down Expand Up @@ -2797,6 +2799,8 @@ export class ClineProvider
diffFuzzyThreshold: diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD,
terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout,
terminalShellIntegrationDisabled: terminalShellIntegrationDisabled ?? true,
collapseCommandOutputByDefault:
collapseCommandOutputByDefault ?? DEFAULT_COLLAPSE_COMMAND_OUTPUT_BY_DEFAULT,
terminalCommandDelay: terminalCommandDelay ?? 0,
terminalPowershellCounter: terminalPowershellCounter ?? false,
terminalZshClearEolMark: terminalZshClearEolMark ?? true,
Expand Down Expand Up @@ -3033,6 +3037,8 @@ export class ClineProvider
terminalShellIntegrationTimeout:
stateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout,
terminalShellIntegrationDisabled: stateValues.terminalShellIntegrationDisabled ?? true,
collapseCommandOutputByDefault:
stateValues.collapseCommandOutputByDefault ?? DEFAULT_COLLAPSE_COMMAND_OUTPUT_BY_DEFAULT,
terminalCommandDelay: stateValues.terminalCommandDelay ?? 0,
terminalPowershellCounter: stateValues.terminalPowershellCounter ?? false,
terminalZshClearEolMark: stateValues.terminalZshClearEolMark ?? true,
Expand Down
10 changes: 10 additions & 0 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2184,6 +2184,16 @@ describe("ClineProvider", () => {
expect(state.writeDelayMs).toBe(DEFAULT_WRITE_DELAY_MS)
})

test.each([true, false, undefined])(
"returns the saved output collapse preference (%s) to the webview",
async (value) => {
await provider.resolveWebviewView(mockWebviewView)
await provider.contextProxy.setValue("collapseCommandOutputByDefault", value)
expect((await provider.getState()).collapseCommandOutputByDefault).toBe(value ?? false)
expect((await provider.getStateToPostToWebview()).collapseCommandOutputByDefault).toBe(value ?? false)
},
)

test("getState applies fallback defaults for write, diff, and terminal settings", async () => {
// Mock globalState.get to return undefined for the fallback settings
// (typed reassignment — Memento.get has no mock type, same pattern as the
Expand Down
17 changes: 17 additions & 0 deletions src/core/webview/__tests__/webviewMessageHandler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2368,3 +2368,20 @@ describe("webviewMessageHandler - telemetrySetting", () => {
expect(TelemetryService.instance.updateTelemetryState).not.toHaveBeenCalled()
})
})

it.each([true, false, undefined])(
"persists the output collapse preference (%s) without changing terminal mode",
async (value) => {
vi.clearAllMocks()
await webviewMessageHandler(mockClineProvider, {
type: "updateSettings",
updatedSettings: { collapseCommandOutputByDefault: value },
})
expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("collapseCommandOutputByDefault", value)
expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith(
"terminalShellIntegrationDisabled",
expect.anything(),
)
expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1)
},
)
27 changes: 27 additions & 0 deletions webview-ui/playwright/gallery/stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,33 @@ const mermaidGantt = `gantt
Ship release :active, release, after scope, 3d`

export const stories: Record<string, Story> = {
"terminal-settings": async () => {
const [{ AppProviders }, { TerminalSettings }] = await Promise.all([
import("../AppProviders"),
import("@/components/settings/TerminalSettings"),
])
function Settings() {
const [collapsed, setCollapsed] = useState(false)
return (
<div
className="w-[420px] p-4 bg-vscode-editor-background text-vscode-foreground"
data-testid="terminal-settings-story">
<TerminalSettings
terminalShellIntegrationDisabled={true}
collapseCommandOutputByDefault={collapsed}
setCachedStateField={(field, value) => {
if (field === "collapseCommandOutputByDefault") setCollapsed(Boolean(value))
}}
/>
</div>
)
}
return (
<AppProviders>
<Settings />
</AppProviders>
)
},
"bedrock-output-budget": async ({ model = "anthropic.claude-opus-5" }) => {
const [{ AppProviders }, { ThinkingBudget }, { bedrockModels, providerIdentifiers }] = await Promise.all([
import("../AppProviders"),
Expand Down
19 changes: 13 additions & 6 deletions webview-ui/src/components/chat/CommandExecution.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { useEvent } from "react-use"
import { t } from "i18next"
import { ChevronDown, OctagonX } from "lucide-react"

import { type ExtensionMessage, type CommandExecutionStatus, commandExecutionStatusSchema } from "@roo-code/types"
import {
type ExtensionMessage,
type CommandExecutionStatus,
commandExecutionStatusSchema,
DEFAULT_COLLAPSE_COMMAND_OUTPUT_BY_DEFAULT,
} from "@roo-code/types"

import { safeJsonParse } from "@roo/core"
import { COMMAND_OUTPUT_STRING } from "@roo/combineCommandSequences"
Expand Down Expand Up @@ -42,6 +47,7 @@ interface CommandExecutionProps {
export const CommandExecution = ({ executionId, text, icon, title, isDenied = false }: CommandExecutionProps) => {
const {
terminalShellIntegrationDisabled = false,
collapseCommandOutputByDefault = DEFAULT_COLLAPSE_COMMAND_OUTPUT_BY_DEFAULT,
destructiveCommandGuardEnabled = false,
allowedCommands = [],
deniedCommands = [],
Expand All @@ -51,9 +57,8 @@ export const CommandExecution = ({ executionId, text, icon, title, isDenied = fa

const { command, output: parsedOutput } = useMemo(() => parseCommandAndOutput(text), [text])

// If we aren't opening the VSCode terminal for this command then we default
// to expanding the command execution output.
const [isExpanded, setIsExpanded] = useState(terminalShellIntegrationDisabled)
// Apply the preference on mount. Manual toggles remain in control afterwards.
const [isExpanded, setIsExpanded] = useState(!collapseCommandOutputByDefault && terminalShellIntegrationDisabled)
const [streamingOutput, setStreamingOutput] = useState("")
// Initialize from the module-level cache so that components mounting after
// the "started" event was delivered still show the running indicator.
Expand Down Expand Up @@ -159,7 +164,9 @@ export const CommandExecution = ({ executionId, text, icon, title, isDenied = fa
// Not a terminal state -- signals a mid-execution retry
// via execa after a shell integration failure. A new
// "started" event will follow, so leave the cache intact.
setIsExpanded(true)
if (!collapseCommandOutputByDefault) {
setIsExpanded(true)
}
break
case "output":
setStreamingOutput(data.output)
Expand All @@ -171,7 +178,7 @@ export const CommandExecution = ({ executionId, text, icon, title, isDenied = fa
}
}
},
[executionId],
[executionId, collapseCommandOutputByDefault],
)

useEvent("message", onMessage)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { act, fireEvent, screen, renderWithExtensionState } from "@/utils/test-utils"
import { CommandExecution } from "../CommandExecution"

vi.mock("@/utils/vscode", () => ({ vscode: { postMessage: vi.fn() } }))
vi.mock("../../common/CodeBlock", () => ({
default: ({ source }: { source: string }) => <div>{source}</div>,
}))
vi.mock("../TerminalOutput", () => ({
TerminalOutput: ({ content }: { content: string }) => <div data-testid="terminal-output">{content}</div>,
}))
vi.mock("../CommandPatternSelector", () => ({ CommandPatternSelector: () => null }))

const sendStatus = (data: Record<string, unknown>) => {
act(() =>
window.dispatchEvent(
new MessageEvent("message", {
data: {
type: "commandExecutionStatus",
text: JSON.stringify({ executionId: "collapse-test", ...data }),
},
}),
),
)
}
const setup = (collapseCommandOutputByDefault?: boolean, terminalShellIntegrationDisabled = true) =>
renderWithExtensionState(
<CommandExecution executionId="collapse-test" text="npm test\nOutput:\nInitial output" />,
{
state: { collapseCommandOutputByDefault, terminalShellIntegrationDisabled },
},
)
const outputContainer = () => screen.getByTestId("terminal-output").parentElement

describe("command output collapse preference", () => {
it.each([
[true, true, true],
[true, false, true],
[false, true, false],
[false, false, true],
[undefined, true, false],
[undefined, false, true],
])("applies preference %s with inline mode %s", (preference, inline, collapsed) => {
setup(preference, inline)
expect(outputContainer()).toHaveClass(collapsed ? "max-h-0" : "max-h-[100%]")
})
it("keeps streaming and completed output collapsed until manually expanded", () => {
setup(true)
sendStatus({ status: "started", pid: 123 })
sendStatus({ status: "output", output: "Streaming output" })
expect(screen.getByTestId("terminal-output")).toHaveTextContent("Streaming output")
expect(outputContainer()).toHaveClass("max-h-0")
fireEvent.click(screen.getAllByRole("button").at(-1)!)
expect(outputContainer()).not.toHaveClass("max-h-0")
sendStatus({ status: "output", output: "More output" })
sendStatus({ status: "exited", exitCode: 0 })
expect(outputContainer()).not.toHaveClass("max-h-0")
fireEvent.click(screen.getAllByRole("button").at(-1)!)
expect(outputContainer()).toHaveClass("max-h-0")
})

it("preserves a manual expansion through fallback and new output", () => {
setup(true, false)
fireEvent.click(screen.getAllByRole("button").at(-1)!)
sendStatus({ status: "fallback" })
sendStatus({ status: "output", output: "Fallback output" })
expect(outputContainer()).not.toHaveClass("max-h-0")
})
it.each([true, false, undefined])("respects preference %s during shell integration fallback", (preference) => {
setup(preference, false)
sendStatus({ status: "fallback" })
expect(outputContainer()).toHaveClass(preference ? "max-h-0" : "max-h-[100%]")
})
})
3 changes: 3 additions & 0 deletions webview-ui/src/components/settings/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
terminalOutputPreviewSize,
terminalShellIntegrationTimeout,
terminalShellIntegrationDisabled, // Added from upstream
collapseCommandOutputByDefault,
terminalCommandDelay,
terminalPowershellCounter,
terminalZshClearEolMark,
Expand Down Expand Up @@ -416,6 +417,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
diffFuzzyThreshold,
terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? 30_000,
terminalShellIntegrationDisabled,
collapseCommandOutputByDefault,
terminalCommandDelay,
terminalPowershellCounter,
terminalZshClearEolMark,
Expand Down Expand Up @@ -897,6 +899,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
terminalOutputPreviewSize={terminalOutputPreviewSize}
terminalShellIntegrationTimeout={terminalShellIntegrationTimeout}
terminalShellIntegrationDisabled={terminalShellIntegrationDisabled}
collapseCommandOutputByDefault={collapseCommandOutputByDefault}
terminalCommandDelay={terminalCommandDelay}
terminalPowershellCounter={terminalPowershellCounter}
terminalZshClearEolMark={terminalZshClearEolMark}
Expand Down
29 changes: 28 additions & 1 deletion webview-ui/src/components/settings/TerminalSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import { buildDocLink } from "@src/utils/docLinks"
import { useEvent, useMount } from "react-use"
import { Terminal } from "lucide-react"

import { type ExtensionMessage, type TerminalOutputPreviewSize } from "@roo-code/types"
import {
type ExtensionMessage,
type TerminalOutputPreviewSize,
DEFAULT_COLLAPSE_COMMAND_OUTPUT_BY_DEFAULT,
} from "@roo-code/types"

import { cn } from "@/lib/utils"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Slider, Button } from "@/components/ui"
Expand All @@ -21,6 +25,7 @@ type TerminalSettingsProps = HTMLAttributes<HTMLDivElement> & {
terminalOutputPreviewSize?: TerminalOutputPreviewSize
terminalShellIntegrationTimeout?: number
terminalShellIntegrationDisabled?: boolean
collapseCommandOutputByDefault?: boolean
terminalCommandDelay?: number
terminalPowershellCounter?: boolean
terminalZshClearEolMark?: boolean
Expand All @@ -33,6 +38,7 @@ type TerminalSettingsProps = HTMLAttributes<HTMLDivElement> & {
| "terminalOutputPreviewSize"
| "terminalShellIntegrationTimeout"
| "terminalShellIntegrationDisabled"
| "collapseCommandOutputByDefault"
| "terminalCommandDelay"
| "terminalPowershellCounter"
| "terminalZshClearEolMark"
Expand All @@ -51,6 +57,7 @@ export const TerminalSettings = ({
terminalOutputPreviewSize,
terminalShellIntegrationTimeout,
terminalShellIntegrationDisabled,
collapseCommandOutputByDefault,
terminalCommandDelay,
terminalPowershellCounter,
terminalZshClearEolMark,
Expand Down Expand Up @@ -151,6 +158,26 @@ export const TerminalSettings = ({
</div>
</div>

<SearchableSetting
settingId="terminal-collapse-command-output"
section="terminal"
label={t("settings:terminal.collapseCommandOutput.label")}>
<VSCodeCheckbox
checked={collapseCommandOutputByDefault ?? DEFAULT_COLLAPSE_COMMAND_OUTPUT_BY_DEFAULT}
onChange={(event) =>
setCachedStateField(
"collapseCommandOutputByDefault",
(event.target as HTMLInputElement).checked,
)
}
data-testid="terminal-collapse-command-output-checkbox">
<span className="font-medium">{t("settings:terminal.collapseCommandOutput.label")}</span>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm mt-1">
{t("settings:terminal.collapseCommandOutput.description")}
</div>
</SearchableSetting>

{/* Advanced Settings */}
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
Expand Down
Loading
Loading