Переглянути джерело

feat: syntax highlighting terminal output with Shiki (#3021)

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Signed-off-by: Eric Wheeler <[email protected]>
Co-authored-by: Eric Wheeler <[email protected]>
KJ7LNW 8 місяців тому
батько
коміт
8b072ad126

+ 39 - 36
webview-ui/src/components/chat/CommandExecution.tsx

@@ -1,9 +1,9 @@
-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 { BaseTerminal } from "../../../../src/integrations/terminal/BaseTerminal"
+import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
 
 interface CommandExecutionProps {
 	command: string
@@ -11,45 +11,48 @@ interface CommandExecutionProps {
 }
 
 export const CommandExecution = forwardRef<HTMLDivElement, CommandExecutionProps>(({ command, output }, ref) => {
-	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 { t } = useTranslation()
+	const { terminalShellIntegrationDisabled = false, terminalOutputLineLimit = 500 } = useExtensionState()
 	const [isExpanded, setIsExpanded] = useState(terminalShellIntegrationDisabled)
+	const compressedOutput = BaseTerminal.compressTerminalOutput(output, terminalOutputLineLimit)
 
-	const lines = useMemo(() => output.split("\n"), [output])
+	const onToggleExpand = () => {
+		setIsExpanded(!isExpanded)
+	}
 
 	return (
-		<div ref={ref} className="w-full p-2 rounded-xs bg-vscode-editor-background">
+		<>
 			<div
-				className={cn("flex flex-row justify-between cursor-pointer active:opacity-75", {
-					"opacity-50": isExpanded,
-				})}
-				onClick={() => setIsExpanded(!isExpanded)}>
-				<Line>{command}</Line>
-				<ChevronDown className={cn("size-4 transition-transform duration-300", { "rotate-180": isExpanded })} />
-			</div>
-			<div className={cn("h-[200px]", { hidden: !isExpanded })}>
-				<Virtuoso
-					className="h-full mt-2"
-					totalCount={lines.length}
-					itemContent={(i) => <Line>{lines[i]}</Line>}
-					followOutput="auto"
-				/>
+				ref={ref}
+				style={{
+					borderRadius: 3,
+					border: "1px solid var(--vscode-editorGroup-border)",
+					overflow: "hidden",
+					backgroundColor: CODE_BLOCK_BG_COLOR,
+				}}>
+				<CodeBlock source={command} language="shell" />
+				{output.length > 0 && (
+					<div style={{ width: "100%" }}>
+						<div
+							onClick={onToggleExpand}
+							style={{
+								display: "flex",
+								alignItems: "center",
+								gap: "4px",
+								width: "100%",
+								justifyContent: "flex-start",
+								cursor: "pointer",
+								padding: `2px 8px ${isExpanded ? 0 : 8}px 8px`,
+							}}>
+							<span className={`codicon codicon-chevron-${isExpanded ? "down" : "right"}`}></span>
+							<span style={{ fontSize: "0.8em" }}>{t("chat:commandOutput")}</span>
+						</div>
+						{isExpanded && <CodeBlock source={compressedOutput} language="log" />}
+					</div>
+				)}
 			</div>
-		</div>
+		</>
 	)
 })
 
-type LineProps = HTMLAttributes<HTMLDivElement>
-
-const Line = ({ className, ...props }: LineProps) => {
-	return (
-		<div
-			className={cn("font-mono text-vscode-editor-foreground whitespace-pre-wrap break-words", className)}
-			{...props}
-		/>
-	)
-}
-
 CommandExecution.displayName = "CommandExecution"

+ 23 - 28
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: () => <div data-testid="chevron-down">ChevronDown</div>,
-}))
-
-jest.mock("react-virtuoso", () => ({
-	Virtuoso: React.forwardRef(({ totalCount, itemContent }: any, ref: any) => (
-		<div ref={ref} data-testid="virtuoso-container">
-			{Array.from({ length: totalCount }).map((_, index) => (
-				<div key={index} data-testid={`virtuoso-item-${index}`}>
-					{itemContent(index)}
-				</div>
-			))}
-		</div>
-	)),
-	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")
 	})
 })

+ 2 - 2
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<CodeBlockProps> = () => <div data-testid="mock-code-block">Mocked Code Block</div>
+const CodeBlock: React.FC<CodeBlockProps> = ({ source = "" }) => <div data-testid="mock-code-block">{source}</div>
 
 export default CodeBlock