Skip to content

Commit fa94be4

Browse files
committed
feat: Add CommandOutputViewer component and integrate it into ChatRow
1 parent 903f3b6 commit fa94be4

File tree

3 files changed

+117
-1
lines changed

3 files changed

+117
-1
lines changed
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import React from "react"
2+
import { render, screen } from "@testing-library/react"
3+
import CommandOutputViewer from "../../../components/common/CommandOutputViewer"
4+
5+
// Mock the cn utility function
6+
jest.mock("../../../lib/utils", () => ({
7+
cn: (...inputs: any[]) => inputs.filter(Boolean).join(" "),
8+
}))
9+
10+
// Mock the Virtuoso component
11+
jest.mock("react-virtuoso", () => ({
12+
Virtuoso: React.forwardRef(({ totalCount, itemContent }: any, ref: any) => (
13+
<div ref={ref} data-testid="virtuoso-container">
14+
{Array.from({ length: totalCount }).map((_, index) => (
15+
<div key={index} data-testid={`virtuoso-item-${index}`}>
16+
{itemContent(index)}
17+
</div>
18+
))}
19+
</div>
20+
)),
21+
VirtuosoHandle: jest.fn(),
22+
}))
23+
24+
describe("CommandOutputViewer", () => {
25+
it("renders command output with virtualized list", () => {
26+
const testOutput = "Line 1\nLine 2\nLine 3"
27+
28+
render(<CommandOutputViewer output={testOutput} />)
29+
30+
// Check if Virtuoso container is rendered
31+
expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument()
32+
33+
// Check if all lines are rendered
34+
expect(screen.getByText("Line 1")).toBeInTheDocument()
35+
expect(screen.getByText("Line 2")).toBeInTheDocument()
36+
expect(screen.getByText("Line 3")).toBeInTheDocument()
37+
})
38+
39+
it("handles empty output", () => {
40+
render(<CommandOutputViewer output="" />)
41+
42+
// Should still render the container but with no items
43+
expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument()
44+
45+
// No virtuoso items should be rendered for empty string (which creates one empty line)
46+
expect(screen.getByTestId("virtuoso-item-0")).toBeInTheDocument()
47+
expect(screen.queryByTestId("virtuoso-item-1")).not.toBeInTheDocument()
48+
})
49+
50+
it("handles large output", () => {
51+
// Create a large output with 1000 lines
52+
const largeOutput = Array.from({ length: 1000 }, (_, i) => `Line ${i + 1}`).join("\n")
53+
54+
render(<CommandOutputViewer output={largeOutput} />)
55+
56+
// Check if Virtuoso container is rendered
57+
expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument()
58+
59+
// Check if first and last lines are rendered
60+
expect(screen.getByText("Line 1")).toBeInTheDocument()
61+
expect(screen.getByText("Line 1000")).toBeInTheDocument()
62+
})
63+
})

webview-ui/src/components/chat/ChatRow.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { findMatchingResourceOrTemplate } from "../../utils/mcp"
1616
import { vscode } from "../../utils/vscode"
1717
import CodeAccordian, { removeLeadingNonAlphanumeric } from "../common/CodeAccordian"
1818
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "../common/CodeBlock"
19+
import CommandOutputViewer from "../common/CommandOutputViewer"
1920
import MarkdownBlock from "../common/MarkdownBlock"
2021
import { ReasoningBlock } from "./ReasoningBlock"
2122
import Thumbnails from "../common/Thumbnails"
@@ -917,7 +918,7 @@ export const ChatRowContent = ({
917918
className={`codicon codicon-chevron-${isExpanded ? "down" : "right"}`}></span>
918919
<span style={{ fontSize: "0.8em" }}>{t("chat:commandOutput")}</span>
919920
</div>
920-
{isExpanded && <CodeBlock source={`${"```"}shell\n${output}\n${"```"}`} />}
921+
{isExpanded && <CommandOutputViewer output={output} />}
921922
</div>
922923
)}
923924
</div>
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { forwardRef, memo, useEffect, useRef } from "react"
2+
import { Virtuoso, VirtuosoHandle } from "react-virtuoso"
3+
import { cn } from "../../lib/utils"
4+
5+
interface CommandOutputViewerProps {
6+
output: string
7+
}
8+
9+
const CommandOutputViewer = memo(
10+
forwardRef<HTMLDivElement, CommandOutputViewerProps>(({ output }, ref) => {
11+
const virtuosoRef = useRef<VirtuosoHandle>(null)
12+
const lines = output.split("\n")
13+
14+
useEffect(() => {
15+
// Scroll to the bottom when output changes
16+
if (virtuosoRef.current && typeof virtuosoRef.current.scrollToIndex === "function") {
17+
virtuosoRef.current.scrollToIndex({
18+
index: lines.length - 1,
19+
behavior: "auto",
20+
})
21+
}
22+
}, [output, lines.length])
23+
24+
return (
25+
<div ref={ref} className="w-full rounded-b-md bg-[var(--vscode-editor-background)] h-[300px]">
26+
<Virtuoso
27+
ref={virtuosoRef}
28+
className="h-full"
29+
totalCount={lines.length}
30+
itemContent={(index) => (
31+
<div
32+
className={cn(
33+
"px-3 py-0.5",
34+
"font-mono text-vscode-editor-foreground",
35+
"text-[var(--vscode-editor-font-size,var(--vscode-font-size,12px))]",
36+
"font-[var(--vscode-editor-font-family)]",
37+
"whitespace-pre-wrap break-all anywhere",
38+
)}>
39+
{lines[index]}
40+
</div>
41+
)}
42+
increaseViewportBy={{ top: 300, bottom: 300 }}
43+
followOutput="auto"
44+
/>
45+
</div>
46+
)
47+
}),
48+
)
49+
50+
CommandOutputViewer.displayName = "CommandOutputViewer"
51+
52+
export default CommandOutputViewer

0 commit comments

Comments
 (0)