Skip to content

Commit 47a13fb

Browse files
authored
Improve command execution UI (#3509)
1 parent 465f855 commit 47a13fb

File tree

3 files changed

+107
-77
lines changed

3 files changed

+107
-77
lines changed

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

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -980,13 +980,12 @@ export const ChatRowContent = ({
980980
)
981981
case "command":
982982
return (
983-
<>
984-
<div style={headerStyle}>
985-
{icon}
986-
{title}
987-
</div>
988-
<CommandExecution executionId={message.ts.toString()} text={message.text} />
989-
</>
983+
<CommandExecution
984+
executionId={message.ts.toString()}
985+
text={message.text}
986+
icon={icon}
987+
title={title}
988+
/>
990989
)
991990
case "use_mcp_server":
992991
const useMcpServer = safeJsonParse<ClineAskUseMcpServer>(message.text)
Lines changed: 75 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useState, memo } from "react"
1+
import { useCallback, useState, memo, useMemo } from "react"
22
import { useEvent } from "react-use"
33
import { ChevronDown, Skull } from "lucide-react"
44

@@ -16,32 +16,25 @@ import CodeBlock from "../common/CodeBlock"
1616
interface CommandExecutionProps {
1717
executionId: string
1818
text?: string
19+
icon?: JSX.Element | null
20+
title?: JSX.Element | null
1921
}
2022

21-
const parseCommandAndOutput = (text: string) => {
22-
const index = text.indexOf(COMMAND_OUTPUT_STRING)
23-
if (index === -1) {
24-
return { command: text, output: "" }
25-
}
26-
return {
27-
command: text.slice(0, index),
28-
output: text.slice(index + COMMAND_OUTPUT_STRING.length),
29-
}
30-
}
31-
32-
export const CommandExecution = ({ executionId, text }: CommandExecutionProps) => {
23+
export const CommandExecution = ({ executionId, text, icon, title }: CommandExecutionProps) => {
3324
const { terminalShellIntegrationDisabled = false } = useExtensionState()
3425

3526
// If we aren't opening the VSCode terminal for this command then we default
3627
// to expanding the command execution output.
3728
const [isExpanded, setIsExpanded] = useState(terminalShellIntegrationDisabled)
3829

39-
const [status, setStatus] = useState<CommandExecutionStatus | null>(null)
40-
const { command: initialCommand, output: initialOutput } = text
41-
? parseCommandAndOutput(text)
42-
: { command: "", output: "" }
30+
const { command: initialCommand, output: initialOutput } = useMemo(
31+
() => (text ? parseCommandAndOutput(text) : { command: "", output: "" }),
32+
[text],
33+
)
34+
4335
const [output, setOutput] = useState(initialOutput)
4436
const [command, setCommand] = useState(initialCommand)
37+
const [status, setStatus] = useState<CommandExecutionStatus | null>(null)
4538

4639
const onMessage = useCallback(
4740
(event: MessageEvent) => {
@@ -81,62 +74,84 @@ export const CommandExecution = ({ executionId, text }: CommandExecutionProps) =
8174
useEvent("message", onMessage)
8275

8376
return (
84-
<div className="w-full bg-vscode-editor-background border border-vscode-border rounded-xs p-2">
85-
<CodeBlock source={text ? parseCommandAndOutput(text).command : command} language="shell" />
86-
<div className="flex flex-row items-center justify-between gap-2 px-1">
77+
<>
78+
<div className="flex flex-row items-center justify-between gap-2 mb-1">
8779
<div className="flex flex-row items-center gap-1">
88-
{status?.status === "started" && (
89-
<div className="flex flex-row items-center gap-2 font-mono text-xs">
90-
<div className="rounded-full size-1.5 bg-lime-400" />
91-
<div>Running</div>
92-
{status.pid && <div className="whitespace-nowrap">(PID: {status.pid})</div>}
93-
<Button
94-
variant="ghost"
95-
size="icon"
96-
onClick={() =>
97-
vscode.postMessage({ type: "terminalOperation", terminalOperation: "abort" })
98-
}>
99-
<Skull />
80+
{icon}
81+
{title}
82+
</div>
83+
<div className="flex flex-row items-center justify-between gap-2 px-1">
84+
<div className="flex flex-row items-center gap-1">
85+
{status?.status === "started" && (
86+
<div className="flex flex-row items-center gap-2 font-mono text-xs">
87+
<div className="rounded-full size-1.5 bg-lime-400" />
88+
<div>Running</div>
89+
{status.pid && <div className="whitespace-nowrap">(PID: {status.pid})</div>}
90+
<Button
91+
variant="ghost"
92+
size="icon"
93+
onClick={() =>
94+
vscode.postMessage({ type: "terminalOperation", terminalOperation: "abort" })
95+
}>
96+
<Skull />
97+
</Button>
98+
</div>
99+
)}
100+
{status?.status === "exited" && (
101+
<div className="flex flex-row items-center gap-2 font-mono text-xs">
102+
<div
103+
className={cn(
104+
"rounded-full size-1.5",
105+
status.exitCode === 0 ? "bg-lime-400" : "bg-red-400",
106+
)}
107+
/>
108+
<div className="whitespace-nowrap">Exited ({status.exitCode})</div>
109+
</div>
110+
)}
111+
{output.length > 0 && (
112+
<Button variant="ghost" size="icon" onClick={() => setIsExpanded(!isExpanded)}>
113+
<ChevronDown
114+
className={cn("size-4 transition-transform duration-300", {
115+
"rotate-180": isExpanded,
116+
})}
117+
/>
100118
</Button>
101-
</div>
102-
)}
103-
{status?.status === "exited" && (
104-
<div className="flex flex-row items-center gap-2 font-mono text-xs">
105-
<div
106-
className={cn(
107-
"rounded-full size-1.5",
108-
status.exitCode === 0 ? "bg-lime-400" : "bg-red-400",
109-
)}
110-
/>
111-
<div className="whitespace-nowrap">Exited ({status.exitCode})</div>
112-
</div>
113-
)}
114-
{output.length > 0 && (
115-
<Button variant="ghost" size="icon" onClick={() => setIsExpanded(!isExpanded)}>
116-
<ChevronDown
117-
className={cn("size-4 transition-transform duration-300", {
118-
"rotate-180": isExpanded,
119-
})}
120-
/>
121-
</Button>
122-
)}
119+
)}
120+
</div>
123121
</div>
124122
</div>
125-
<MemoizedOutputContainer isExpanded={isExpanded} output={output} />
126-
</div>
123+
124+
<div className="w-full bg-vscode-editor-background border border-vscode-border rounded-xs p-2">
125+
<CodeBlock source={command} language="shell" />
126+
<OutputContainer isExpanded={isExpanded} output={output} />
127+
</div>
128+
</>
127129
)
128130
}
129131

130132
CommandExecution.displayName = "CommandExecution"
131133

132-
const OutputContainer = ({ isExpanded, output }: { isExpanded: boolean; output: string }) => (
134+
const OutputContainerInternal = ({ isExpanded, output }: { isExpanded: boolean; output: string }) => (
133135
<div
134-
className={cn("mt-1 pt-1 border-t border-border/25 overflow-hidden transition-[max-height] duration-300", {
136+
className={cn("overflow-hidden", {
135137
"max-h-0": !isExpanded,
136-
"max-h-[100%]": isExpanded,
138+
"max-h-[100%] mt-1 pt-1 border-t border-border/25": isExpanded,
137139
})}>
138140
{output.length > 0 && <CodeBlock source={output} language="log" />}
139141
</div>
140142
)
141143

142-
const MemoizedOutputContainer = memo(OutputContainer)
144+
const OutputContainer = memo(OutputContainerInternal)
145+
146+
const parseCommandAndOutput = (text: string) => {
147+
const index = text.indexOf(COMMAND_OUTPUT_STRING)
148+
149+
if (index === -1) {
150+
return { command: text, output: "" }
151+
}
152+
153+
return {
154+
command: text.slice(0, index),
155+
output: text.slice(index + COMMAND_OUTPUT_STRING.length),
156+
}
157+
}

webview-ui/src/components/common/CodeBlock.tsx

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import { bundledLanguages } from "shiki"
66
import type { ShikiTransformer } from "shiki"
77
import { ChevronDown, ChevronUp, WrapText, AlignJustify, Copy, Check } from "lucide-react"
88
import { useAppTranslation } from "@src/i18n/TranslationContext"
9+
910
export const CODE_BLOCK_BG_COLOR = "var(--vscode-editor-background, --vscode-sideBar-background, rgb(30 30 30))"
1011
export const WRAPPER_ALPHA = "cc" // 80% opacity
12+
1113
// Configuration constants
1214
export const WINDOW_SHADE_SETTINGS = {
1315
transitionDelayS: 0.2,
@@ -95,7 +97,6 @@ const CodeBlockButtonWrapper = styled.div`
9597
const CodeBlockContainer = styled.div`
9698
position: relative;
9799
overflow: hidden;
98-
border-bottom: 4px solid var(--vscode-sideBar-background);
99100
background-color: ${CODE_BLOCK_BG_COLOR};
100101
101102
${CodeBlockButtonWrapper} {
@@ -122,7 +123,6 @@ export const StyledPre = styled.div<{
122123
windowshade === "true" ? `${collapsedHeight || WINDOW_SHADE_SETTINGS.collapsedHeight}px` : "none"};
123124
overflow-y: auto;
124125
padding: 10px;
125-
// transition: max-height ${WINDOW_SHADE_SETTINGS.transitionDelayS} ease-out;
126126
border-radius: 5px;
127127
${({ preStyle }) => preStyle && { ...preStyle }}
128128
@@ -137,7 +137,7 @@ export const StyledPre = styled.div<{
137137
138138
pre,
139139
code {
140-
/* Undefined wordwrap defaults to true (pre-wrap) behavior */
140+
/* Undefined wordwrap defaults to true (pre-wrap) behavior. */
141141
white-space: ${({ wordwrap }) => (wordwrap === "false" ? "pre" : "pre-wrap")};
142142
word-break: ${({ wordwrap }) => (wordwrap === "false" ? "normal" : "normal")};
143143
overflow-wrap: ${({ wordwrap }) => (wordwrap === "false" ? "normal" : "break-word")};
@@ -233,24 +233,28 @@ const CodeBlock = memo(
233233
const { showCopyFeedback, copyWithFeedback } = useCopyToClipboard()
234234
const { t } = useAppTranslation()
235235

236-
// Update current language when prop changes, but only if user hasn't made a selection
236+
// Update current language when prop changes, but only if user hasn't
237+
// made a selection.
237238
useEffect(() => {
238239
const normalizedLang = normalizeLanguage(language)
240+
239241
if (normalizedLang !== currentLanguage && !userChangedLanguageRef.current) {
240242
setCurrentLanguage(normalizedLang)
241243
}
242244
}, [language, currentLanguage])
243245

244-
// Syntax highlighting with cached Shiki instance
246+
// Syntax highlighting with cached Shiki instance.
245247
useEffect(() => {
246248
const fallback = `<pre style="padding: 0; margin: 0;"><code class="hljs language-${currentLanguage || "txt"}">${source || ""}</code></pre>`
249+
247250
const highlight = async () => {
248-
// Show plain text if language needs to be loaded
251+
// Show plain text if language needs to be loaded.
249252
if (currentLanguage && !isLanguageLoaded(currentLanguage)) {
250253
setHighlightedCode(fallback)
251254
}
252255

253256
const highlighter = await getHighlighter(currentLanguage)
257+
254258
const html = await highlighter.codeToHtml(source || "", {
255259
lang: currentLanguage || "txt",
256260
theme: document.body.className.toLowerCase().includes("light") ? "github-light" : "github-dark",
@@ -273,6 +277,7 @@ const CodeBlock = memo(
273277
},
274278
] as ShikiTransformer[],
275279
})
280+
276281
setHighlightedCode(html)
277282
}
278283

@@ -285,13 +290,15 @@ const CodeBlock = memo(
285290
// Check if content height exceeds collapsed height whenever content changes
286291
useEffect(() => {
287292
const codeBlock = codeBlockRef.current
293+
288294
if (codeBlock) {
289295
const actualHeight = codeBlock.scrollHeight
290296
setShowCollapseButton(actualHeight >= WINDOW_SHADE_SETTINGS.collapsedHeight)
291297
}
292298
}, [highlightedCode])
293299

294-
// Ref to track if user was scrolled up *before* the source update potentially changes scrollHeight
300+
// Ref to track if user was scrolled up *before* the source update
301+
// potentially changes scrollHeight
295302
const wasScrolledUpRef = useRef(false)
296303

297304
// Ref to track if outer container was near bottom
@@ -331,13 +338,14 @@ const CodeBlock = memo(
331338
}
332339

333340
scrollContainer.addEventListener("scroll", handleOuterScroll, { passive: true })
341+
334342
// Initial check
335343
handleOuterScroll()
336344

337345
return () => {
338346
scrollContainer.removeEventListener("scroll", handleOuterScroll)
339347
}
340-
}, []) // Empty dependency array: runs once on mount
348+
}, [])
341349

342350
// Store whether we should scroll after highlighting completes
343351
const shouldScrollAfterHighlightRef = useRef(false)
@@ -355,16 +363,24 @@ const CodeBlock = memo(
355363
const updateCodeBlockButtonPosition = useCallback((forceHide = false) => {
356364
const codeBlock = codeBlockRef.current
357365
const copyWrapper = copyButtonWrapperRef.current
358-
if (!codeBlock) return
366+
367+
if (!codeBlock) {
368+
return
369+
}
359370

360371
const rectCodeBlock = codeBlock.getBoundingClientRect()
361372
const scrollContainer = document.querySelector('[data-virtuoso-scroller="true"]')
362-
if (!scrollContainer) return
373+
374+
if (!scrollContainer) {
375+
return
376+
}
363377

364378
// Get wrapper height dynamically
365379
let wrapperHeight
380+
366381
if (copyWrapper) {
367382
const copyRect = copyWrapper.getBoundingClientRect()
383+
368384
// If height is 0 due to styling, estimate from children
369385
if (copyRect.height > 0) {
370386
wrapperHeight = copyRect.height

0 commit comments

Comments
 (0)