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 <roo-code@z.ewheeler.org>

* 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 <roo-code@z.ewheeler.org>

* 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 <roo-code@z.ewheeler.org>

---------

Signed-off-by: Eric Wheeler <roo-code@z.ewheeler.org>
Co-authored-by: Eric Wheeler <roo-code@z.ewheeler.org>
This commit is contained in:
KJ7LNW 2025-04-28 19:54:10 -07:00 committed by GitHub
parent 65958d81b9
commit 8b072ad126
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 64 additions and 66 deletions

View file

@ -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 })} />
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 className={cn("h-[200px]", { hidden: !isExpanded })}>
<Virtuoso
className="h-full mt-2"
totalCount={lines.length}
itemContent={(i) => <Line>{lines[i]}</Line>}
followOutput="auto"
/>
</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"

View file

@ -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")
})
})

View file

@ -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