mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Improve command execution component (#3057)
This commit is contained in:
parent
7863303aac
commit
1d7569b14c
21 changed files with 325 additions and 247 deletions
5
.changeset/cyan-countries-sniff.md
Normal file
5
.changeset/cyan-countries-sniff.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"roo-cline": patch
|
||||
---
|
||||
|
||||
Add a kill command button to the execute command component
|
||||
|
|
@ -76,14 +76,14 @@ export async function attemptCompletionTool(
|
|||
}
|
||||
|
||||
// Complete command message.
|
||||
const didApprove = await askApproval("command", command)
|
||||
const executionId = Date.now().toString()
|
||||
const didApprove = await askApproval("command", command, { id: executionId })
|
||||
|
||||
if (!didApprove) {
|
||||
return
|
||||
}
|
||||
|
||||
const options: ExecuteCommandOptions = { command }
|
||||
|
||||
const options: ExecuteCommandOptions = { executionId, command }
|
||||
const [userRejected, execCommandResult] = await executeCommand(cline, options)
|
||||
|
||||
if (userRejected) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import * as path from "path"
|
|||
import delay from "delay"
|
||||
|
||||
import { Cline } from "../Cline"
|
||||
import { CommandExecutionStatus } from "../../schemas"
|
||||
import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag, ToolResponse } from "../../shared/tools"
|
||||
import { formatResponse } from "../prompts/responses"
|
||||
import { unescapeHtmlEntities } from "../../utils/text-normalization"
|
||||
|
|
@ -47,8 +48,9 @@ export async function executeCommandTool(
|
|||
|
||||
cline.consecutiveMistakeCount = 0
|
||||
|
||||
const executionId = Date.now().toString()
|
||||
command = unescapeHtmlEntities(command) // Unescape HTML entities.
|
||||
const didApprove = await askApproval("command", command)
|
||||
const didApprove = await askApproval("command", command, { id: executionId })
|
||||
|
||||
if (!didApprove) {
|
||||
return
|
||||
|
|
@ -59,6 +61,7 @@ export async function executeCommandTool(
|
|||
const { terminalOutputLineLimit = 500, terminalShellIntegrationDisabled = false } = clineProviderState ?? {}
|
||||
|
||||
const options: ExecuteCommandOptions = {
|
||||
executionId,
|
||||
command,
|
||||
customCwd,
|
||||
terminalShellIntegrationDisabled,
|
||||
|
|
@ -74,8 +77,10 @@ export async function executeCommandTool(
|
|||
|
||||
pushToolResult(result)
|
||||
} catch (error: unknown) {
|
||||
await cline.say("shell_integration_warning")
|
||||
const status: CommandExecutionStatus = { executionId, status: "fallback" }
|
||||
clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
|
||||
clineProvider?.setValue("terminalShellIntegrationDisabled", true)
|
||||
await cline.say("shell_integration_warning")
|
||||
|
||||
if (error instanceof ShellIntegrationError) {
|
||||
const [rejected, result] = await executeCommand(cline, {
|
||||
|
|
@ -102,6 +107,7 @@ export async function executeCommandTool(
|
|||
}
|
||||
|
||||
export type ExecuteCommandOptions = {
|
||||
executionId: string
|
||||
command: string
|
||||
customCwd?: string
|
||||
terminalShellIntegrationDisabled?: boolean
|
||||
|
|
@ -111,6 +117,7 @@ export type ExecuteCommandOptions = {
|
|||
export async function executeCommand(
|
||||
cline: Cline,
|
||||
{
|
||||
executionId,
|
||||
command,
|
||||
customCwd,
|
||||
terminalShellIntegrationDisabled = false,
|
||||
|
|
@ -141,6 +148,7 @@ export async function executeCommand(
|
|||
let shellIntegrationError: string | undefined
|
||||
|
||||
const terminalProvider = terminalShellIntegrationDisabled ? "execa" : "vscode"
|
||||
const clineProvider = await cline.providerRef.deref()
|
||||
|
||||
const callbacks: RooTerminalCallbacks = {
|
||||
onLine: async (output: string, process: RooTerminalProcess) => {
|
||||
|
|
@ -165,7 +173,15 @@ export async function executeCommand(
|
|||
result = Terminal.compressTerminalOutput(output ?? "", terminalOutputLineLimit)
|
||||
completed = true
|
||||
},
|
||||
onShellExecutionComplete: (details: ExitCodeDetails) => (exitDetails = details),
|
||||
onShellExecutionStarted: (pid: number | undefined) => {
|
||||
const status: CommandExecutionStatus = { executionId, status: "running", pid }
|
||||
clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
|
||||
},
|
||||
onShellExecutionComplete: (details: ExitCodeDetails) => {
|
||||
const status: CommandExecutionStatus = { executionId, status: "exited", exitCode: details.exitCode }
|
||||
clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
|
||||
exitDetails = details
|
||||
},
|
||||
}
|
||||
|
||||
if (terminalProvider === "vscode") {
|
||||
|
|
|
|||
2
src/exports/roo-code.d.ts
vendored
2
src/exports/roo-code.d.ts
vendored
|
|
@ -347,6 +347,7 @@ type ClineMessage = {
|
|||
| undefined
|
||||
progressStatus?:
|
||||
| {
|
||||
id?: string | undefined
|
||||
icon?: string | undefined
|
||||
text?: string | undefined
|
||||
}
|
||||
|
|
@ -422,6 +423,7 @@ type RooCodeEvents = {
|
|||
| undefined
|
||||
progressStatus?:
|
||||
| {
|
||||
id?: string | undefined
|
||||
icon?: string | undefined
|
||||
text?: string | undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -352,6 +352,7 @@ type ClineMessage = {
|
|||
| undefined
|
||||
progressStatus?:
|
||||
| {
|
||||
id?: string | undefined
|
||||
icon?: string | undefined
|
||||
text?: string | undefined
|
||||
}
|
||||
|
|
@ -431,6 +432,7 @@ type RooCodeEvents = {
|
|||
| undefined
|
||||
progressStatus?:
|
||||
| {
|
||||
id?: string | undefined
|
||||
icon?: string | undefined
|
||||
text?: string | undefined
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export abstract class BaseTerminal implements RooTerminal {
|
|||
* @param stream The stream to set, or undefined to clean up
|
||||
* @throws Error if process is undefined when a stream is provided
|
||||
*/
|
||||
public setActiveStream(stream: AsyncIterable<string> | undefined): void {
|
||||
public setActiveStream(stream: AsyncIterable<string> | undefined, pid?: number): void {
|
||||
if (stream) {
|
||||
if (!this.process) {
|
||||
this.running = false
|
||||
|
|
@ -58,6 +58,7 @@ export abstract class BaseTerminal implements RooTerminal {
|
|||
|
||||
this.running = true
|
||||
this.streamClosed = false
|
||||
this.process.emit("shell_execution_started", pid)
|
||||
this.process.emit("stream_available", stream)
|
||||
} else {
|
||||
this.streamClosed = true
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export class ExecaTerminal extends BaseTerminal {
|
|||
|
||||
process.on("line", (line) => callbacks.onLine(line, process))
|
||||
process.once("completed", (output) => callbacks.onCompleted(output, process))
|
||||
process.once("shell_execution_started", (pid) => callbacks.onShellExecutionStarted(pid, process))
|
||||
process.once("shell_execution_complete", (details) => callbacks.onShellExecutionComplete(details, process))
|
||||
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
|
|||
cancelSignal: this.controller.signal,
|
||||
})`${command}`
|
||||
|
||||
this.terminal.setActiveStream(subprocess)
|
||||
this.terminal.setActiveStream(subprocess, subprocess.pid)
|
||||
this.emit("line", "")
|
||||
|
||||
for await (const line of subprocess) {
|
||||
|
|
@ -60,10 +60,7 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
|
|||
} catch (error) {
|
||||
if (error instanceof ExecaError) {
|
||||
console.error(`[ExecaTerminalProcess] shell execution error: ${error.message}`)
|
||||
this.emit("shell_execution_complete", {
|
||||
exitCode: error.exitCode ?? 1,
|
||||
signalName: error.signal,
|
||||
})
|
||||
this.emit("shell_execution_complete", { exitCode: error.exitCode ?? 1, signalName: error.signal })
|
||||
} else {
|
||||
this.emit("shell_execution_complete", { exitCode: 1 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export class Terminal extends BaseTerminal {
|
|||
// configured before the process starts.
|
||||
process.on("line", (line) => callbacks.onLine(line, process))
|
||||
process.once("completed", (output) => callbacks.onCompleted(output, process))
|
||||
process.once("shell_execution_started", (pid) => callbacks.onShellExecutionStarted(pid, process))
|
||||
process.once("shell_execution_complete", (details) => callbacks.onShellExecutionComplete(details, process))
|
||||
process.once("no_shell_integration", (msg) => callbacks.onNoShellIntegration?.(msg, process))
|
||||
|
||||
|
|
|
|||
|
|
@ -52,8 +52,7 @@ export class TerminalRegistry {
|
|||
const stream = e.execution.read()
|
||||
const terminal = this.getTerminalByVSCETerminal(e.terminal)
|
||||
|
||||
console.info("[onDidStartTerminalShellExecution] Shell execution started:", {
|
||||
hasExecution: !!e.execution,
|
||||
console.info("[onDidStartTerminalShellExecution]", {
|
||||
command: e.execution?.commandLine?.value,
|
||||
terminalId: terminal?.id,
|
||||
})
|
||||
|
|
@ -79,8 +78,7 @@ export class TerminalRegistry {
|
|||
const process = terminal?.process
|
||||
const exitDetails = TerminalProcess.interpretExitCode(e.exitCode)
|
||||
|
||||
console.info("[TerminalRegistry] Shell execution ended:", {
|
||||
hasExecution: !!e.execution,
|
||||
console.info("[onDidEndTerminalShellExecution]", {
|
||||
command: e.execution?.commandLine?.value,
|
||||
terminalId: terminal?.id,
|
||||
...exitDetails,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export interface RooTerminal {
|
|||
getCurrentWorkingDirectory(): string
|
||||
isClosed: () => boolean
|
||||
runCommand: (command: string, callbacks: RooTerminalCallbacks) => RooTerminalProcessResultPromise
|
||||
setActiveStream(stream: AsyncIterable<string> | undefined): void
|
||||
setActiveStream(stream: AsyncIterable<string> | undefined, pid?: number): void
|
||||
shellExecutionComplete(exitDetails: ExitCodeDetails): void
|
||||
getProcessesWithOutput(): RooTerminalProcess[]
|
||||
getUnretrievedOutput(): string
|
||||
|
|
@ -23,6 +23,7 @@ export interface RooTerminal {
|
|||
export interface RooTerminalCallbacks {
|
||||
onLine: (line: string, process: RooTerminalProcess) => void
|
||||
onCompleted: (output: string | undefined, process: RooTerminalProcess) => void
|
||||
onShellExecutionStarted: (pid: number | undefined, process: RooTerminalProcess) => void
|
||||
onShellExecutionComplete: (details: ExitCodeDetails, process: RooTerminalProcess) => void
|
||||
onNoShellIntegration?: (message: string, process: RooTerminalProcess) => void
|
||||
}
|
||||
|
|
@ -43,15 +44,11 @@ export interface RooTerminalProcessEvents {
|
|||
line: [line: string]
|
||||
continue: []
|
||||
completed: [output?: string]
|
||||
stream_available: [stream: AsyncIterable<string>]
|
||||
shell_execution_started: [pid: number | undefined]
|
||||
shell_execution_complete: [exitDetails: ExitCodeDetails]
|
||||
error: [error: Error]
|
||||
no_shell_integration: [message: string]
|
||||
/**
|
||||
* Emitted when a shell execution completes
|
||||
* @param id The terminal ID
|
||||
* @param exitDetails Contains exit code and signal information if process was terminated by signal
|
||||
*/
|
||||
shell_execution_complete: [exitDetails: ExitCodeDetails]
|
||||
stream_available: [stream: AsyncIterable<string>]
|
||||
}
|
||||
|
||||
export interface ExitCodeDetails {
|
||||
|
|
|
|||
|
|
@ -286,6 +286,29 @@ export const customSupportPromptsSchema = z.record(z.string(), z.string().option
|
|||
|
||||
export type CustomSupportPrompts = z.infer<typeof customSupportPromptsSchema>
|
||||
|
||||
/**
|
||||
* CommandExecutionStatus
|
||||
*/
|
||||
|
||||
export const commandExecutionStatusSchema = z.discriminatedUnion("status", [
|
||||
z.object({
|
||||
executionId: z.string(),
|
||||
status: z.literal("running"),
|
||||
pid: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
executionId: z.string(),
|
||||
status: z.literal("exited"),
|
||||
exitCode: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
executionId: z.string(),
|
||||
status: z.literal("fallback"),
|
||||
}),
|
||||
])
|
||||
|
||||
export type CommandExecutionStatus = z.infer<typeof commandExecutionStatusSchema>
|
||||
|
||||
/**
|
||||
* ExperimentId
|
||||
*/
|
||||
|
|
@ -784,6 +807,7 @@ export type ClineSay = z.infer<typeof clineSaySchema>
|
|||
*/
|
||||
|
||||
export const toolProgressStatusSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export interface ExtensionMessage {
|
|||
| "toggleApiConfigPin"
|
||||
| "acceptInput"
|
||||
| "setHistoryPreviewCollapsed"
|
||||
| "commandExecutionStatus"
|
||||
text?: string
|
||||
action?:
|
||||
| "chatButtonClicked"
|
||||
|
|
|
|||
|
|
@ -987,7 +987,11 @@ export const ChatRowContent = ({
|
|||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
<CommandExecution command={command} output={output} />
|
||||
<CommandExecution
|
||||
executionId={message.progressStatus?.id}
|
||||
command={command}
|
||||
output={output}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
case "use_mcp_server":
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import debounce from "debounce"
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"
|
||||
import { useDeepCompareEffect, useEvent, useMount } from "react-use"
|
||||
import debounce from "debounce"
|
||||
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
|
||||
import removeMd from "remove-markdown"
|
||||
import { Trans } from "react-i18next"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
|
||||
import {
|
||||
ClineAsk,
|
||||
|
|
@ -28,6 +28,7 @@ import { validateCommand } from "@src/utils/command-validation"
|
|||
import { useAppTranslation } from "@src/i18n/TranslationContext"
|
||||
|
||||
import TelemetryBanner from "../common/TelemetryBanner"
|
||||
import { useTaskSearch } from "../history/useTaskSearch"
|
||||
import HistoryPreview from "../history/HistoryPreview"
|
||||
import RooHero from "@src/components/welcome/RooHero"
|
||||
import RooTips from "@src/components/welcome/RooTips"
|
||||
|
|
@ -38,7 +39,7 @@ import ChatTextArea from "./ChatTextArea"
|
|||
import TaskHeader from "./TaskHeader"
|
||||
import AutoApproveMenu from "./AutoApproveMenu"
|
||||
import SystemPromptWarning from "./SystemPromptWarning"
|
||||
import { useTaskSearch } from "../history/useTaskSearch"
|
||||
import { CheckpointWarning } from "./CheckpointWarning"
|
||||
|
||||
export interface ChatViewProps {
|
||||
isHidden: boolean
|
||||
|
|
@ -267,7 +268,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
}
|
||||
break
|
||||
case "say":
|
||||
// don't want to reset since there could be a "say" after an "ask" while ask is waiting for response
|
||||
// Don't want to reset since there could be a "say" after
|
||||
// an "ask" while ask is waiting for response.
|
||||
switch (lastMessage.say) {
|
||||
case "api_req_retry_delayed":
|
||||
setTextAreaDisabled(true)
|
||||
|
|
@ -301,13 +303,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
}
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// this would get called after sending the first message, so we have to watch messages.length instead
|
||||
// No messages, so user has to submit a task
|
||||
// setTextAreaDisabled(false)
|
||||
// setClineAsk(undefined)
|
||||
// setPrimaryButtonText(undefined)
|
||||
// setSecondaryButtonText(undefined)
|
||||
}
|
||||
}, [lastMessage, secondLastMessage])
|
||||
|
||||
|
|
@ -321,23 +316,32 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
}
|
||||
}, [messages.length])
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedRows({})
|
||||
}, [task?.ts])
|
||||
useEffect(() => setExpandedRows({}), [task?.ts])
|
||||
|
||||
const isStreaming = useMemo(() => {
|
||||
const isLastAsk = !!modifiedMessages.at(-1)?.ask // checking clineAsk isn't enough since messages effect may be called again for a tool for example, set clineAsk to its value, and if the next message is not an ask then it doesn't reset. This is likely due to how much more often we're updating messages as compared to before, and should be resolved with optimizations as it's likely a rendering bug. but as a final guard for now, the cancel button will show if the last message is not an ask
|
||||
// Checking clineAsk isn't enough since messages effect may be called
|
||||
// again for a tool for example, set clineAsk to its value, and if the
|
||||
// next message is not an ask then it doesn't reset. This is likely due
|
||||
// to how much more often we're updating messages as compared to before,
|
||||
// and should be resolved with optimizations as it's likely a rendering
|
||||
// bug. But as a final guard for now, the cancel button will show if the
|
||||
// last message is not an ask.
|
||||
const isLastAsk = !!modifiedMessages.at(-1)?.ask
|
||||
|
||||
const isToolCurrentlyAsking =
|
||||
isLastAsk && clineAsk !== undefined && enableButtons && primaryButtonText !== undefined
|
||||
|
||||
if (isToolCurrentlyAsking) {
|
||||
return false
|
||||
}
|
||||
|
||||
const isLastMessagePartial = modifiedMessages.at(-1)?.partial === true
|
||||
|
||||
if (isLastMessagePartial) {
|
||||
return true
|
||||
} else {
|
||||
const lastApiReqStarted = findLast(modifiedMessages, (message) => message.say === "api_req_started")
|
||||
|
||||
if (
|
||||
lastApiReqStarted &&
|
||||
lastApiReqStarted.text !== null &&
|
||||
|
|
@ -345,9 +349,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
lastApiReqStarted.say === "api_req_started"
|
||||
) {
|
||||
const cost = JSON.parse(lastApiReqStarted.text).cost
|
||||
|
||||
if (cost === undefined) {
|
||||
// api request has not finished yet
|
||||
return true
|
||||
return true // API request has not finished yet.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -371,6 +375,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
const handleSendMessage = useCallback(
|
||||
(text: string, images: string[]) => {
|
||||
text = text.trim()
|
||||
|
||||
if (text || images.length > 0) {
|
||||
if (messages.length === 0) {
|
||||
vscode.postMessage({ type: "newTask", text, images })
|
||||
|
|
@ -391,6 +396,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
// There is no other case that a textfield should be enabled.
|
||||
}
|
||||
}
|
||||
|
||||
handleChatReset()
|
||||
}
|
||||
},
|
||||
|
|
@ -412,16 +418,15 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
[inputValue, selectedImages],
|
||||
)
|
||||
|
||||
const startNewTask = useCallback(() => {
|
||||
vscode.postMessage({ type: "clearTask" })
|
||||
}, [])
|
||||
const startNewTask = useCallback(() => vscode.postMessage({ type: "clearTask" }), [])
|
||||
|
||||
/*
|
||||
This logic depends on the useEffect[messages] above to set clineAsk, after which buttons are shown and we then send an askResponse to the extension.
|
||||
*/
|
||||
// This logic depends on the useEffect[messages] above to set clineAsk,
|
||||
// after which buttons are shown and we then send an askResponse to the
|
||||
// extension.
|
||||
const handlePrimaryButtonClick = useCallback(
|
||||
(text?: string, images?: string[]) => {
|
||||
const trimmedInput = text?.trim()
|
||||
|
||||
switch (clineAsk) {
|
||||
case "api_req_failed":
|
||||
case "command":
|
||||
|
|
@ -454,6 +459,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
vscode.postMessage({ type: "terminalOperation", terminalOperation: "continue" })
|
||||
break
|
||||
}
|
||||
|
||||
setTextAreaDisabled(true)
|
||||
setClineAsk(undefined)
|
||||
setEnableButtons(false)
|
||||
|
|
@ -508,15 +514,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
[clineAsk, startNewTask, isStreaming],
|
||||
)
|
||||
|
||||
const handleTaskCloseButtonClick = useCallback(() => {
|
||||
startNewTask()
|
||||
}, [startNewTask])
|
||||
const handleTaskCloseButtonClick = useCallback(() => startNewTask(), [startNewTask])
|
||||
|
||||
const { info: model } = useSelectedModel(apiConfiguration)
|
||||
|
||||
const selectImages = useCallback(() => {
|
||||
vscode.postMessage({ type: "selectImages" })
|
||||
}, [])
|
||||
const selectImages = useCallback(() => vscode.postMessage({ type: "selectImages" }), [])
|
||||
|
||||
const shouldDisableImages =
|
||||
!model?.supportsImages || textAreaDisabled || selectedImages.length >= MAX_IMAGES_PER_MESSAGE
|
||||
|
|
@ -524,6 +526,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
const handleMessage = useCallback(
|
||||
(e: MessageEvent) => {
|
||||
const message: ExtensionMessage = e.data
|
||||
|
||||
switch (message.type) {
|
||||
case "action":
|
||||
switch (message.action!) {
|
||||
|
|
@ -564,7 +567,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
break
|
||||
}
|
||||
}
|
||||
// textAreaRef.current is not explicitly required here since react gaurantees that ref will be stable across re-renders, and we're not using its value but its reference.
|
||||
// textAreaRef.current is not explicitly required here since React
|
||||
// guarantees that ref will be stable across re-renders, and we're
|
||||
// not using its value but its reference.
|
||||
},
|
||||
[
|
||||
isHidden,
|
||||
|
|
@ -580,10 +585,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
|
||||
useEvent("message", handleMessage)
|
||||
|
||||
useMount(() => {
|
||||
// NOTE: the vscode window needs to be focused for this to work
|
||||
textAreaRef.current?.focus()
|
||||
})
|
||||
// NOTE: the VSCode window needs to be focused for this to work.
|
||||
useMount(() => textAreaRef.current?.focus())
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
|
|
@ -591,6 +594,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
textAreaRef.current?.focus()
|
||||
}
|
||||
}, 50)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
|
@ -600,23 +604,28 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
return modifiedMessages.filter((message) => {
|
||||
switch (message.ask) {
|
||||
case "completion_result":
|
||||
// don't show a chat row for a completion_result ask without text. This specific type of message only occurs if cline wants to execute a command as part of its completion result, in which case we interject the completion_result tool with the execute_command tool.
|
||||
// Don't show a chat row for a completion_result ask without
|
||||
// text. This specific type of message only occurs if cline
|
||||
// wants to execute a command as part of its completion
|
||||
// result, in which case we interject the completion_result
|
||||
// tool with the execute_command tool.
|
||||
if (message.text === "") {
|
||||
return false
|
||||
}
|
||||
break
|
||||
case "api_req_failed": // this message is used to update the latest api_req_started that the request failed
|
||||
case "api_req_failed": // This message is used to update the latest `api_req_started` that the request failed.
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
return false
|
||||
}
|
||||
switch (message.say) {
|
||||
case "api_req_finished": // combineApiRequests removes this from modifiedMessages anyways
|
||||
case "api_req_retried": // this message is used to update the latest api_req_started that the request was retried
|
||||
case "api_req_deleted": // aggregated api_req metrics from deleted messages
|
||||
case "api_req_finished": // `combineApiRequests` removes this from `modifiedMessages` anyways.
|
||||
case "api_req_retried": // This message is used to update the latest `api_req_started` that the request was retried.
|
||||
case "api_req_deleted": // Aggregated `api_req` metrics from deleted messages.
|
||||
return false
|
||||
case "api_req_retry_delayed":
|
||||
// Only show the retry message if it's the last message or the last messages is api_req_retry_delayed+resume_task
|
||||
// Only show the retry message if it's the last message or
|
||||
// the last messages is api_req_retry_delayed+resume_task.
|
||||
const last1 = modifiedMessages.at(-1)
|
||||
const last2 = modifiedMessages.at(-2)
|
||||
if (last1?.ask === "resume_task" && last2 === message) {
|
||||
|
|
@ -624,7 +633,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
}
|
||||
return message === last1
|
||||
case "text":
|
||||
// Sometimes cline returns an empty text message, we don't want to render these. (We also use a say text for user messages, so in case they just sent images we still render that)
|
||||
// Sometimes cline returns an empty text message, we don't
|
||||
// want to render these. (We also use a say text for user
|
||||
// messages, so in case they just sent images we still
|
||||
// render that.)
|
||||
if ((message.text ?? "") === "" && (message.images?.length ?? 0) === 0) {
|
||||
return false
|
||||
}
|
||||
|
|
@ -641,7 +653,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
if (!message.text) {
|
||||
return true
|
||||
}
|
||||
|
||||
const tool = JSON.parse(message.text)
|
||||
|
||||
return [
|
||||
"readFile",
|
||||
"listFiles",
|
||||
|
|
@ -651,6 +665,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
"searchFiles",
|
||||
].includes(tool.tool)
|
||||
}
|
||||
|
||||
return false
|
||||
}, [])
|
||||
|
||||
|
|
@ -659,7 +674,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
if (!message.text) {
|
||||
return true
|
||||
}
|
||||
|
||||
const tool = JSON.parse(message.text)
|
||||
|
||||
return [
|
||||
"editedExistingFile",
|
||||
"appliedDiff",
|
||||
|
|
@ -668,6 +685,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
"insertContent",
|
||||
].includes(tool.tool)
|
||||
}
|
||||
|
||||
return false
|
||||
}, [])
|
||||
|
||||
|
|
@ -677,19 +695,22 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
if (!message.text) {
|
||||
return true
|
||||
}
|
||||
|
||||
const mcpServerUse = JSON.parse(message.text) as { type: string; serverName: string; toolName: string }
|
||||
|
||||
if (mcpServerUse.type === "use_mcp_tool") {
|
||||
const server = mcpServers?.find((s: McpServer) => s.name === mcpServerUse.serverName)
|
||||
const tool = server?.tools?.find((t: McpTool) => t.name === mcpServerUse.toolName)
|
||||
return tool?.alwaysAllow || false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
[mcpServers],
|
||||
)
|
||||
|
||||
// Check if a command message is allowed
|
||||
// Check if a command message is allowed.
|
||||
const isAllowedCommand = useCallback(
|
||||
(message: ClineMessage | undefined): boolean => {
|
||||
if (message?.type !== "ask") return false
|
||||
|
|
@ -717,6 +738,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
// For read/write operations, check if it's outside workspace and if we have permission for that
|
||||
if (message.ask === "tool") {
|
||||
let tool: any = {}
|
||||
|
||||
try {
|
||||
tool = JSON.parse(message.text || "{}")
|
||||
} catch (error) {
|
||||
|
|
@ -731,6 +753,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
if (tool.content === "create_mode") {
|
||||
return alwaysAllowModeSwitch
|
||||
}
|
||||
|
||||
if (tool.content === "create_mcp_server") {
|
||||
return alwaysAllowMcp
|
||||
}
|
||||
|
|
@ -776,9 +799,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
)
|
||||
|
||||
useEffect(() => {
|
||||
// this ensures the first message is not read, future user messages are labelled as user_feedback
|
||||
// This ensures the first message is not read, future user messages are
|
||||
// labeled as `user_feedback`.
|
||||
if (lastMessage && messages.length > 1) {
|
||||
//console.log(JSON.stringify(lastMessage))
|
||||
if (
|
||||
lastMessage.text && // has text
|
||||
(lastMessage.say === "text" || lastMessage.say === "completion_result") && // is a text message
|
||||
|
|
@ -804,9 +827,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
}
|
||||
}
|
||||
|
||||
// Only execute when isStreaming changes from true to false
|
||||
// Only execute when isStreaming changes from true to false.
|
||||
if (wasStreaming && !isStreaming && lastMessage) {
|
||||
// Play appropriate sound based on lastMessage content
|
||||
// Play appropriate sound based on lastMessage content.
|
||||
if (lastMessage.type === "ask") {
|
||||
// Don't play sounds for auto-approved actions
|
||||
if (!isAutoApproved(lastMessage)) {
|
||||
|
|
@ -834,18 +857,21 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
}
|
||||
}
|
||||
}
|
||||
// Update previous value
|
||||
|
||||
// Update previous value.
|
||||
setWasStreaming(isStreaming)
|
||||
}, [isStreaming, lastMessage, wasStreaming, isAutoApproved, messages.length])
|
||||
|
||||
const isBrowserSessionMessage = (message: ClineMessage): boolean => {
|
||||
// which of visible messages are browser session messages, see above
|
||||
// Which of visible messages are browser session messages, see above.
|
||||
if (message.type === "ask") {
|
||||
return ["browser_action_launch"].includes(message.ask!)
|
||||
}
|
||||
|
||||
if (message.type === "say") {
|
||||
return ["api_req_started", "text", "browser_action", "browser_action_result"].includes(message.say!)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -864,20 +890,24 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
|
||||
visibleMessages.forEach((message) => {
|
||||
if (message.ask === "browser_action_launch") {
|
||||
// complete existing browser session if any
|
||||
// Complete existing browser session if any.
|
||||
endBrowserSession()
|
||||
// start new
|
||||
// Start new.
|
||||
isInBrowserSession = true
|
||||
currentGroup.push(message)
|
||||
} else if (isInBrowserSession) {
|
||||
// end session if api_req_started is cancelled
|
||||
// End session if `api_req_started` is cancelled.
|
||||
|
||||
if (message.say === "api_req_started") {
|
||||
// get last api_req_started in currentGroup to check if it's cancelled. If it is then this api req is not part of the current browser session
|
||||
// Get last `api_req_started` in currentGroup to check if
|
||||
// it's cancelled. If it is then this api req is not part
|
||||
// of the current browser session.
|
||||
const lastApiReqStarted = [...currentGroup].reverse().find((m) => m.say === "api_req_started")
|
||||
|
||||
if (lastApiReqStarted?.text !== null && lastApiReqStarted?.text !== undefined) {
|
||||
const info = JSON.parse(lastApiReqStarted.text)
|
||||
const isCancelled = info.cancelReason !== null && info.cancelReason !== undefined
|
||||
|
||||
if (isCancelled) {
|
||||
endBrowserSession()
|
||||
result.push(message)
|
||||
|
|
@ -918,27 +948,20 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
|
||||
const scrollToBottomSmooth = useMemo(
|
||||
() =>
|
||||
debounce(
|
||||
() => {
|
||||
virtuosoRef.current?.scrollTo({
|
||||
top: Number.MAX_SAFE_INTEGER,
|
||||
behavior: "smooth",
|
||||
})
|
||||
},
|
||||
10,
|
||||
{ immediate: true },
|
||||
),
|
||||
debounce(() => virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "smooth" }), 10, {
|
||||
immediate: true,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
const scrollToBottomAuto = useCallback(() => {
|
||||
virtuosoRef.current?.scrollTo({
|
||||
top: Number.MAX_SAFE_INTEGER,
|
||||
behavior: "auto", // instant causes crash
|
||||
behavior: "auto", // Instant causes crash.
|
||||
})
|
||||
}, [])
|
||||
|
||||
// scroll when user toggles certain rows
|
||||
// Scroll when user toggles certain rows.
|
||||
const toggleRowExpansion = useCallback(
|
||||
(ts: number) => {
|
||||
const isCollapsing = expandedRows[ts] ?? false
|
||||
|
|
@ -955,29 +978,23 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
lastGroup?.say === "api_req_started" &&
|
||||
!expandedRows[lastGroup.ts]
|
||||
|
||||
setExpandedRows((prev) => ({
|
||||
...prev,
|
||||
[ts]: !prev[ts],
|
||||
}))
|
||||
setExpandedRows((prev) => ({ ...prev, [ts]: !prev[ts] }))
|
||||
|
||||
// disable auto scroll when user expands row
|
||||
// Disable auto scroll when user expands row
|
||||
if (!isCollapsing) {
|
||||
disableAutoScrollRef.current = true
|
||||
}
|
||||
|
||||
if (isCollapsing && isAtBottom) {
|
||||
const timer = setTimeout(() => {
|
||||
scrollToBottomAuto()
|
||||
}, 0)
|
||||
const timer = setTimeout(() => scrollToBottomAuto(), 0)
|
||||
return () => clearTimeout(timer)
|
||||
} else if (isLast || isSecondToLast) {
|
||||
if (isCollapsing) {
|
||||
if (isSecondToLast && !isLastCollapsedApiReq) {
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
scrollToBottomAuto()
|
||||
}, 0)
|
||||
|
||||
const timer = setTimeout(() => scrollToBottomAuto(), 0)
|
||||
return () => clearTimeout(timer)
|
||||
} else {
|
||||
const timer = setTimeout(() => {
|
||||
|
|
@ -986,6 +1003,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
align: "start",
|
||||
})
|
||||
}, 0)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
|
@ -999,9 +1017,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
if (isTaller) {
|
||||
scrollToBottomSmooth()
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
scrollToBottomAuto()
|
||||
}, 0)
|
||||
setTimeout(() => scrollToBottomAuto(), 0)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -1010,22 +1026,23 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
|
||||
useEffect(() => {
|
||||
if (!disableAutoScrollRef.current) {
|
||||
setTimeout(() => {
|
||||
scrollToBottomSmooth()
|
||||
}, 50)
|
||||
// return () => clearTimeout(timer) // dont cleanup since if visibleMessages.length changes it cancels.
|
||||
setTimeout(() => scrollToBottomSmooth(), 50)
|
||||
// Don't cleanup since if visibleMessages.length changes it cancels.
|
||||
// return () => clearTimeout(timer)
|
||||
}
|
||||
}, [groupedMessages.length, scrollToBottomSmooth])
|
||||
|
||||
const handleWheel = useCallback((event: Event) => {
|
||||
const wheelEvent = event as WheelEvent
|
||||
|
||||
if (wheelEvent.deltaY && wheelEvent.deltaY < 0) {
|
||||
if (scrollContainerRef.current?.contains(wheelEvent.target as Node)) {
|
||||
// user scrolled up
|
||||
// User scrolled up
|
||||
disableAutoScrollRef.current = true
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEvent("wheel", handleWheel, window, { passive: true }) // passive improves scrolling performance
|
||||
|
||||
// Effect to handle showing the checkpoint warning after a delay
|
||||
|
|
@ -1047,40 +1064,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
}
|
||||
}, [modifiedMessages.length, isStreaming])
|
||||
|
||||
// Checkpoint warning component
|
||||
const CheckpointWarningMessage = useCallback(
|
||||
() => (
|
||||
<div className="flex items-center p-3 my-3 bg-vscode-inputValidation-warningBackground border border-vscode-inputValidation-warningBorder rounded">
|
||||
<span className="codicon codicon-loading codicon-modifier-spin mr-2" />
|
||||
<span className="text-vscode-foreground">
|
||||
<Trans
|
||||
i18nKey="chat:checkpoint.initializingWarning"
|
||||
components={{
|
||||
settingsLink: (
|
||||
<VSCodeLink
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
window.postMessage(
|
||||
{
|
||||
type: "action",
|
||||
action: "settingsButtonClicked",
|
||||
values: { section: "checkpoints" },
|
||||
},
|
||||
"*",
|
||||
)
|
||||
}}
|
||||
className="inline px-0.5"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
const placeholderText = task ? t("chat:typeMessage") : t("chat:typeTask")
|
||||
|
||||
const itemContent = useCallback(
|
||||
|
|
@ -1142,15 +1125,18 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
)
|
||||
|
||||
useEffect(() => {
|
||||
// Only proceed if we have an ask and buttons are enabled
|
||||
if (!clineAsk || !enableButtons) return
|
||||
// Only proceed if we have an ask and buttons are enabled.
|
||||
if (!clineAsk || !enableButtons) {
|
||||
return
|
||||
}
|
||||
|
||||
const autoApprove = async () => {
|
||||
if (isAutoApproved(lastMessage)) {
|
||||
// Add delay for write operations
|
||||
// Add delay for write operations.
|
||||
if (lastMessage?.ask === "tool" && isWriteToolAction(lastMessage)) {
|
||||
await new Promise((resolve) => setTimeout(resolve, writeDelayMs))
|
||||
}
|
||||
|
||||
handlePrimaryButtonClick()
|
||||
}
|
||||
}
|
||||
|
|
@ -1235,17 +1221,15 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
onClose={handleTaskCloseButtonClick}
|
||||
/>
|
||||
|
||||
{/* System prompt override warning */}
|
||||
{hasSystemPromptOverride && (
|
||||
<div className="px-3">
|
||||
<SystemPromptWarning />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Checkpoint warning message */}
|
||||
{showCheckpointWarning && (
|
||||
<div className="px-3">
|
||||
<CheckpointWarningMessage />
|
||||
<CheckpointWarning />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
34
webview-ui/src/components/chat/CheckpointWarning.tsx
Normal file
34
webview-ui/src/components/chat/CheckpointWarning.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { Trans } from "react-i18next"
|
||||
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
|
||||
export const CheckpointWarning = () => {
|
||||
return (
|
||||
<div className="flex items-center p-3 my-3 bg-vscode-inputValidation-warningBackground border border-vscode-inputValidation-warningBorder rounded">
|
||||
<span className="codicon codicon-loading codicon-modifier-spin mr-2" />
|
||||
<span className="text-vscode-foreground">
|
||||
<Trans
|
||||
i18nKey="chat:checkpoint.initializingWarning"
|
||||
components={{
|
||||
settingsLink: (
|
||||
<VSCodeLink
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
window.postMessage(
|
||||
{
|
||||
type: "action",
|
||||
action: "settingsButtonClicked",
|
||||
values: { section: "checkpoints" },
|
||||
},
|
||||
"*",
|
||||
)
|
||||
}}
|
||||
className="inline px-0.5"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,45 +1,118 @@
|
|||
import { HTMLAttributes, forwardRef, useMemo, useState } from "react"
|
||||
import { HTMLAttributes, forwardRef, useCallback, useMemo, useState } from "react"
|
||||
import { useEvent } from "react-use"
|
||||
import { Virtuoso } from "react-virtuoso"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
import { ChevronDown, Skull } from "lucide-react"
|
||||
|
||||
import { CommandExecutionStatus, commandExecutionStatusSchema } from "@roo/schemas"
|
||||
import { ExtensionMessage } from "@roo/shared/ExtensionMessage"
|
||||
import { safeJsonParse } from "@roo/shared/safeJsonParse"
|
||||
|
||||
import { vscode } from "@src/utils/vscode"
|
||||
import { useExtensionState } from "@src/context/ExtensionStateContext"
|
||||
import { cn } from "@src/lib/utils"
|
||||
import { Button } from "@src/components/ui"
|
||||
|
||||
interface CommandExecutionProps {
|
||||
executionId?: string
|
||||
command: string
|
||||
output: string
|
||||
}
|
||||
|
||||
export const CommandExecution = forwardRef<HTMLDivElement, CommandExecutionProps>(({ command, output }, ref) => {
|
||||
const { terminalShellIntegrationDisabled = false } = useExtensionState()
|
||||
export const CommandExecution = forwardRef<HTMLDivElement, CommandExecutionProps>(
|
||||
({ executionId, command, output }, ref) => {
|
||||
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)
|
||||
// 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 lines = useMemo(() => output.split("\n"), [output])
|
||||
const [status, setStatus] = useState<CommandExecutionStatus | null>(null)
|
||||
|
||||
return (
|
||||
<div ref={ref} className="w-full p-2 rounded-xs bg-vscode-editor-background">
|
||||
<div
|
||||
className={cn("flex flex-row justify-between cursor-pointer active:opacity-75", {
|
||||
"opacity-50": isExpanded,
|
||||
})}
|
||||
onClick={() => setIsExpanded(!isExpanded)}>
|
||||
<Line>{command}</Line>
|
||||
<ChevronDown className={cn("size-4 transition-transform duration-300", { "rotate-180": isExpanded })} />
|
||||
const lines = useMemo(() => output.split("\n").filter((line) => line.trim() !== ""), [output])
|
||||
|
||||
const onMessage = useCallback(
|
||||
(event: MessageEvent) => {
|
||||
if (!executionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const message: ExtensionMessage = event.data
|
||||
|
||||
if (message.type === "commandExecutionStatus") {
|
||||
const result = commandExecutionStatusSchema.safeParse(safeJsonParse(message.text, {}))
|
||||
|
||||
if (result.success) {
|
||||
if (result.data.executionId !== executionId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (result.data.status === "fallback") {
|
||||
setIsExpanded(true)
|
||||
} else {
|
||||
setStatus(result.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[executionId],
|
||||
)
|
||||
|
||||
useEvent("message", onMessage)
|
||||
|
||||
return (
|
||||
<div ref={ref} className="w-full p-2 rounded-xs bg-vscode-editor-background">
|
||||
<div className="flex flex-row justify-between">
|
||||
<Line>{command}</Line>
|
||||
<div>
|
||||
{status?.status === "running" && (
|
||||
<div className="flex flex-row items-center gap-2 shrink-0 font-mono text-sm">
|
||||
<div className="rounded-full size-1.5 bg-lime-400" />
|
||||
<div>Running</div>
|
||||
{status.pid && <div>(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 shrink-0 font-mono text-sm">
|
||||
<div className="rounded-full size-1.5 bg-red-400" />
|
||||
<div>Exited ({status.exitCode})</div>
|
||||
</div>
|
||||
)}
|
||||
{lines.length > 0 && (
|
||||
<div className="flex flex-row items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setIsExpanded(!isExpanded)}>
|
||||
<div>Output</div>
|
||||
<ChevronDown
|
||||
className={cn("size-4 transition-transform duration-300", {
|
||||
"rotate-180": isExpanded,
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn("h-[200px] mt-1 pt-1 border-t border-border/25", { hidden: !isExpanded })}>
|
||||
{lines.length > 0 && (
|
||||
<Virtuoso
|
||||
className="h-full"
|
||||
totalCount={lines.length}
|
||||
itemContent={(i) => <Line className="text-sm">{lines[i]}</Line>}
|
||||
followOutput="auto"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn("h-[200px]", { hidden: !isExpanded })}>
|
||||
<Virtuoso
|
||||
className="h-full mt-2"
|
||||
totalCount={lines.length}
|
||||
itemContent={(i) => <Line>{lines[i]}</Line>}
|
||||
followOutput="auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
type LineProps = HTMLAttributes<HTMLDivElement>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
// npx jest src/components/chat/__tests__/CommandExecution.test.tsx
|
||||
|
||||
import React from "react"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
|
||||
import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
|
||||
|
||||
import { CommandExecution } from "../CommandExecution"
|
||||
|
||||
jest.mock("@src/lib/utils", () => ({
|
||||
cn: (...inputs: any[]) => inputs.filter(Boolean).join(" "),
|
||||
}))
|
||||
|
||||
jest.mock("lucide-react", () => ({
|
||||
ChevronDown: () => <div data-testid="chevron-down">ChevronDown</div>,
|
||||
}))
|
||||
|
||||
jest.mock("react-virtuoso", () => ({
|
||||
Virtuoso: React.forwardRef(({ totalCount, itemContent }: any, ref: any) => (
|
||||
<div ref={ref} data-testid="virtuoso-container">
|
||||
{Array.from({ length: totalCount }).map((_, index) => (
|
||||
<div key={index} data-testid={`virtuoso-item-${index}`}>
|
||||
{itemContent(index)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)),
|
||||
VirtuosoHandle: jest.fn(),
|
||||
}))
|
||||
|
||||
describe("CommandExecution", () => {
|
||||
const renderComponent = (command: string, output: string) => {
|
||||
return render(
|
||||
<ExtensionStateContextProvider>
|
||||
<CommandExecution command={command} output={output} />
|
||||
</ExtensionStateContextProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
it("renders command output with virtualized list", () => {
|
||||
const testOutput = "Line 1\nLine 2\nLine 3"
|
||||
renderComponent("ls", testOutput)
|
||||
expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument()
|
||||
expect(screen.getByText("Line 1")).toBeInTheDocument()
|
||||
expect(screen.getByText("Line 2")).toBeInTheDocument()
|
||||
expect(screen.getByText("Line 3")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("handles empty output", () => {
|
||||
renderComponent("ls", "")
|
||||
expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument()
|
||||
expect(screen.getByTestId("virtuoso-item-0")).toBeInTheDocument()
|
||||
expect(screen.queryByTestId("virtuoso-item-1")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("handles large output", () => {
|
||||
const largeOutput = Array.from({ length: 1000 }, (_, i) => `Line ${i + 1}`).join("\n")
|
||||
renderComponent("ls", largeOutput)
|
||||
expect(screen.getByTestId("virtuoso-container")).toBeInTheDocument()
|
||||
expect(screen.getByText("Line 1")).toBeInTheDocument()
|
||||
expect(screen.getByText("Line 1000")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -117,7 +117,6 @@ const CodeAccordian = ({
|
|||
)}
|
||||
{(!(path || isFeedback || isConsoleLogs) || isExpanded) && (
|
||||
<div
|
||||
//className="code-block-scrollable" this doesn't seem to be necessary anymore, on silicon macs it shows the native mac scrollbar instead of the vscode styled one
|
||||
style={{
|
||||
overflowX: "auto",
|
||||
overflowY: "hidden",
|
||||
|
|
|
|||
|
|
@ -598,6 +598,10 @@ const CodeBlock = memo(
|
|||
[source, rawSource, copyWithFeedback],
|
||||
)
|
||||
|
||||
if (source?.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeBlockContainer ref={codeBlockRef}>
|
||||
<StyledPre
|
||||
|
|
@ -607,9 +611,7 @@ const CodeBlock = memo(
|
|||
windowshade={windowShade ? "true" : "false"}
|
||||
collapsedHeight={collapsedHeight}
|
||||
onMouseDown={() => updateCodeBlockButtonPosition(true)}
|
||||
onMouseUp={() => updateCodeBlockButtonPosition(false)}
|
||||
// onScroll prop is removed - handled by the useEffect scroll listener now
|
||||
>
|
||||
onMouseUp={() => updateCodeBlockButtonPosition(false)}>
|
||||
<div dangerouslySetInnerHTML={{ __html: highlightedCode }} />
|
||||
</StyledPre>
|
||||
{!isSelecting && (
|
||||
|
|
@ -685,10 +687,7 @@ const CodeBlock = memo(
|
|||
// After UI updates, ensure code block is visible and update button position
|
||||
setTimeout(
|
||||
() => {
|
||||
codeBlock.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
})
|
||||
codeBlock.scrollIntoView({ behavior: "smooth", block: "nearest" })
|
||||
|
||||
// Wait for scroll to complete before updating button position
|
||||
setTimeout(() => {
|
||||
|
|
|
|||
|
|
@ -185,6 +185,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
(value: ApiConfigMeta[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })),
|
||||
[],
|
||||
)
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(event: MessageEvent) => {
|
||||
const message: ExtensionMessage = event.data
|
||||
|
|
@ -352,8 +353,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
|
||||
export const useExtensionState = () => {
|
||||
const context = useContext(ExtensionStateContext)
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error("useExtensionState must be used within an ExtensionStateContextProvider")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue