Improve command execution UI (#3509)

This commit is contained in:
Chris Estreich 2025-05-12 13:16:46 -07:00 committed by hannesrudolph
parent d4fa359be8
commit 6df5f1d0b0
3 changed files with 107 additions and 77 deletions

View file

@ -980,13 +980,12 @@ export const ChatRowContent = ({
)
case "command":
return (
<>
<div style={headerStyle}>
{icon}
{title}
</div>
<CommandExecution executionId={message.ts.toString()} text={message.text} />
</>
<CommandExecution
executionId={message.ts.toString()}
text={message.text}
icon={icon}
title={title}
/>
)
case "use_mcp_server":
const useMcpServer = safeJsonParse<ClineAskUseMcpServer>(message.text)

View file

@ -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<CommandExecutionStatus | null>(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<CommandExecutionStatus | null>(null)
const onMessage = useCallback(
(event: MessageEvent) => {
@ -81,62 +74,84 @@ export const CommandExecution = ({ executionId, text }: CommandExecutionProps) =
useEvent("message", onMessage)
return (
<div className="w-full bg-vscode-editor-background border border-vscode-border rounded-xs p-2">
<CodeBlock source={text ? parseCommandAndOutput(text).command : command} language="shell" />
<div className="flex flex-row items-center justify-between gap-2 px-1">
<>
<div className="flex flex-row items-center justify-between gap-2 mb-1">
<div className="flex flex-row items-center gap-1">
{status?.status === "started" && (
<div className="flex flex-row items-center gap-2 font-mono text-xs">
<div className="rounded-full size-1.5 bg-lime-400" />
<div>Running</div>
{status.pid && <div className="whitespace-nowrap">(PID: {status.pid})</div>}
<Button
variant="ghost"
size="icon"
onClick={() =>
vscode.postMessage({ type: "terminalOperation", terminalOperation: "abort" })
}>
<Skull />
{icon}
{title}
</div>
<div className="flex flex-row items-center justify-between gap-2 px-1">
<div className="flex flex-row items-center gap-1">
{status?.status === "started" && (
<div className="flex flex-row items-center gap-2 font-mono text-xs">
<div className="rounded-full size-1.5 bg-lime-400" />
<div>Running</div>
{status.pid && <div className="whitespace-nowrap">(PID: {status.pid})</div>}
<Button
variant="ghost"
size="icon"
onClick={() =>
vscode.postMessage({ type: "terminalOperation", terminalOperation: "abort" })
}>
<Skull />
</Button>
</div>
)}
{status?.status === "exited" && (
<div className="flex flex-row items-center gap-2 font-mono text-xs">
<div
className={cn(
"rounded-full size-1.5",
status.exitCode === 0 ? "bg-lime-400" : "bg-red-400",
)}
/>
<div className="whitespace-nowrap">Exited ({status.exitCode})</div>
</div>
)}
{output.length > 0 && (
<Button variant="ghost" size="icon" onClick={() => setIsExpanded(!isExpanded)}>
<ChevronDown
className={cn("size-4 transition-transform duration-300", {
"rotate-180": isExpanded,
})}
/>
</Button>
</div>
)}
{status?.status === "exited" && (
<div className="flex flex-row items-center gap-2 font-mono text-xs">
<div
className={cn(
"rounded-full size-1.5",
status.exitCode === 0 ? "bg-lime-400" : "bg-red-400",
)}
/>
<div className="whitespace-nowrap">Exited ({status.exitCode})</div>
</div>
)}
{output.length > 0 && (
<Button variant="ghost" size="icon" onClick={() => setIsExpanded(!isExpanded)}>
<ChevronDown
className={cn("size-4 transition-transform duration-300", {
"rotate-180": isExpanded,
})}
/>
</Button>
)}
)}
</div>
</div>
</div>
<MemoizedOutputContainer isExpanded={isExpanded} output={output} />
</div>
<div className="w-full bg-vscode-editor-background border border-vscode-border rounded-xs p-2">
<CodeBlock source={command} language="shell" />
<OutputContainer isExpanded={isExpanded} output={output} />
</div>
</>
)
}
CommandExecution.displayName = "CommandExecution"
const OutputContainer = ({ isExpanded, output }: { isExpanded: boolean; output: string }) => (
const OutputContainerInternal = ({ isExpanded, output }: { isExpanded: boolean; output: string }) => (
<div
className={cn("mt-1 pt-1 border-t border-border/25 overflow-hidden transition-[max-height] duration-300", {
className={cn("overflow-hidden", {
"max-h-0": !isExpanded,
"max-h-[100%]": isExpanded,
"max-h-[100%] mt-1 pt-1 border-t border-border/25": isExpanded,
})}>
{output.length > 0 && <CodeBlock source={output} language="log" />}
</div>
)
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),
}
}

View file

@ -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 = `<pre style="padding: 0; margin: 0;"><code class="hljs language-${currentLanguage || "txt"}">${source || ""}</code></pre>`
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