Skip to content

Commit 27549df

Browse files
committed
Use the Terminal / TerminalProcess paradigm
1 parent a6991f5 commit 27549df

21 files changed

+420
-275
lines changed

src/activate/__tests__/registerCommands.test.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
// npx jest src/activate/__tests__/registerCommands.test.ts
2+
3+
import * as vscode from "vscode"
4+
import { ClineProvider } from "../../core/webview/ClineProvider"
5+
6+
import { getVisibleProviderOrLog } from "../registerCommands"
7+
8+
jest.mock("execa", () => ({
9+
execa: jest.fn(),
10+
}))
11+
112
jest.mock("vscode", () => ({
213
CodeActionKind: {
314
QuickFix: { value: "quickfix" },
@@ -8,12 +19,6 @@ jest.mock("vscode", () => ({
819
},
920
}))
1021

11-
import * as vscode from "vscode"
12-
import { ClineProvider } from "../../core/webview/ClineProvider"
13-
14-
// Import the helper function from the actual file
15-
import { getVisibleProviderOrLog } from "../registerCommands"
16-
1722
jest.mock("../../core/webview/ClineProvider")
1823

1924
describe("getVisibleProviderOrLog", () => {

src/core/__tests__/Cline.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import { ClineProvider } from "../webview/ClineProvider"
1212
import { ApiConfiguration, ModelInfo } from "../../shared/api"
1313
import { ApiStreamChunk } from "../../api/transform/stream"
1414

15+
jest.mock("execa", () => ({
16+
execa: jest.fn(),
17+
}))
18+
1519
// Mock RooIgnoreController
1620
jest.mock("../ignore/RooIgnoreController")
1721

src/core/command-executors/CommandExecutor.ts

Lines changed: 0 additions & 17 deletions
This file was deleted.

src/core/command-executors/ExecaCommandExecutor.ts

Lines changed: 0 additions & 33 deletions
This file was deleted.

src/core/command-executors/VSCodeCommandExecutor.ts

Lines changed: 0 additions & 15 deletions
This file was deleted.

src/core/command-executors/__tests__/ExecaCommandExecutor.test.ts

Lines changed: 0 additions & 30 deletions
This file was deleted.

src/core/mentions/index.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1-
import * as vscode from "vscode"
1+
import fs from "fs/promises"
22
import * as path from "path"
3+
4+
import * as vscode from "vscode"
5+
import { isBinaryFile } from "isbinaryfile"
6+
37
import { openFile } from "../../integrations/misc/open-file"
48
import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
5-
import { mentionRegexGlobal, formatGitSuggestion, type MentionSuggestion } from "../../shared/context-mentions"
6-
import fs from "fs/promises"
9+
import { mentionRegexGlobal } from "../../shared/context-mentions"
710
import { extractTextFromFile } from "../../integrations/misc/extract-text"
8-
import { isBinaryFile } from "isbinaryfile"
911
import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
1012
import { getCommitInfo, getWorkingState } from "../../utils/git"
11-
import { getLatestTerminalOutput } from "../../integrations/terminal/get-latest-output"
1213
import { getWorkspacePath } from "../../utils/path"
1314
import { FileContextTracker } from "../context-tracking/FileContextTracker"
1415

@@ -221,3 +222,50 @@ async function getWorkspaceProblems(cwd: string): Promise<string> {
221222
}
222223
return result
223224
}
225+
226+
/**
227+
* Gets the contents of the active terminal
228+
* @returns The terminal contents as a string
229+
*/
230+
export async function getLatestTerminalOutput(): Promise<string> {
231+
// Store original clipboard content to restore later
232+
const originalClipboard = await vscode.env.clipboard.readText()
233+
234+
try {
235+
// Select terminal content
236+
await vscode.commands.executeCommand("workbench.action.terminal.selectAll")
237+
238+
// Copy selection to clipboard
239+
await vscode.commands.executeCommand("workbench.action.terminal.copySelection")
240+
241+
// Clear the selection
242+
await vscode.commands.executeCommand("workbench.action.terminal.clearSelection")
243+
244+
// Get terminal contents from clipboard
245+
let terminalContents = (await vscode.env.clipboard.readText()).trim()
246+
247+
// Check if there's actually a terminal open
248+
if (terminalContents === originalClipboard) {
249+
return ""
250+
}
251+
252+
// Clean up command separation
253+
const lines = terminalContents.split("\n")
254+
const lastLine = lines.pop()?.trim()
255+
256+
if (lastLine) {
257+
let i = lines.length - 1
258+
259+
while (i >= 0 && !lines[i].trim().startsWith(lastLine)) {
260+
i--
261+
}
262+
263+
terminalContents = lines.slice(Math.max(i, 0)).join("\n")
264+
}
265+
266+
return terminalContents
267+
} finally {
268+
// Restore original clipboard content
269+
await vscode.env.clipboard.writeText(originalClipboard)
270+
}
271+
}

src/core/tools/__tests__/executeCommandTool.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
// npx jest src/core/tools/__tests__/executeCommandTool.test.ts
22

33
import { describe, expect, it, jest, beforeEach } from "@jest/globals"
4+
45
import { Cline } from "../../Cline"
56
import { formatResponse } from "../../prompts/responses"
67
import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../../shared/tools"
78
import { ToolUsage } from "../../../schemas"
89
import { unescapeHtmlEntities } from "../../../utils/text-normalization"
910

1011
// Mock dependencies
12+
jest.mock("execa", () => ({
13+
execa: jest.fn(),
14+
}))
15+
1116
jest.mock("../../Cline")
1217
jest.mock("../../prompts/responses")
1318

src/core/tools/executeCommandTool.ts

Lines changed: 78 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import { Cline } from "../Cline"
77
import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag, ToolResponse } from "../../shared/tools"
88
import { formatResponse } from "../prompts/responses"
99
import { unescapeHtmlEntities } from "../../utils/text-normalization"
10-
import { Terminal } from "../../integrations/terminal/Terminal"
1110
import { telemetryService } from "../../services/telemetry/TelemetryService"
12-
import { ExitCodeDetails } from "../../integrations/terminal/TerminalProcess"
13-
import { execaCommandExecutor } from "../command-executors/ExecaCommandExecutor"
14-
// import { vsCodeCommandExecutor } from "../command-executors/VSCodeCommandExecutor"
11+
import { ExitCodeDetails, RooTerminalProcess } from "../../integrations/terminal/types"
12+
import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
13+
import { Terminal } from "../../integrations/terminal/Terminal"
14+
import { ExecaTerminal } from "../../integrations/terminal/ExecaTerminal"
1515

1616
export async function executeCommandTool(
1717
cline: Cline,
@@ -73,6 +73,7 @@ export async function executeCommand(
7373
cline: Cline,
7474
command: string,
7575
customCwd?: string,
76+
terminalProvider: "vscode" | "execa" = "vscode",
7677
): Promise<[boolean, ToolResponse]> {
7778
let workingDir: string
7879

@@ -95,62 +96,103 @@ export async function executeCommand(
9596
// a different working directory so that the model will know where the
9697
// command actually executed:
9798
// workingDir = terminalInfo.getCurrentWorkingDirectory()
98-
9999
const workingDirInfo = workingDir ? ` from '${workingDir.toPosix()}'` : ""
100100

101101
let userFeedback: { text?: string; images?: string[] } | undefined
102-
let didContinue = false
102+
let runInBackground: boolean | undefined = undefined
103103
let completed = false
104104
let result: string = ""
105105
let exitDetails: ExitCodeDetails | undefined
106106
const { terminalOutputLineLimit = 500 } = (await cline.providerRef.deref()?.getState()) ?? {}
107107

108-
const commandExecutor = execaCommandExecutor // vsCodeCommandExecutor
108+
const debounceLineLimit = 100 // Flush after this many lines.
109+
const debounceTimeoutMs = 200 // Flush after this much time inactivity (ms).
110+
let buffer: string[] = []
111+
let debounceTimer: NodeJS.Timeout | null = null
109112

110-
await commandExecutor.execute({
111-
command,
112-
cwd: workingDir,
113-
taskId: cline.taskId,
114-
onLine: async (line, process) => {
115-
if (didContinue) {
116-
cline.say("command_output", Terminal.compressTerminalOutput(line, terminalOutputLineLimit))
117-
return
118-
}
113+
async function flush(process?: RooTerminalProcess) {
114+
if (debounceTimer) {
115+
clearTimeout(debounceTimer)
116+
debounceTimer = null
117+
}
118+
119+
if (buffer.length === 0) {
120+
return
121+
}
119122

120-
const { response, text, images } = await cline.ask("command_output", line)
123+
const output = buffer.join("\n")
124+
buffer = []
121125

122-
if (response === "yesButtonClicked") {
123-
// Proceed while running.
126+
result = Terminal.compressTerminalOutput(result + output, terminalOutputLineLimit)
127+
const compressed = Terminal.compressTerminalOutput(output, terminalOutputLineLimit)
128+
cline.say("command_output", compressed)
129+
130+
if (typeof runInBackground !== "undefined") {
131+
return
132+
}
133+
134+
console.log(`ask command_output: waiting for response`)
135+
const { response, text, images } = await cline.ask("command_output", compressed)
136+
console.log(`ask command_output =>`, response)
137+
138+
if (response === "yesButtonClicked") {
139+
runInBackground = false
140+
} else {
141+
runInBackground = true
142+
userFeedback = { text, images }
143+
}
144+
145+
process?.continue()
146+
}
147+
148+
const callbacks = {
149+
onLine: async (line: string, process: RooTerminalProcess) => {
150+
buffer.push(line)
151+
152+
if (buffer.length >= debounceLineLimit) {
153+
await flush(process)
124154
} else {
125-
userFeedback = { text, images }
126-
}
155+
if (debounceTimer) {
156+
clearTimeout(debounceTimer)
157+
}
127158

128-
didContinue = true
129-
process?.continue() // Continue past the await.
130-
},
131-
onStarted: () => {},
132-
onCompleted: (output) => {
133-
result = output ?? ""
134-
completed = true
135-
},
136-
onShellExecutionComplete: (details) => {
137-
exitDetails = details
159+
debounceTimer = setTimeout(() => flush(process), debounceTimeoutMs)
160+
}
138161
},
139-
onNoShellIntegration: async (message) => {
162+
onCompleted: () => (completed = true),
163+
onShellExecutionComplete: (details: ExitCodeDetails) => (exitDetails = details),
164+
onNoShellIntegration: async (message: string) => {
140165
telemetryService.captureShellIntegrationError(cline.taskId)
141166
await cline.say("shell_integration_warning", message)
142167
},
143-
})
168+
}
169+
170+
let terminal: Terminal | ExecaTerminal
144171

145-
// Wait for a short delay to ensure all messages are sent to the webview
172+
if (terminalProvider === "vscode") {
173+
terminal = await TerminalRegistry.getOrCreateTerminal(workingDir, !!workingDir, cline.taskId)
174+
terminal.terminal.show()
175+
} else {
176+
terminal = new ExecaTerminal(workingDir)
177+
}
178+
179+
await terminal.runCommand(command, callbacks)
180+
181+
if (debounceTimer) {
182+
clearTimeout(debounceTimer)
183+
debounceTimer = null
184+
}
185+
186+
// If there are any lines in the buffer, flush them to `result`.
187+
await flush()
188+
189+
// Wait for a short delay to ensure all messages are sent to the webview.
146190
// This delay allows time for non-awaited promises to be created and
147191
// for their associated messages to be sent to the webview, maintaining
148192
// the correct order of messages (although the webview is smart about
149193
// grouping command_output messages despite any gaps anyways).
150194
await delay(50)
151195

152-
result = Terminal.compressTerminalOutput(result, terminalOutputLineLimit)
153-
154196
if (userFeedback) {
155197
await cline.say("user_feedback", userFeedback.text, userFeedback.images)
156198

0 commit comments

Comments
 (0)