From b4fd9ee8defbe405f023946eb37629b4cd68d21b Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Mon, 28 Apr 2025 17:05:24 -0700 Subject: [PATCH 1/3] feat: syntax highlighting terminal output with Shiki Refactored CommandExecution.tsx to: - Implement CodeBlock component for terminal display - Use Shiki for syntax highlighting of shell commands and terminal output Signed-off-by: Eric Wheeler --- .../src/components/chat/CommandExecution.tsx | 71 ++++++++++--------- 1 file changed, 36 insertions(+), 35 deletions(-) diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index f2977849b71..a6161eb0514 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -1,9 +1,8 @@ -import { HTMLAttributes, forwardRef, useMemo, useState } from "react" -import { Virtuoso } from "react-virtuoso" -import { ChevronDown } from "lucide-react" +import { forwardRef, useState } from "react" +import { useTranslation } from "react-i18next" import { useExtensionState } from "@src/context/ExtensionStateContext" -import { cn } from "@src/lib/utils" +import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock" interface CommandExecutionProps { command: string @@ -11,45 +10,47 @@ interface CommandExecutionProps { } export const CommandExecution = forwardRef(({ command, output }, ref) => { + const { t } = useTranslation() const { terminalShellIntegrationDisabled = false } = useExtensionState() - - // 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) - const lines = useMemo(() => output.split("\n"), [output]) + const onToggleExpand = () => { + setIsExpanded(!isExpanded) + } return ( -
+ <>
setIsExpanded(!isExpanded)}> - {command} - -
-
- {lines[i]}} - followOutput="auto" - /> + ref={ref} + style={{ + borderRadius: 3, + border: "1px solid var(--vscode-editorGroup-border)", + overflow: "hidden", + backgroundColor: CODE_BLOCK_BG_COLOR, + }}> + + {output.length > 0 && ( +
+
+ + {t("chat:commandOutput")} +
+ {isExpanded && } +
+ )}
-
+ ) }) -type LineProps = HTMLAttributes - -const Line = ({ className, ...props }: LineProps) => { - return ( -
- ) -} - CommandExecution.displayName = "CommandExecution" From b342ee4aa739705341279c189eaa33612855ea84 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Mon, 28 Apr 2025 18:10:58 -0700 Subject: [PATCH 2/3] ui: present same compressed command output that the model will receive Avoids rendering problems and performance regressions: - Compresses output using BaseTerminal.compressTerminalOutput - Eliminates need for Virtuoso by limiting output size Ensures command output shown in UI matches the compressed format provided to the model: - Applies same BaseTerminal.compressTerminalOutput processing - Uses configured terminalOutputLineLimit for consistency Signed-off-by: Eric Wheeler --- webview-ui/src/components/chat/CommandExecution.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index a6161eb0514..ed6c90036d6 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -2,6 +2,7 @@ import { forwardRef, useState } from "react" import { useTranslation } from "react-i18next" import { useExtensionState } from "@src/context/ExtensionStateContext" +import { BaseTerminal } from "../../../../src/integrations/terminal/BaseTerminal" import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock" interface CommandExecutionProps { @@ -11,8 +12,9 @@ interface CommandExecutionProps { export const CommandExecution = forwardRef(({ command, output }, ref) => { const { t } = useTranslation() - const { terminalShellIntegrationDisabled = false } = useExtensionState() + const { terminalShellIntegrationDisabled = false, terminalOutputLineLimit = 500 } = useExtensionState() const [isExpanded, setIsExpanded] = useState(terminalShellIntegrationDisabled) + const compressedOutput = BaseTerminal.compressTerminalOutput(output, terminalOutputLineLimit) const onToggleExpand = () => { setIsExpanded(!isExpanded) @@ -45,7 +47,7 @@ export const CommandExecution = forwardRef {t("chat:commandOutput")}
- {isExpanded && } + {isExpanded && } )} From 7ec9381e593679c7c8cb66fbdac8ce1150bd28bd Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Mon, 28 Apr 2025 18:36:04 -0700 Subject: [PATCH 3/3] test: update CommandExecution tests to use CodeBlock mock - Replace virtuoso mock with CodeBlock mock to fix shiki import error - Update CodeBlock mock to properly handle source prop - Add proper empty output test case verification - Keep original test values and structure Signed-off-by: Eric Wheeler --- .../chat/__tests__/CommandExecution.test.tsx | 51 +++++++++---------- .../components/common/__mocks__/CodeBlock.tsx | 4 +- 2 files changed, 25 insertions(+), 30 deletions(-) diff --git a/webview-ui/src/components/chat/__tests__/CommandExecution.test.tsx b/webview-ui/src/components/chat/__tests__/CommandExecution.test.tsx index 1fa5c831e33..0c0bd0733f2 100644 --- a/webview-ui/src/components/chat/__tests__/CommandExecution.test.tsx +++ b/webview-ui/src/components/chat/__tests__/CommandExecution.test.tsx @@ -1,33 +1,18 @@ // npx jest src/components/chat/__tests__/CommandExecution.test.tsx import React from "react" -import { render, screen } from "@testing-library/react" +import { render, screen, fireEvent } from "@testing-library/react" import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" import { CommandExecution } from "../CommandExecution" +jest.mock("../../../components/common/CodeBlock") + jest.mock("@src/lib/utils", () => ({ cn: (...inputs: any[]) => inputs.filter(Boolean).join(" "), })) -jest.mock("lucide-react", () => ({ - ChevronDown: () =>
ChevronDown
, -})) - -jest.mock("react-virtuoso", () => ({ - Virtuoso: React.forwardRef(({ totalCount, itemContent }: any, ref: any) => ( -
- {Array.from({ length: totalCount }).map((_, index) => ( -
- {itemContent(index)} -
- ))} -
- )), - VirtuosoHandle: jest.fn(), -})) - describe("CommandExecution", () => { const renderComponent = (command: string, output: string) => { return render( @@ -40,24 +25,34 @@ describe("CommandExecution", () => { it("renders command output with virtualized list", () => { const testOutput = "Line 1\nLine 2\nLine 3" renderComponent("ls", testOutput) - expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument() - expect(screen.getByText("Line 1")).toBeInTheDocument() - expect(screen.getByText("Line 2")).toBeInTheDocument() - expect(screen.getByText("Line 3")).toBeInTheDocument() + const codeBlock = screen.getByTestId("mock-code-block") + expect(codeBlock).toHaveTextContent("ls") + + fireEvent.click(screen.getByText("commandOutput")) + const outputBlock = screen.getAllByTestId("mock-code-block")[1] + + expect(outputBlock).toHaveTextContent("Line 1") + expect(outputBlock).toHaveTextContent("Line 2") + expect(outputBlock).toHaveTextContent("Line 3") }) it("handles empty output", () => { renderComponent("ls", "") - expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument() - expect(screen.getByTestId("virtuoso-item-0")).toBeInTheDocument() - expect(screen.queryByTestId("virtuoso-item-1")).not.toBeInTheDocument() + const codeBlock = screen.getByTestId("mock-code-block") + expect(codeBlock).toHaveTextContent("ls") + expect(screen.queryByText("commandOutput")).not.toBeInTheDocument() + expect(screen.queryAllByTestId("mock-code-block")).toHaveLength(1) }) it("handles large output", () => { const largeOutput = Array.from({ length: 1000 }, (_, i) => `Line ${i + 1}`).join("\n") renderComponent("ls", largeOutput) - expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument() - expect(screen.getByText("Line 1")).toBeInTheDocument() - expect(screen.getByText("Line 1000")).toBeInTheDocument() + const codeBlock = screen.getByTestId("mock-code-block") + expect(codeBlock).toHaveTextContent("ls") + + fireEvent.click(screen.getByText("commandOutput")) + const outputBlock = screen.getAllByTestId("mock-code-block")[1] + expect(outputBlock).toHaveTextContent("Line 1") + expect(outputBlock).toHaveTextContent("Line 1000") }) }) diff --git a/webview-ui/src/components/common/__mocks__/CodeBlock.tsx b/webview-ui/src/components/common/__mocks__/CodeBlock.tsx index b4d73633673..7eaa790ff22 100644 --- a/webview-ui/src/components/common/__mocks__/CodeBlock.tsx +++ b/webview-ui/src/components/common/__mocks__/CodeBlock.tsx @@ -1,10 +1,10 @@ import * as React from "react" interface CodeBlockProps { - children?: React.ReactNode + source?: string language?: string } -const CodeBlock: React.FC = () =>
Mocked Code Block
+const CodeBlock: React.FC = ({ source = "" }) =>
{source}
export default CodeBlock