This commit is contained in:
Deleted user 2026-05-27 11:04:23 +08:00 committed by GitHub
commit 97cf8e0cb6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 971 additions and 2 deletions

View file

@ -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.

View file

@ -167,6 +167,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(),

View file

@ -253,6 +253,7 @@ export type ExtensionState = Pick<
| "soundEnabled"
| "soundVolume"
| "terminalOutputPreviewSize"
| "terminalOutputFilterEnabled"
| "terminalShellIntegrationTimeout"
| "terminalShellIntegrationDisabled"
| "terminalCommandDelay"

View file

@ -15,6 +15,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"
@ -200,16 +201,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
@ -471,6 +475,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)]
}
@ -499,9 +514,20 @@ export async function executeCommandInTerminal(
exitStatus = `Exit code: <undefined, notify user>`
}
// 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 [

View file

@ -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(" ")
}

View file

@ -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 <dev@example.com>`,
`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")
}
})
})
})

View file

@ -173,6 +173,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
ttsSpeed,
soundVolume,
terminalOutputPreviewSize,
terminalOutputFilterEnabled,
terminalShellIntegrationTimeout,
terminalShellIntegrationDisabled, // Added from upstream
terminalCommandDelay,
@ -384,6 +385,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ 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),
@ -840,6 +842,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
{renderTab === "terminal" && (
<TerminalSettings
terminalOutputPreviewSize={terminalOutputPreviewSize}
terminalOutputFilterEnabled={terminalOutputFilterEnabled}
terminalShellIntegrationTimeout={terminalShellIntegrationTimeout}
terminalShellIntegrationDisabled={terminalShellIntegrationDisabled}
terminalCommandDelay={terminalCommandDelay}

View file

@ -18,6 +18,7 @@ import { SearchableSetting } from "./SearchableSetting"
type TerminalSettingsProps = HTMLAttributes<HTMLDivElement> & {
terminalOutputPreviewSize?: TerminalOutputPreviewSize
terminalOutputFilterEnabled?: boolean
terminalShellIntegrationTimeout?: number
terminalShellIntegrationDisabled?: boolean
terminalCommandDelay?: number
@ -28,6 +29,7 @@ type TerminalSettingsProps = HTMLAttributes<HTMLDivElement> & {
terminalZdotdir?: boolean
setCachedStateField: SetCachedStateField<
| "terminalOutputPreviewSize"
| "terminalOutputFilterEnabled"
| "terminalShellIntegrationTimeout"
| "terminalShellIntegrationDisabled"
| "terminalCommandDelay"
@ -41,6 +43,7 @@ type TerminalSettingsProps = HTMLAttributes<HTMLDivElement> & {
export const TerminalSettings = ({
terminalOutputPreviewSize,
terminalOutputFilterEnabled,
terminalShellIntegrationTimeout,
terminalShellIntegrationDisabled,
terminalCommandDelay,
@ -93,6 +96,22 @@ export const TerminalSettings = ({
</div>
</div>
<div className="flex flex-col gap-3 pl-3 border-l-2 border-vscode-button-background">
<SearchableSetting
settingId="terminal-output-filter-enabled"
section="terminal"
label={t("settings:terminal.outputFilterEnabled.label")}>
<VSCodeCheckbox
checked={terminalOutputFilterEnabled !== false}
onChange={(e: any) =>
setCachedStateField("terminalOutputFilterEnabled", e.target.checked)
}>
<span className="font-medium">{t("settings:terminal.outputFilterEnabled.label")}</span>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm mt-1">
{t("settings:terminal.outputFilterEnabled.description")}
</div>
</SearchableSetting>
<SearchableSetting
settingId="terminal-output-preview-size"
section="terminal"

View file

@ -745,6 +745,10 @@
"label": "Terminal character limit",
"description": "Overrides the line limit to prevent memory issues by enforcing a hard cap on output size. If exceeded, keeps the beginning and end and shows a placeholder to Roo where content is skipped. <0>Learn more</0>"
},
"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.",