Skip to content

Commit 8b072ad

Browse files
KJ7LNWEric Wheeler
andauthored
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]>
1 parent 65958d8 commit 8b072ad

File tree

3 files changed

+64
-66
lines changed

3 files changed

+64
-66
lines changed
Lines changed: 39 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,58 @@
1-
import { HTMLAttributes, forwardRef, useMemo, useState } from "react"
2-
import { Virtuoso } from "react-virtuoso"
3-
import { ChevronDown } from "lucide-react"
1+
import { forwardRef, useState } from "react"
2+
import { useTranslation } from "react-i18next"
43

54
import { useExtensionState } from "@src/context/ExtensionStateContext"
6-
import { cn } from "@src/lib/utils"
5+
import { BaseTerminal } from "../../../../src/integrations/terminal/BaseTerminal"
6+
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
77

88
interface CommandExecutionProps {
99
command: string
1010
output: string
1111
}
1212

1313
export const CommandExecution = forwardRef<HTMLDivElement, CommandExecutionProps>(({ command, output }, ref) => {
14-
const { terminalShellIntegrationDisabled = false } = useExtensionState()
15-
16-
// If we aren't opening the VSCode terminal for this command then we default
17-
// to expanding the command execution output.
14+
const { t } = useTranslation()
15+
const { terminalShellIntegrationDisabled = false, terminalOutputLineLimit = 500 } = useExtensionState()
1816
const [isExpanded, setIsExpanded] = useState(terminalShellIntegrationDisabled)
17+
const compressedOutput = BaseTerminal.compressTerminalOutput(output, terminalOutputLineLimit)
1918

20-
const lines = useMemo(() => output.split("\n"), [output])
19+
const onToggleExpand = () => {
20+
setIsExpanded(!isExpanded)
21+
}
2122

2223
return (
23-
<div ref={ref} className="w-full p-2 rounded-xs bg-vscode-editor-background">
24+
<>
2425
<div
25-
className={cn("flex flex-row justify-between cursor-pointer active:opacity-75", {
26-
"opacity-50": isExpanded,
27-
})}
28-
onClick={() => setIsExpanded(!isExpanded)}>
29-
<Line>{command}</Line>
30-
<ChevronDown className={cn("size-4 transition-transform duration-300", { "rotate-180": isExpanded })} />
31-
</div>
32-
<div className={cn("h-[200px]", { hidden: !isExpanded })}>
33-
<Virtuoso
34-
className="h-full mt-2"
35-
totalCount={lines.length}
36-
itemContent={(i) => <Line>{lines[i]}</Line>}
37-
followOutput="auto"
38-
/>
26+
ref={ref}
27+
style={{
28+
borderRadius: 3,
29+
border: "1px solid var(--vscode-editorGroup-border)",
30+
overflow: "hidden",
31+
backgroundColor: CODE_BLOCK_BG_COLOR,
32+
}}>
33+
<CodeBlock source={command} language="shell" />
34+
{output.length > 0 && (
35+
<div style={{ width: "100%" }}>
36+
<div
37+
onClick={onToggleExpand}
38+
style={{
39+
display: "flex",
40+
alignItems: "center",
41+
gap: "4px",
42+
width: "100%",
43+
justifyContent: "flex-start",
44+
cursor: "pointer",
45+
padding: `2px 8px ${isExpanded ? 0 : 8}px 8px`,
46+
}}>
47+
<span className={`codicon codicon-chevron-${isExpanded ? "down" : "right"}`}></span>
48+
<span style={{ fontSize: "0.8em" }}>{t("chat:commandOutput")}</span>
49+
</div>
50+
{isExpanded && <CodeBlock source={compressedOutput} language="log" />}
51+
</div>
52+
)}
3953
</div>
40-
</div>
54+
</>
4155
)
4256
})
4357

44-
type LineProps = HTMLAttributes<HTMLDivElement>
45-
46-
const Line = ({ className, ...props }: LineProps) => {
47-
return (
48-
<div
49-
className={cn("font-mono text-vscode-editor-foreground whitespace-pre-wrap break-words", className)}
50-
{...props}
51-
/>
52-
)
53-
}
54-
5558
CommandExecution.displayName = "CommandExecution"
Lines changed: 23 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,18 @@
11
// npx jest src/components/chat/__tests__/CommandExecution.test.tsx
22

33
import React from "react"
4-
import { render, screen } from "@testing-library/react"
4+
import { render, screen, fireEvent } from "@testing-library/react"
55

66
import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
77

88
import { CommandExecution } from "../CommandExecution"
99

10+
jest.mock("../../../components/common/CodeBlock")
11+
1012
jest.mock("@src/lib/utils", () => ({
1113
cn: (...inputs: any[]) => inputs.filter(Boolean).join(" "),
1214
}))
1315

14-
jest.mock("lucide-react", () => ({
15-
ChevronDown: () => <div data-testid="chevron-down">ChevronDown</div>,
16-
}))
17-
18-
jest.mock("react-virtuoso", () => ({
19-
Virtuoso: React.forwardRef(({ totalCount, itemContent }: any, ref: any) => (
20-
<div ref={ref} data-testid="virtuoso-container">
21-
{Array.from({ length: totalCount }).map((_, index) => (
22-
<div key={index} data-testid={`virtuoso-item-${index}`}>
23-
{itemContent(index)}
24-
</div>
25-
))}
26-
</div>
27-
)),
28-
VirtuosoHandle: jest.fn(),
29-
}))
30-
3116
describe("CommandExecution", () => {
3217
const renderComponent = (command: string, output: string) => {
3318
return render(
@@ -40,24 +25,34 @@ describe("CommandExecution", () => {
4025
it("renders command output with virtualized list", () => {
4126
const testOutput = "Line 1\nLine 2\nLine 3"
4227
renderComponent("ls", testOutput)
43-
expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument()
44-
expect(screen.getByText("Line 1")).toBeInTheDocument()
45-
expect(screen.getByText("Line 2")).toBeInTheDocument()
46-
expect(screen.getByText("Line 3")).toBeInTheDocument()
28+
const codeBlock = screen.getByTestId("mock-code-block")
29+
expect(codeBlock).toHaveTextContent("ls")
30+
31+
fireEvent.click(screen.getByText("commandOutput"))
32+
const outputBlock = screen.getAllByTestId("mock-code-block")[1]
33+
34+
expect(outputBlock).toHaveTextContent("Line 1")
35+
expect(outputBlock).toHaveTextContent("Line 2")
36+
expect(outputBlock).toHaveTextContent("Line 3")
4737
})
4838

4939
it("handles empty output", () => {
5040
renderComponent("ls", "")
51-
expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument()
52-
expect(screen.getByTestId("virtuoso-item-0")).toBeInTheDocument()
53-
expect(screen.queryByTestId("virtuoso-item-1")).not.toBeInTheDocument()
41+
const codeBlock = screen.getByTestId("mock-code-block")
42+
expect(codeBlock).toHaveTextContent("ls")
43+
expect(screen.queryByText("commandOutput")).not.toBeInTheDocument()
44+
expect(screen.queryAllByTestId("mock-code-block")).toHaveLength(1)
5445
})
5546

5647
it("handles large output", () => {
5748
const largeOutput = Array.from({ length: 1000 }, (_, i) => `Line ${i + 1}`).join("\n")
5849
renderComponent("ls", largeOutput)
59-
expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument()
60-
expect(screen.getByText("Line 1")).toBeInTheDocument()
61-
expect(screen.getByText("Line 1000")).toBeInTheDocument()
50+
const codeBlock = screen.getByTestId("mock-code-block")
51+
expect(codeBlock).toHaveTextContent("ls")
52+
53+
fireEvent.click(screen.getByText("commandOutput"))
54+
const outputBlock = screen.getAllByTestId("mock-code-block")[1]
55+
expect(outputBlock).toHaveTextContent("Line 1")
56+
expect(outputBlock).toHaveTextContent("Line 1000")
6257
})
6358
})
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import * as React from "react"
22

33
interface CodeBlockProps {
4-
children?: React.ReactNode
4+
source?: string
55
language?: string
66
}
77

8-
const CodeBlock: React.FC<CodeBlockProps> = () => <div data-testid="mock-code-block">Mocked Code Block</div>
8+
const CodeBlock: React.FC<CodeBlockProps> = ({ source = "" }) => <div data-testid="mock-code-block">{source}</div>
99

1010
export default CodeBlock

0 commit comments

Comments
 (0)