Explorar o código

Move environment details to a separate module, add tests (#3078)

Chris Estreich hai 10 meses
pai
achega
78beb718c8

+ 5 - 0
.changeset/pretty-peaches-bake.md

@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Split Cline.getEnvironmentDetails out into a standalone function

+ 5 - 260
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<Anthropic.Messages.ContentBlockParam>
 
@@ -145,7 +142,7 @@ export class Cline extends EventEmitter<ClineEvents> {
 	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<ClineEvents> {
 		)
 
 		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<ClineEvents> {
 		)
 	}
 
-	// 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<ExperimentId, boolean>,
-			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 += `<slug>${currentMode}</slug>\n`
-		details += `<name>${modeDetails.name}</name>\n`
-		details += `<model>${apiModelId}</model>\n`
-
-		if (Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.POWER_STEERING)) {
-			details += `<role>${modeDetails.roleDefinition}</role>\n`
-
-			if (modeDetails.customInstructions) {
-				details += `<custom_instructions>${modeDetails.customInstructions}</custom_instructions>\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 `<environment_details>\n${details.trim()}\n</environment_details>`
-	}
-
 	// Checkpoints
 
 	private getCheckpointService() {

+ 4 - 95
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<string> {
-						// 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" }

+ 316 - 0
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<Cline>
+	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<ClineProvider>,
+		}
+
+		// 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("<environment_details>")
+		expect(result).toContain("</environment_details>")
+		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("<role>You are a code assistant</role>")
+		expect(result).toContain("<custom_instructions>Custom instructions</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("<environment_details>")
+		expect(result).toContain("</environment_details>")
+
+		// 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("<environment_details>")
+		expect(result2).toContain("</environment_details>")
+	})
+
+	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()
+	})
+})

+ 270 - 0
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<ExperimentId, boolean>,
+		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 += `<slug>${currentMode}</slug>\n`
+	details += `<name>${modeDetails.name}</name>\n`
+	details += `<model>${apiModelId}</model>\n`
+
+	if (Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.POWER_STEERING)) {
+		details += `<role>${modeDetails.roleDefinition}</role>\n`
+
+		if (modeDetails.customInstructions) {
+			details += `<custom_instructions>${modeDetails.customInstructions}</custom_instructions>\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 `<environment_details>\n${details.trim()}\n</environment_details>`
+}

+ 1 - 0
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
 	}

+ 5 - 3
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(