Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React from "react"
import { render, screen } from "@testing-library/react"
import CommandOutputViewer from "../../../components/common/CommandOutputViewer"

// Mock the cn utility function
jest.mock("../../../lib/utils", () => ({
cn: (...inputs: any[]) => inputs.filter(Boolean).join(" "),
}))

// Mock the Virtuoso component
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("CommandOutputViewer", () => {
it("renders command output with virtualized list", () => {
const testOutput = "Line 1\nLine 2\nLine 3"

render(<CommandOutputViewer output={testOutput} />)

// Check if Virtuoso container is rendered
expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument()

// Check if all lines are rendered
expect(screen.getByText("Line 1")).toBeInTheDocument()
expect(screen.getByText("Line 2")).toBeInTheDocument()
expect(screen.getByText("Line 3")).toBeInTheDocument()
})

it("handles empty output", () => {
render(<CommandOutputViewer output="" />)

// Should still render the container but with no items
expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument()

// No virtuoso items should be rendered for empty string (which creates one empty line)
expect(screen.getByTestId("virtuoso-item-0")).toBeInTheDocument()
expect(screen.queryByTestId("virtuoso-item-1")).not.toBeInTheDocument()
})

it("handles large output", () => {
// Create a large output with 1000 lines
const largeOutput = Array.from({ length: 1000 }, (_, i) => `Line ${i + 1}`).join("\n")

render(<CommandOutputViewer output={largeOutput} />)

// Check if Virtuoso container is rendered
expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument()

// Check if first and last lines are rendered
expect(screen.getByText("Line 1")).toBeInTheDocument()
expect(screen.getByText("Line 1000")).toBeInTheDocument()
})
})
3 changes: 2 additions & 1 deletion webview-ui/src/components/chat/ChatRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { findMatchingResourceOrTemplate } from "../../utils/mcp"
import { vscode } from "../../utils/vscode"
import CodeAccordian, { removeLeadingNonAlphanumeric } from "../common/CodeAccordian"
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
import CommandOutputViewer from "../common/CommandOutputViewer"
import MarkdownBlock from "../common/MarkdownBlock"
import { ReasoningBlock } from "./ReasoningBlock"
import Thumbnails from "../common/Thumbnails"
Expand Down Expand Up @@ -917,7 +918,7 @@ export const ChatRowContent = ({
className={`codicon codicon-chevron-${isExpanded ? "down" : "right"}`}></span>
<span style={{ fontSize: "0.8em" }}>{t("chat:commandOutput")}</span>
</div>
{isExpanded && <CodeBlock source={`${"```"}shell\n${output}\n${"```"}`} />}
{isExpanded && <CommandOutputViewer output={output} />}
</div>
)}
</div>
Expand Down
50 changes: 50 additions & 0 deletions webview-ui/src/components/common/CommandOutputViewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { forwardRef, useEffect, useRef } from "react"
import { Virtuoso, VirtuosoHandle } from "react-virtuoso"
import { cn } from "../../lib/utils"

interface CommandOutputViewerProps {
output: string
}

const CommandOutputViewer = forwardRef<HTMLDivElement, CommandOutputViewerProps>(({ output }, ref) => {
const virtuosoRef = useRef<VirtuosoHandle>(null)
const lines = output.split("\n")

useEffect(() => {
// Scroll to the bottom when output changes
if (virtuosoRef.current && typeof virtuosoRef.current.scrollToIndex === "function") {
virtuosoRef.current.scrollToIndex({
index: lines.length - 1,
behavior: "auto",
})
}
}, [output, lines.length])

return (
<div ref={ref} className="w-full rounded-b-md bg-[var(--vscode-editor-background)] h-[300px]">
<Virtuoso
ref={virtuosoRef}
className="h-full"
totalCount={lines.length}
itemContent={(index) => (
<div
className={cn(
"px-3 py-0.5",
"font-mono text-vscode-editor-foreground",
"text-[var(--vscode-editor-font-size,var(--vscode-font-size,12px))]",
"font-[var(--vscode-editor-font-family)]",
"whitespace-pre-wrap break-all anywhere",
)}>
{lines[index]}
</div>
)}
increaseViewportBy={{ top: 300, bottom: 300 }}
followOutput="auto"
/>
</div>
)
})

CommandOutputViewer.displayName = "CommandOutputViewer"

export default CommandOutputViewer