diff --git a/.changeset/pretty-peaches-bake.md b/.changeset/pretty-peaches-bake.md new file mode 100644 index 0000000000..046228ea50 --- /dev/null +++ b/.changeset/pretty-peaches-bake.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Split Cline.getEnvironmentDetails out into a standalone function diff --git a/src/core/Cline.ts b/src/core/Cline.ts index c188e22245..f38da0b853 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -33,14 +33,11 @@ import { import { getApiMetrics } from "../shared/getApiMetrics" import { HistoryItem } from "../shared/HistoryItem" import { ClineAskResponse } from "../shared/WebviewMessage" -import { defaultModeSlug, getModeBySlug, getFullModeDetails, isToolAllowedForMode } from "../shared/modes" -import { EXPERIMENT_IDS, experiments as Experiments, ExperimentId } from "../shared/experiments" -import { formatLanguage } from "../shared/language" +import { defaultModeSlug, getModeBySlug } from "../shared/modes" import { ToolParamName, ToolResponse, DiffStrategy } from "../shared/tools" // services import { UrlContentFetcher } from "../services/browser/UrlContentFetcher" -import { listFiles } from "../services/glob/list-files" import { BrowserSession } from "../services/browser/BrowserSession" import { McpHub } from "../services/mcp/McpHub" import { McpServerManager } from "../services/mcp/McpServerManager" @@ -51,12 +48,11 @@ import { CheckpointServiceOptions, RepoPerTaskCheckpointService } from "../servi import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../integrations/editor/DiffViewProvider" import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown" import { RooTerminalProcess } from "../integrations/terminal/types" -import { Terminal } from "../integrations/terminal/Terminal" import { TerminalRegistry } from "../integrations/terminal/TerminalRegistry" // utils import { calculateApiCostAnthropic } from "../utils/cost" -import { arePathsEqual, getWorkspacePath } from "../utils/path" +import { getWorkspacePath } from "../utils/path" // tools import { fetchInstructionsTool } from "./tools/fetchInstructionsTool" @@ -91,6 +87,7 @@ import { ClineProvider } from "./webview/ClineProvider" import { validateToolUse } from "./mode-validator" import { MultiSearchReplaceDiffStrategy } from "./diff/strategies/multi-search-replace" import { readApiMessages, saveApiMessages, readTaskMessages, saveTaskMessages, taskMetadata } from "./task-persistence" +import { getEnvironmentDetails } from "./environment/getEnvironmentDetails" type UserContent = Array @@ -145,7 +142,7 @@ export class Cline extends EventEmitter { private promptCacheKey: string rooIgnoreController?: RooIgnoreController - private fileContextTracker: FileContextTracker + fileContextTracker: FileContextTracker private urlContentFetcher: UrlContentFetcher browserSession: BrowserSession didEditFile: boolean = false @@ -1021,7 +1018,7 @@ export class Cline extends EventEmitter { ) const parsedUserContent = await this.parseUserContent(userContent) - const environmentDetails = await this.getEnvironmentDetails(includeFileDetails) + const environmentDetails = await getEnvironmentDetails(this, includeFileDetails) // Add environment details as its own text block, separate from tool // results. @@ -2077,258 +2074,6 @@ export class Cline extends EventEmitter { ) } - // Environment - - public async getEnvironmentDetails(includeFileDetails: boolean = false) { - let details = "" - - const { terminalOutputLineLimit = 500, maxWorkspaceFiles = 200 } = - (await this.providerRef.deref()?.getState()) ?? {} - - // It could be useful for cline to know if the user went from one or no file to another between messages, so we always include this context - details += "\n\n# VSCode Visible Files" - - const visibleFilePaths = vscode.window.visibleTextEditors - ?.map((editor) => editor.document?.uri?.fsPath) - .filter(Boolean) - .map((absolutePath) => path.relative(this.cwd, absolutePath)) - .slice(0, maxWorkspaceFiles) - - // Filter paths through rooIgnoreController - const allowedVisibleFiles = this.rooIgnoreController - ? this.rooIgnoreController.filterPaths(visibleFilePaths) - : visibleFilePaths.map((p) => p.toPosix()).join("\n") - - if (allowedVisibleFiles) { - details += `\n${allowedVisibleFiles}` - } else { - details += "\n(No visible files)" - } - - details += "\n\n# VSCode Open Tabs" - const { maxOpenTabsContext } = (await this.providerRef.deref()?.getState()) ?? {} - const maxTabs = maxOpenTabsContext ?? 20 - const openTabPaths = vscode.window.tabGroups.all - .flatMap((group) => group.tabs) - .map((tab) => (tab.input as vscode.TabInputText)?.uri?.fsPath) - .filter(Boolean) - .map((absolutePath) => path.relative(this.cwd, absolutePath).toPosix()) - .slice(0, maxTabs) - - // Filter paths through rooIgnoreController - const allowedOpenTabs = this.rooIgnoreController - ? this.rooIgnoreController.filterPaths(openTabPaths) - : openTabPaths.map((p) => p.toPosix()).join("\n") - - if (allowedOpenTabs) { - details += `\n${allowedOpenTabs}` - } else { - details += "\n(No open tabs)" - } - - // Get task-specific and background terminals. - const busyTerminals = [ - ...TerminalRegistry.getTerminals(true, this.taskId), - ...TerminalRegistry.getBackgroundTerminals(true), - ] - - const inactiveTerminals = [ - ...TerminalRegistry.getTerminals(false, this.taskId), - ...TerminalRegistry.getBackgroundTerminals(false), - ] - - if (busyTerminals.length > 0) { - if (this.didEditFile) { - await delay(300) // Delay after saving file to let terminals catch up. - } - - // Wait for terminals to cool down. - await pWaitFor(() => busyTerminals.every((t) => !TerminalRegistry.isProcessHot(t.id)), { - interval: 100, - timeout: 5_000, - }).catch(() => {}) - } - - // Reset, this lets us know when to wait for saved files to update terminals. - this.didEditFile = false - - // Waiting for updated diagnostics lets terminal output be the most - // up-to-date possible. - let terminalDetails = "" - - if (busyTerminals.length > 0) { - // Terminals are cool, let's retrieve their output. - terminalDetails += "\n\n# Actively Running Terminals" - - for (const busyTerminal of busyTerminals) { - terminalDetails += `\n## Original command: \`${busyTerminal.getLastCommand()}\`` - let newOutput = TerminalRegistry.getUnretrievedOutput(busyTerminal.id) - - if (newOutput) { - newOutput = Terminal.compressTerminalOutput(newOutput, terminalOutputLineLimit) - terminalDetails += `\n### New Output\n${newOutput}` - } - } - } - - // First check if any inactive terminals in this task have completed - // processes with output. - const terminalsWithOutput = inactiveTerminals.filter((terminal) => { - const completedProcesses = terminal.getProcessesWithOutput() - return completedProcesses.length > 0 - }) - - // Only add the header if there are terminals with output. - if (terminalsWithOutput.length > 0) { - terminalDetails += "\n\n# Inactive Terminals with Completed Process Output" - - // Process each terminal with output. - for (const inactiveTerminal of terminalsWithOutput) { - let terminalOutputs: string[] = [] - - // Get output from completed processes queue. - const completedProcesses = inactiveTerminal.getProcessesWithOutput() - - for (const process of completedProcesses) { - let output = process.getUnretrievedOutput() - - if (output) { - output = Terminal.compressTerminalOutput(output, terminalOutputLineLimit) - terminalOutputs.push(`Command: \`${process.command}\`\n${output}`) - } - } - - // Clean the queue after retrieving output. - inactiveTerminal.cleanCompletedProcessQueue() - - // Add this terminal's outputs to the details. - if (terminalOutputs.length > 0) { - terminalDetails += `\n## Terminal ${inactiveTerminal.id}` - terminalOutputs.forEach((output) => { - terminalDetails += `\n### New Output\n${output}` - }) - } - } - } - - // console.log(`[Cline#getEnvironmentDetails] terminalDetails: ${terminalDetails}`) - - // Add recently modified files section. - const recentlyModifiedFiles = this.fileContextTracker.getAndClearRecentlyModifiedFiles() - - if (recentlyModifiedFiles.length > 0) { - details += - "\n\n# Recently Modified Files\nThese files have been modified since you last accessed them (file was just edited so you may need to re-read it before editing):" - for (const filePath of recentlyModifiedFiles) { - details += `\n${filePath}` - } - } - - if (terminalDetails) { - details += terminalDetails - } - - // Add current time information with timezone. - const now = new Date() - - const formatter = new Intl.DateTimeFormat(undefined, { - year: "numeric", - month: "numeric", - day: "numeric", - hour: "numeric", - minute: "numeric", - second: "numeric", - hour12: true, - }) - - const timeZone = formatter.resolvedOptions().timeZone - const timeZoneOffset = -now.getTimezoneOffset() / 60 // Convert to hours and invert sign to match conventional notation - const timeZoneOffsetHours = Math.floor(Math.abs(timeZoneOffset)) - const timeZoneOffsetMinutes = Math.abs(Math.round((Math.abs(timeZoneOffset) - timeZoneOffsetHours) * 60)) - const timeZoneOffsetStr = `${timeZoneOffset >= 0 ? "+" : "-"}${timeZoneOffsetHours}:${timeZoneOffsetMinutes.toString().padStart(2, "0")}` - details += `\n\n# Current Time\n${formatter.format(now)} (${timeZone}, UTC${timeZoneOffsetStr})` - - // Add context tokens information. - const { contextTokens, totalCost } = getApiMetrics(this.clineMessages) - const modelInfo = this.api.getModel().info - const contextWindow = modelInfo.contextWindow - - const contextPercentage = - contextTokens && contextWindow ? Math.round((contextTokens / contextWindow) * 100) : undefined - - details += `\n\n# Current Context Size (Tokens)\n${contextTokens ? `${contextTokens.toLocaleString()} (${contextPercentage}%)` : "(Not available)"}` - details += `\n\n# Current Cost\n${totalCost !== null ? `$${totalCost.toFixed(2)}` : "(Not available)"}` - - // Add current mode and any mode-specific warnings. - const { - mode, - customModes, - apiModelId, - customModePrompts, - experiments = {} as Record, - customInstructions: globalCustomInstructions, - language, - } = (await this.providerRef.deref()?.getState()) ?? {} - - const currentMode = mode ?? defaultModeSlug - - const modeDetails = await getFullModeDetails(currentMode, customModes, customModePrompts, { - cwd: this.cwd, - globalCustomInstructions, - language: language ?? formatLanguage(vscode.env.language), - }) - - details += `\n\n# Current Mode\n` - details += `${currentMode}\n` - details += `${modeDetails.name}\n` - details += `${apiModelId}\n` - - if (Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.POWER_STEERING)) { - details += `${modeDetails.roleDefinition}\n` - - if (modeDetails.customInstructions) { - details += `${modeDetails.customInstructions}\n` - } - } - - // Add warning if not in code mode. - if ( - !isToolAllowedForMode("write_to_file", currentMode, customModes ?? [], { apply_diff: this.diffEnabled }) && - !isToolAllowedForMode("apply_diff", currentMode, customModes ?? [], { apply_diff: this.diffEnabled }) - ) { - const currentModeName = getModeBySlug(currentMode, customModes)?.name ?? currentMode - const defaultModeName = getModeBySlug(defaultModeSlug, customModes)?.name ?? defaultModeSlug - details += `\n\nNOTE: You are currently in '${currentModeName}' mode, which does not allow write operations. To write files, the user will need to switch to a mode that supports file writing, such as '${defaultModeName}' mode.` - } - - if (includeFileDetails) { - details += `\n\n# Current Workspace Directory (${this.cwd.toPosix()}) Files\n` - const isDesktop = arePathsEqual(this.cwd, path.join(os.homedir(), "Desktop")) - - if (isDesktop) { - // Don't want to immediately access desktop since it would show - // permission popup. - details += "(Desktop files not shown automatically. Use list_files to explore if needed.)" - } else { - const maxFiles = maxWorkspaceFiles ?? 200 - const [files, didHitLimit] = await listFiles(this.cwd, true, maxFiles) - const { showRooIgnoredFiles = true } = (await this.providerRef.deref()?.getState()) ?? {} - - const result = formatResponse.formatFilesList( - this.cwd, - files, - didHitLimit, - this.rooIgnoreController, - showRooIgnoredFiles, - ) - - details += result - } - } - - return `\n${details.trim()}\n` - } - // Checkpoints private getCheckpointService() { diff --git a/src/core/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts index 3de198acb3..14540c834c 100644 --- a/src/core/__tests__/Cline.test.ts +++ b/src/core/__tests__/Cline.test.ts @@ -13,6 +13,10 @@ import { ApiConfiguration, ModelInfo } from "../../shared/api" import { ApiStreamChunk } from "../../api/transform/stream" import { ContextProxy } from "../config/ContextProxy" +jest.mock("../environment/getEnvironmentDetails", () => ({ + getEnvironmentDetails: jest.fn().mockResolvedValue(""), +})) + jest.mock("execa", () => ({ execa: jest.fn(), })) @@ -316,90 +320,7 @@ describe("Cline", () => { }) describe("getEnvironmentDetails", () => { - let originalDate: DateConstructor - let mockDate: Date - - beforeEach(() => { - originalDate = global.Date - const fixedTime = new Date("2024-01-01T12:00:00Z") - mockDate = new Date(fixedTime) - mockDate.getTimezoneOffset = jest.fn().mockReturnValue(420) // UTC-7 - - class MockDate extends Date { - constructor() { - super() - return mockDate - } - static override now() { - return mockDate.getTime() - } - } - - global.Date = MockDate as DateConstructor - - // Create a proper mock of Intl.DateTimeFormat - const mockDateTimeFormat = { - resolvedOptions: () => ({ - timeZone: "America/Los_Angeles", - }), - format: () => "1/1/2024, 5:00:00 AM", - } - - const MockDateTimeFormat = function (this: any) { - return mockDateTimeFormat - } as any - - MockDateTimeFormat.prototype = mockDateTimeFormat - MockDateTimeFormat.supportedLocalesOf = jest.fn().mockReturnValue(["en-US"]) - - global.Intl.DateTimeFormat = MockDateTimeFormat - }) - - afterEach(() => { - global.Date = originalDate - }) - - it("should include timezone information in environment details", async () => { - const cline = new Cline({ - provider: mockProvider, - apiConfiguration: mockApiConfig, - task: "test task", - startTask: false, - }) - - const details = await cline["getEnvironmentDetails"](false) - - // Verify timezone information is present and formatted correctly. - expect(details).toContain("America/Los_Angeles") - expect(details).toMatch(/UTC-7:00/) // Fixed offset for America/Los_Angeles. - expect(details).toContain("# Current Time") - expect(details).toMatch(/1\/1\/2024.*5:00:00 AM.*\(America\/Los_Angeles, UTC-7:00\)/) // Full time string format. - }) - describe("API conversation handling", () => { - /** - * Mock environment details retrieval to avoid filesystem access in tests - * - * This setup: - * 1. Prevents file listing operations that might cause test instability - * 2. Preserves test-specific mocks when they exist (via _mockGetEnvironmentDetails) - * 3. Provides a stable, empty environment by default - */ - beforeEach(() => { - // Mock the method with a stable implementation - jest.spyOn(Cline.prototype, "getEnvironmentDetails").mockImplementation( - // Use 'any' type to allow for dynamic test properties - async function (this: any, _verbose: boolean = false): Promise { - // Use test-specific mock if available - if (this._mockGetEnvironmentDetails) { - return this._mockGetEnvironmentDetails() - } - // Default to empty environment details for stability - return "" - }, - ) - }) - it("should clean conversation history before sending to API", async () => { // Cline.create will now use our mocked getEnvironmentDetails const [cline, task] = Cline.create({ @@ -420,12 +341,6 @@ describe("Cline", () => { const cleanMessageSpy = jest.fn().mockReturnValue(mockStreamForClean) jest.spyOn(cline.api, "createMessage").mockImplementation(cleanMessageSpy) - // Mock getEnvironmentDetails to return empty details. - jest.spyOn(cline as any, "getEnvironmentDetails").mockResolvedValue("") - - // Mock loadContext to return unmodified content. - jest.spyOn(cline as any, "parseUserContent").mockImplementation(async (content) => [content, ""]) - // Add test message to conversation history. cline.apiConversationHistory = [ { @@ -574,12 +489,6 @@ describe("Cline", () => { configurable: true, }) - // Mock environment details and context loading - jest.spyOn(clineWithImages as any, "getEnvironmentDetails").mockResolvedValue("") - jest.spyOn(clineWithoutImages as any, "getEnvironmentDetails").mockResolvedValue("") - jest.spyOn(clineWithImages as any, "parseUserContent").mockImplementation(async (content) => content) - jest.spyOn(clineWithoutImages as any, "parseUserContent").mockImplementation(async (content) => content) - // Set up mock streams const mockStreamWithImages = (async function* () { yield { type: "text", text: "test response" } diff --git a/src/core/environment/__tests__/getEnvironmentDetails.test.ts b/src/core/environment/__tests__/getEnvironmentDetails.test.ts new file mode 100644 index 0000000000..730055c3c1 --- /dev/null +++ b/src/core/environment/__tests__/getEnvironmentDetails.test.ts @@ -0,0 +1,316 @@ +// npx jest src/core/environment/__tests__/getEnvironmentDetails.test.ts + +import pWaitFor from "p-wait-for" +import delay from "delay" + +import { getEnvironmentDetails } from "../getEnvironmentDetails" +import { EXPERIMENT_IDS, experiments } from "../../../shared/experiments" +import { defaultModeSlug, getFullModeDetails, getModeBySlug, isToolAllowedForMode } from "../../../shared/modes" +import { getApiMetrics } from "../../../shared/getApiMetrics" +import { listFiles } from "../../../services/glob/list-files" +import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry" +import { Terminal } from "../../../integrations/terminal/Terminal" +import { arePathsEqual } from "../../../utils/path" +import { FileContextTracker } from "../../context-tracking/FileContextTracker" +import { ApiHandler } from "../../../api/index" +import { ClineProvider } from "../../webview/ClineProvider" +import { RooIgnoreController } from "../../ignore/RooIgnoreController" +import { formatResponse } from "../../prompts/responses" +import { Cline } from "../../Cline" + +jest.mock("vscode", () => ({ + window: { + tabGroups: { all: [], onDidChangeTabs: jest.fn() }, + visibleTextEditors: [], + }, + env: { + language: "en-US", + }, +})) + +jest.mock("p-wait-for") + +jest.mock("delay") + +jest.mock("execa", () => ({ + execa: jest.fn(), +})) + +jest.mock("../../../shared/experiments") +jest.mock("../../../shared/modes") +jest.mock("../../../shared/getApiMetrics") +jest.mock("../../../services/glob/list-files") +jest.mock("../../../integrations/terminal/TerminalRegistry") +jest.mock("../../../integrations/terminal/Terminal") +jest.mock("../../../utils/path") +jest.mock("../../prompts/responses") + +describe("getEnvironmentDetails", () => { + const mockCwd = "/test/path" + const mockTaskId = "test-task-id" + + type MockTerminal = { + id: string + getLastCommand: jest.Mock + getProcessesWithOutput: jest.Mock + cleanCompletedProcessQueue?: jest.Mock + } + + let mockCline: Partial + let mockProvider: any + let mockState: any + + beforeEach(() => { + jest.clearAllMocks() + + mockState = { + terminalOutputLineLimit: 100, + maxWorkspaceFiles: 50, + maxOpenTabsContext: 10, + mode: "code", + customModes: [], + apiModelId: "test-model", + experiments: {}, + customInstructions: "test instructions", + language: "en", + showRooIgnoredFiles: true, + } + + mockProvider = { + getState: jest.fn().mockResolvedValue(mockState), + } + + mockCline = { + cwd: mockCwd, + taskId: mockTaskId, + didEditFile: false, + fileContextTracker: { + getAndClearRecentlyModifiedFiles: jest.fn().mockReturnValue([]), + } as unknown as FileContextTracker, + rooIgnoreController: { + filterPaths: jest.fn((paths: string[]) => paths.join("\n")), + cwd: mockCwd, + ignoreInstance: {}, + disposables: [], + rooIgnoreContent: "", + isPathIgnored: jest.fn(), + getIgnoreContent: jest.fn(), + updateIgnoreContent: jest.fn(), + addToIgnore: jest.fn(), + removeFromIgnore: jest.fn(), + dispose: jest.fn(), + } as unknown as RooIgnoreController, + clineMessages: [], + api: { + getModel: jest.fn().mockReturnValue({ info: { contextWindow: 100000 } }), + createMessage: jest.fn(), + countTokens: jest.fn(), + } as unknown as ApiHandler, + diffEnabled: true, + providerRef: { + deref: jest.fn().mockReturnValue(mockProvider), + [Symbol.toStringTag]: "WeakRef", + } as unknown as WeakRef, + } + + // Mock other dependencies. + ;(getApiMetrics as jest.Mock).mockReturnValue({ contextTokens: 50000, totalCost: 0.25 }) + ;(getFullModeDetails as jest.Mock).mockResolvedValue({ + name: "💻 Code", + roleDefinition: "You are a code assistant", + customInstructions: "Custom instructions", + }) + ;(isToolAllowedForMode as jest.Mock).mockReturnValue(true) + ;(listFiles as jest.Mock).mockResolvedValue([["file1.ts", "file2.ts"], false]) + ;(formatResponse.formatFilesList as jest.Mock).mockReturnValue("file1.ts\nfile2.ts") + ;(arePathsEqual as jest.Mock).mockReturnValue(false) + ;(Terminal.compressTerminalOutput as jest.Mock).mockImplementation((output: string) => output) + ;(TerminalRegistry.getTerminals as jest.Mock).mockReturnValue([]) + ;(TerminalRegistry.getBackgroundTerminals as jest.Mock).mockReturnValue([]) + ;(TerminalRegistry.isProcessHot as jest.Mock).mockReturnValue(false) + ;(TerminalRegistry.getUnretrievedOutput as jest.Mock).mockReturnValue("") + ;(pWaitFor as unknown as jest.Mock).mockResolvedValue(undefined) + ;(delay as jest.Mock).mockResolvedValue(undefined) + }) + + it("should return basic environment details", async () => { + const result = await getEnvironmentDetails(mockCline as Cline) + + expect(result).toContain("") + expect(result).toContain("") + expect(result).toContain("# VSCode Visible Files") + expect(result).toContain("# VSCode Open Tabs") + expect(result).toContain("# Current Time") + expect(result).toContain("# Current Context Size (Tokens)") + expect(result).toContain("# Current Cost") + expect(result).toContain("# Current Mode") + + expect(mockProvider.getState).toHaveBeenCalled() + + expect(getFullModeDetails).toHaveBeenCalledWith("code", [], undefined, { + cwd: mockCwd, + globalCustomInstructions: "test instructions", + language: "en", + }) + + expect(getApiMetrics).toHaveBeenCalledWith(mockCline.clineMessages) + }) + + it("should include file details when includeFileDetails is true", async () => { + const result = await getEnvironmentDetails(mockCline as Cline, true) + expect(result).toContain("# Current Workspace Directory") + expect(result).toContain("Files") + + expect(listFiles).toHaveBeenCalledWith(mockCwd, true, 50) + + expect(formatResponse.formatFilesList).toHaveBeenCalledWith( + mockCwd, + ["file1.ts", "file2.ts"], + false, + mockCline.rooIgnoreController, + true, + ) + }) + + it("should not include file details when includeFileDetails is false", async () => { + await getEnvironmentDetails(mockCline as Cline, false) + expect(listFiles).not.toHaveBeenCalled() + expect(formatResponse.formatFilesList).not.toHaveBeenCalled() + }) + + it("should handle desktop directory specially", async () => { + ;(arePathsEqual as jest.Mock).mockReturnValue(true) + const result = await getEnvironmentDetails(mockCline as Cline, true) + expect(result).toContain("Desktop files not shown automatically") + expect(listFiles).not.toHaveBeenCalled() + }) + + it("should include recently modified files if any", async () => { + ;(mockCline.fileContextTracker!.getAndClearRecentlyModifiedFiles as jest.Mock).mockReturnValue([ + "modified1.ts", + "modified2.ts", + ]) + + const result = await getEnvironmentDetails(mockCline as Cline) + + expect(result).toContain("# Recently Modified Files") + expect(result).toContain("modified1.ts") + expect(result).toContain("modified2.ts") + }) + + it("should include active terminal information", async () => { + const mockActiveTerminal = { + id: "terminal-1", + getLastCommand: jest.fn().mockReturnValue("npm test"), + getProcessesWithOutput: jest.fn().mockReturnValue([]), + } as MockTerminal + + ;(TerminalRegistry.getTerminals as jest.Mock).mockReturnValue([mockActiveTerminal]) + ;(TerminalRegistry.getUnretrievedOutput as jest.Mock).mockReturnValue("Test output") + + const result = await getEnvironmentDetails(mockCline as Cline) + + expect(result).toContain("# Actively Running Terminals") + expect(result).toContain("Original command: `npm test`") + expect(result).toContain("Test output") + + mockCline.didEditFile = true + await getEnvironmentDetails(mockCline as Cline) + expect(delay).toHaveBeenCalledWith(300) + + expect(pWaitFor).toHaveBeenCalled() + }) + + it("should include inactive terminals with output", async () => { + const mockProcess = { + command: "npm build", + getUnretrievedOutput: jest.fn().mockReturnValue("Build output"), + } + + const mockInactiveTerminal = { + id: "terminal-2", + getProcessesWithOutput: jest.fn().mockReturnValue([mockProcess]), + cleanCompletedProcessQueue: jest.fn(), + } as MockTerminal + + ;(TerminalRegistry.getTerminals as jest.Mock).mockImplementation((active: boolean) => + active ? [] : [mockInactiveTerminal], + ) + + const result = await getEnvironmentDetails(mockCline as Cline) + + expect(result).toContain("# Inactive Terminals with Completed Process Output") + expect(result).toContain("Terminal terminal-2") + expect(result).toContain("Command: `npm build`") + expect(result).toContain("Build output") + + expect(mockInactiveTerminal.cleanCompletedProcessQueue).toHaveBeenCalled() + }) + + it("should include warning when file writing is not allowed", async () => { + ;(isToolAllowedForMode as jest.Mock).mockReturnValue(false) + ;(getModeBySlug as jest.Mock).mockImplementation((slug: string) => { + if (slug === "code") { + return { name: "💻 Code" } + } + + if (slug === defaultModeSlug) { + return { name: "Default Mode" } + } + + return null + }) + + const result = await getEnvironmentDetails(mockCline as Cline) + + expect(result).toContain("NOTE: You are currently in '💻 Code' mode, which does not allow write operations") + }) + + it("should include experiment-specific details when Power Steering is enabled", async () => { + mockState.experiments = { [EXPERIMENT_IDS.POWER_STEERING]: true } + ;(experiments.isEnabled as jest.Mock).mockReturnValue(true) + + const result = await getEnvironmentDetails(mockCline as Cline) + + expect(result).toContain("You are a code assistant") + expect(result).toContain("Custom instructions") + }) + + it("should handle missing provider or state", async () => { + // Mock provider to return null. + mockCline.providerRef!.deref = jest.fn().mockReturnValue(null) + + const result = await getEnvironmentDetails(mockCline as Cline) + + // Verify the function still returns a result. + expect(result).toContain("") + expect(result).toContain("") + + // Mock provider to return null state. + mockCline.providerRef!.deref = jest.fn().mockReturnValue({ + getState: jest.fn().mockResolvedValue(null), + }) + + const result2 = await getEnvironmentDetails(mockCline as Cline) + + // Verify the function still returns a result. + expect(result2).toContain("") + expect(result2).toContain("") + }) + + it("should handle errors gracefully", async () => { + ;(pWaitFor as unknown as jest.Mock).mockRejectedValue(new Error("Test error")) + + const mockErrorTerminal = { + id: "terminal-1", + getLastCommand: jest.fn().mockReturnValue("npm test"), + getProcessesWithOutput: jest.fn().mockReturnValue([]), + } as MockTerminal + + ;(TerminalRegistry.getTerminals as jest.Mock).mockReturnValue([mockErrorTerminal]) + ;(TerminalRegistry.getBackgroundTerminals as jest.Mock).mockReturnValue([]) + ;(mockCline.fileContextTracker!.getAndClearRecentlyModifiedFiles as jest.Mock).mockReturnValue([]) + + await expect(getEnvironmentDetails(mockCline as Cline)).resolves.not.toThrow() + }) +}) diff --git a/src/core/environment/getEnvironmentDetails.ts b/src/core/environment/getEnvironmentDetails.ts new file mode 100644 index 0000000000..bb662cf9e3 --- /dev/null +++ b/src/core/environment/getEnvironmentDetails.ts @@ -0,0 +1,270 @@ +import path from "path" +import os from "os" + +import * as vscode from "vscode" +import pWaitFor from "p-wait-for" +import delay from "delay" + +import { EXPERIMENT_IDS, experiments as Experiments, ExperimentId } from "../../shared/experiments" +import { formatLanguage } from "../../shared/language" +import { defaultModeSlug, getFullModeDetails, getModeBySlug, isToolAllowedForMode } from "../../shared/modes" +import { getApiMetrics } from "../../shared/getApiMetrics" +import { listFiles } from "../../services/glob/list-files" +import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" +import { Terminal } from "../../integrations/terminal/Terminal" +import { arePathsEqual } from "../../utils/path" +import { formatResponse } from "../prompts/responses" + +import { Cline } from "../Cline" + +export async function getEnvironmentDetails(cline: Cline, includeFileDetails: boolean = false) { + let details = "" + + const clineProvider = cline.providerRef.deref() + const state = await clineProvider?.getState() + const { terminalOutputLineLimit = 500, maxWorkspaceFiles = 200 } = state ?? {} + + // It could be useful for cline to know if the user went from one or no + // file to another between messages, so we always include this context. + details += "\n\n# VSCode Visible Files" + + const visibleFilePaths = vscode.window.visibleTextEditors + ?.map((editor) => editor.document?.uri?.fsPath) + .filter(Boolean) + .map((absolutePath) => path.relative(cline.cwd, absolutePath)) + .slice(0, maxWorkspaceFiles) + + // Filter paths through rooIgnoreController + const allowedVisibleFiles = cline.rooIgnoreController + ? cline.rooIgnoreController.filterPaths(visibleFilePaths) + : visibleFilePaths.map((p) => p.toPosix()).join("\n") + + if (allowedVisibleFiles) { + details += `\n${allowedVisibleFiles}` + } else { + details += "\n(No visible files)" + } + + details += "\n\n# VSCode Open Tabs" + const { maxOpenTabsContext } = state ?? {} + const maxTabs = maxOpenTabsContext ?? 20 + const openTabPaths = vscode.window.tabGroups.all + .flatMap((group) => group.tabs) + .map((tab) => (tab.input as vscode.TabInputText)?.uri?.fsPath) + .filter(Boolean) + .map((absolutePath) => path.relative(cline.cwd, absolutePath).toPosix()) + .slice(0, maxTabs) + + // Filter paths through rooIgnoreController + const allowedOpenTabs = cline.rooIgnoreController + ? cline.rooIgnoreController.filterPaths(openTabPaths) + : openTabPaths.map((p) => p.toPosix()).join("\n") + + if (allowedOpenTabs) { + details += `\n${allowedOpenTabs}` + } else { + details += "\n(No open tabs)" + } + + // Get task-specific and background terminals. + const busyTerminals = [ + ...TerminalRegistry.getTerminals(true, cline.taskId), + ...TerminalRegistry.getBackgroundTerminals(true), + ] + + const inactiveTerminals = [ + ...TerminalRegistry.getTerminals(false, cline.taskId), + ...TerminalRegistry.getBackgroundTerminals(false), + ] + + if (busyTerminals.length > 0) { + if (cline.didEditFile) { + await delay(300) // Delay after saving file to let terminals catch up. + } + + // Wait for terminals to cool down. + await pWaitFor(() => busyTerminals.every((t) => !TerminalRegistry.isProcessHot(t.id)), { + interval: 100, + timeout: 5_000, + }).catch(() => {}) + } + + // Reset, this lets us know when to wait for saved files to update terminals. + cline.didEditFile = false + + // Waiting for updated diagnostics lets terminal output be the most + // up-to-date possible. + let terminalDetails = "" + + if (busyTerminals.length > 0) { + // Terminals are cool, let's retrieve their output. + terminalDetails += "\n\n# Actively Running Terminals" + + for (const busyTerminal of busyTerminals) { + terminalDetails += `\n## Original command: \`${busyTerminal.getLastCommand()}\`` + let newOutput = TerminalRegistry.getUnretrievedOutput(busyTerminal.id) + + if (newOutput) { + newOutput = Terminal.compressTerminalOutput(newOutput, terminalOutputLineLimit) + terminalDetails += `\n### New Output\n${newOutput}` + } + } + } + + // First check if any inactive terminals in this task have completed + // processes with output. + const terminalsWithOutput = inactiveTerminals.filter((terminal) => { + const completedProcesses = terminal.getProcessesWithOutput() + return completedProcesses.length > 0 + }) + + // Only add the header if there are terminals with output. + if (terminalsWithOutput.length > 0) { + terminalDetails += "\n\n# Inactive Terminals with Completed Process Output" + + // Process each terminal with output. + for (const inactiveTerminal of terminalsWithOutput) { + let terminalOutputs: string[] = [] + + // Get output from completed processes queue. + const completedProcesses = inactiveTerminal.getProcessesWithOutput() + + for (const process of completedProcesses) { + let output = process.getUnretrievedOutput() + + if (output) { + output = Terminal.compressTerminalOutput(output, terminalOutputLineLimit) + terminalOutputs.push(`Command: \`${process.command}\`\n${output}`) + } + } + + // Clean the queue after retrieving output. + inactiveTerminal.cleanCompletedProcessQueue() + + // Add this terminal's outputs to the details. + if (terminalOutputs.length > 0) { + terminalDetails += `\n## Terminal ${inactiveTerminal.id}` + terminalOutputs.forEach((output) => { + terminalDetails += `\n### New Output\n${output}` + }) + } + } + } + + // console.log(`[Cline#getEnvironmentDetails] terminalDetails: ${terminalDetails}`) + + // Add recently modified files section. + const recentlyModifiedFiles = cline.fileContextTracker.getAndClearRecentlyModifiedFiles() + + if (recentlyModifiedFiles.length > 0) { + details += + "\n\n# Recently Modified Files\nThese files have been modified since you last accessed them (file was just edited so you may need to re-read it before editing):" + for (const filePath of recentlyModifiedFiles) { + details += `\n${filePath}` + } + } + + if (terminalDetails) { + details += terminalDetails + } + + // Add current time information with timezone. + const now = new Date() + + const formatter = new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + hour12: true, + }) + + const timeZone = formatter.resolvedOptions().timeZone + const timeZoneOffset = -now.getTimezoneOffset() / 60 // Convert to hours and invert sign to match conventional notation + const timeZoneOffsetHours = Math.floor(Math.abs(timeZoneOffset)) + const timeZoneOffsetMinutes = Math.abs(Math.round((Math.abs(timeZoneOffset) - timeZoneOffsetHours) * 60)) + const timeZoneOffsetStr = `${timeZoneOffset >= 0 ? "+" : "-"}${timeZoneOffsetHours}:${timeZoneOffsetMinutes.toString().padStart(2, "0")}` + details += `\n\n# Current Time\n${formatter.format(now)} (${timeZone}, UTC${timeZoneOffsetStr})` + + // Add context tokens information. + const { contextTokens, totalCost } = getApiMetrics(cline.clineMessages) + const modelInfo = cline.api.getModel().info + const contextWindow = modelInfo.contextWindow + + const contextPercentage = + contextTokens && contextWindow ? Math.round((contextTokens / contextWindow) * 100) : undefined + + details += `\n\n# Current Context Size (Tokens)\n${contextTokens ? `${contextTokens.toLocaleString()} (${contextPercentage}%)` : "(Not available)"}` + details += `\n\n# Current Cost\n${totalCost !== null ? `$${totalCost.toFixed(2)}` : "(Not available)"}` + + // Add current mode and any mode-specific warnings. + const { + mode, + customModes, + apiModelId, + customModePrompts, + experiments = {} as Record, + customInstructions: globalCustomInstructions, + language, + } = state ?? {} + + const currentMode = mode ?? defaultModeSlug + + const modeDetails = await getFullModeDetails(currentMode, customModes, customModePrompts, { + cwd: cline.cwd, + globalCustomInstructions, + language: language ?? formatLanguage(vscode.env.language), + }) + + details += `\n\n# Current Mode\n` + details += `${currentMode}\n` + details += `${modeDetails.name}\n` + details += `${apiModelId}\n` + + if (Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.POWER_STEERING)) { + details += `${modeDetails.roleDefinition}\n` + + if (modeDetails.customInstructions) { + details += `${modeDetails.customInstructions}\n` + } + } + + // Add warning if not in code mode. + if ( + !isToolAllowedForMode("write_to_file", currentMode, customModes ?? [], { apply_diff: cline.diffEnabled }) && + !isToolAllowedForMode("apply_diff", currentMode, customModes ?? [], { apply_diff: cline.diffEnabled }) + ) { + const currentModeName = getModeBySlug(currentMode, customModes)?.name ?? currentMode + const defaultModeName = getModeBySlug(defaultModeSlug, customModes)?.name ?? defaultModeSlug + details += `\n\nNOTE: You are currently in '${currentModeName}' mode, which does not allow write operations. To write files, the user will need to switch to a mode that supports file writing, such as '${defaultModeName}' mode.` + } + + if (includeFileDetails) { + details += `\n\n# Current Workspace Directory (${cline.cwd.toPosix()}) Files\n` + const isDesktop = arePathsEqual(cline.cwd, path.join(os.homedir(), "Desktop")) + + if (isDesktop) { + // Don't want to immediately access desktop since it would show + // permission popup. + details += "(Desktop files not shown automatically. Use list_files to explore if needed.)" + } else { + const maxFiles = maxWorkspaceFiles ?? 200 + const [files, didHitLimit] = await listFiles(cline.cwd, true, maxFiles) + const { showRooIgnoredFiles = true } = state ?? {} + + const result = formatResponse.formatFilesList( + cline.cwd, + files, + didHitLimit, + cline.rooIgnoreController, + showRooIgnoredFiles, + ) + + details += result + } + } + + return `\n${details.trim()}\n` +} diff --git a/src/services/glob/list-files.ts b/src/services/glob/list-files.ts index 6d30930ecb..e1809ba4e8 100644 --- a/src/services/glob/list-files.ts +++ b/src/services/glob/list-files.ts @@ -40,6 +40,7 @@ const DIRS_TO_IGNORE = [ export async function listFiles(dirPath: string, recursive: boolean, limit: number): Promise<[string[], boolean]> { // Handle special directories const specialResult = await handleSpecialDirectories(dirPath) + if (specialResult) { return specialResult } diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 12b66ae0d7..2b0d363221 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -85,11 +85,13 @@ const ApiOptions = ({ return Object.entries(headers) }) - // Effect to synchronize internal customHeaders state with prop changes useEffect(() => { const propHeaders = apiConfiguration?.openAiHeaders || {} - if (JSON.stringify(customHeaders) !== JSON.stringify(Object.entries(propHeaders))) setCustomHeaders(Object.entries(propHeaders)) - }, [apiConfiguration?.openAiHeaders]) + + if (JSON.stringify(customHeaders) !== JSON.stringify(Object.entries(propHeaders))) { + setCustomHeaders(Object.entries(propHeaders)) + } + }, [apiConfiguration?.openAiHeaders, customHeaders]) const [anthropicBaseUrlSelected, setAnthropicBaseUrlSelected] = useState(!!apiConfiguration?.anthropicBaseUrl) const [openAiNativeBaseUrlSelected, setOpenAiNativeBaseUrlSelected] = useState(