feat: recursively load .roo/rules and AGENTS.md from subdirectories (#10446)

Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
Matt Rubens 2026-01-03 15:05:48 -05:00 committed by GitHub
parent 586cf47fb2
commit 08c3431fc5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 736 additions and 128 deletions

View file

@ -143,6 +143,7 @@ export const globalSettingsSchema = z.object({
maxOpenTabsContext: z.number().optional(),
maxWorkspaceFiles: z.number().optional(),
showRooIgnoredFiles: z.boolean().optional(),
enableSubfolderRules: z.boolean().optional(),
maxReadFileLine: z.number().optional(),
maxImageFileSize: z.number().optional(),
maxTotalImageSize: z.number().optional(),

View file

@ -1,15 +1,27 @@
import * as path from "path"
// Use vi.hoisted to ensure mocks are available during hoisting
const { mockHomedir, mockStat, mockReadFile, mockReaddir, mockGetRooDirectoriesForCwd, mockGetGlobalRooDirectory } =
vi.hoisted(() => ({
mockHomedir: vi.fn(),
mockStat: vi.fn(),
mockReadFile: vi.fn(),
mockReaddir: vi.fn(),
mockGetRooDirectoriesForCwd: vi.fn(),
mockGetGlobalRooDirectory: vi.fn(),
}))
const {
mockHomedir,
mockStat,
mockReadFile,
mockReaddir,
mockLstat,
mockGetRooDirectoriesForCwd,
mockGetAllRooDirectoriesForCwd,
mockGetAgentsDirectoriesForCwd,
mockGetGlobalRooDirectory,
} = vi.hoisted(() => ({
mockHomedir: vi.fn(),
mockStat: vi.fn(),
mockReadFile: vi.fn(),
mockReaddir: vi.fn(),
mockLstat: vi.fn(),
mockGetRooDirectoriesForCwd: vi.fn(),
mockGetAllRooDirectoriesForCwd: vi.fn(),
mockGetAgentsDirectoriesForCwd: vi.fn(),
mockGetGlobalRooDirectory: vi.fn(),
}))
// Mock os module
vi.mock("os", () => ({
@ -25,12 +37,15 @@ vi.mock("fs/promises", () => ({
stat: mockStat,
readFile: mockReadFile,
readdir: mockReaddir,
lstat: mockLstat,
},
}))
// Mock the roo-config service
vi.mock("../../../../services/roo-config", () => ({
getRooDirectoriesForCwd: mockGetRooDirectoriesForCwd,
getAllRooDirectoriesForCwd: mockGetAllRooDirectoriesForCwd,
getAgentsDirectoriesForCwd: mockGetAgentsDirectoriesForCwd,
getGlobalRooDirectory: mockGetGlobalRooDirectory,
}))
@ -46,7 +61,13 @@ describe("custom-instructions global .roo support", () => {
vi.clearAllMocks()
mockHomedir.mockReturnValue(mockHomeDir)
mockGetRooDirectoriesForCwd.mockReturnValue([globalRooDir, projectRooDir])
// getAllRooDirectoriesForCwd is now async and returns the same directories by default
mockGetAllRooDirectoriesForCwd.mockResolvedValue([globalRooDir, projectRooDir])
// getAgentsDirectoriesForCwd returns parent directories (without .roo)
mockGetAgentsDirectoriesForCwd.mockResolvedValue([mockCwd])
mockGetGlobalRooDirectory.mockReturnValue(globalRooDir)
// Default lstat to reject (file not found)
mockLstat.mockRejectedValue(new Error("ENOENT"))
})
afterEach(() => {
@ -65,7 +86,11 @@ describe("custom-instructions global .roo support", () => {
// Mock directory reading for global rules
mockReaddir.mockResolvedValueOnce([
{ name: "rules.md", isFile: () => true, isSymbolicLink: () => false } as any,
{
name: "rules.md",
isFile: () => true,
isSymbolicLink: () => false,
} as any,
])
// Mock file reading for the rules.md file
@ -87,7 +112,11 @@ describe("custom-instructions global .roo support", () => {
// Mock directory reading for project rules
mockReaddir.mockResolvedValueOnce([
{ name: "rules.md", isFile: () => true, isSymbolicLink: () => false } as any,
{
name: "rules.md",
isFile: () => true,
isSymbolicLink: () => false,
} as any,
])
// Mock file reading
@ -112,8 +141,20 @@ describe("custom-instructions global .roo support", () => {
// Mock directory reading
mockReaddir
.mockResolvedValueOnce([{ name: "global.md", isFile: () => true, isSymbolicLink: () => false } as any])
.mockResolvedValueOnce([{ name: "project.md", isFile: () => true, isSymbolicLink: () => false } as any])
.mockResolvedValueOnce([
{
name: "global.md",
isFile: () => true,
isSymbolicLink: () => false,
} as any,
])
.mockResolvedValueOnce([
{
name: "project.md",
isFile: () => true,
isSymbolicLink: () => false,
} as any,
])
// Mock file reading
mockReadFile.mockResolvedValueOnce("global rule content").mockResolvedValueOnce("project rule content")
@ -182,10 +223,18 @@ describe("custom-instructions global .roo support", () => {
// Mock directory reading for mode-specific rules
mockReaddir
.mockResolvedValueOnce([
{ name: "global-mode.md", isFile: () => true, isSymbolicLink: () => false } as any,
{
name: "global-mode.md",
isFile: () => true,
isSymbolicLink: () => false,
} as any,
])
.mockResolvedValueOnce([
{ name: "project-mode.md", isFile: () => true, isSymbolicLink: () => false } as any,
{
name: "project-mode.md",
isFile: () => true,
isSymbolicLink: () => false,
} as any,
])
// Mock file reading for mode-specific rules

View file

@ -36,7 +36,16 @@ vi.mock("path", async () => ({
.map((arg) => arg.toString().replace(/[/\\]+/g, separator))
return cleanArgs.join(separator)
}),
relative: vi.fn().mockImplementation((from, to) => to),
relative: vi.fn().mockImplementation((from, to) => {
// Simple relative path computation for test scenarios
const separator = process.platform === "win32" ? "\\" : "/"
const normalizedFrom = from.replace(/[/\\]+$/, "") // Remove trailing slashes
const normalizedTo = to.replace(/[/\\]+/g, separator)
if (normalizedTo.startsWith(normalizedFrom + separator)) {
return normalizedTo.slice(normalizedFrom.length + 1)
}
return to
}),
dirname: vi.fn().mockImplementation((path) => {
const separator = process.platform === "win32" ? "\\" : "/"
const parts = path.split(/[/\\]/)
@ -200,16 +209,16 @@ describe("loadRuleFiles", () => {
})
const result = await loadRuleFiles("/fake/path")
const expectedPath1 =
process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file1.txt" : "/fake/path/.roo/rules/file1.txt"
const expectedPath2 =
process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file2.txt" : "/fake/path/.roo/rules/file2.txt"
expect(result).toContain(`# Rules from ${expectedPath1}:`)
// Paths in output should be relative to cwd
const expectedRelativePath1 = process.platform === "win32" ? ".roo\\rules\\file1.txt" : ".roo/rules/file1.txt"
const expectedRelativePath2 = process.platform === "win32" ? ".roo\\rules\\file2.txt" : ".roo/rules/file2.txt"
expect(result).toContain(`# Rules from ${expectedRelativePath1}:`)
expect(result).toContain("content of file1")
expect(result).toContain(`# Rules from ${expectedPath2}:`)
expect(result).toContain(`# Rules from ${expectedRelativePath2}:`)
expect(result).toContain("content of file2")
// We expect both checks because our new implementation checks the files again for validation
// These are the absolute paths used internally
const expectedRulesDir = process.platform === "win32" ? "\\fake\\path\\.roo\\rules" : "/fake/path/.roo/rules"
const expectedFile1Path =
process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file1.txt" : "/fake/path/.roo/rules/file1.txt"
@ -436,28 +445,25 @@ describe("loadRuleFiles", () => {
const result = await loadRuleFiles("/fake/path")
// Check root file content
const expectedRootPath =
process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\root.txt" : "/fake/path/.roo/rules/root.txt"
const expectedNested1Path =
// Check root file content - paths in output should be relative
const expectedRelativeRootPath = process.platform === "win32" ? ".roo\\rules\\root.txt" : ".roo/rules/root.txt"
const expectedRelativeNested1Path =
process.platform === "win32" ? ".roo\\rules\\subdir\\nested1.txt" : ".roo/rules/subdir/nested1.txt"
const expectedRelativeNested2Path =
process.platform === "win32"
? "\\fake\\path\\.roo\\rules\\subdir\\nested1.txt"
: "/fake/path/.roo/rules/subdir/nested1.txt"
const expectedNested2Path =
process.platform === "win32"
? "\\fake\\path\\.roo\\rules\\subdir\\subdir2\\nested2.txt"
: "/fake/path/.roo/rules/subdir/subdir2/nested2.txt"
? ".roo\\rules\\subdir\\subdir2\\nested2.txt"
: ".roo/rules/subdir/subdir2/nested2.txt"
expect(result).toContain(`# Rules from ${expectedRootPath}:`)
expect(result).toContain(`# Rules from ${expectedRelativeRootPath}:`)
expect(result).toContain("root file content")
// Check nested files content
expect(result).toContain(`# Rules from ${expectedNested1Path}:`)
expect(result).toContain(`# Rules from ${expectedRelativeNested1Path}:`)
expect(result).toContain("nested file 1 content")
expect(result).toContain(`# Rules from ${expectedNested2Path}:`)
expect(result).toContain(`# Rules from ${expectedRelativeNested2Path}:`)
expect(result).toContain("nested file 2 content")
// Verify correct paths were checked
// Verify correct absolute paths were checked internally
const expectedRootPath2 =
process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\root.txt" : "/fake/path/.roo/rules/root.txt"
const expectedNested1Path2 =
@ -1053,39 +1059,34 @@ describe("addCustomInstructions", () => {
{ language: "es" },
)
const expectedTestModeDir =
process.platform === "win32" ? "\\fake\\path\\.roo\\rules-test-mode" : "/fake/path/.roo/rules-test-mode"
const expectedRule1Path =
process.platform === "win32"
? "\\fake\\path\\.roo\\rules-test-mode\\rule1.txt"
: "/fake/path/.roo/rules-test-mode/rule1.txt"
const expectedRule2Path =
process.platform === "win32"
? "\\fake\\path\\.roo\\rules-test-mode\\rule2.txt"
: "/fake/path/.roo/rules-test-mode/rule2.txt"
// Paths in output should be relative
const expectedRelativeRule1Path =
process.platform === "win32" ? ".roo\\rules-test-mode\\rule1.txt" : ".roo/rules-test-mode/rule1.txt"
const expectedRelativeRule2Path =
process.platform === "win32" ? ".roo\\rules-test-mode\\rule2.txt" : ".roo/rules-test-mode/rule2.txt"
expect(result).toContain(`# Rules from ${expectedTestModeDir}`)
expect(result).toContain(`# Rules from ${expectedRule1Path}:`)
expect(result).toContain(`# Rules from ${expectedRelativeRule1Path}:`)
expect(result).toContain("mode specific rule 1")
expect(result).toContain(`# Rules from ${expectedRule2Path}:`)
expect(result).toContain(`# Rules from ${expectedRelativeRule2Path}:`)
expect(result).toContain("mode specific rule 2")
const expectedTestModeDir2 =
// Verify absolute paths were used internally
const expectedAbsTestModeDir =
process.platform === "win32" ? "\\fake\\path\\.roo\\rules-test-mode" : "/fake/path/.roo/rules-test-mode"
const expectedRule1Path2 =
const expectedAbsRule1Path =
process.platform === "win32"
? "\\fake\\path\\.roo\\rules-test-mode\\rule1.txt"
: "/fake/path/.roo/rules-test-mode/rule1.txt"
const expectedRule2Path2 =
const expectedAbsRule2Path =
process.platform === "win32"
? "\\fake\\path\\.roo\\rules-test-mode\\rule2.txt"
: "/fake/path/.roo/rules-test-mode/rule2.txt"
expect(statMock).toHaveBeenCalledWith(expectedTestModeDir2)
expect(statMock).toHaveBeenCalledWith(expectedRule1Path2)
expect(statMock).toHaveBeenCalledWith(expectedRule2Path2)
expect(readFileMock).toHaveBeenCalledWith(expectedRule1Path2, "utf-8")
expect(readFileMock).toHaveBeenCalledWith(expectedRule2Path2, "utf-8")
expect(statMock).toHaveBeenCalledWith(expectedAbsTestModeDir)
expect(statMock).toHaveBeenCalledWith(expectedAbsRule1Path)
expect(statMock).toHaveBeenCalledWith(expectedAbsRule2Path)
expect(readFileMock).toHaveBeenCalledWith(expectedAbsRule1Path, "utf-8")
expect(readFileMock).toHaveBeenCalledWith(expectedAbsRule2Path, "utf-8")
})
it("should fall back to .roorules-test-mode when .roo/rules-test-mode/ does not exist", async () => {
@ -1188,15 +1189,11 @@ describe("addCustomInstructions", () => {
"test-mode",
)
const expectedTestModeDir =
process.platform === "win32" ? "\\fake\\path\\.roo\\rules-test-mode" : "/fake/path/.roo/rules-test-mode"
const expectedRule1Path =
process.platform === "win32"
? "\\fake\\path\\.roo\\rules-test-mode\\rule1.txt"
: "/fake/path/.roo/rules-test-mode/rule1.txt"
// Paths in output should be relative
const expectedRelativeRule1Path =
process.platform === "win32" ? ".roo\\rules-test-mode\\rule1.txt" : ".roo/rules-test-mode/rule1.txt"
expect(result).toContain(`# Rules from ${expectedTestModeDir}`)
expect(result).toContain(`# Rules from ${expectedRule1Path}:`)
expect(result).toContain(`# Rules from ${expectedRelativeRule1Path}:`)
expect(result).toContain("mode specific rule content")
expect(statCallCount).toBeGreaterThan(0)
@ -1338,31 +1335,25 @@ describe("Rules directory reading", () => {
const result = await loadRuleFiles("/fake/path")
// Verify both regular file and symlink target content are included
const expectedRegularPath =
// Verify both regular file and symlink target content are included (paths should be relative)
const expectedRelativeRegularPath =
process.platform === "win32" ? ".roo\\rules\\regular.txt" : ".roo/rules/regular.txt"
const expectedRelativeSymlinkPath =
process.platform === "win32" ? ".roo\\symlink-target.txt" : ".roo/symlink-target.txt"
const expectedRelativeSubdirPath =
process.platform === "win32"
? "\\fake\\path\\.roo\\rules\\regular.txt"
: "/fake/path/.roo/rules/regular.txt"
const expectedSymlinkPath =
process.platform === "win32"
? "\\fake\\path\\.roo\\symlink-target.txt"
: "/fake/path/.roo/symlink-target.txt"
const expectedSubdirPath =
process.platform === "win32"
? "\\fake\\path\\.roo\\rules\\symlink-target-dir\\subdir_link.txt"
: "/fake/path/.roo/rules/symlink-target-dir/subdir_link.txt"
const expectedNestedPath =
process.platform === "win32"
? "\\fake\\path\\.roo\\nested-symlink-target.txt"
: "/fake/path/.roo/nested-symlink-target.txt"
? ".roo\\rules\\symlink-target-dir\\subdir_link.txt"
: ".roo/rules/symlink-target-dir/subdir_link.txt"
const expectedRelativeNestedPath =
process.platform === "win32" ? ".roo\\nested-symlink-target.txt" : ".roo/nested-symlink-target.txt"
expect(result).toContain(`# Rules from ${expectedRegularPath}:`)
expect(result).toContain(`# Rules from ${expectedRelativeRegularPath}:`)
expect(result).toContain("regular file content")
expect(result).toContain(`# Rules from ${expectedSymlinkPath}:`)
expect(result).toContain(`# Rules from ${expectedRelativeSymlinkPath}:`)
expect(result).toContain("symlink target content")
expect(result).toContain(`# Rules from ${expectedSubdirPath}:`)
expect(result).toContain(`# Rules from ${expectedRelativeSubdirPath}:`)
expect(result).toContain("regular file content under symlink target dir")
expect(result).toContain(`# Rules from ${expectedNestedPath}:`)
expect(result).toContain(`# Rules from ${expectedRelativeNestedPath}:`)
expect(result).toContain("nested symlink target content")
// Verify readlink was called with the symlink path
@ -1424,18 +1415,19 @@ describe("Rules directory reading", () => {
const result = await loadRuleFiles("/fake/path")
const expectedFile1Path =
process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file1.txt" : "/fake/path/.roo/rules/file1.txt"
const expectedFile2Path =
process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file2.txt" : "/fake/path/.roo/rules/file2.txt"
const expectedFile3Path =
process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\file3.txt" : "/fake/path/.roo/rules/file3.txt"
// Paths in output should be relative
const expectedRelativeFile1Path =
process.platform === "win32" ? ".roo\\rules\\file1.txt" : ".roo/rules/file1.txt"
const expectedRelativeFile2Path =
process.platform === "win32" ? ".roo\\rules\\file2.txt" : ".roo/rules/file2.txt"
const expectedRelativeFile3Path =
process.platform === "win32" ? ".roo\\rules\\file3.txt" : ".roo/rules/file3.txt"
expect(result).toContain(`# Rules from ${expectedFile1Path}:`)
expect(result).toContain(`# Rules from ${expectedRelativeFile1Path}:`)
expect(result).toContain("content of file1")
expect(result).toContain(`# Rules from ${expectedFile2Path}:`)
expect(result).toContain(`# Rules from ${expectedRelativeFile2Path}:`)
expect(result).toContain("content of file2")
expect(result).toContain(`# Rules from ${expectedFile3Path}:`)
expect(result).toContain(`# Rules from ${expectedRelativeFile3Path}:`)
expect(result).toContain("content of file3")
})
@ -1483,17 +1475,16 @@ describe("Rules directory reading", () => {
expect(alphaIndex).toBeLessThan(betaIndex)
expect(betaIndex).toBeLessThan(zebraIndex)
// Verify the expected file paths are in the result
const expectedAlphaPath =
process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\alpha.txt" : "/fake/path/.roo/rules/alpha.txt"
const expectedBetaPath =
process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\Beta.txt" : "/fake/path/.roo/rules/Beta.txt"
const expectedZebraPath =
process.platform === "win32" ? "\\fake\\path\\.roo\\rules\\zebra.txt" : "/fake/path/.roo/rules/zebra.txt"
// Verify the expected file paths are in the result (should be relative)
const expectedRelativeAlphaPath =
process.platform === "win32" ? ".roo\\rules\\alpha.txt" : ".roo/rules/alpha.txt"
const expectedRelativeBetaPath = process.platform === "win32" ? ".roo\\rules\\Beta.txt" : ".roo/rules/Beta.txt"
const expectedRelativeZebraPath =
process.platform === "win32" ? ".roo\\rules\\zebra.txt" : ".roo/rules/zebra.txt"
expect(result).toContain(`# Rules from ${expectedAlphaPath}:`)
expect(result).toContain(`# Rules from ${expectedBetaPath}:`)
expect(result).toContain(`# Rules from ${expectedZebraPath}:`)
expect(result).toContain(`# Rules from ${expectedRelativeAlphaPath}:`)
expect(result).toContain(`# Rules from ${expectedRelativeBetaPath}:`)
expect(result).toContain(`# Rules from ${expectedRelativeZebraPath}:`)
})
it("should sort symlinks by their symlink names, not target names", async () => {

View file

@ -9,7 +9,12 @@ import type { SystemPromptSettings } from "../types"
import { getEffectiveProtocol, isNativeProtocol } from "@roo-code/types"
import { LANGUAGES } from "../../../shared/language"
import { getRooDirectoriesForCwd, getGlobalRooDirectory } from "../../../services/roo-config"
import {
getRooDirectoriesForCwd,
getAllRooDirectoriesForCwd,
getAgentsDirectoriesForCwd,
getGlobalRooDirectory,
} from "../../../services/roo-config"
/**
* Safely read a file and return its trimmed content
@ -87,9 +92,15 @@ async function resolveSymLink(
const stats = await fs.stat(resolvedTarget)
if (stats.isFile()) {
// For symlinks to files, store the symlink path as original and target as resolved
fileInfo.push({ originalPath: symlinkPath, resolvedPath: resolvedTarget })
fileInfo.push({
originalPath: symlinkPath,
resolvedPath: resolvedTarget,
})
} else if (stats.isDirectory()) {
const anotherEntries = await fs.readdir(resolvedTarget, { withFileTypes: true, recursive: true })
const anotherEntries = await fs.readdir(resolvedTarget, {
withFileTypes: true,
recursive: true,
})
// Collect promises for recursive calls within the directory
const directoryPromises: Promise<void>[] = []
for (const anotherEntry of anotherEntries) {
@ -111,7 +122,10 @@ async function resolveSymLink(
*/
async function readTextFilesFromDirectory(dirPath: string): Promise<Array<{ filename: string; content: string }>> {
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true, recursive: true })
const entries = await fs.readdir(dirPath, {
withFileTypes: true,
recursive: true,
})
// Process all entries - regular files and symlinks that might point to files
// Store both original path (for sorting) and resolved path (for reading)
@ -168,32 +182,40 @@ async function readTextFilesFromDirectory(dirPath: string): Promise<Array<{ file
/**
* Format content from multiple files with filenames as headers
* @param files - Array of files with filename (absolute path) and content
* @param cwd - Current working directory for computing relative paths
*/
function formatDirectoryContent(dirPath: string, files: Array<{ filename: string; content: string }>): string {
function formatDirectoryContent(files: Array<{ filename: string; content: string }>, cwd: string): string {
if (files.length === 0) return ""
return files
.map((file) => {
return `# Rules from ${file.filename}:\n${file.content}`
// Compute relative path for display
const displayPath = path.relative(cwd, file.filename)
return `# Rules from ${displayPath}:\n${file.content}`
})
.join("\n\n")
}
/**
* Load rule files from global and project-local directories
* Global rules are loaded first, then project-local rules which can override global ones
* Load rule files from global, project-local, and optionally subfolder directories
* Rules are loaded in order: global first, then project-local, then subfolders (alphabetically)
*
* @param cwd - Current working directory (project root)
* @param enableSubfolderRules - Whether to include rules from subdirectories (default: false)
*/
export async function loadRuleFiles(cwd: string): Promise<string> {
export async function loadRuleFiles(cwd: string, enableSubfolderRules: boolean = false): Promise<string> {
const rules: string[] = []
const rooDirectories = getRooDirectoriesForCwd(cwd)
// Use recursive discovery only if enableSubfolderRules is true
const rooDirectories = enableSubfolderRules ? await getAllRooDirectoriesForCwd(cwd) : getRooDirectoriesForCwd(cwd)
// Check for .roo/rules/ directories in order (global first, then project-local)
// Check for .roo/rules/ directories in order (global, project-local, and optionally subfolders)
for (const rooDir of rooDirectories) {
const rulesDir = path.join(rooDir, "rules")
if (await directoryExists(rulesDir)) {
const files = await readTextFilesFromDirectory(rulesDir)
if (files.length > 0) {
const content = formatDirectoryContent(rulesDir, files)
const content = formatDirectoryContent(files, cwd)
rules.push(content)
}
}
@ -201,7 +223,7 @@ export async function loadRuleFiles(cwd: string): Promise<string> {
// If we found rules in .roo/rules/ directories, return them
if (rules.length > 0) {
return "\n" + rules.join("\n\n")
return "\n# Rules from .roo directories:\n\n" + rules.join("\n\n")
}
// Fall back to existing behavior for legacy .roorules/.clinerules files
@ -218,16 +240,24 @@ export async function loadRuleFiles(cwd: string): Promise<string> {
}
/**
* Load AGENTS.md or AGENT.md file from the project root if it exists
* Load AGENTS.md or AGENT.md file from a specific directory
* Checks for both AGENTS.md (standard) and AGENT.md (alternative) for compatibility
*
* @param directory - Directory to check for AGENTS.md
* @param showPath - Whether to include the directory path in the header
* @param cwd - Current working directory for computing relative paths (optional)
*/
async function loadAgentRulesFile(cwd: string): Promise<string> {
async function loadAgentRulesFileFromDirectory(
directory: string,
showPath: boolean = false,
cwd?: string,
): Promise<string> {
// Try both filenames - AGENTS.md (standard) first, then AGENT.md (alternative)
const filenames = ["AGENTS.md", "AGENT.md"]
for (const filename of filenames) {
try {
const agentPath = path.join(cwd, filename)
const agentPath = path.join(directory, filename)
let resolvedPath = agentPath
// Check if file exists and handle symlinks
@ -235,7 +265,10 @@ async function loadAgentRulesFile(cwd: string): Promise<string> {
const stats = await fs.lstat(agentPath)
if (stats.isSymbolicLink()) {
// Create a temporary fileInfo array to use with resolveSymLink
const fileInfo: Array<{ originalPath: string; resolvedPath: string }> = []
const fileInfo: Array<{
originalPath: string
resolvedPath: string
}> = []
// Use the existing resolveSymLink function to handle symlink resolution
await resolveSymLink(agentPath, fileInfo, 0)
@ -253,7 +286,12 @@ async function loadAgentRulesFile(cwd: string): Promise<string> {
// Read the content from the resolved path
const content = await safeReadFile(resolvedPath)
if (content) {
return `# Agent Rules Standard (${filename}):\n${content}`
// Compute relative path for display if cwd is provided
const displayPath = cwd ? path.relative(cwd, directory) : directory
const header = showPath
? `# Agent Rules Standard (${filename}) from ${displayPath}:`
: `# Agent Rules Standard (${filename}):`
return `${header}\n${content}`
}
} catch (err) {
// Silently ignore errors - agent rules files are optional
@ -262,6 +300,51 @@ async function loadAgentRulesFile(cwd: string): Promise<string> {
return ""
}
/**
* Load AGENTS.md or AGENT.md file from the project root if it exists
* Checks for both AGENTS.md (standard) and AGENT.md (alternative) for compatibility
*
* @deprecated Use loadAllAgentRulesFiles for loading from all directories
*/
async function loadAgentRulesFile(cwd: string): Promise<string> {
return loadAgentRulesFileFromDirectory(cwd, false, cwd)
}
/**
* Load all AGENTS.md files from project root and optionally subdirectories with .roo folders
* Returns combined content with clear path headers for each file
*
* @param cwd - Current working directory (project root)
* @param enableSubfolderRules - Whether to include AGENTS.md from subdirectories (default: false)
* @returns Combined AGENTS.md content from all locations
*/
async function loadAllAgentRulesFiles(cwd: string, enableSubfolderRules: boolean = false): Promise<string> {
const agentRules: string[] = []
// When subfolder rules are disabled, only load from root
if (!enableSubfolderRules) {
const content = await loadAgentRulesFileFromDirectory(cwd, false, cwd)
if (content && content.trim()) {
agentRules.push(content.trim())
}
return agentRules.join("\n\n")
}
// When enabled, load from root and all subdirectories with .roo folders
const directories = await getAgentsDirectoriesForCwd(cwd)
for (const directory of directories) {
// Show path for all directories except the root
const showPath = directory !== cwd
const content = await loadAgentRulesFileFromDirectory(directory, showPath, cwd)
if (content && content.trim()) {
agentRules.push(content.trim())
}
}
return agentRules.join("\n\n")
}
export async function addCustomInstructions(
modeCustomInstructions: string,
globalCustomInstructions: string,
@ -275,21 +358,27 @@ export async function addCustomInstructions(
): Promise<string> {
const sections = []
// Get the enableSubfolderRules setting (default: false)
const enableSubfolderRules = options.settings?.enableSubfolderRules ?? false
// Load mode-specific rules if mode is provided
let modeRuleContent = ""
let usedRuleFile = ""
if (mode) {
const modeRules: string[] = []
const rooDirectories = getRooDirectoriesForCwd(cwd)
// Use recursive discovery only if enableSubfolderRules is true
const rooDirectories = enableSubfolderRules
? await getAllRooDirectoriesForCwd(cwd)
: getRooDirectoriesForCwd(cwd)
// Check for .roo/rules-${mode}/ directories in order (global first, then project-local)
// Check for .roo/rules-${mode}/ directories in order (global, project-local, and optionally subfolders)
for (const rooDir of rooDirectories) {
const modeRulesDir = path.join(rooDir, `rules-${mode}`)
if (await directoryExists(modeRulesDir)) {
const files = await readTextFilesFromDirectory(modeRulesDir)
if (files.length > 0) {
const content = formatDirectoryContent(modeRulesDir, files)
const content = formatDirectoryContent(files, cwd)
modeRules.push(content)
}
}
@ -350,15 +439,16 @@ export async function addCustomInstructions(
}
// Add AGENTS.md content if enabled (default: true)
// Load from root and optionally subdirectories with .roo folders based on enableSubfolderRules setting
if (options.settings?.useAgentRules !== false) {
const agentRulesContent = await loadAgentRulesFile(cwd)
const agentRulesContent = await loadAllAgentRulesFiles(cwd, enableSubfolderRules)
if (agentRulesContent && agentRulesContent.trim()) {
rules.push(agentRulesContent.trim())
}
}
// Add generic rules
const genericRuleContent = await loadRuleFiles(cwd)
const genericRuleContent = await loadRuleFiles(cwd, enableSubfolderRules)
if (genericRuleContent && genericRuleContent.trim()) {
rules.push(genericRuleContent.trim())
}

View file

@ -8,6 +8,8 @@ export interface SystemPromptSettings {
todoListEnabled: boolean
browserToolEnabled?: boolean
useAgentRules: boolean
/** When true, recursively discover and load .roo/rules from subdirectories */
enableSubfolderRules?: boolean
newTaskRequireTodos: boolean
toolProtocol?: ToolProtocol
/** When true, model should hide vendor/company identity in responses */

View file

@ -3505,6 +3505,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
maxConcurrentFileReads,
maxReadFileLine,
apiConfiguration,
enableSubfolderRules,
} = state ?? {}
return await (async () => {
@ -3557,6 +3558,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
browserToolEnabled: browserToolEnabled ?? true,
useAgentRules:
vscode.workspace.getConfiguration(Package.name).get<boolean>("useAgentRules") ?? true,
enableSubfolderRules: enableSubfolderRules ?? false,
newTaskRequireTodos: vscode.workspace
.getConfiguration(Package.name)
.get<boolean>("newTaskRequireTodos", false),

View file

@ -1873,6 +1873,7 @@ export class ClineProvider
browserToolEnabled,
telemetrySetting,
showRooIgnoredFiles,
enableSubfolderRules,
language,
maxReadFileLine,
maxImageFileSize,
@ -2020,6 +2021,7 @@ export class ClineProvider
telemetryKey,
machineId,
showRooIgnoredFiles: showRooIgnoredFiles ?? false,
enableSubfolderRules: enableSubfolderRules ?? false,
language: language ?? formatLanguage(vscode.env.language),
renderContext: this.renderContext,
maxReadFileLine: maxReadFileLine ?? -1,
@ -2270,6 +2272,7 @@ export class ClineProvider
browserToolEnabled: stateValues.browserToolEnabled ?? true,
telemetrySetting: stateValues.telemetrySetting || "unset",
showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false,
enableSubfolderRules: stateValues.enableSubfolderRules ?? false,
maxReadFileLine: stateValues.maxReadFileLine ?? -1,
maxImageFileSize: stateValues.maxImageFileSize ?? 5,
maxTotalImageSize: stateValues.maxTotalImageSize ?? 20,

View file

@ -568,6 +568,7 @@ describe("ClineProvider", () => {
browserToolEnabled: true,
telemetrySetting: "unset",
showRooIgnoredFiles: false,
enableSubfolderRules: false,
renderContext: "sidebar",
maxReadFileLine: 500,
maxImageFileSize: 5,

View file

@ -27,6 +27,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web
language,
maxReadFileLine,
maxConcurrentFileReads,
enableSubfolderRules,
} = await provider.getState()
// Check experiment to determine which diff strategy to use
@ -93,6 +94,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web
maxConcurrentFileReads: maxConcurrentFileReads ?? 5,
todoListEnabled: apiConfiguration?.todoListEnabled ?? true,
useAgentRules: vscode.workspace.getConfiguration(Package.name).get<boolean>("useAgentRules") ?? true,
enableSubfolderRules: enableSubfolderRules ?? false,
newTaskRequireTodos: vscode.workspace
.getConfiguration(Package.name)
.get<boolean>("newTaskRequireTodos", false),

View file

@ -1,10 +1,11 @@
import * as path from "path"
// Use vi.hoisted to ensure mocks are available during hoisting
const { mockStat, mockReadFile, mockHomedir } = vi.hoisted(() => ({
const { mockStat, mockReadFile, mockHomedir, mockExecuteRipgrep } = vi.hoisted(() => ({
mockStat: vi.fn(),
mockReadFile: vi.fn(),
mockHomedir: vi.fn(),
mockExecuteRipgrep: vi.fn(),
}))
// Mock fs/promises module
@ -20,6 +21,11 @@ vi.mock("os", () => ({
homedir: mockHomedir,
}))
// Mock executeRipgrep from search service
vi.mock("../../search/file-search", () => ({
executeRipgrep: mockExecuteRipgrep,
}))
import {
getGlobalRooDirectory,
getProjectRooDirectoryForCwd,
@ -27,6 +33,9 @@ import {
fileExists,
readFileIfExists,
getRooDirectoriesForCwd,
getAllRooDirectoriesForCwd,
getAgentsDirectoriesForCwd,
discoverSubfolderRooDirectories,
loadConfiguration,
} from "../index"
@ -297,4 +306,188 @@ describe("RooConfigService", () => {
expect(mockReadFile).toHaveBeenCalledWith(path.join("/project/path", ".roo", "rules/rules.md"), "utf-8")
})
})
describe("discoverSubfolderRooDirectories", () => {
it("should return empty array when no subfolder .roo directories found", async () => {
mockExecuteRipgrep.mockResolvedValue([])
const result = await discoverSubfolderRooDirectories("/project/path")
expect(result).toEqual([])
})
it("should discover .roo directories from subfolders", async () => {
// Find any file inside .roo directories
mockExecuteRipgrep.mockResolvedValueOnce([
{ path: "package-a/.roo/rules/rule.md", type: "file" },
{ path: "package-b/.roo/rules-code/rule.md", type: "file" },
])
const result = await discoverSubfolderRooDirectories("/project/path")
expect(result).toEqual([
path.join("/project/path", "package-a", ".roo"),
path.join("/project/path", "package-b", ".roo"),
])
})
it("should sort discovered directories alphabetically", async () => {
mockExecuteRipgrep.mockResolvedValueOnce([
{ path: "zebra/.roo/rules/rule.md", type: "file" },
{ path: "apple/.roo/rules/rule.md", type: "file" },
{ path: "mango/.roo/rules/rule.md", type: "file" },
])
const result = await discoverSubfolderRooDirectories("/project/path")
expect(result).toEqual([
path.join("/project/path", "apple", ".roo"),
path.join("/project/path", "mango", ".roo"),
path.join("/project/path", "zebra", ".roo"),
])
})
it("should exclude root .roo directory", async () => {
// This would match the root .roo, which should be excluded
mockExecuteRipgrep.mockResolvedValueOnce([
{ path: ".roo/rules/rule.md", type: "file" }, // This is root - should be excluded
{ path: "subfolder/.roo/rules/rule.md", type: "file" },
])
const result = await discoverSubfolderRooDirectories("/project/path")
// Should only include subfolder, not root
expect(result).toEqual([path.join("/project/path", "subfolder", ".roo")])
})
it("should handle nested subdirectories", async () => {
mockExecuteRipgrep.mockResolvedValueOnce([
{ path: "packages/core/.roo/rules/rule.md", type: "file" },
{ path: "packages/utils/.roo/rules-code/rule.md", type: "file" },
])
const result = await discoverSubfolderRooDirectories("/project/path")
expect(result).toEqual([
path.join("/project/path", "packages/core", ".roo"),
path.join("/project/path", "packages/utils", ".roo"),
])
})
it("should return empty array on ripgrep error", async () => {
mockExecuteRipgrep.mockRejectedValue(new Error("ripgrep failed"))
const result = await discoverSubfolderRooDirectories("/project/path")
expect(result).toEqual([])
})
it("should deduplicate .roo directories from multiple files", async () => {
mockExecuteRipgrep.mockResolvedValueOnce([
{ path: "package-a/.roo/rules/rule1.md", type: "file" },
{ path: "package-a/.roo/rules/rule2.md", type: "file" },
{ path: "package-a/.roo/rules-code/rule3.md", type: "file" },
])
const result = await discoverSubfolderRooDirectories("/project/path")
// Should only include package-a/.roo once
expect(result).toEqual([path.join("/project/path", "package-a", ".roo")])
})
it("should discover .roo directories with any content", async () => {
// Should find .roo directories regardless of what's inside them
mockExecuteRipgrep.mockResolvedValueOnce([
{ path: "package-a/.roo/rules/rule.md", type: "file" },
{ path: "package-b/.roo/rules-code/code-rule.md", type: "file" },
{ path: "package-c/.roo/rules-architect/arch-rule.md", type: "file" },
{ path: "package-d/.roo/config/settings.json", type: "file" },
])
const result = await discoverSubfolderRooDirectories("/project/path")
expect(result).toEqual([
path.join("/project/path", "package-a", ".roo"),
path.join("/project/path", "package-b", ".roo"),
path.join("/project/path", "package-c", ".roo"),
path.join("/project/path", "package-d", ".roo"),
])
})
})
describe("getAllRooDirectoriesForCwd", () => {
it("should return global, project, and subfolder directories", async () => {
mockExecuteRipgrep.mockResolvedValueOnce([{ path: "subfolder/.roo/rules/rule.md", type: "file" }])
const result = await getAllRooDirectoriesForCwd("/project/path")
expect(result).toEqual([
path.join("/mock/home", ".roo"), // global
path.join("/project/path", ".roo"), // project
path.join("/project/path", "subfolder", ".roo"), // subfolder
])
})
it("should return only global and project when no subfolders", async () => {
mockExecuteRipgrep.mockResolvedValue([])
const result = await getAllRooDirectoriesForCwd("/project/path")
expect(result).toEqual([path.join("/mock/home", ".roo"), path.join("/project/path", ".roo")])
})
it("should maintain order: global, project, subfolders (alphabetically)", async () => {
mockExecuteRipgrep.mockResolvedValueOnce([
{ path: "zebra/.roo/rules/rule.md", type: "file" },
{ path: "apple/.roo/rules/rule.md", type: "file" },
])
const result = await getAllRooDirectoriesForCwd("/project/path")
expect(result).toEqual([
path.join("/mock/home", ".roo"), // global first
path.join("/project/path", ".roo"), // project second
path.join("/project/path", "apple", ".roo"), // subfolders alphabetically
path.join("/project/path", "zebra", ".roo"),
])
})
})
describe("getAgentsDirectoriesForCwd", () => {
it("should return root directory and parent directories of subfolder .roo dirs", async () => {
mockExecuteRipgrep.mockResolvedValueOnce([{ path: "package-a/.roo/rules/rule.md", type: "file" }])
const result = await getAgentsDirectoriesForCwd("/project/path")
expect(result).toEqual([
"/project/path", // root
path.join("/project/path", "package-a"), // parent of .roo
])
})
it("should always include root even when no subfolders", async () => {
mockExecuteRipgrep.mockResolvedValue([])
const result = await getAgentsDirectoriesForCwd("/project/path")
expect(result).toEqual(["/project/path"])
})
it("should include multiple subfolder parent directories", async () => {
mockExecuteRipgrep.mockResolvedValueOnce([
{ path: "package-a/.roo/rules/rule.md", type: "file" },
{ path: "package-b/.roo/rules-code/rule.md", type: "file" },
{ path: "packages/core/.roo/rules/rule.md", type: "file" },
])
const result = await getAgentsDirectoriesForCwd("/project/path")
expect(result).toEqual([
"/project/path",
path.join("/project/path", "package-a"),
path.join("/project/path", "package-b"),
path.join("/project/path", "packages/core"),
])
})
})
})

View file

@ -111,6 +111,89 @@ export async function readFileIfExists(filePath: string): Promise<string | null>
}
}
/**
* Discovers all .roo directories in subdirectories of the workspace
*
* @param cwd - Current working directory (workspace root)
* @returns Array of absolute paths to .roo directories found in subdirectories,
* sorted alphabetically. Does not include the root .roo directory.
*
* @example
* ```typescript
* const subfolderRoos = await discoverSubfolderRooDirectories('/Users/john/monorepo')
* // Returns:
* // [
* // '/Users/john/monorepo/package-a/.roo',
* // '/Users/john/monorepo/package-b/.roo',
* // '/Users/john/monorepo/packages/shared/.roo'
* // ]
* ```
*
* @example Directory structure:
* ```
* /Users/john/monorepo/
* .roo/ # Root .roo (NOT included - use getProjectRooDirectoryForCwd)
* package-a/
* .roo/ # Included
* rules/
* package-b/
* .roo/ # Included
* rules-code/
* packages/
* shared/
* .roo/ # Included (nested)
* rules/
* ```
*/
export async function discoverSubfolderRooDirectories(cwd: string): Promise<string[]> {
try {
// Dynamic import to avoid vscode dependency at module load time
// This is necessary because file-search.ts imports vscode, which is not
// available in the webview context
const { executeRipgrep } = await import("../search/file-search")
// Use ripgrep to find any file inside any .roo directory
// This efficiently discovers all .roo folders regardless of their content
const args = [
"--files",
"--hidden",
"--follow",
"-g",
"**/.roo/**",
"-g",
"!node_modules/**",
"-g",
"!.git/**",
cwd,
]
const results = await executeRipgrep({ args, workspacePath: cwd })
// Extract unique .roo directory paths
const rooDirs = new Set<string>()
const rootRooDir = path.join(cwd, ".roo")
for (const result of results) {
// Match paths like "subfolder/.roo/anything" or "subfolder/nested/.roo/anything"
// Handle both forward slashes (Unix) and backslashes (Windows)
const match = result.path.match(/^(.+?)[/\\]\.roo[/\\]/)
if (match) {
const rooDir = path.join(cwd, match[1], ".roo")
// Exclude the root .roo directory (already handled by getProjectRooDirectoryForCwd)
if (rooDir !== rootRooDir) {
rooDirs.add(rooDir)
}
}
}
// Return sorted alphabetically
return Array.from(rooDirs).sort()
} catch (error) {
// If discovery fails (e.g., ripgrep not available), return empty array
return []
}
}
/**
* Gets the ordered list of .roo directories to check (global first, then project-local)
*
@ -156,6 +239,71 @@ export function getRooDirectoriesForCwd(cwd: string): string[] {
return directories
}
/**
* Gets the ordered list of all .roo directories including subdirectories
*
* @param cwd - Current working directory (project path)
* @returns Array of directory paths in order: [global, project-local, ...subfolders (alphabetically)]
*
* @example
* ```typescript
* // For a monorepo at /Users/john/monorepo with .roo in subfolders
* const directories = await getAllRooDirectoriesForCwd('/Users/john/monorepo')
* // Returns:
* // [
* // '/Users/john/.roo', // Global directory
* // '/Users/john/monorepo/.roo', // Project-local directory
* // '/Users/john/monorepo/package-a/.roo', // Subfolder (alphabetical)
* // '/Users/john/monorepo/package-b/.roo' // Subfolder (alphabetical)
* // ]
* ```
*/
export async function getAllRooDirectoriesForCwd(cwd: string): Promise<string[]> {
const directories: string[] = []
// Add global directory first
directories.push(getGlobalRooDirectory())
// Add project-local directory second
directories.push(getProjectRooDirectoryForCwd(cwd))
// Discover and add subfolder .roo directories
const subfolderDirs = await discoverSubfolderRooDirectories(cwd)
directories.push(...subfolderDirs)
return directories
}
/**
* Gets parent directories containing .roo folders, in order from root to subfolders
*
* @param cwd - Current working directory (project path)
* @returns Array of parent directory paths (not .roo paths) containing AGENTS.md or .roo
*
* @example
* ```typescript
* const dirs = await getAgentsDirectoriesForCwd('/Users/john/monorepo')
* // Returns: ['/Users/john/monorepo', '/Users/john/monorepo/package-a', ...]
* ```
*/
export async function getAgentsDirectoriesForCwd(cwd: string): Promise<string[]> {
const directories: string[] = []
// Always include the root directory
directories.push(cwd)
// Get all subfolder .roo directories
const subfolderRooDirs = await discoverSubfolderRooDirectories(cwd)
// Extract parent directories (remove .roo from path)
for (const rooDir of subfolderRooDirs) {
const parentDir = path.dirname(rooDir)
directories.push(parentDir)
}
return directories
}
/**
* Loads configuration from multiple .roo directories with project overriding global
*

View file

@ -308,6 +308,7 @@ export type ExtensionState = Pick<
maxOpenTabsContext: number // Maximum number of VSCode open tabs to include in context (0-500)
maxWorkspaceFiles: number // Maximum number of files to include in current working directory details (0-500)
showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings
enableSubfolderRules: boolean // Whether to load rules from subdirectories
maxReadFileLine: number // Maximum number of lines to read from a file before truncating
maxImageFileSize: number // Maximum size of image files to process in MB
maxTotalImageSize: number // Maximum total size for all images in a single read operation in MB

25
src/shared/string-extensions.d.ts vendored Normal file
View file

@ -0,0 +1,25 @@
/**
* Global string extensions declaration.
* This file provides type declarations for String.prototype extensions
* that are used across the codebase.
*
* The actual implementation is in src/utils/path.ts.
*
* This separate declaration file is necessary because the webview-ui package
* includes ../src/shared in its tsconfig.json but not ../src/utils/path.ts.
* Without this file, the webview-ui compilation would fail when processing
* files that use the toPosix() method.
*/
declare global {
interface String {
/**
* Convert a path string to POSIX format (forward slashes).
* Extended-Length Paths in Windows (\\?\) are preserved.
* @returns The path with backslashes converted to forward slashes
*/
toPosix(): string
}
}
// This export is needed to make this file a module
export {}

View file

@ -19,6 +19,7 @@ type ContextManagementSettingsProps = HTMLAttributes<HTMLDivElement> & {
maxOpenTabsContext: number
maxWorkspaceFiles: number
showRooIgnoredFiles?: boolean
enableSubfolderRules?: boolean
maxReadFileLine?: number
maxImageFileSize?: number
maxTotalImageSize?: number
@ -36,6 +37,7 @@ type ContextManagementSettingsProps = HTMLAttributes<HTMLDivElement> & {
| "maxOpenTabsContext"
| "maxWorkspaceFiles"
| "showRooIgnoredFiles"
| "enableSubfolderRules"
| "maxReadFileLine"
| "maxImageFileSize"
| "maxTotalImageSize"
@ -57,6 +59,7 @@ export const ContextManagementSettings = ({
maxOpenTabsContext,
maxWorkspaceFiles,
showRooIgnoredFiles,
enableSubfolderRules,
setCachedStateField,
maxReadFileLine,
maxImageFileSize,
@ -203,6 +206,20 @@ export const ContextManagementSettings = ({
</div>
</div>
<div>
<VSCodeCheckbox
checked={enableSubfolderRules}
onChange={(e: any) => setCachedStateField("enableSubfolderRules", e.target.checked)}
data-testid="enable-subfolder-rules-checkbox">
<label className="block font-medium mb-1">
{t("settings:contextManagement.enableSubfolderRules.label")}
</label>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm mt-1 mb-3">
{t("settings:contextManagement.enableSubfolderRules.description")}
</div>
</div>
<div>
<div className="flex flex-col gap-2">
<span className="font-medium">{t("settings:contextManagement.maxReadFile.label")}</span>

View file

@ -188,6 +188,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
terminalZdotdir,
writeDelayMs,
showRooIgnoredFiles,
enableSubfolderRules,
remoteBrowserEnabled,
maxReadFileLine,
maxImageFileSize,
@ -395,6 +396,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
maxOpenTabsContext: Math.min(Math.max(0, maxOpenTabsContext ?? 20), 500),
maxWorkspaceFiles: Math.min(Math.max(0, maxWorkspaceFiles ?? 200), 500),
showRooIgnoredFiles: showRooIgnoredFiles ?? true,
enableSubfolderRules: enableSubfolderRules ?? false,
maxReadFileLine: maxReadFileLine ?? -1,
maxImageFileSize: maxImageFileSize ?? 5,
maxTotalImageSize: maxTotalImageSize ?? 20,
@ -771,6 +773,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
maxOpenTabsContext={maxOpenTabsContext}
maxWorkspaceFiles={maxWorkspaceFiles ?? 200}
showRooIgnoredFiles={showRooIgnoredFiles}
enableSubfolderRules={enableSubfolderRules}
maxReadFileLine={maxReadFileLine}
maxImageFileSize={maxImageFileSize}
maxTotalImageSize={maxTotalImageSize}

View file

@ -73,6 +73,7 @@ export interface ExtensionStateContextType extends ExtensionState {
setAlwaysAllowSubtasks: (value: boolean) => void
setBrowserToolEnabled: (value: boolean) => void
setShowRooIgnoredFiles: (value: boolean) => void
setEnableSubfolderRules: (value: boolean) => void
setShowAnnouncement: (value: boolean) => void
setAllowedCommands: (value: string[]) => void
setDeniedCommands: (value: string[]) => void
@ -234,6 +235,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
browserToolEnabled: true,
telemetrySetting: "unset",
showRooIgnoredFiles: true, // Default to showing .rooignore'd files with lock symbol (current behavior).
enableSubfolderRules: false, // Default to disabled - must be enabled to load rules from subdirectories
renderContext: "sidebar",
maxReadFileLine: -1, // Default max read file line limit
maxImageFileSize: 5, // Default max image file size in MB
@ -538,6 +540,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setBrowserToolEnabled: (value) => setState((prevState) => ({ ...prevState, browserToolEnabled: value })),
setTelemetrySetting: (value) => setState((prevState) => ({ ...prevState, telemetrySetting: value })),
setShowRooIgnoredFiles: (value) => setState((prevState) => ({ ...prevState, showRooIgnoredFiles: value })),
setEnableSubfolderRules: (value) => setState((prevState) => ({ ...prevState, enableSubfolderRules: value })),
setRemoteBrowserEnabled: (value) => setState((prevState) => ({ ...prevState, remoteBrowserEnabled: value })),
setAwsUsePromptCache: (value) => setState((prevState) => ({ ...prevState, awsUsePromptCache: value })),
setMaxReadFileLine: (value) => setState((prevState) => ({ ...prevState, maxReadFileLine: value })),

View file

@ -198,6 +198,7 @@ describe("mergeExtensionState", () => {
apiConfiguration: { providerId: "openrouter" } as ProviderSettings,
telemetrySetting: "unset",
showRooIgnoredFiles: true,
enableSubfolderRules: false,
renderContext: "sidebar",
maxReadFileLine: 500,
cloudUserInfo: null,

View file

@ -661,6 +661,10 @@
"maxGitStatusFiles": {
"label": "Git status màx. fitxers",
"description": "Nombre màxim d'entrades de fitxers a incloure en el context d'estat de git. Establiu a 0 per desactivar. La informació de la branca i els commits sempre es mostren quan és > 0."
},
"enableSubfolderRules": {
"label": "Activa les regles dels subdirectoris",
"description": "Descobreix i carrega recursivament fitxers .roo/rules i AGENTS.md des de subdirectoris. Útil per a monorepos amb regles per paquet."
}
},
"terminal": {

View file

@ -661,6 +661,10 @@
"maxGitStatusFiles": {
"label": "Git-Status max. Dateien",
"description": "Maximale Anzahl von Dateieinträgen, die in den Git-Status-Kontext aufgenommen werden sollen. Auf 0 setzen, um zu deaktivieren. Branch-Informationen und Commits werden immer angezeigt, wenn > 0."
},
"enableSubfolderRules": {
"label": "Unterordner-Regeln aktivieren",
"description": "Rekursiv .roo/rules und AGENTS.md-Dateien aus Unterverzeichnissen laden. Nützlich für Monorepos mit paketspezifischen Regeln."
}
},
"terminal": {

View file

@ -670,6 +670,10 @@
"maxGitStatusFiles": {
"label": "Git status max files",
"description": "Maximum number of file entries to include in git status context. Set to 0 to disable. Branch info is always shown when > 0."
},
"enableSubfolderRules": {
"label": "Enable subfolder rules",
"description": "Recursively discover and load .roo/rules and AGENTS.md files from subdirectories. Useful for monorepos with per-package rules."
}
},
"terminal": {

View file

@ -661,6 +661,10 @@
"maxGitStatusFiles": {
"label": "Git status máx. archivos",
"description": "Número máximo de entradas de archivo para incluir en el contexto de estado de git. Establézcalo en 0 para deshabilitar. La información de la rama y los commits siempre se muestran cuando es > 0."
},
"enableSubfolderRules": {
"label": "Activar reglas de subcarpetas",
"description": "Descubrir y cargar recursivamente archivos .roo/rules y AGENTS.md desde subdirectorios. Útil para monorepos con reglas por paquete."
}
},
"terminal": {

View file

@ -661,6 +661,10 @@
"maxGitStatusFiles": {
"label": "Git status max fichiers",
"description": "Nombre maximum de fichiers à inclure dans le contexte de statut git. Mettre à 0 pour désactiver. Les informations de branche et les commits sont toujours affichés si > 0."
},
"enableSubfolderRules": {
"label": "Activer les règles des sous-dossiers",
"description": "Découvrir et charger récursivement les fichiers .roo/rules et AGENTS.md depuis les sous-répertoires. Utile pour les monorepos avec des règles par package."
}
},
"terminal": {

View file

@ -662,6 +662,10 @@
"maxGitStatusFiles": {
"label": "गिट स्थिति अधिकतम फ़ाइलें",
"description": "गिट स्थिति संदर्भ में शामिल करने के लिए फ़ाइल प्रविष्टियों की अधिकतम संख्या। अक्षम करने के लिए 0 पर सेट करें। शाखा जानकारी और कमिट हमेशा दिखाए जाते हैं जब > 0 होता है।"
},
"enableSubfolderRules": {
"label": "सबफ़ोल्डर नियम सक्षम करें",
"description": "उपनिर्देशिकाओं से .roo/rules और AGENTS.md फ़ाइलों को पुनरावर्ती रूप से खोजें और लोड करें। प्रति-पैकेज नियमों वाले मोनोरेपो के लिए उपयोगी।"
}
},
"terminal": {

View file

@ -666,6 +666,10 @@
"maxGitStatusFiles": {
"label": "Git status maks file",
"description": "Jumlah maksimum entri file untuk disertakan dalam konteks status git. Atur ke 0 untuk menonaktifkan. Info cabang dan commit selalu ditampilkan saat > 0."
},
"enableSubfolderRules": {
"label": "Aktifkan aturan subfolder",
"description": "Temukan dan muat file .roo/rules dan AGENTS.md secara rekursif dari subdirektori. Berguna untuk monorepo dengan aturan per-paket."
}
},
"terminal": {

View file

@ -662,6 +662,10 @@
"maxGitStatusFiles": {
"label": "Git status max file",
"description": "Numero massimo di voci di file da includere nel contesto dello stato di git. Imposta a 0 per disabilitare. Le informazioni sul ramo e sui commit vengono sempre mostrate quando > 0."
},
"enableSubfolderRules": {
"label": "Abilita regole sottocartelle",
"description": "Scopri e carica ricorsivamente i file .roo/rules e AGENTS.md dalle sottodirectory. Utile per i monorepo con regole per pacchetto."
}
},
"terminal": {

View file

@ -662,6 +662,10 @@
"maxGitStatusFiles": {
"label": "Gitステータス最大ファイル数",
"description": "gitステータスコンテキストに含めるファイルエントリの最大数。無効にするには0に設定します。ブランチ情報とコミットは、> 0の場合に常に表示されます。"
},
"enableSubfolderRules": {
"label": "サブフォルダルールを有効化",
"description": "サブディレクトリから.roo/rulesとAGENTS.mdファイルを再帰的に検出してロードします。パッケージごとのルールを持つモレポに便利です。"
}
},
"terminal": {

View file

@ -662,6 +662,10 @@
"maxGitStatusFiles": {
"label": "Git 상태 최대 파일",
"description": "git 상태 컨텍스트에 포함할 최대 파일 항목 수입니다. 비활성화하려면 0으로 설정하세요. 분기 정보와 커밋은 > 0일 때 항상 표시됩니다."
},
"enableSubfolderRules": {
"label": "하위 폴더 규칙 활성화",
"description": "하위 디렉토리에서 .roo/rules 및 AGENTS.md 파일을 재귀적으로 검색하고 로드합니다. 패키지별 규칙이 있는 모노레포에 유용합니다."
}
},
"terminal": {

View file

@ -662,6 +662,10 @@
"maxGitStatusFiles": {
"label": "Git status max bestanden",
"description": "Maximum aantal bestandsvermeldingen dat in de git-statuscontext moet worden opgenomen. Stel in op 0 om uit te schakelen. Branch-info en commits worden altijd getoond wanneer > 0."
},
"enableSubfolderRules": {
"label": "Submap-regels inschakelen",
"description": "Recursief .roo/rules en AGENTS.md-bestanden uit submappen ontdekken en laden. Handig voor monorepo's met regels per pakket."
}
},
"terminal": {

View file

@ -662,6 +662,10 @@
"maxGitStatusFiles": {
"label": "Git status maks. plików",
"description": "Maksymalna liczba wpisów plików do uwzględnienia w kontekście statusu git. Ustaw na 0, aby wyłączyć. Informacje o gałęzi i zatwierdzenia są zawsze pokazywane, gdy > 0."
},
"enableSubfolderRules": {
"label": "Włącz reguły podfolderów",
"description": "Rekursywnie odkrywaj i ładuj pliki .roo/rules i AGENTS.md z podkatalogów. Przydatne dla monorepo z regułami dla poszczególnych pakietów."
}
},
"terminal": {

View file

@ -662,6 +662,10 @@
"maxGitStatusFiles": {
"label": "Git status máx. arquivos",
"description": "Número máximo de entradas de arquivo a serem incluídas no contexto de status do git. Defina como 0 para desativar. Informações sobre o branch e os commits são sempre exibidos quando > 0."
},
"enableSubfolderRules": {
"label": "Ativar regras de subpastas",
"description": "Descobrir e carregar recursivamente arquivos .roo/rules e AGENTS.md de subdiretórios. Útil para monorepos com regras por pacote."
}
},
"terminal": {

View file

@ -662,6 +662,10 @@
"maxGitStatusFiles": {
"label": "Git статус макс. файлов",
"description": "Максимальное количество записей файлов для включения в контекст статуса git. Установите значение 0, чтобы отключить. Информация о ветке и коммитах всегда отображается, если значение > 0."
},
"enableSubfolderRules": {
"label": "Включить правила подпапок",
"description": "Рекурсивно обнаруживать и загружать файлы .roo/rules и AGENTS.md из подкаталогов. Полезно для монорепозиториев с правилами для каждого пакета."
}
},
"terminal": {

View file

@ -662,6 +662,10 @@
"maxGitStatusFiles": {
"label": "Git durumu maks. dosya",
"description": "Git durum bağlamına dahil edilecek maksimum dosya girişi sayısı. Devre dışı bırakmak için 0 olarak ayarlayın. Dal bilgisi ve commit'ler > 0 olduğunda her zaman gösterilir."
},
"enableSubfolderRules": {
"label": "Alt klasör kurallarını etkinleştir",
"description": "Alt dizinlerden .roo/rules ve AGENTS.md dosyalarını yinelemeli olarak keşfet ve yükle. Paket başına kuralları olan monorepo'lar için kullanışlı."
}
},
"terminal": {

View file

@ -662,6 +662,10 @@
"maxGitStatusFiles": {
"label": "Git status tệp tối đa",
"description": "Số lượng mục tệp tối đa để bao gồm trong ngữ cảnh trạng thái git. Đặt thành 0 để tắt. Thông tin nhánh và các commit luôn được hiển thị khi > 0."
},
"enableSubfolderRules": {
"label": "Bật quy tắc thư mục con",
"description": "Khám phá và tải đệ quy các tệp .roo/rules và AGENTS.md từ các thư mục con. Hữu ích cho monorepo có quy tắc theo gói."
}
},
"terminal": {

View file

@ -662,6 +662,10 @@
"maxGitStatusFiles": {
"label": "Git 状态最大文件数",
"description": "git状态上下文中包含的最大文件条目数。设为0禁用。分支信息和提交在>0时始终显示。"
},
"enableSubfolderRules": {
"label": "启用子文件夹规则",
"description": "递归发现并加载子目录中的 .roo/rules 和 AGENTS.md 文件。适用于具有每包规则的 monorepo。"
}
},
"terminal": {

View file

@ -662,6 +662,10 @@
"maxGitStatusFiles": {
"label": "Git 狀態最大檔案數",
"description": "git狀態上下文中包含的最大檔案條目數。設為0禁用。分支資訊和提交在>0時始終顯示。"
},
"enableSubfolderRules": {
"label": "啟用子資料夾規則",
"description": "遞迴發現並載入子目錄中的 .roo/rules 和 AGENTS.md 檔案。適用於具有每包規則的 monorepo。"
}
},
"terminal": {

View file

@ -103,6 +103,10 @@ export default defineConfig(({ mode }) => {
// Use a single combined CSS bundle so both webviews share styles
cssCodeSplit: false,
rollupOptions: {
// Externalize vscode module - it's imported by file-search.ts which is
// dynamically imported by roo-config/index.ts, but should never be bundled
// in the webview since it's not available in the browser context
external: ["vscode"],
input: {
index: resolve(__dirname, "index.html"),
"browser-panel": resolve(__dirname, "browser-panel.html"),