mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
65958d81b9
commit
8b072ad126
3 changed files with 64 additions and 66 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue