From 6df5f1d0b02dbc44e631999e620de1806076e2d9 Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Mon, 12 May 2025 13:16:46 -0700 Subject: [PATCH] Improve command execution UI (#3509) --- webview-ui/src/components/chat/ChatRow.tsx | 13 +- .../src/components/chat/CommandExecution.tsx | 135 ++++++++++-------- .../src/components/common/CodeBlock.tsx | 36 +++-- 3 files changed, 107 insertions(+), 77 deletions(-) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 9496e55058..046c2c9d53 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -980,13 +980,12 @@ export const ChatRowContent = ({ ) case "command": return ( - <> -
- {icon} - {title} -
- - + ) case "use_mcp_server": const useMcpServer = safeJsonParse(message.text) diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index 79b0306674..1e6a031d12 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState, memo } from "react" +import { useCallback, useState, memo, useMemo } from "react" import { useEvent } from "react-use" import { ChevronDown, Skull } from "lucide-react" @@ -16,32 +16,25 @@ import CodeBlock from "../common/CodeBlock" interface CommandExecutionProps { executionId: string text?: string + icon?: JSX.Element | null + title?: JSX.Element | null } -const parseCommandAndOutput = (text: string) => { - const index = text.indexOf(COMMAND_OUTPUT_STRING) - if (index === -1) { - return { command: text, output: "" } - } - return { - command: text.slice(0, index), - output: text.slice(index + COMMAND_OUTPUT_STRING.length), - } -} - -export const CommandExecution = ({ executionId, text }: CommandExecutionProps) => { +export const CommandExecution = ({ executionId, text, icon, title }: CommandExecutionProps) => { 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 [isExpanded, setIsExpanded] = useState(terminalShellIntegrationDisabled) - const [status, setStatus] = useState(null) - const { command: initialCommand, output: initialOutput } = text - ? parseCommandAndOutput(text) - : { command: "", output: "" } + const { command: initialCommand, output: initialOutput } = useMemo( + () => (text ? parseCommandAndOutput(text) : { command: "", output: "" }), + [text], + ) + const [output, setOutput] = useState(initialOutput) const [command, setCommand] = useState(initialCommand) + const [status, setStatus] = useState(null) const onMessage = useCallback( (event: MessageEvent) => { @@ -81,62 +74,84 @@ export const CommandExecution = ({ executionId, text }: CommandExecutionProps) = useEvent("message", onMessage) return ( -
- -
+ <> +
- {status?.status === "started" && ( -
-
-
Running
- {status.pid &&
(PID: {status.pid})
} -
+
+
+ {status?.status === "started" && ( +
+
+
Running
+ {status.pid &&
(PID: {status.pid})
} + +
+ )} + {status?.status === "exited" && ( +
+
+
Exited ({status.exitCode})
+
+ )} + {output.length > 0 && ( + -
- )} - {status?.status === "exited" && ( -
-
-
Exited ({status.exitCode})
-
- )} - {output.length > 0 && ( - - )} + )} +
- -
+ +
+ + +
+ ) } CommandExecution.displayName = "CommandExecution" -const OutputContainer = ({ isExpanded, output }: { isExpanded: boolean; output: string }) => ( +const OutputContainerInternal = ({ isExpanded, output }: { isExpanded: boolean; output: string }) => (
{output.length > 0 && }
) -const MemoizedOutputContainer = memo(OutputContainer) +const OutputContainer = memo(OutputContainerInternal) + +const parseCommandAndOutput = (text: string) => { + const index = text.indexOf(COMMAND_OUTPUT_STRING) + + if (index === -1) { + return { command: text, output: "" } + } + + return { + command: text.slice(0, index), + output: text.slice(index + COMMAND_OUTPUT_STRING.length), + } +} diff --git a/webview-ui/src/components/common/CodeBlock.tsx b/webview-ui/src/components/common/CodeBlock.tsx index 70f1840dd5..151a133c25 100644 --- a/webview-ui/src/components/common/CodeBlock.tsx +++ b/webview-ui/src/components/common/CodeBlock.tsx @@ -6,8 +6,10 @@ import { bundledLanguages } from "shiki" import type { ShikiTransformer } from "shiki" import { ChevronDown, ChevronUp, WrapText, AlignJustify, Copy, Check } from "lucide-react" import { useAppTranslation } from "@src/i18n/TranslationContext" + export const CODE_BLOCK_BG_COLOR = "var(--vscode-editor-background, --vscode-sideBar-background, rgb(30 30 30))" export const WRAPPER_ALPHA = "cc" // 80% opacity + // Configuration constants export const WINDOW_SHADE_SETTINGS = { transitionDelayS: 0.2, @@ -95,7 +97,6 @@ const CodeBlockButtonWrapper = styled.div` const CodeBlockContainer = styled.div` position: relative; overflow: hidden; - border-bottom: 4px solid var(--vscode-sideBar-background); background-color: ${CODE_BLOCK_BG_COLOR}; ${CodeBlockButtonWrapper} { @@ -122,7 +123,6 @@ export const StyledPre = styled.div<{ windowshade === "true" ? `${collapsedHeight || WINDOW_SHADE_SETTINGS.collapsedHeight}px` : "none"}; overflow-y: auto; padding: 10px; - // transition: max-height ${WINDOW_SHADE_SETTINGS.transitionDelayS} ease-out; border-radius: 5px; ${({ preStyle }) => preStyle && { ...preStyle }} @@ -137,7 +137,7 @@ export const StyledPre = styled.div<{ pre, code { - /* Undefined wordwrap defaults to true (pre-wrap) behavior */ + /* Undefined wordwrap defaults to true (pre-wrap) behavior. */ white-space: ${({ wordwrap }) => (wordwrap === "false" ? "pre" : "pre-wrap")}; word-break: ${({ wordwrap }) => (wordwrap === "false" ? "normal" : "normal")}; overflow-wrap: ${({ wordwrap }) => (wordwrap === "false" ? "normal" : "break-word")}; @@ -233,24 +233,28 @@ const CodeBlock = memo( const { showCopyFeedback, copyWithFeedback } = useCopyToClipboard() const { t } = useAppTranslation() - // Update current language when prop changes, but only if user hasn't made a selection + // Update current language when prop changes, but only if user hasn't + // made a selection. useEffect(() => { const normalizedLang = normalizeLanguage(language) + if (normalizedLang !== currentLanguage && !userChangedLanguageRef.current) { setCurrentLanguage(normalizedLang) } }, [language, currentLanguage]) - // Syntax highlighting with cached Shiki instance + // Syntax highlighting with cached Shiki instance. useEffect(() => { const fallback = `
${source || ""}
` + const highlight = async () => { - // Show plain text if language needs to be loaded + // Show plain text if language needs to be loaded. if (currentLanguage && !isLanguageLoaded(currentLanguage)) { setHighlightedCode(fallback) } const highlighter = await getHighlighter(currentLanguage) + const html = await highlighter.codeToHtml(source || "", { lang: currentLanguage || "txt", theme: document.body.className.toLowerCase().includes("light") ? "github-light" : "github-dark", @@ -273,6 +277,7 @@ const CodeBlock = memo( }, ] as ShikiTransformer[], }) + setHighlightedCode(html) } @@ -285,13 +290,15 @@ const CodeBlock = memo( // Check if content height exceeds collapsed height whenever content changes useEffect(() => { const codeBlock = codeBlockRef.current + if (codeBlock) { const actualHeight = codeBlock.scrollHeight setShowCollapseButton(actualHeight >= WINDOW_SHADE_SETTINGS.collapsedHeight) } }, [highlightedCode]) - // Ref to track if user was scrolled up *before* the source update potentially changes scrollHeight + // Ref to track if user was scrolled up *before* the source update + // potentially changes scrollHeight const wasScrolledUpRef = useRef(false) // Ref to track if outer container was near bottom @@ -331,13 +338,14 @@ const CodeBlock = memo( } scrollContainer.addEventListener("scroll", handleOuterScroll, { passive: true }) + // Initial check handleOuterScroll() return () => { scrollContainer.removeEventListener("scroll", handleOuterScroll) } - }, []) // Empty dependency array: runs once on mount + }, []) // Store whether we should scroll after highlighting completes const shouldScrollAfterHighlightRef = useRef(false) @@ -355,16 +363,24 @@ const CodeBlock = memo( const updateCodeBlockButtonPosition = useCallback((forceHide = false) => { const codeBlock = codeBlockRef.current const copyWrapper = copyButtonWrapperRef.current - if (!codeBlock) return + + if (!codeBlock) { + return + } const rectCodeBlock = codeBlock.getBoundingClientRect() const scrollContainer = document.querySelector('[data-virtuoso-scroller="true"]') - if (!scrollContainer) return + + if (!scrollContainer) { + return + } // Get wrapper height dynamically let wrapperHeight + if (copyWrapper) { const copyRect = copyWrapper.getBoundingClientRect() + // If height is 0 due to styling, estimate from children if (copyRect.height > 0) { wrapperHeight = copyRect.height