refactor: Split environment details into modular components

Converted monolithic environment details implementation into separate context modules.
Each module handles a specific aspect of the environment (VSCode, terminal, files, etc.).
Replaced reminder.ts with integrated todo implementation and standardized XML attributes.

Signed-off-by: Eric Wheeler <roo-code@z.ewheeler.org>
This commit is contained in:
Eric Wheeler 2025-07-17 14:38:09 -07:00
parent 2f4d833ebc
commit 426614047d
8 changed files with 324 additions and 288 deletions

View file

@ -0,0 +1,13 @@
import type { Task } from "../../task/Task"
export function getFileContext(cline: Task) {
const recentlyModifiedFiles = cline.fileContextTracker.getAndClearRecentlyModifiedFiles()
if (recentlyModifiedFiles.length > 0) {
return {
recentlyModified: {
file: recentlyModifiedFiles.map((p) => ({ "@path": p })),
},
}
}
return {}
}

View file

@ -0,0 +1,65 @@
import * as vscode from "vscode"
import type { ExperimentId } from "@roo-code/types"
import { EXPERIMENT_IDS, experiments as Experiments } from "../../../shared/experiments"
import { formatLanguage } from "../../../shared/language"
import { defaultModeSlug, getFullModeDetails } from "../../../shared/modes"
import { getApiMetrics } from "../../../shared/getApiMetrics"
import type { Task } from "../../task/Task"
export async function getMetadataContext(cline: Task) {
const state = await cline.providerRef.deref()?.getState()
const {
mode,
customModes,
customModePrompts,
experiments = {} as Record<ExperimentId, boolean>,
customInstructions: globalCustomInstructions,
language,
} = state ?? {}
// High-Frequency Metadata
const now = new Date()
const timeZoneOffset = -now.getTimezoneOffset()
const offsetHours = Math.floor(Math.abs(timeZoneOffset) / 60)
const offsetMinutes = Math.abs(timeZoneOffset) % 60
const offsetSign = timeZoneOffset >= 0 ? "+" : "-"
const offsetString = `${offsetSign}${offsetHours
.toString()
.padStart(2, "0")}:${offsetMinutes.toString().padStart(2, "0")}`
const isoDateWithOffset = now.toISOString().replace(/Z$/, offsetString)
const time = {
"@v": isoDateWithOffset,
}
const { totalCost } = getApiMetrics(cline.clineMessages)
const cost = {
"@t": totalCost !== null ? totalCost.toFixed(2) : "0.00",
"@c": "USD",
"#text": "Must form responses to minimize cost growth",
}
// Low-Frequency Metadata
const currentMode = mode ?? defaultModeSlug
const modeDetails = await getFullModeDetails(currentMode, customModes, customModePrompts, {
cwd: cline.cwd,
globalCustomInstructions,
language: language ?? formatLanguage(vscode.env.language),
})
const { id: modelId } = cline.api.getModel()
const modeInfo = {
"@slug": currentMode,
"@name": modeDetails.name,
"@model": modelId,
...(Experiments.isEnabled(experiments ?? {}, EXPERIMENT_IDS.POWER_STEERING) && {
"@role": modeDetails.roleDefinition,
...(modeDetails.customInstructions && {
"@customInstructions": modeDetails.customInstructions,
}),
}),
}
return { time, cost, mode: modeInfo }
}

View file

@ -0,0 +1,76 @@
import pWaitFor from "p-wait-for"
import delay from "delay"
import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry"
import { Terminal } from "../../../integrations/terminal/Terminal"
import type { Task } from "../../task/Task"
export async function getTerminalContext(cline: Task) {
const state = await cline.providerRef.deref()?.getState()
const { terminalOutputLineLimit = 500 } = state ?? {}
const terminalsData: any[] = []
const busyTerminals = [
...TerminalRegistry.getTerminals(true, cline.taskId),
...TerminalRegistry.getBackgroundTerminals(true),
]
const inactiveTerminals = [
...TerminalRegistry.getTerminals(false, cline.taskId),
...TerminalRegistry.getBackgroundTerminals(false),
]
// Wait for terminals to cool down if needed
if (busyTerminals.length > 0 && cline.didEditFile) {
await delay(300) // Delay after saving file to let terminals catch up
await pWaitFor(() => busyTerminals.every((t) => !TerminalRegistry.isProcessHot(t.id)), {
interval: 100,
timeout: 5_000,
}).catch(() => {})
}
cline.didEditFile = false
// Process active terminals
busyTerminals.forEach((terminal) => {
const cwd = terminal.getCurrentWorkingDirectory()
const command = terminal.getLastCommand()
let output = TerminalRegistry.getUnretrievedOutput(terminal.id)
const terminalData: any = {
"@id": terminal.id.toString(),
"@status": "Active",
"@cwd": cwd,
"@command": command,
}
if (output) {
terminalData["#cdata"] = Terminal.compressTerminalOutput(output, terminalOutputLineLimit)
}
terminalsData.push(terminalData)
})
// Process inactive terminals with output
inactiveTerminals
.filter((t) => t.getProcessesWithOutput().length > 0)
.forEach((terminal) => {
const cwd = terminal.getCurrentWorkingDirectory()
const processes = terminal.getProcessesWithOutput()
processes.forEach((process) => {
const output = process.getUnretrievedOutput()
if (output) {
terminalsData.push({
"@id": terminal.id.toString(),
"@status": "Inactive",
"@cwd": cwd,
"@command": process.command,
"#cdata": Terminal.compressTerminalOutput(output, terminalOutputLineLimit),
})
}
})
terminal.cleanCompletedProcessQueue()
})
return terminalsData.length > 0 ? { terminal: terminalsData } : undefined
}

View file

@ -0,0 +1,31 @@
import type { Task } from "../../task/Task"
export function getTodoContext(cline: Task) {
if (cline.todoList && cline.todoList.length > 0) {
const todoLines = cline.todoList
.map((todo) => {
let statusPrefix = "[ ]" // pending
if (todo.status === "in_progress") statusPrefix = "[-]"
else if (todo.status === "completed") statusPrefix = "[x]"
return `${statusPrefix} ${todo.content}`
})
.join("\n")
const todoText = [
todoLines,
"IMPORTANT: When task status changes, remember to call the `update_todo_list` tool to update your progress.",
].join("\n")
return {
todo: {
"#text": todoText,
},
}
}
return {
todo: {
"#text":
"You have not created a todo list yet. Create one with `update_todo_list` if your task is complicated or involves multiple steps.",
},
}
}

View file

@ -0,0 +1,50 @@
import path from "path"
import * as vscode from "vscode"
import type { Task } from "../../task/Task"
export async function getVscodeEditorContext(cline: Task) {
const state = await cline.providerRef.deref()?.getState()
const { maxWorkspaceFiles = 200 } = state ?? {}
// Get visible files in the editor
const visibleFilePaths = vscode.window.visibleTextEditors
?.map((editor) => editor.document?.uri?.fsPath)
.filter(Boolean)
.map((absolutePath) => path.relative(cline.cwd, absolutePath))
.slice(0, maxWorkspaceFiles)
const allowedVisibleFiles = cline.rooIgnoreController
? cline.rooIgnoreController.filterPaths(visibleFilePaths)
: visibleFilePaths.map((p) => p.toPosix())
const visibleFiles =
allowedVisibleFiles?.length > 0
? {
file: allowedVisibleFiles.map((p) => ({ "@path": p })),
}
: undefined
// Get open tabs (high-frequency data - using compact representation)
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)
const allowedOpenTabs = cline.rooIgnoreController
? cline.rooIgnoreController.filterPaths(openTabPaths)
: openTabPaths
const openTabs =
allowedOpenTabs?.length > 0
? {
t: allowedOpenTabs.map((p) => ({ "@p": p })),
}
: undefined
return { visibleFiles, openTabs }
}

View file

@ -0,0 +1,56 @@
import path from "path"
import os from "os"
import { listFiles } from "../../../services/glob/list-files"
import { arePathsEqual } from "../../../utils/path"
import { formatResponse } from "../../prompts/responses"
import type { Task } from "../../task/Task"
export async function getWorkspaceContext(cline: Task, includeFileDetails: boolean) {
if (!includeFileDetails) {
return {}
}
const state = await cline.providerRef.deref()?.getState()
const { maxWorkspaceFiles = 200, showRooIgnoredFiles = true } = state ?? {}
const isDesktop = arePathsEqual(cline.cwd, path.join(os.homedir(), "Desktop"))
const workspaceData: any = { "@directory": cline.cwd.toPosix() }
if (isDesktop) {
workspaceData.note = "Desktop files not shown automatically. Use list_files to explore if needed."
} else if (maxWorkspaceFiles === 0) {
workspaceData.note = "Workspace files context disabled. Use list_files to explore if needed."
} else {
const [files, didHitLimit] = await listFiles(cline.cwd, true, maxWorkspaceFiles)
const formattedFilesList = formatResponse.formatFilesList(
cline.cwd,
files,
didHitLimit,
cline.rooIgnoreController,
showRooIgnoredFiles,
)
if (formattedFilesList && formattedFilesList !== "No files found.") {
const fileLines = formattedFilesList.split("\n").filter((line) => line.trim() !== "")
const fileObjects: any[] = []
const dirObjects: any[] = []
fileLines.forEach((line) => {
if (line.endsWith("/")) {
dirObjects.push({ "@path": line })
} else if (!line.includes("File list truncated")) {
fileObjects.push({ "@path": line })
}
})
if (fileObjects.length > 0) workspaceData.file = fileObjects
if (dirObjects.length > 0) workspaceData.directory = dirObjects
if (didHitLimit) {
workspaceData.note =
"File list truncated. Use list_files on specific subdirectories if you need to explore further."
}
}
}
return { workspace: workspaceData }
}

View file

@ -1,259 +1,42 @@
import path from "path"
import os from "os"
import * as vscode from "vscode"
import pWaitFor from "p-wait-for"
import delay from "delay"
import type { ExperimentId } from "@roo-code/types"
import { EXPERIMENT_IDS, experiments as Experiments } 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 { XMLBuilder } from "fast-xml-parser"
import { Task } from "../task/Task"
import { formatReminderSection } from "./reminder"
import { getVscodeEditorContext } from "./context/vscode"
import { getTerminalContext } from "./context/terminal"
import { getFileContext } from "./context/file"
import { getMetadataContext } from "./context/metadata"
import { getWorkspaceContext } from "./context/workspace"
import { getTodoContext } from "./context/todo"
export async function getEnvironmentDetails(cline: Task, includeFileDetails: boolean = false) {
let details = ""
export async function getEnvironmentDetails(task: Task, includeFileDetails: boolean = false) {
const [vscodeContext, terminalContext, fileContext, metadataContext, workspaceContext, todoContext] =
await Promise.all([
getVscodeEditorContext(task),
getTerminalContext(task),
getFileContext(task),
getMetadataContext(task),
getWorkspaceContext(task, includeFileDetails),
getTodoContext(task),
])
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)"
const envDetails = {
...vscodeContext,
...terminalContext,
...fileContext,
...metadataContext,
...workspaceContext,
...todoContext,
}
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) {
const cwd = busyTerminal.getCurrentWorkingDirectory()
terminalDetails += `\n## Terminal ${busyTerminal.id} (Active)`
terminalDetails += `\n### Working Directory: \`${cwd}\``
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
const builder = new XMLBuilder({
format: true, // Enable pretty printing
indentBy: " ", // Use two spaces for indentation
ignoreAttributes: false,
attributeNamePrefix: "@",
suppressEmptyNode: true, // Ensures tags with no children are excluded
cdataPropName: "#cdata",
textNodeName: "#text",
})
// 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) {
const cwd = inactiveTerminal.getCurrentWorkingDirectory()
terminalDetails += `\n## Terminal ${inactiveTerminal.id} (Inactive)`
terminalDetails += `\n### Working Directory: \`${cwd}\``
terminalOutputs.forEach((output) => {
terminalDetails += `\n### New Output\n${output}`
})
}
}
}
// console.log(`[Task#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 timeZone = Intl.DateTimeFormat().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\nCurrent time in ISO 8601 UTC format: ${now.toISOString()}\nUser time zone: ${timeZone}, UTC${timeZoneOffsetStr}`
// Add context tokens information.
const { contextTokens, totalCost } = getApiMetrics(cline.clineMessages)
const { id: modelId } = cline.api.getModel()
details += `\n\n# Current Cost\n${totalCost !== null ? `$${totalCost.toFixed(2)}` : "(Not available)"}`
// Add current mode and any mode-specific warnings.
const {
mode,
customModes,
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>${modelId}</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`
}
}
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
// Early return for limit of 0
if (maxFiles === 0) {
details += "(Workspace files context disabled. Use list_files to explore if needed.)"
} else {
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
}
}
}
const reminderSection = formatReminderSection(cline.todoList)
return `<environment_details>\n${details.trim()}\n${reminderSection}\n</environment_details>`
return builder.build({ environment_details: envDetails })
}

View file

@ -1,38 +0,0 @@
import { TodoItem, TodoStatus } from "@roo-code/types"
/**
* Format the reminders section as a markdown block in English, with basic instructions.
*/
export function formatReminderSection(todoList?: TodoItem[]): string {
if (!todoList || todoList.length === 0) {
return "You have not created a todo list yet. Create one with `update_todo_list` if your task is complicated or involves multiple steps."
}
const statusMap: Record<TodoStatus, string> = {
pending: "Pending",
in_progress: "In Progress",
completed: "Completed",
}
const lines: string[] = [
"====",
"",
"REMINDERS",
"",
"Below is your current list of reminders for this task. Keep them updated as you progress.",
"",
]
lines.push("| # | Content | Status |")
lines.push("|---|---------|--------|")
todoList.forEach((item, idx) => {
const escapedContent = item.content.replace(/\\/g, "\\\\").replace(/\|/g, "\\|")
lines.push(`| ${idx + 1} | ${escapedContent} | ${statusMap[item.status] || item.status} |`)
})
lines.push("")
lines.push(
"",
"IMPORTANT: When task status changes, remember to call the `update_todo_list` tool to update your progress.",
"",
)
return lines.join("\n")
}