mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Merge 5b6a5a69b0 into b867ec9145
This commit is contained in:
commit
97cf8e0cb6
9 changed files with 971 additions and 2 deletions
17
.changeset/terminal-output-filter.md
Normal file
17
.changeset/terminal-output-filter.md
Normal 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.
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -253,6 +253,7 @@ export type ExtensionState = Pick<
|
|||
| "soundEnabled"
|
||||
| "soundVolume"
|
||||
| "terminalOutputPreviewSize"
|
||||
| "terminalOutputFilterEnabled"
|
||||
| "terminalShellIntegrationTimeout"
|
||||
| "terminalShellIntegrationDisabled"
|
||||
| "terminalCommandDelay"
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
517
src/integrations/terminal/TerminalOutputFilter.ts
Normal file
517
src/integrations/terminal/TerminalOutputFilter.ts
Normal 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(" ")
|
||||
}
|
||||
374
src/integrations/terminal/__tests__/TerminalOutputFilter.spec.ts
Normal file
374
src/integrations/terminal/__tests__/TerminalOutputFilter.spec.ts
Normal 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")
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue