diff --git a/.changeset/terminal-output-filter.md b/.changeset/terminal-output-filter.md new file mode 100644 index 00000000000..f3f803fda93 --- /dev/null +++ b/.changeset/terminal-output-filter.md @@ -0,0 +1,17 @@ +--- +"roo-cline": minor +--- + +Add built-in terminal output filtering to reduce LLM token usage + +Introduces a new `TerminalOutputFilter` module that applies command-aware output filtering before terminal output reaches the LLM context. This reduces token consumption by stripping noise (passing tests, progress bars, verbose logs) while preserving actionable information (errors, failures, summaries). + +Built-in filters for common commands: + +- **Test runners** (jest, vitest, mocha, pytest, cargo test, go test): Extract pass/fail summary + failure details +- **git status**: Compact file-change summary +- **git log**: One-line-per-commit format +- **Package managers** (npm, yarn, pnpm, pip): Strip progress/download noise, keep warnings + summary +- **Build tools** (tsc, cargo build, webpack, etc.): Strip progress, keep errors/warnings + +New setting `terminalOutputFilterEnabled` (default: true) with toggle in Terminal Settings UI. diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 6189f645eb2..1f0929117a8 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -178,6 +178,14 @@ export const globalSettingsSchema = z.object({ maxTotalImageSize: z.number().optional(), terminalOutputPreviewSize: z.enum(["small", "medium", "large"]).optional(), + /** + * Whether to enable command-aware output filtering for terminal output. + * When enabled, command output is semantically filtered before reaching the LLM, + * reducing token usage by stripping noise (passing tests, progress bars, verbose logs) + * while preserving actionable information (errors, failures, summaries). + * @default true + */ + terminalOutputFilterEnabled: z.boolean().optional(), terminalShellIntegrationTimeout: z.number().optional(), terminalShellIntegrationDisabled: z.boolean().optional(), terminalCommandDelay: z.number().optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index f54de2330f5..c8b31b2517d 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -303,6 +303,7 @@ export type ExtensionState = Pick< | "soundVolume" | "maxConcurrentFileReads" | "terminalOutputPreviewSize" + | "terminalOutputFilterEnabled" | "terminalShellIntegrationTimeout" | "terminalShellIntegrationDisabled" | "terminalCommandDelay" diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index ef1370202e1..b9aa4620d82 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -16,6 +16,7 @@ import { ExitCodeDetails, RooTerminalCallbacks, RooTerminalProcess } from "../.. import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" import { Terminal } from "../../integrations/terminal/Terminal" import { OutputInterceptor } from "../../integrations/terminal/OutputInterceptor" +import { filterTerminalOutput, formatFilterIndicator } from "../../integrations/terminal/TerminalOutputFilter" import { Package } from "../../shared/package" import { t } from "../../i18n" import { getTaskDirectoryPath } from "../../utils/storage" @@ -185,16 +186,19 @@ export async function executeCommandInTerminal( const terminalProvider = terminalShellIntegrationDisabled ? "execa" : "vscode" const provider = await task.providerRef.deref() + const providerState = await provider?.getState() // Get global storage path for persisted output artifacts const globalStoragePath = provider?.context?.globalStorageUri?.fsPath let interceptor: OutputInterceptor | undefined + // Check if terminal output filtering is enabled (default: true) + const terminalOutputFilterEnabled = providerState?.terminalOutputFilterEnabled !== false + // Create OutputInterceptor if we have storage available if (globalStoragePath) { const taskDir = await getTaskDirectoryPath(globalStoragePath, task.taskId) const storageDir = path.join(taskDir, "command-output") - const providerState = await provider?.getState() const terminalOutputPreviewSize = providerState?.terminalOutputPreviewSize ?? DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE @@ -391,6 +395,17 @@ export async function executeCommandInTerminal( // Use persisted output format when output was truncated and spilled to disk if (persistedResult?.truncated) { + // Apply command-aware filtering to persisted preview if enabled + if (terminalOutputFilterEnabled) { + const filterResult = filterTerminalOutput(command, persistedResult.preview) + if (filterResult) { + const indicator = formatFilterIndicator(filterResult, true) + persistedResult = { + ...persistedResult, + preview: filterResult.output + "\n\n" + indicator, + } + } + } return [false, formatPersistedOutput(persistedResult, exitDetails, currentWorkingDir)] } @@ -419,9 +434,20 @@ export async function executeCommandInTerminal( exitStatus = `Exit code: ` } + // Apply command-aware output filtering for inline results if enabled + let outputForLlm = result + let filterIndicator = "" + if (terminalOutputFilterEnabled) { + const filterResult = filterTerminalOutput(command, result) + if (filterResult) { + outputForLlm = filterResult.output + filterIndicator = "\n" + formatFilterIndicator(filterResult, !!persistedResult?.artifactPath) + } + } + return [ false, - `Command executed in terminal within working directory '${currentWorkingDir}'. ${exitStatus}\nOutput:\n${result}`, + `Command executed in terminal within working directory '${currentWorkingDir}'. ${exitStatus}\nOutput:\n${outputForLlm}${filterIndicator}`, ] } else { return [ diff --git a/src/integrations/terminal/TerminalOutputFilter.ts b/src/integrations/terminal/TerminalOutputFilter.ts new file mode 100644 index 00000000000..e66473e1c54 --- /dev/null +++ b/src/integrations/terminal/TerminalOutputFilter.ts @@ -0,0 +1,517 @@ +/** + * TerminalOutputFilter - Command-aware output filtering/compression for LLM context. + * + * Applies semantic filters to terminal command output before it reaches the LLM, + * reducing token usage by stripping noise (passing tests, progress bars, verbose logs) + * while preserving actionable information (errors, failures, summaries). + * + * Inspired by RTK (https://github.com/rtk-ai/rtk) which demonstrated ~89% token savings. + * + * @see https://github.com/RooCodeInc/Roo-Code/issues/11459 + */ + +/** + * A filter rule that matches a command and transforms its output. + */ +export interface OutputFilterRule { + /** Human-readable name for this filter */ + name: string + /** Regex pattern to match against the command string */ + commandPattern: RegExp + /** Transform the output for the matched command */ + filter: (output: string, command: string) => FilterResult +} + +/** + * Result of applying an output filter. + */ +export interface FilterResult { + /** The filtered output text */ + output: string + /** The name of the filter that was applied */ + filterName: string + /** Number of lines in original output */ + originalLineCount: number + /** Number of lines in filtered output */ + filteredLineCount: number +} + +// ─── Built-in filter implementations ──────────────────────────────────────── + +/** + * Filter for test runner output (jest, vitest, mocha, pytest, cargo test, go test, etc.) + * + * Extracts pass/fail summary and failure details, strips passing test lines, + * progress indicators, and verbose formatting. + */ +function filterTestOutput(output: string, _command: string): FilterResult { + const lines = output.split("\n") + const originalLineCount = lines.length + + const summaryLines: string[] = [] + const failureLines: string[] = [] + let inFailureBlock = false + let failureIndent = 0 + + for (const line of lines) { + const trimmed = line.trim() + + // Capture summary/result lines + if (isTestSummaryLine(trimmed)) { + summaryLines.push(line) + inFailureBlock = false + continue + } + + // Detect start of failure block + if (isTestFailureLine(trimmed)) { + inFailureBlock = true + failureIndent = line.length - line.trimStart().length + failureLines.push(line) + continue + } + + // Continue capturing failure block content (indented continuation) + if (inFailureBlock) { + const currentIndent = line.length - line.trimStart().length + if (trimmed === "" || currentIndent > failureIndent) { + failureLines.push(line) + continue + } + // End of failure block + inFailureBlock = false + } + + // Capture error/warning lines outside failure blocks + if (isErrorLine(trimmed)) { + failureLines.push(line) + continue + } + } + + // Build filtered output + const resultParts: string[] = [] + + if (failureLines.length > 0) { + resultParts.push("Failures:") + resultParts.push(...failureLines) + resultParts.push("") + } + + if (summaryLines.length > 0) { + resultParts.push("Summary:") + resultParts.push(...summaryLines) + } + + // If we couldn't extract meaningful summary info, return original + if (resultParts.length === 0) { + return { + output, + filterName: "test-runner", + originalLineCount, + filteredLineCount: originalLineCount, + } + } + + const filtered = resultParts.join("\n") + return { + output: filtered, + filterName: "test-runner", + originalLineCount, + filteredLineCount: filtered.split("\n").length, + } +} + +/** + * Filter for git status output. + * Produces a compact summary of changes. + */ +function filterGitStatusOutput(output: string, _command: string): FilterResult { + const lines = output.split("\n") + const originalLineCount = lines.length + + const staged: string[] = [] + const unstaged: string[] = [] + const untracked: string[] = [] + const branchInfo: string[] = [] + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + + // Branch info + if (trimmed.startsWith("On branch ") || trimmed.startsWith("Your branch ")) { + branchInfo.push(trimmed) + continue + } + + // Detect file status markers (short format: XY filename) + const shortMatch = trimmed.match(/^([MADRCU?! ]{1,2})\s+(.+)$/) + if (shortMatch) { + const status = shortMatch[1] + const file = shortMatch[2] + if (status.startsWith("?")) { + untracked.push(file) + } else if (status[0] !== " ") { + staged.push(`${status.trim()} ${file}`) + } else { + unstaged.push(`${status.trim()} ${file}`) + } + continue + } + + // Long format patterns + if (/^\s*(modified|new file|deleted|renamed|copied):\s+/.test(trimmed)) { + // Determine context from previous section headers + unstaged.push(trimmed) + } + } + + const parts: string[] = [] + if (branchInfo.length > 0) { + parts.push(...branchInfo) + } + + if (staged.length > 0) { + parts.push(`Staged (${staged.length}): ${staged.join(", ")}`) + } + if (unstaged.length > 0) { + parts.push(`Unstaged (${unstaged.length}): ${unstaged.join(", ")}`) + } + if (untracked.length > 0) { + parts.push(`Untracked (${untracked.length}): ${untracked.join(", ")}`) + } + + if (parts.length === 0) { + // Clean working tree or unparseable - return as-is + return { + output, + filterName: "git-status", + originalLineCount, + filteredLineCount: originalLineCount, + } + } + + const filtered = parts.join("\n") + return { + output: filtered, + filterName: "git-status", + originalLineCount, + filteredLineCount: filtered.split("\n").length, + } +} + +/** + * Filter for git log output. + * Compacts to one-line-per-commit format. + */ +function filterGitLogOutput(output: string, _command: string): FilterResult { + const lines = output.split("\n") + const originalLineCount = lines.length + + const commits: string[] = [] + let currentCommit = "" + let currentMessage = "" + + for (const line of lines) { + const trimmed = line.trim() + + const commitMatch = trimmed.match(/^commit\s+([a-f0-9]{7,40})/) + if (commitMatch) { + if (currentCommit) { + commits.push(`${currentCommit} ${currentMessage.trim()}`) + } + currentCommit = commitMatch[1].substring(0, 7) + currentMessage = "" + continue + } + + // Skip Author/Date/Merge lines + if (/^(Author|Date|Merge):\s/.test(trimmed)) { + continue + } + + // Collect commit message (non-empty, non-metadata lines) + if (trimmed && currentCommit) { + if (!currentMessage) { + currentMessage = trimmed + } + } + } + + // Don't forget the last commit + if (currentCommit) { + commits.push(`${currentCommit} ${currentMessage.trim()}`) + } + + if (commits.length === 0) { + return { + output, + filterName: "git-log", + originalLineCount, + filteredLineCount: originalLineCount, + } + } + + const filtered = commits.join("\n") + return { + output: filtered, + filterName: "git-log", + originalLineCount, + filteredLineCount: filtered.split("\n").length, + } +} + +/** + * Filter for package manager install output (npm, yarn, pnpm, pip). + * Strips progress bars and verbose download info, keeping only warnings/errors and final summary. + */ +function filterPackageInstallOutput(output: string, _command: string): FilterResult { + const lines = output.split("\n") + const originalLineCount = lines.length + + const kept: string[] = [] + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + + // Skip progress bars and download indicators + if (isProgressLine(trimmed)) { + continue + } + + // Skip npm timing/http lines + if (/^(npm\s+)?(timing|http\s+(fetch|GET|POST))\s/i.test(trimmed)) { + continue + } + + // Skip yarn/pnpm fetch progress + if (/^(Resolving|Fetching|Linking|Building)\s.*\d+\/\d+/.test(trimmed)) { + continue + } + + // Skip pip download progress + if (/^(Downloading|Using cached|Collecting)\s/.test(trimmed) && !/error|warn/i.test(trimmed)) { + continue + } + + // Keep everything else (warnings, errors, summary, added packages) + kept.push(line) + } + + if (kept.length === 0 || kept.length >= originalLineCount) { + return { + output, + filterName: "package-install", + originalLineCount, + filteredLineCount: originalLineCount, + } + } + + const filtered = kept.join("\n") + return { + output: filtered, + filterName: "package-install", + originalLineCount, + filteredLineCount: filtered.split("\n").length, + } +} + +/** + * Filter for build tool output (tsc, cargo build, webpack, etc.) + * Strips progress lines, keeps errors/warnings and final status. + */ +function filterBuildOutput(output: string, _command: string): FilterResult { + const lines = output.split("\n") + const originalLineCount = lines.length + + const kept: string[] = [] + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + + // Skip progress indicators + if (isProgressLine(trimmed)) { + continue + } + + // Skip "Compiling X of Y" style progress + if (/^(Compiling|Downloading|Checking)\s+.*\(\d+\s*(of|\/)\s*\d+\)/.test(trimmed)) { + continue + } + + // Keep errors, warnings, and summary lines + kept.push(line) + } + + if (kept.length === 0 || kept.length >= originalLineCount) { + return { + output, + filterName: "build", + originalLineCount, + filteredLineCount: originalLineCount, + } + } + + const filtered = kept.join("\n") + return { + output: filtered, + filterName: "build", + originalLineCount, + filteredLineCount: filtered.split("\n").length, + } +} + +// ─── Helper functions ─────────────────────────────────────────────────────── + +function isTestSummaryLine(line: string): boolean { + // Jest/Vitest summary patterns + if (/^(Tests?|Test Suites?):\s+\d+/.test(line)) return true + if (/^(PASS|FAIL)\s/.test(line)) return true + if (/^\d+\s+(passing|failing|pending|skipped)/i.test(line)) return true + + // Pytest summary + if (/^=+\s*(PASSED|FAILED|ERROR|WARNING|short test summary|FAILURES)/.test(line)) return true + if (/^=+\s+\d+\s+(failed|passed)/.test(line)) return true + if (/^\d+\s+passed/.test(line)) return true + + // Cargo test summary + if (/^test result:/.test(line)) return true + if (/^(ok|FAILED)\.\s+\d+\s+passed/.test(line)) return true + + // Go test summary + if (/^(ok|FAIL)\s+\S+\s+[\d.]+s/.test(line)) return true + + // General patterns + if (/^(Ran|Running)\s+\d+\s+test/.test(line)) return true + if (/^Time:\s+[\d.]+/.test(line)) return true + + return false +} + +function isTestFailureLine(line: string): boolean { + if (/^(FAIL|✕|✗|×|✘)\s/.test(line)) return true + if (/^(\s+●\s)/.test(line)) return true // Jest failure indicator + if (/^\s*(FAILED|Error|AssertionError|expect\()/.test(line)) return true + if (/^[-]+\s*FAILED/.test(line)) return true // pytest FAILED separator + if (/^failures:$/i.test(line)) return true // cargo test failures header + if (/^---\s+FAIL:/.test(line)) return true // Go test failure + + return false +} + +function isErrorLine(line: string): boolean { + if (/^(error|Error|ERROR)\b/.test(line)) return true + if (/^(warn|Warn|WARN|warning|Warning|WARNING)\b/.test(line)) return true + if (/^\s*(at\s+)?\S+\.(ts|js|py|rs|go|java|rb):\d+/.test(line)) return true // Stack trace lines + + return false +} + +function isProgressLine(line: string): boolean { + // Common progress bar patterns + if (/[█▓▒░■□●○◆◇⣿⣀⠀]/.test(line)) return true + if (/\[[\s#=\->]+\]\s*\d+%/.test(line)) return true + if (/^\s*\d+%\s/.test(line)) return true + if (/\d+\/\d+\s*\[/.test(line)) return true + // Spinner patterns + if (/^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏|/-\\]\s/.test(line)) return true + + return false +} + +// ─── Built-in filter rules ────────────────────────────────────────────────── + +/** + * Built-in filter rules applied in order. First matching rule wins. + */ +export const BUILT_IN_FILTER_RULES: OutputFilterRule[] = [ + { + name: "test-runner", + commandPattern: + /(?:^|\s)(jest|vitest|mocha|pytest|py\.test|cargo\s+test|go\s+test|npx\s+(jest|vitest|mocha)|npm\s+(test|run\s+test)|yarn\s+test|pnpm\s+test|dotnet\s+test|rspec|phpunit|mix\s+test)/i, + filter: filterTestOutput, + }, + { + name: "git-status", + commandPattern: /\bgit\s+status\b/, + filter: filterGitStatusOutput, + }, + { + name: "git-log", + commandPattern: /\bgit\s+log\b/, + filter: filterGitLogOutput, + }, + { + name: "package-install", + commandPattern: + /(?:^|\s)(npm\s+install|npm\s+i\b|yarn\s+(install|add)|pnpm\s+(install|add)|pip\s+install|pip3\s+install|cargo\s+install|gem\s+install|composer\s+(install|require))/i, + filter: filterPackageInstallOutput, + }, + { + name: "build", + commandPattern: + /(?:^|\s)(tsc|cargo\s+build|go\s+build|make\b|cmake\s+--build|webpack|vite\s+build|next\s+build|npm\s+run\s+build|yarn\s+build|pnpm\s+build|dotnet\s+build|gradle\s+build|mvn\s+(compile|package))/i, + filter: filterBuildOutput, + }, +] + +// ─── Main filter function ─────────────────────────────────────────────────── + +/** + * Apply command-aware output filtering. + * + * Matches the command against built-in filter rules and applies the first + * matching filter. If no filter matches or the filter doesn't reduce output + * meaningfully, returns null (indicating no filtering was applied). + * + * @param command - The command that was executed + * @param output - The raw terminal output + * @returns FilterResult if a filter was applied and reduced output, null otherwise + */ +export function filterTerminalOutput(command: string, output: string): FilterResult | null { + // Don't filter very small outputs (not worth it) + const lines = output.split("\n") + if (lines.length < 5) { + return null + } + + for (const rule of BUILT_IN_FILTER_RULES) { + if (rule.commandPattern.test(command)) { + const result = rule.filter(output, command) + + // Only return the result if filtering actually reduced the output meaningfully + // (at least 20% reduction) + const reductionRatio = 1 - result.filteredLineCount / result.originalLineCount + if (reductionRatio >= 0.2) { + return result + } + + // Filter matched but didn't reduce enough - return null + return null + } + } + + return null +} + +/** + * Format the filter indicator appended to filtered output. + * This tells the LLM that filtering occurred and how to access full output. + */ +export function formatFilterIndicator(result: FilterResult, hasArtifact: boolean): string { + const reduction = Math.round((1 - result.filteredLineCount / result.originalLineCount) * 100) + const parts = [ + `[Output filtered by "${result.filterName}": ${result.originalLineCount} lines -> ${result.filteredLineCount} lines (${reduction}% reduction).`, + ] + + if (hasArtifact) { + parts.push("Use read_command_output for full output.]") + } else { + parts.push("Full output was not persisted as it was within preview limits.]") + } + + return parts.join(" ") +} diff --git a/src/integrations/terminal/__tests__/TerminalOutputFilter.spec.ts b/src/integrations/terminal/__tests__/TerminalOutputFilter.spec.ts new file mode 100644 index 00000000000..b6a93891de5 --- /dev/null +++ b/src/integrations/terminal/__tests__/TerminalOutputFilter.spec.ts @@ -0,0 +1,374 @@ +import { + filterTerminalOutput, + formatFilterIndicator, + BUILT_IN_FILTER_RULES, + type FilterResult, +} from "../TerminalOutputFilter" + +describe("TerminalOutputFilter", () => { + describe("filterTerminalOutput", () => { + it("should return null for very small outputs (< 5 lines)", () => { + const output = "line1\nline2\nline3\nline4" + const result = filterTerminalOutput("npm test", output) + expect(result).toBeNull() + }) + + it("should return null when no filter matches the command", () => { + const output = Array(20).fill("some output line").join("\n") + const result = filterTerminalOutput("echo hello", output) + expect(result).toBeNull() + }) + + it("should return null when filter matches but reduction is less than 20%", () => { + // A very short test output that wouldn't be reduced much + const output = [ + "PASS src/test.ts", + "Test Suites: 1 passed, 1 total", + "Tests: 3 passed, 3 total", + "Time: 1.5s", + "extra line", + ].join("\n") + const result = filterTerminalOutput("npm test", output) + expect(result).toBeNull() + }) + }) + + describe("test-runner filter", () => { + const makeTestOutput = (passCount: number, failCount: number): string => { + const lines: string[] = [] + lines.push("PASS src/utils/helper.spec.ts") + for (let i = 0; i < passCount; i++) { + lines.push(` ✓ should pass test ${i} (${i + 1}ms)`) + } + if (failCount > 0) { + lines.push("") + lines.push("FAIL src/core/main.spec.ts") + for (let i = 0; i < failCount; i++) { + lines.push(` ✕ should fail test ${i}`) + lines.push(` expect(received).toBe(expected)`) + lines.push(` Expected: true`) + lines.push(` Received: false`) + lines.push("") + } + } + lines.push("") + lines.push(`Test Suites: ${failCount > 0 ? "1 failed, " : ""}1 passed, ${failCount > 0 ? 2 : 1} total`) + lines.push( + `Tests: ${failCount > 0 ? `${failCount} failed, ` : ""}${passCount} passed, ${passCount + failCount} total`, + ) + lines.push("Time: 5.234s") + lines.push("Ran all test suites.") + return lines.join("\n") + } + + it("should filter jest/vitest output with passing tests", () => { + const output = makeTestOutput(50, 0) + const result = filterTerminalOutput("npx vitest run", output) + expect(result).not.toBeNull() + expect(result!.filterName).toBe("test-runner") + expect(result!.filteredLineCount).toBeLessThan(result!.originalLineCount) + expect(result!.output).toContain("Tests:") + expect(result!.output).toContain("50 passed") + }) + + it("should preserve failure details while filtering passing tests", () => { + const output = makeTestOutput(50, 2) + const result = filterTerminalOutput("npm test", output) + expect(result).not.toBeNull() + expect(result!.filterName).toBe("test-runner") + expect(result!.output).toContain("Failures:") + expect(result!.output).toContain("expect(received).toBe(expected)") + expect(result!.output).toContain("2 failed") + }) + + it("should match various test runner commands", () => { + const testOutput = makeTestOutput(30, 0) + const commands = [ + "jest", + "vitest run", + "npx jest --coverage", + "npx vitest run src/tests", + "npm test", + "npm run test", + "yarn test", + "pnpm test", + "cargo test", + "go test ./...", + "pytest", + "py.test -v", + ] + + for (const cmd of commands) { + const result = filterTerminalOutput(cmd, testOutput) + expect(result).not.toBeNull() + expect(result!.filterName).toBe("test-runner") + } + }) + + it("should handle pytest output format", () => { + const output = [ + "============================= test session starts ==============================", + "platform linux -- Python 3.10.0, pytest-7.0.0, pluggy-1.0.0", + "collected 50 items", + "", + ...Array(45).fill("test_module.py::test_case PASSED"), + "test_module.py::test_broken FAILED", + "", + "=================================== FAILURES ===================================", + "_________________________ test_broken __________________________", + "", + " def test_broken():", + "> assert False", + "E AssertionError", + "", + "=========================== short test summary info ============================", + "FAILED test_module.py::test_broken", + "======================== 1 failed, 49 passed in 2.50s ========================", + ].join("\n") + + const result = filterTerminalOutput("pytest", output) + expect(result).not.toBeNull() + expect(result!.filterName).toBe("test-runner") + expect(result!.filteredLineCount).toBeLessThan(result!.originalLineCount) + expect(result!.output).toContain("1 failed, 49 passed") + }) + + it("should handle cargo test output format", () => { + const output = [ + " Compiling myproject v0.1.0", + " Finished test profile [unoptimized + debuginfo]", + " Running unittests src/main.rs", + "", + ...Array(20).fill("test module::test_case ... ok"), + "test module::test_fail ... FAILED", + "", + "failures:", + "---- module::test_fail stdout ----", + "thread panicked at 'assertion failed'", + "", + "failures:", + " module::test_fail", + "", + "test result: FAILED. 20 passed; 1 failed; 0 ignored", + ].join("\n") + + const result = filterTerminalOutput("cargo test", output) + expect(result).not.toBeNull() + expect(result!.filterName).toBe("test-runner") + expect(result!.output).toContain("test result:") + }) + }) + + describe("git-status filter", () => { + it("should compact git status short format", () => { + const output = [ + "On branch main", + "Your branch is up to date with 'origin/main'.", + "", + "M src/file1.ts", + "M src/file2.ts", + " M src/file3.ts", + "?? src/new-file.ts", + "?? tests/new-test.ts", + "A src/added.ts", + ...Array(10).fill("M src/bulk-change.ts"), + ].join("\n") + + const result = filterTerminalOutput("git status", output) + expect(result).not.toBeNull() + expect(result!.filterName).toBe("git-status") + expect(result!.output).toContain("On branch main") + expect(result!.output).toContain("Staged") + expect(result!.output).toContain("Untracked") + }) + + it("should pass through clean working directory with few lines", () => { + const output = ["On branch main", "nothing to commit, working tree clean", ""].join("\n") + + const result = filterTerminalOutput("git status", output) + // Very small output (< 5 lines) is not filtered + expect(result).toBeNull() + }) + + it("should still reduce verbose clean status output", () => { + const output = [ + "On branch main", + "Your branch is up to date with 'origin/main'.", + "", + "nothing to commit, working tree clean", + "", + "", + ].join("\n") + + const result = filterTerminalOutput("git status", output) + // 6 lines input; only branch info is extractable => compact + if (result) { + expect(result.filterName).toBe("git-status") + expect(result.output).toContain("On branch main") + } + }) + }) + + describe("git-log filter", () => { + it("should compact git log to one-line-per-commit", () => { + const output = Array(10) + .fill(null) + .map((_, i) => + [ + `commit ${"a".repeat(40).replace(/a/g, () => Math.floor(Math.random() * 16).toString(16))}`, + `Author: Developer `, + `Date: Mon Jan 1 12:00:00 2024 +0000`, + "", + ` Fix bug number ${i}`, + "", + ].join("\n"), + ) + .join("\n") + + const result = filterTerminalOutput("git log", output) + expect(result).not.toBeNull() + expect(result!.filterName).toBe("git-log") + expect(result!.filteredLineCount).toBeLessThan(result!.originalLineCount) + // Each commit should be condensed to one line + const filteredLines = result!.output.split("\n").filter((l) => l.trim()) + expect(filteredLines.length).toBe(10) + }) + }) + + describe("package-install filter", () => { + it("should filter npm install progress and keep warnings/summary", () => { + const output = [ + "npm warn deprecated some-package@1.0.0: Use something-else instead", + ...Array(30).fill("npm http fetch GET 200 https://registry.npmjs.org/some-package"), + ...Array(10).fill("Resolving packages 5/10"), + "", + "added 150 packages, removed 5 packages in 12s", + "", + "5 packages are looking for funding", + " run `npm fund` for details", + ].join("\n") + + const result = filterTerminalOutput("npm install", output) + expect(result).not.toBeNull() + expect(result!.filterName).toBe("package-install") + expect(result!.output).toContain("warn") + expect(result!.output).toContain("added 150 packages") + expect(result!.output).not.toContain("http fetch GET") + }) + + it("should match various package manager commands", () => { + const output = [...Array(30).fill("Resolving packages 5/10"), "", "Done in 5s"].join("\n") + + const commands = [ + "npm install", + "npm i", + "yarn install", + "yarn add lodash", + "pnpm install", + "pip install flask", + ] + + for (const cmd of commands) { + const result = filterTerminalOutput(cmd, output) + // Some may not reduce enough, but they should at least match the pattern + const rule = BUILT_IN_FILTER_RULES.find((r) => r.name === "package-install") + expect(rule!.commandPattern.test(cmd)).toBe(true) + } + }) + }) + + describe("build filter", () => { + it("should filter build progress and keep errors", () => { + const output = [ + ...Array(20).fill("Compiling module (5 of 20)"), + "error[E0308]: mismatched types", + " --> src/main.rs:10:5", + " |", + '10 | let x: i32 = "hello";', + " | ^^^^^^^ expected `i32`, found `&str`", + "", + "error: aborting due to previous error", + "", + "For more information about this error, try `rustc --explain E0308`.", + ].join("\n") + + const result = filterTerminalOutput("cargo build", output) + expect(result).not.toBeNull() + expect(result!.filterName).toBe("build") + expect(result!.output).toContain("error[E0308]") + expect(result!.output).not.toContain("Compiling module (5 of 20)") + }) + + it("should match various build commands", () => { + const commands = [ + "tsc", + "cargo build", + "go build ./...", + "make", + "webpack", + "vite build", + "next build", + "npm run build", + "yarn build", + "pnpm build", + ] + + for (const cmd of commands) { + const rule = BUILT_IN_FILTER_RULES.find((r) => r.name === "build") + expect(rule!.commandPattern.test(cmd)).toBe(true) + } + }) + }) + + describe("formatFilterIndicator", () => { + it("should format indicator with artifact available", () => { + const result: FilterResult = { + output: "filtered output", + filterName: "test-runner", + originalLineCount: 100, + filteredLineCount: 5, + } + + const indicator = formatFilterIndicator(result, true) + expect(indicator).toContain("test-runner") + expect(indicator).toContain("100 lines -> 5 lines") + expect(indicator).toContain("95% reduction") + expect(indicator).toContain("read_command_output") + }) + + it("should format indicator without artifact", () => { + const result: FilterResult = { + output: "filtered output", + filterName: "git-status", + originalLineCount: 20, + filteredLineCount: 3, + } + + const indicator = formatFilterIndicator(result, false) + expect(indicator).toContain("git-status") + expect(indicator).toContain("20 lines -> 3 lines") + expect(indicator).not.toContain("read_command_output") + }) + }) + + describe("BUILT_IN_FILTER_RULES", () => { + it("should have all expected built-in rules", () => { + const ruleNames = BUILT_IN_FILTER_RULES.map((r) => r.name) + expect(ruleNames).toContain("test-runner") + expect(ruleNames).toContain("git-status") + expect(ruleNames).toContain("git-log") + expect(ruleNames).toContain("package-install") + expect(ruleNames).toContain("build") + }) + + it("should apply first matching rule only", () => { + // Simulate an output that could match multiple rules + // "npm test" should match test-runner, not package-install + const output = Array(20).fill("test output").join("\n") + "\nTests: 5 passed, 5 total" + const result = filterTerminalOutput("npm test", output) + if (result) { + expect(result.filterName).toBe("test-runner") + } + }) + }) +}) diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 5a65490cf2d..a7027bebde2 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -180,6 +180,7 @@ const SettingsView = forwardRef(({ onDone, t soundVolume, telemetrySetting, terminalOutputPreviewSize, + terminalOutputFilterEnabled, terminalShellIntegrationTimeout, terminalShellIntegrationDisabled, // Added from upstream terminalCommandDelay, @@ -411,6 +412,7 @@ const SettingsView = forwardRef(({ onDone, t terminalZshP10k, terminalZdotdir, terminalOutputPreviewSize: terminalOutputPreviewSize ?? "medium", + terminalOutputFilterEnabled: terminalOutputFilterEnabled !== false, mcpEnabled, maxOpenTabsContext: Math.min(Math.max(0, maxOpenTabsContext ?? 20), 500), maxWorkspaceFiles: Math.min(Math.max(0, maxWorkspaceFiles ?? 200), 500), @@ -882,6 +884,7 @@ const SettingsView = forwardRef(({ onDone, t {renderTab === "terminal" && ( & { terminalOutputPreviewSize?: TerminalOutputPreviewSize + terminalOutputFilterEnabled?: boolean terminalShellIntegrationTimeout?: number terminalShellIntegrationDisabled?: boolean terminalCommandDelay?: number @@ -28,6 +29,7 @@ type TerminalSettingsProps = HTMLAttributes & { terminalZdotdir?: boolean setCachedStateField: SetCachedStateField< | "terminalOutputPreviewSize" + | "terminalOutputFilterEnabled" | "terminalShellIntegrationTimeout" | "terminalShellIntegrationDisabled" | "terminalCommandDelay" @@ -41,6 +43,7 @@ type TerminalSettingsProps = HTMLAttributes & { export const TerminalSettings = ({ terminalOutputPreviewSize, + terminalOutputFilterEnabled, terminalShellIntegrationTimeout, terminalShellIntegrationDisabled, terminalCommandDelay, @@ -93,6 +96,22 @@ export const TerminalSettings = ({
+ + + setCachedStateField("terminalOutputFilterEnabled", e.target.checked) + }> + {t("settings:terminal.outputFilterEnabled.label")} + +
+ {t("settings:terminal.outputFilterEnabled.description")} +
+
+ Learn more" }, + "outputFilterEnabled": { + "label": "Smart output filtering", + "description": "Automatically filters terminal output before sending to the AI, reducing token usage by removing noise (passing tests, progress bars, verbose logs) while preserving errors, failures, and summaries. Inspired by RTK." + }, "outputPreviewSize": { "label": "Command output preview size", "description": "Controls how much command output Roo sees directly. Full output is always saved and accessible when needed.",