diff --git a/webview-ui/src/__tests__/components/common/CommandOutputViewer.test.tsx b/webview-ui/src/__tests__/components/common/CommandOutputViewer.test.tsx new file mode 100644 index 00000000000..03562c51b68 --- /dev/null +++ b/webview-ui/src/__tests__/components/common/CommandOutputViewer.test.tsx @@ -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) => ( +
+ {Array.from({ length: totalCount }).map((_, index) => ( +
+ {itemContent(index)} +
+ ))} +
+ )), + VirtuosoHandle: jest.fn(), +})) + +describe("CommandOutputViewer", () => { + it("renders command output with virtualized list", () => { + const testOutput = "Line 1\nLine 2\nLine 3" + + render() + + // 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() + + // 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() + + // 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() + }) +}) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index c09ae237834..4c950915ba7 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -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" @@ -917,7 +918,7 @@ export const ChatRowContent = ({ className={`codicon codicon-chevron-${isExpanded ? "down" : "right"}`}> {t("chat:commandOutput")} - {isExpanded && } + {isExpanded && } )} diff --git a/webview-ui/src/components/common/CommandOutputViewer.tsx b/webview-ui/src/components/common/CommandOutputViewer.tsx new file mode 100644 index 00000000000..443f3bc9e81 --- /dev/null +++ b/webview-ui/src/components/common/CommandOutputViewer.tsx @@ -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(({ output }, ref) => { + const virtuosoRef = useRef(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 ( +
+ ( +
+ {lines[index]} +
+ )} + increaseViewportBy={{ top: 300, bottom: 300 }} + followOutput="auto" + /> +
+ ) +}) + +CommandOutputViewer.displayName = "CommandOutputViewer" + +export default CommandOutputViewer