mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
feat: add read_command_output tool for retrieving truncated command output
Implements a new tool that allows the LLM to retrieve full command output when execute_command produces output exceeding the preview threshold. Key components: - ReadCommandOutputTool: Reads persisted output with search/pagination - OutputInterceptor: Intercepts and persists large command outputs to disk - Terminal settings UI: Configuration for output interception behavior - Type definitions for output interception settings The tool supports: - Reading full output beyond the truncated preview - Search/filtering with regex patterns (like grep) - Pagination through large outputs using offset/limit Includes comprehensive tests for ReadCommandOutputTool and OutputInterceptor.
This commit is contained in:
parent
d7fa963b13
commit
0a70e046e7
15 changed files with 346 additions and 555 deletions
|
|
@ -29,9 +29,9 @@ export const DEFAULT_WRITE_DELAY_MS = 1000
|
|||
* the LLM decides to retrieve more via `read_command_output`. Larger previews
|
||||
* mean more immediate context but consume more of the context window.
|
||||
*
|
||||
* - `small`: 5KB preview - Best for long-running commands with verbose output
|
||||
* - `medium`: 10KB preview - Balanced default for most use cases
|
||||
* - `large`: 20KB preview - Best when commands produce critical info early
|
||||
* - `small`: 2KB preview - Best for long-running commands with verbose output
|
||||
* - `medium`: 4KB preview - Balanced default for most use cases
|
||||
* - `large`: 8KB preview - Best when commands produce critical info early
|
||||
*
|
||||
* @see OutputInterceptor - Uses this setting to determine when to spill to disk
|
||||
* @see PersistedCommandOutput - Contains the resulting preview and artifact reference
|
||||
|
|
@ -46,14 +46,14 @@ export type TerminalOutputPreviewSize = "small" | "medium" | "large"
|
|||
* to disk and made available via the `read_command_output` tool.
|
||||
*/
|
||||
export const TERMINAL_PREVIEW_BYTES: Record<TerminalOutputPreviewSize, number> = {
|
||||
small: 5 * 1024, // 5KB
|
||||
medium: 10 * 1024, // 10KB
|
||||
large: 20 * 1024, // 20KB
|
||||
small: 2048, // 2KB
|
||||
medium: 4096, // 4KB
|
||||
large: 8192, // 8KB
|
||||
}
|
||||
|
||||
/**
|
||||
* Default terminal output preview size.
|
||||
* The "medium" (10KB) setting provides a good balance between immediate
|
||||
* The "medium" (4KB) setting provides a good balance between immediate
|
||||
* visibility and context window conservation for most use cases.
|
||||
*/
|
||||
export const DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE: TerminalOutputPreviewSize = "medium"
|
||||
|
|
@ -176,6 +176,8 @@ export const globalSettingsSchema = z.object({
|
|||
maxImageFileSize: z.number().optional(),
|
||||
maxTotalImageSize: z.number().optional(),
|
||||
|
||||
terminalOutputLineLimit: z.number().optional(),
|
||||
terminalOutputCharacterLimit: z.number().optional(),
|
||||
terminalOutputPreviewSize: z.enum(["small", "medium", "large"]).optional(),
|
||||
terminalShellIntegrationTimeout: z.number().optional(),
|
||||
terminalShellIntegrationDisabled: z.boolean().optional(),
|
||||
|
|
|
|||
|
|
@ -302,6 +302,8 @@ export type ExtensionState = Pick<
|
|||
| "soundEnabled"
|
||||
| "soundVolume"
|
||||
| "maxConcurrentFileReads"
|
||||
| "terminalOutputLineLimit"
|
||||
| "terminalOutputCharacterLimit"
|
||||
| "terminalOutputPreviewSize"
|
||||
| "terminalShellIntegrationTimeout"
|
||||
| "terminalShellIntegrationDisabled"
|
||||
|
|
|
|||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
|
|
@ -1017,6 +1017,9 @@ importers:
|
|||
'@types/glob':
|
||||
specifier: ^8.1.0
|
||||
version: 8.1.0
|
||||
'@types/json-stream-stringify':
|
||||
specifier: ^2.0.4
|
||||
version: 2.0.4
|
||||
'@types/lodash.debounce':
|
||||
specifier: ^4.0.9
|
||||
version: 4.0.9
|
||||
|
|
@ -4302,6 +4305,10 @@ packages:
|
|||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/json-stream-stringify@2.0.4':
|
||||
resolution: {integrity: sha512-xSFsVnoQ8Y/7BiVF3/fEIwRx9RoGzssDKVwhy1g23wkA4GAmA3v8lsl6CxsmUD6vf4EiRd+J0ULLkMbAWRSsgQ==}
|
||||
deprecated: This is a stub types definition. json-stream-stringify provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@types/katex@0.16.7':
|
||||
resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==}
|
||||
|
||||
|
|
@ -14316,6 +14323,10 @@ snapshots:
|
|||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/json-stream-stringify@2.0.4':
|
||||
dependencies:
|
||||
json-stream-stringify: 3.1.6
|
||||
|
||||
'@types/katex@0.16.7': {}
|
||||
|
||||
'@types/lodash.debounce@4.0.9':
|
||||
|
|
|
|||
|
|
@ -20,15 +20,15 @@ The tool supports two modes:
|
|||
|
||||
Parameters:
|
||||
- artifact_id: (required) The artifact filename from the truncated output message (e.g., "cmd-1706119234567.txt")
|
||||
- search: (optional) Pattern to filter lines. Supports regex or literal strings. Case-insensitive. **Omit this parameter entirely if you don't need to filter - do not pass null or empty string.**
|
||||
- search: (optional) Pattern to filter lines. Supports regex or literal strings. Case-insensitive.
|
||||
- offset: (optional) Byte offset to start reading from. Default: 0. Use for pagination.
|
||||
- limit: (optional) Maximum bytes to return. Default: 40KB.
|
||||
- limit: (optional) Maximum bytes to return. Default: 32KB.
|
||||
|
||||
Example: Reading truncated command output
|
||||
{ "artifact_id": "cmd-1706119234567.txt" }
|
||||
|
||||
Example: Reading with pagination (after first 40KB)
|
||||
{ "artifact_id": "cmd-1706119234567.txt", "offset": 40960 }
|
||||
Example: Reading with pagination (after first 32KB)
|
||||
{ "artifact_id": "cmd-1706119234567.txt", "offset": 32768 }
|
||||
|
||||
Example: Searching for errors in build output
|
||||
{ "artifact_id": "cmd-1706119234567.txt", "search": "error|failed|Error" }
|
||||
|
|
@ -38,22 +38,18 @@ Example: Finding specific test failures
|
|||
|
||||
const ARTIFACT_ID_DESCRIPTION = `The artifact filename from the truncated command output (e.g., "cmd-1706119234567.txt")`
|
||||
|
||||
const SEARCH_DESCRIPTION = `Optional regex or literal pattern to filter lines (case-insensitive, like grep). Omit this parameter if not searching - do not pass null or empty string.`
|
||||
const SEARCH_DESCRIPTION = `Optional regex or literal pattern to filter lines (case-insensitive, like grep)`
|
||||
|
||||
const OFFSET_DESCRIPTION = `Byte offset to start reading from (default: 0, for pagination)`
|
||||
|
||||
const LIMIT_DESCRIPTION = `Maximum bytes to return (default: 40KB)`
|
||||
const LIMIT_DESCRIPTION = `Maximum bytes to return (default: 32KB)`
|
||||
|
||||
export default {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_command_output",
|
||||
description: READ_COMMAND_OUTPUT_DESCRIPTION,
|
||||
// Note: strict mode is intentionally disabled for this tool.
|
||||
// With strict: true, OpenAI requires ALL properties to be in the 'required' array,
|
||||
// which forces the LLM to always provide explicit values (even null) for optional params.
|
||||
// This creates verbose tool calls and poor UX. By disabling strict mode, the LLM can
|
||||
// omit optional parameters entirely, making the tool easier to use.
|
||||
strict: true,
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
|
@ -62,19 +58,19 @@ export default {
|
|||
description: ARTIFACT_ID_DESCRIPTION,
|
||||
},
|
||||
search: {
|
||||
type: "string",
|
||||
type: ["string", "null"],
|
||||
description: SEARCH_DESCRIPTION,
|
||||
},
|
||||
offset: {
|
||||
type: "number",
|
||||
type: ["number", "null"],
|
||||
description: OFFSET_DESCRIPTION,
|
||||
},
|
||||
limit: {
|
||||
type: "number",
|
||||
type: ["number", "null"],
|
||||
description: LIMIT_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
required: ["artifact_id"],
|
||||
required: ["artifact_id", "search", "offset", "limit"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,7 +4,12 @@ import * as vscode from "vscode"
|
|||
|
||||
import delay from "delay"
|
||||
|
||||
import { CommandExecutionStatus, DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE, PersistedCommandOutput } from "@roo-code/types"
|
||||
import {
|
||||
CommandExecutionStatus,
|
||||
DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
|
||||
DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE,
|
||||
PersistedCommandOutput,
|
||||
} from "@roo-code/types"
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
|
||||
import { Task } from "../task/Task"
|
||||
|
|
@ -196,6 +201,7 @@ export async function executeCommandInTerminal(
|
|||
const providerState = await provider?.getState()
|
||||
const terminalOutputPreviewSize =
|
||||
providerState?.terminalOutputPreviewSize ?? DEFAULT_TERMINAL_OUTPUT_PREVIEW_SIZE
|
||||
const terminalCompressProgressBar = providerState?.terminalCompressProgressBar ?? true
|
||||
|
||||
interceptor = new OutputInterceptor({
|
||||
executionId,
|
||||
|
|
@ -203,6 +209,7 @@ export async function executeCommandInTerminal(
|
|||
command,
|
||||
storageDir,
|
||||
previewSize: terminalOutputPreviewSize,
|
||||
compressProgressBar: terminalCompressProgressBar,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { getTaskDirectoryPath } from "../../utils/storage"
|
|||
|
||||
import { BaseTool, ToolCallbacks } from "./BaseTool"
|
||||
|
||||
/** Default byte limit for read operations (40KB) */
|
||||
const DEFAULT_LIMIT = 40 * 1024 // 40KB default limit
|
||||
/** Default byte limit for read operations (32KB) */
|
||||
const DEFAULT_LIMIT = 32 * 1024 // 32KB default limit
|
||||
|
||||
/**
|
||||
* Parameters accepted by the read_command_output tool.
|
||||
|
|
@ -159,38 +159,15 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> {
|
|||
}
|
||||
|
||||
let result: string
|
||||
let readStart = 0
|
||||
let readEnd = 0
|
||||
let matchCount: number | undefined
|
||||
|
||||
if (search) {
|
||||
// Search mode: filter lines matching the pattern
|
||||
const searchResult = await this.searchInArtifact(artifactPath, search, totalSize, limit)
|
||||
result = searchResult.content
|
||||
matchCount = searchResult.matchCount
|
||||
// For search, we're scanning the whole file
|
||||
readStart = 0
|
||||
readEnd = totalSize
|
||||
result = await this.searchInArtifact(artifactPath, search, totalSize, limit)
|
||||
} else {
|
||||
// Normal read mode with offset/limit
|
||||
result = await this.readArtifact(artifactPath, offset, limit, totalSize)
|
||||
// Calculate actual read range
|
||||
readStart = offset
|
||||
readEnd = Math.min(offset + limit, totalSize)
|
||||
}
|
||||
|
||||
// Report to UI that we read command output
|
||||
await task.say(
|
||||
"tool",
|
||||
JSON.stringify({
|
||||
tool: "readCommandOutput",
|
||||
readStart,
|
||||
readEnd,
|
||||
totalBytes: totalSize,
|
||||
...(search && { searchPattern: search, matchCount }),
|
||||
}),
|
||||
)
|
||||
|
||||
task.consecutiveMistakeCount = 0
|
||||
pushToolResult(result)
|
||||
} catch (error) {
|
||||
|
|
@ -246,10 +223,14 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> {
|
|||
const { bytesRead } = await fileHandle.read(buffer, 0, buffer.length, offset)
|
||||
const content = buffer.slice(0, bytesRead).toString("utf8")
|
||||
|
||||
// Calculate line numbers based on offset using chunked reading to avoid large allocations
|
||||
// Calculate line numbers based on offset
|
||||
let startLineNumber = 1
|
||||
if (offset > 0) {
|
||||
startLineNumber = await this.countNewlinesBeforeOffset(fileHandle, offset)
|
||||
// Count newlines before offset to determine starting line number
|
||||
const prefixBuffer = Buffer.alloc(offset)
|
||||
await fileHandle.read(prefixBuffer, 0, offset, 0)
|
||||
const prefix = prefixBuffer.toString("utf8")
|
||||
startLineNumber = (prefix.match(/\n/g) || []).length + 1
|
||||
}
|
||||
|
||||
const endOffset = offset + bytesRead
|
||||
|
|
@ -272,14 +253,10 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Search artifact content for lines matching a pattern using chunked streaming.
|
||||
* Search artifact content for lines matching a pattern.
|
||||
*
|
||||
* Performs grep-like searching through the artifact file using bounded memory.
|
||||
* Instead of loading the entire file into memory, this reads in fixed-size chunks
|
||||
* and processes lines as they are encountered. This keeps memory usage predictable
|
||||
* even for very large command outputs (e.g., 100MB+ build logs).
|
||||
*
|
||||
* The pattern is treated as a case-insensitive regex. If the pattern is invalid
|
||||
* Performs grep-like searching through the artifact file. The pattern
|
||||
* is treated as a case-insensitive regex. If the pattern is invalid
|
||||
* regex syntax, it's escaped and treated as a literal string.
|
||||
*
|
||||
* Results are limited by the byte limit to prevent excessive output.
|
||||
|
|
@ -296,8 +273,10 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> {
|
|||
pattern: string,
|
||||
totalSize: number,
|
||||
limit: number,
|
||||
): Promise<{ content: string; matchCount: number }> {
|
||||
const CHUNK_SIZE = 64 * 1024 // 64KB chunks for bounded memory
|
||||
): Promise<string> {
|
||||
// Read the entire file for search (we need all content to search)
|
||||
const content = await fs.readFile(artifactPath, "utf8")
|
||||
const lines = content.split("\n")
|
||||
|
||||
// Create case-insensitive regex for search
|
||||
let regex: RegExp
|
||||
|
|
@ -308,89 +287,45 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> {
|
|||
regex = new RegExp(this.escapeRegExp(pattern), "i")
|
||||
}
|
||||
|
||||
const fileHandle = await fs.open(artifactPath, "r")
|
||||
// Find matching lines with their line numbers
|
||||
const matches: Array<{ lineNumber: number; content: string }> = []
|
||||
let totalMatchBytes = 0
|
||||
let lineNumber = 0
|
||||
let partialLine = "" // Holds incomplete line from previous chunk
|
||||
let bytesRead = 0
|
||||
let hitLimit = false
|
||||
|
||||
try {
|
||||
while (bytesRead < totalSize && !hitLimit) {
|
||||
const chunkSize = Math.min(CHUNK_SIZE, totalSize - bytesRead)
|
||||
const buffer = Buffer.alloc(chunkSize)
|
||||
const result = await fileHandle.read(buffer, 0, chunkSize, bytesRead)
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (regex.test(lines[i])) {
|
||||
const lineContent = lines[i]
|
||||
const lineBytes = Buffer.byteLength(lineContent, "utf8")
|
||||
|
||||
if (result.bytesRead === 0) {
|
||||
// Stop if we've exceeded the byte limit
|
||||
if (totalMatchBytes + lineBytes > limit) {
|
||||
break
|
||||
}
|
||||
|
||||
const chunk = buffer.slice(0, result.bytesRead).toString("utf8")
|
||||
bytesRead += result.bytesRead
|
||||
|
||||
// Combine with partial line from previous chunk
|
||||
const combined = partialLine + chunk
|
||||
const lines = combined.split("\n")
|
||||
|
||||
// Last element may be incomplete (no trailing newline), save for next iteration
|
||||
partialLine = lines.pop() ?? ""
|
||||
|
||||
// Process complete lines
|
||||
for (const line of lines) {
|
||||
lineNumber++
|
||||
|
||||
if (regex.test(line)) {
|
||||
const lineBytes = Buffer.byteLength(line, "utf8")
|
||||
|
||||
// Stop if we've exceeded the byte limit
|
||||
if (totalMatchBytes + lineBytes > limit) {
|
||||
hitLimit = true
|
||||
break
|
||||
}
|
||||
|
||||
matches.push({ lineNumber, content: line })
|
||||
totalMatchBytes += lineBytes
|
||||
}
|
||||
}
|
||||
matches.push({ lineNumber: i + 1, content: lineContent })
|
||||
totalMatchBytes += lineBytes
|
||||
}
|
||||
|
||||
// Process any remaining partial line at end of file
|
||||
if (!hitLimit && partialLine.length > 0) {
|
||||
lineNumber++
|
||||
if (regex.test(partialLine)) {
|
||||
const lineBytes = Buffer.byteLength(partialLine, "utf8")
|
||||
if (totalMatchBytes + lineBytes <= limit) {
|
||||
matches.push({ lineNumber, content: partialLine })
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await fileHandle.close()
|
||||
}
|
||||
|
||||
const artifactId = path.basename(artifactPath)
|
||||
|
||||
if (matches.length === 0) {
|
||||
const content = [
|
||||
return [
|
||||
`[Command Output: ${artifactId}] (search: "${pattern}")`,
|
||||
`Total size: ${this.formatBytes(totalSize)}`,
|
||||
"",
|
||||
"No matches found for the search pattern.",
|
||||
].join("\n")
|
||||
return { content, matchCount: 0 }
|
||||
}
|
||||
|
||||
// Format matches with line numbers
|
||||
const matchedLines = matches.map((m) => `${String(m.lineNumber).padStart(5)} | ${m.content}`).join("\n")
|
||||
|
||||
const content = [
|
||||
return [
|
||||
`[Command Output: ${artifactId}] (search: "${pattern}")`,
|
||||
`Total matches: ${matches.length} | Showing first ${matches.length}`,
|
||||
"",
|
||||
matchedLines,
|
||||
].join("\n")
|
||||
return { content, matchCount: matches.length }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -439,45 +374,6 @@ export class ReadCommandOutputTool extends BaseTool<"read_command_output"> {
|
|||
private escapeRegExp(string: string): string {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
}
|
||||
|
||||
/**
|
||||
* Count newlines before a given byte offset using fixed-size chunks.
|
||||
*
|
||||
* This avoids allocating a buffer of size `offset` which could be huge
|
||||
* for large files. Instead, we read in 64KB chunks and count newlines.
|
||||
*
|
||||
* @param fileHandle - Open file handle for reading
|
||||
* @param offset - The byte offset to count newlines up to
|
||||
* @returns The line number at the given offset (1-indexed)
|
||||
* @private
|
||||
*/
|
||||
private async countNewlinesBeforeOffset(fileHandle: fs.FileHandle, offset: number): Promise<number> {
|
||||
const CHUNK_SIZE = 64 * 1024 // 64KB chunks
|
||||
let newlineCount = 0
|
||||
let bytesRead = 0
|
||||
|
||||
while (bytesRead < offset) {
|
||||
const chunkSize = Math.min(CHUNK_SIZE, offset - bytesRead)
|
||||
const buffer = Buffer.alloc(chunkSize)
|
||||
const result = await fileHandle.read(buffer, 0, chunkSize, bytesRead)
|
||||
|
||||
if (result.bytesRead === 0) {
|
||||
break
|
||||
}
|
||||
|
||||
// Count newlines in this chunk
|
||||
for (let i = 0; i < result.bytesRead; i++) {
|
||||
if (buffer[i] === 0x0a) {
|
||||
// '\n'
|
||||
newlineCount++
|
||||
}
|
||||
}
|
||||
|
||||
bytesRead += result.bytesRead
|
||||
}
|
||||
|
||||
return newlineCount + 1 // Line numbers are 1-indexed
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton instance of the ReadCommandOutputTool */
|
||||
|
|
|
|||
|
|
@ -159,16 +159,16 @@ describe("ReadCommandOutputTool", () => {
|
|||
})
|
||||
|
||||
describe("Pagination (offset/limit)", () => {
|
||||
it("should use default limit of 40KB", async () => {
|
||||
it("should use default limit of 32KB", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const largeContent = "x".repeat(50 * 1024) // 50KB
|
||||
const fileSize = Buffer.byteLength(largeContent, "utf8")
|
||||
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
|
||||
|
||||
// Mock read to return only up to default limit (40KB)
|
||||
// Mock read to return only up to default limit (32KB)
|
||||
mockFileHandle.read.mockImplementation((buf: Buffer) => {
|
||||
const defaultLimit = 40 * 1024
|
||||
const defaultLimit = 32 * 1024
|
||||
const bytesToRead = Math.min(buf.length, defaultLimit)
|
||||
buf.write(largeContent.slice(0, bytesToRead))
|
||||
return Promise.resolve({ bytesRead: bytesToRead })
|
||||
|
|
@ -276,31 +276,13 @@ describe("ReadCommandOutputTool", () => {
|
|||
})
|
||||
|
||||
describe("Search filtering", () => {
|
||||
// Helper to setup file handle mock for search (which now uses streaming)
|
||||
const setupSearchMock = (content: string) => {
|
||||
const buffer = Buffer.from(content)
|
||||
const fileSize = buffer.length
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
|
||||
|
||||
// Mock streaming read - return entire content in one chunk (simulates small file)
|
||||
mockFileHandle.read.mockImplementation(
|
||||
(buf: Buffer, bufOffset: number, length: number, position: number | null) => {
|
||||
const pos = position ?? 0
|
||||
if (pos >= fileSize) {
|
||||
return Promise.resolve({ bytesRead: 0 })
|
||||
}
|
||||
const bytesToRead = Math.min(length, fileSize - pos)
|
||||
buffer.copy(buf, 0, pos, pos + bytesToRead)
|
||||
return Promise.resolve({ bytesRead: bytesToRead })
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
it("should filter lines matching pattern", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const content = "Line 1: error occurred\nLine 2: success\nLine 3: error found\nLine 4: complete\n"
|
||||
const fileSize = Buffer.byteLength(content, "utf8")
|
||||
|
||||
setupSearchMock(content)
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
|
||||
vi.mocked(fs.readFile).mockResolvedValue(content)
|
||||
|
||||
await tool.execute({ artifact_id: artifactId, search: "error" }, mockTask, mockCallbacks)
|
||||
|
||||
|
|
@ -314,8 +296,10 @@ describe("ReadCommandOutputTool", () => {
|
|||
it("should use case-insensitive matching", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const content = "ERROR: Something bad\nwarning: minor issue\nERROR: Another problem\n"
|
||||
const fileSize = Buffer.byteLength(content, "utf8")
|
||||
|
||||
setupSearchMock(content)
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
|
||||
vi.mocked(fs.readFile).mockResolvedValue(content)
|
||||
|
||||
await tool.execute({ artifact_id: artifactId, search: "error" }, mockTask, mockCallbacks)
|
||||
|
||||
|
|
@ -327,8 +311,10 @@ describe("ReadCommandOutputTool", () => {
|
|||
it("should show match count and line numbers", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const content = "Line 1\nError on line 2\nLine 3\nError on line 4\n"
|
||||
const fileSize = Buffer.byteLength(content, "utf8")
|
||||
|
||||
setupSearchMock(content)
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
|
||||
vi.mocked(fs.readFile).mockResolvedValue(content)
|
||||
|
||||
await tool.execute({ artifact_id: artifactId, search: "Error" }, mockTask, mockCallbacks)
|
||||
|
||||
|
|
@ -341,8 +327,10 @@ describe("ReadCommandOutputTool", () => {
|
|||
it("should handle empty search results gracefully", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const content = "Line 1\nLine 2\nLine 3\n"
|
||||
const fileSize = Buffer.byteLength(content, "utf8")
|
||||
|
||||
setupSearchMock(content)
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
|
||||
vi.mocked(fs.readFile).mockResolvedValue(content)
|
||||
|
||||
await tool.execute({ artifact_id: artifactId, search: "NOTFOUND" }, mockTask, mockCallbacks)
|
||||
|
||||
|
|
@ -353,8 +341,10 @@ describe("ReadCommandOutputTool", () => {
|
|||
it("should handle regex patterns in search", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const content = "test123\ntest456\nabc789\ntest000\n"
|
||||
const fileSize = Buffer.byteLength(content, "utf8")
|
||||
|
||||
setupSearchMock(content)
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
|
||||
vi.mocked(fs.readFile).mockResolvedValue(content)
|
||||
|
||||
await tool.execute({ artifact_id: artifactId, search: "test\\d+" }, mockTask, mockCallbacks)
|
||||
|
||||
|
|
@ -368,8 +358,10 @@ describe("ReadCommandOutputTool", () => {
|
|||
it("should handle invalid regex patterns by treating as literal", async () => {
|
||||
const artifactId = "cmd-1706119234567.txt"
|
||||
const content = "Line with [brackets]\nLine without\n"
|
||||
const fileSize = Buffer.byteLength(content, "utf8")
|
||||
|
||||
setupSearchMock(content)
|
||||
vi.mocked(fs.stat).mockResolvedValue({ size: fileSize } as any)
|
||||
vi.mocked(fs.readFile).mockResolvedValue(content)
|
||||
|
||||
// Invalid regex but valid as literal string
|
||||
await tool.execute({ artifact_id: artifactId, search: "[" }, mockTask, mockCallbacks)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import * as path from "path"
|
|||
|
||||
import { TerminalOutputPreviewSize, TERMINAL_PREVIEW_BYTES, PersistedCommandOutput } from "@roo-code/types"
|
||||
|
||||
import { processCarriageReturns, processBackspaces } from "../misc/extract-text"
|
||||
|
||||
/**
|
||||
* Configuration options for creating an OutputInterceptor instance.
|
||||
*/
|
||||
|
|
@ -17,6 +19,8 @@ export interface OutputInterceptorOptions {
|
|||
storageDir: string
|
||||
/** Size category for the preview buffer (small/medium/large) */
|
||||
previewSize: TerminalOutputPreviewSize
|
||||
/** Whether to compress progress bar output using carriage return processing */
|
||||
compressProgressBar: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -26,14 +30,13 @@ export interface OutputInterceptorOptions {
|
|||
* files, with only a preview shown to the LLM. The LLM can then use the `read_command_output`
|
||||
* tool to retrieve full contents or search through the output.
|
||||
*
|
||||
* The interceptor uses a **head/tail buffer** strategy (inspired by Codex):
|
||||
* - 50% of the preview budget is allocated to the "head" (beginning of output)
|
||||
* - 50% of the preview budget is allocated to the "tail" (end of output)
|
||||
* - Middle content is dropped when output exceeds the preview threshold
|
||||
* The interceptor operates in two modes:
|
||||
* 1. **Buffer mode**: Output is accumulated in memory until it exceeds the preview threshold
|
||||
* 2. **Spill mode**: Once threshold is exceeded, output is streamed directly to disk
|
||||
*
|
||||
* This approach ensures the LLM sees both:
|
||||
* - The beginning (command startup, environment info, early errors)
|
||||
* - The end (final results, exit codes, error summaries)
|
||||
* This approach prevents large command outputs (like build logs, test results, or verbose
|
||||
* operations) from overwhelming the context window while still allowing the LLM to access
|
||||
* the full output when needed.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
|
|
@ -43,6 +46,7 @@ export interface OutputInterceptorOptions {
|
|||
* command: 'npm test',
|
||||
* storageDir: '/path/to/task/command-output',
|
||||
* previewSize: 'medium',
|
||||
* compressProgressBar: true
|
||||
* });
|
||||
*
|
||||
* // Write output chunks as they arrive
|
||||
|
|
@ -51,38 +55,18 @@ export interface OutputInterceptorOptions {
|
|||
*
|
||||
* // Finalize and get the result
|
||||
* const result = interceptor.finalize();
|
||||
* // result.preview contains head + [omitted] + tail for display
|
||||
* // result.preview contains truncated output for display
|
||||
* // result.artifactPath contains path to full output if truncated
|
||||
* ```
|
||||
*/
|
||||
export class OutputInterceptor {
|
||||
/** Buffer for the head (beginning) of output */
|
||||
private headBuffer: string = ""
|
||||
/** Buffer for the tail (end) of output - rolling buffer that drops front when full */
|
||||
private tailBuffer: string = ""
|
||||
/** Number of bytes currently in the head buffer */
|
||||
private headBytes: number = 0
|
||||
/** Number of bytes currently in the tail buffer */
|
||||
private tailBytes: number = 0
|
||||
/** Number of bytes omitted from the middle */
|
||||
private omittedBytes: number = 0
|
||||
|
||||
/**
|
||||
* Pending chunks accumulated before spilling to disk.
|
||||
* These contain ALL content (lossless) until we decide to spill.
|
||||
* Once spilled, this array is cleared and subsequent writes go directly to disk.
|
||||
*/
|
||||
private pendingChunks: string[] = []
|
||||
|
||||
private buffer: string = ""
|
||||
private writeStream: fs.WriteStream | null = null
|
||||
private artifactPath: string
|
||||
private totalBytes: number = 0
|
||||
private spilledToDisk: boolean = false
|
||||
private readonly previewBytes: number
|
||||
/** Budget for the head buffer (50% of total preview) */
|
||||
private readonly headBudget: number
|
||||
/** Budget for the tail buffer (50% of total preview) */
|
||||
private readonly tailBudget: number
|
||||
private readonly compressProgressBar: boolean
|
||||
|
||||
/**
|
||||
* Creates a new OutputInterceptor instance.
|
||||
|
|
@ -91,19 +75,16 @@ export class OutputInterceptor {
|
|||
*/
|
||||
constructor(private readonly options: OutputInterceptorOptions) {
|
||||
this.previewBytes = TERMINAL_PREVIEW_BYTES[options.previewSize]
|
||||
this.headBudget = Math.floor(this.previewBytes / 2)
|
||||
this.tailBudget = this.previewBytes - this.headBudget
|
||||
this.compressProgressBar = options.compressProgressBar
|
||||
this.artifactPath = path.join(options.storageDir, `cmd-${options.executionId}.txt`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a chunk of output to the interceptor.
|
||||
*
|
||||
* Output is first added to the head buffer until it's full (50% of preview budget).
|
||||
* Subsequent output goes to a rolling tail buffer that keeps the most recent content.
|
||||
*
|
||||
* If the total output exceeds the preview threshold, the interceptor spills to disk
|
||||
* for full output storage while maintaining head/tail buffers for the preview.
|
||||
* If the accumulated output exceeds the preview threshold, the interceptor
|
||||
* automatically spills to disk and switches to streaming mode. Subsequent
|
||||
* chunks are written directly to the disk file.
|
||||
*
|
||||
* @param chunk - The output string to write
|
||||
*
|
||||
|
|
@ -117,15 +98,10 @@ export class OutputInterceptor {
|
|||
const chunkBytes = Buffer.byteLength(chunk, "utf8")
|
||||
this.totalBytes += chunkBytes
|
||||
|
||||
// Always update the head/tail preview buffers
|
||||
this.addToPreviewBuffers(chunk)
|
||||
|
||||
// Handle disk spilling for full output preservation
|
||||
if (!this.spilledToDisk) {
|
||||
// Accumulate ALL chunks for lossless disk storage
|
||||
this.pendingChunks.push(chunk)
|
||||
this.buffer += chunk
|
||||
|
||||
if (this.totalBytes > this.previewBytes) {
|
||||
if (Buffer.byteLength(this.buffer, "utf8") > this.previewBytes) {
|
||||
this.spillToDisk()
|
||||
}
|
||||
} else {
|
||||
|
|
@ -134,127 +110,6 @@ export class OutputInterceptor {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a chunk to the head/tail preview buffers using 50/50 split strategy.
|
||||
*
|
||||
* Fill head first until budget exhausted, then maintain a rolling tail buffer.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private addToPreviewBuffers(chunk: string): void {
|
||||
let remaining = chunk
|
||||
let remainingBytes = Buffer.byteLength(chunk, "utf8")
|
||||
|
||||
// First, fill the head buffer if there's room
|
||||
if (this.headBytes < this.headBudget) {
|
||||
const headRoom = this.headBudget - this.headBytes
|
||||
if (remainingBytes <= headRoom) {
|
||||
// Entire chunk fits in head
|
||||
this.headBuffer += remaining
|
||||
this.headBytes += remainingBytes
|
||||
return
|
||||
}
|
||||
// Split: part goes to head, rest goes to tail
|
||||
const headPortion = this.sliceByBytes(remaining, headRoom)
|
||||
this.headBuffer += headPortion
|
||||
this.headBytes += headRoom
|
||||
remaining = remaining.slice(headPortion.length)
|
||||
remainingBytes = Buffer.byteLength(remaining, "utf8")
|
||||
}
|
||||
|
||||
// Add remainder to tail buffer
|
||||
this.addToTailBuffer(remaining, remainingBytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add content to the rolling tail buffer, dropping old content as needed.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private addToTailBuffer(chunk: string, chunkBytes: number): void {
|
||||
if (this.tailBudget === 0) {
|
||||
this.omittedBytes += chunkBytes
|
||||
return
|
||||
}
|
||||
|
||||
// If this single chunk is larger than the tail budget, keep only the last tailBudget bytes
|
||||
if (chunkBytes >= this.tailBudget) {
|
||||
const dropped = this.tailBytes + (chunkBytes - this.tailBudget)
|
||||
this.omittedBytes += dropped
|
||||
this.tailBuffer = this.sliceByBytesFromEnd(chunk, this.tailBudget)
|
||||
this.tailBytes = this.tailBudget
|
||||
return
|
||||
}
|
||||
|
||||
// Append to tail
|
||||
this.tailBuffer += chunk
|
||||
this.tailBytes += chunkBytes
|
||||
|
||||
// Trim from front if over budget
|
||||
this.trimTailToFit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim the tail buffer from the front to fit within the tail budget.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private trimTailToFit(): void {
|
||||
while (this.tailBytes > this.tailBudget && this.tailBuffer.length > 0) {
|
||||
const excess = this.tailBytes - this.tailBudget
|
||||
// Remove characters from the front until we're under budget
|
||||
// We need to be careful with multi-byte characters
|
||||
let removed = 0
|
||||
let removeChars = 0
|
||||
while (removed < excess && removeChars < this.tailBuffer.length) {
|
||||
const charBytes = Buffer.byteLength(this.tailBuffer[removeChars], "utf8")
|
||||
removed += charBytes
|
||||
removeChars++
|
||||
}
|
||||
this.omittedBytes += removed
|
||||
this.tailBytes -= removed
|
||||
this.tailBuffer = this.tailBuffer.slice(removeChars)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice a string to get approximately the first N bytes (UTF-8).
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private sliceByBytes(str: string, maxBytes: number): string {
|
||||
let bytes = 0
|
||||
let i = 0
|
||||
while (i < str.length && bytes < maxBytes) {
|
||||
const charBytes = Buffer.byteLength(str[i], "utf8")
|
||||
if (bytes + charBytes > maxBytes) {
|
||||
break
|
||||
}
|
||||
bytes += charBytes
|
||||
i++
|
||||
}
|
||||
return str.slice(0, i)
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice a string to get approximately the last N bytes (UTF-8).
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private sliceByBytesFromEnd(str: string, maxBytes: number): string {
|
||||
let bytes = 0
|
||||
let i = str.length - 1
|
||||
while (i >= 0 && bytes < maxBytes) {
|
||||
const charBytes = Buffer.byteLength(str[i], "utf8")
|
||||
if (bytes + charBytes > maxBytes) {
|
||||
break
|
||||
}
|
||||
bytes += charBytes
|
||||
i--
|
||||
}
|
||||
return str.slice(i + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Spill buffered content to disk and switch to streaming mode.
|
||||
*
|
||||
|
|
@ -272,36 +127,30 @@ export class OutputInterceptor {
|
|||
}
|
||||
|
||||
this.writeStream = fs.createWriteStream(this.artifactPath)
|
||||
|
||||
// Write ALL pending chunks to disk for lossless storage.
|
||||
// This ensures no content is lost, even if the preview buffers have dropped middle content.
|
||||
for (const chunk of this.pendingChunks) {
|
||||
this.writeStream.write(chunk)
|
||||
}
|
||||
|
||||
// Clear pending chunks to free memory - subsequent writes go directly to disk
|
||||
this.pendingChunks = []
|
||||
|
||||
this.writeStream.write(this.buffer)
|
||||
this.spilledToDisk = true
|
||||
|
||||
// Keep only preview portion in memory
|
||||
this.buffer = this.buffer.slice(0, this.previewBytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize the interceptor and return the persisted output result.
|
||||
*
|
||||
* Closes any open file streams and waits for them to fully flush before returning.
|
||||
* This ensures the artifact file is completely written and ready for reading.
|
||||
*
|
||||
* Returns a summary object containing:
|
||||
* - A preview of the output (head + [omitted indicator] + tail)
|
||||
* Closes any open file streams and returns a summary object containing:
|
||||
* - A preview of the output (truncated to preview size)
|
||||
* - The total byte count of all output
|
||||
* - The path to the full output file (if truncated)
|
||||
* - A flag indicating whether the output was truncated
|
||||
*
|
||||
* If `compressProgressBar` was enabled, the preview will have carriage returns
|
||||
* and backspaces processed to show only final line states.
|
||||
*
|
||||
* @returns The persisted command output summary
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await interceptor.finalize();
|
||||
* const result = interceptor.finalize();
|
||||
* console.log(`Preview: ${result.preview}`);
|
||||
* console.log(`Total bytes: ${result.totalBytes}`);
|
||||
* if (result.truncated) {
|
||||
|
|
@ -309,24 +158,19 @@ export class OutputInterceptor {
|
|||
* }
|
||||
* ```
|
||||
*/
|
||||
async finalize(): Promise<PersistedCommandOutput> {
|
||||
// Close write stream if open and wait for it to fully flush.
|
||||
// This ensures the artifact is completely written before we advertise the artifact_id.
|
||||
finalize(): PersistedCommandOutput {
|
||||
// Close write stream if open
|
||||
if (this.writeStream) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.writeStream!.end(() => resolve())
|
||||
this.writeStream!.on("error", reject)
|
||||
})
|
||||
this.writeStream.end()
|
||||
}
|
||||
|
||||
// Prepare preview: head + [omission indicator] + tail
|
||||
let preview: string
|
||||
if (this.omittedBytes > 0) {
|
||||
const omissionIndicator = `\n[...${this.omittedBytes} bytes omitted...]\n`
|
||||
preview = this.headBuffer + omissionIndicator + this.tailBuffer
|
||||
} else {
|
||||
// No truncation, just combine head and tail (or head alone if tail is empty)
|
||||
preview = this.headBuffer + this.tailBuffer
|
||||
// Prepare preview
|
||||
let preview = this.buffer.slice(0, this.previewBytes)
|
||||
|
||||
// Apply compression to preview only (for readability)
|
||||
if (this.compressProgressBar) {
|
||||
preview = processCarriageReturns(preview)
|
||||
preview = processBackspaces(preview)
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -340,15 +184,13 @@ export class OutputInterceptor {
|
|||
/**
|
||||
* Get the current buffer content for UI display.
|
||||
*
|
||||
* Returns the combined head + tail content for real-time UI updates.
|
||||
* Note: Does not include the omission indicator to avoid flickering during streaming.
|
||||
* Returns the in-memory buffer which contains either all output (if not spilled)
|
||||
* or just the preview portion (if spilled to disk).
|
||||
*
|
||||
* @returns The current buffer content as a string
|
||||
*/
|
||||
getBufferForUI(): string {
|
||||
// For UI, return combined head + tail without omission indicator
|
||||
// This provides a smoother streaming experience
|
||||
return this.headBuffer + this.tailBuffer
|
||||
return this.buffer
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -32,16 +32,12 @@ describe("OutputInterceptor", () => {
|
|||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
storageDir = path.normalize("/tmp/test-storage")
|
||||
storageDir = "/tmp/test-storage"
|
||||
|
||||
// Setup mock write stream with callback support for end()
|
||||
// Setup mock write stream
|
||||
mockWriteStream = {
|
||||
write: vi.fn(),
|
||||
end: vi.fn((callback?: () => void) => {
|
||||
// Immediately call the callback to simulate stream flush completing
|
||||
if (callback) callback()
|
||||
}),
|
||||
on: vi.fn(),
|
||||
end: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true)
|
||||
|
|
@ -53,13 +49,14 @@ describe("OutputInterceptor", () => {
|
|||
})
|
||||
|
||||
describe("Buffering behavior", () => {
|
||||
it("should keep small output in memory without spilling to disk", async () => {
|
||||
it("should keep small output in memory without spilling to disk", () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "echo test",
|
||||
storageDir,
|
||||
previewSize: "small", // 5KB
|
||||
previewSize: "small", // 2KB
|
||||
compressProgressBar: false,
|
||||
})
|
||||
|
||||
const smallOutput = "Hello World\n"
|
||||
|
|
@ -68,7 +65,7 @@ describe("OutputInterceptor", () => {
|
|||
expect(interceptor.hasSpilledToDisk()).toBe(false)
|
||||
expect(fs.createWriteStream).not.toHaveBeenCalled()
|
||||
|
||||
const result = await interceptor.finalize()
|
||||
const result = interceptor.finalize()
|
||||
expect(result.preview).toBe(smallOutput)
|
||||
expect(result.truncated).toBe(false)
|
||||
expect(result.artifactPath).toBe(null)
|
||||
|
|
@ -81,45 +78,44 @@ describe("OutputInterceptor", () => {
|
|||
taskId: "task-1",
|
||||
command: "echo test",
|
||||
storageDir,
|
||||
previewSize: "small", // 5KB = 5120 bytes
|
||||
previewSize: "small", // 2KB = 2048 bytes
|
||||
compressProgressBar: false,
|
||||
})
|
||||
|
||||
// Write enough data to exceed 5KB threshold
|
||||
const chunk = "x".repeat(2 * 1024) // 2KB chunk
|
||||
// Write enough data to exceed 2KB threshold
|
||||
const chunk = "x".repeat(1024) // 1KB chunk
|
||||
interceptor.write(chunk) // 1KB - should stay in memory
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(false)
|
||||
|
||||
interceptor.write(chunk) // 2KB - should stay in memory
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(false)
|
||||
|
||||
interceptor.write(chunk) // 4KB - should stay in memory
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(false)
|
||||
|
||||
interceptor.write(chunk) // 6KB - should trigger spill
|
||||
interceptor.write(chunk) // 3KB - should trigger spill
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(true)
|
||||
expect(fs.createWriteStream).toHaveBeenCalledWith(path.join(storageDir, "cmd-12345.txt"))
|
||||
expect(mockWriteStream.write).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should truncate preview after spilling to disk using head/tail split", async () => {
|
||||
it("should truncate preview after spilling to disk", () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "echo test",
|
||||
storageDir,
|
||||
previewSize: "small", // 5KB
|
||||
previewSize: "small", // 2KB
|
||||
compressProgressBar: false,
|
||||
})
|
||||
|
||||
// Write data that exceeds threshold
|
||||
const chunk = "x".repeat(6000)
|
||||
const chunk = "x".repeat(3000)
|
||||
interceptor.write(chunk)
|
||||
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(true)
|
||||
|
||||
const result = await interceptor.finalize()
|
||||
const result = interceptor.finalize()
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.artifactPath).toBe(path.join(storageDir, "cmd-12345.txt"))
|
||||
// Preview is head (1024) + omission indicator + tail (1024)
|
||||
// The omission indicator adds some extra bytes
|
||||
expect(result.preview).toContain("[...")
|
||||
expect(result.preview).toContain("bytes omitted...]")
|
||||
expect(Buffer.byteLength(result.preview, "utf8")).toBeLessThanOrEqual(2048)
|
||||
})
|
||||
|
||||
it("should write subsequent chunks directly to disk after spilling", () => {
|
||||
|
|
@ -129,10 +125,11 @@ describe("OutputInterceptor", () => {
|
|||
command: "echo test",
|
||||
storageDir,
|
||||
previewSize: "small",
|
||||
compressProgressBar: false,
|
||||
})
|
||||
|
||||
// Trigger spill (must exceed 5KB = 5120 bytes)
|
||||
const largeChunk = "x".repeat(6000)
|
||||
// Trigger spill
|
||||
const largeChunk = "x".repeat(3000)
|
||||
interceptor.write(largeChunk)
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(true)
|
||||
|
||||
|
|
@ -148,56 +145,59 @@ describe("OutputInterceptor", () => {
|
|||
})
|
||||
|
||||
describe("Threshold settings", () => {
|
||||
it("should handle small (5KB) threshold correctly", () => {
|
||||
it("should handle small (2KB) threshold correctly", () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small",
|
||||
compressProgressBar: false,
|
||||
})
|
||||
|
||||
// Write exactly 5KB
|
||||
interceptor.write("x".repeat(5 * 1024))
|
||||
// Write exactly 2KB
|
||||
interceptor.write("x".repeat(2048))
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(false)
|
||||
|
||||
// Write more to exceed 5KB
|
||||
// Write more to exceed 2KB
|
||||
interceptor.write("x")
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle medium (10KB) threshold correctly", () => {
|
||||
it("should handle medium (4KB) threshold correctly", () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "medium",
|
||||
compressProgressBar: false,
|
||||
})
|
||||
|
||||
// Write exactly 10KB
|
||||
interceptor.write("x".repeat(10 * 1024))
|
||||
// Write exactly 4KB
|
||||
interceptor.write("x".repeat(4096))
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(false)
|
||||
|
||||
// Write more to exceed 10KB
|
||||
// Write more to exceed 4KB
|
||||
interceptor.write("x")
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle large (20KB) threshold correctly", () => {
|
||||
it("should handle large (8KB) threshold correctly", () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "large",
|
||||
compressProgressBar: false,
|
||||
})
|
||||
|
||||
// Write exactly 20KB
|
||||
interceptor.write("x".repeat(20 * 1024))
|
||||
// Write exactly 8KB
|
||||
interceptor.write("x".repeat(8192))
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(false)
|
||||
|
||||
// Write more to exceed 20KB
|
||||
// Write more to exceed 8KB
|
||||
interceptor.write("x")
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(true)
|
||||
})
|
||||
|
|
@ -213,10 +213,11 @@ describe("OutputInterceptor", () => {
|
|||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small",
|
||||
compressProgressBar: false,
|
||||
})
|
||||
|
||||
// Trigger spill (must exceed 5KB = 5120 bytes)
|
||||
interceptor.write("x".repeat(6000))
|
||||
// Trigger spill
|
||||
interceptor.write("x".repeat(3000))
|
||||
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith(storageDir, { recursive: true })
|
||||
})
|
||||
|
|
@ -229,31 +230,30 @@ describe("OutputInterceptor", () => {
|
|||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small",
|
||||
compressProgressBar: false,
|
||||
})
|
||||
|
||||
// Trigger spill (must exceed 5KB = 5120 bytes)
|
||||
interceptor.write("x".repeat(6000))
|
||||
// Trigger spill
|
||||
interceptor.write("x".repeat(3000))
|
||||
|
||||
expect(fs.createWriteStream).toHaveBeenCalledWith(path.join(storageDir, `cmd-${executionId}.txt`))
|
||||
})
|
||||
|
||||
it("should write head and tail buffers to artifact when spilling", () => {
|
||||
it("should write full output to artifact, not truncated", () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small", // 5KB = 5120 bytes, so head=2560, tail=2560
|
||||
previewSize: "small",
|
||||
compressProgressBar: false,
|
||||
})
|
||||
|
||||
const fullOutput = "x".repeat(10000)
|
||||
const fullOutput = "x".repeat(5000)
|
||||
interceptor.write(fullOutput)
|
||||
|
||||
// The write stream should receive the head buffer content first
|
||||
// (spillToDisk writes head + tail that existed at spill time)
|
||||
expect(mockWriteStream.write).toHaveBeenCalled()
|
||||
// Verify that we're writing to disk
|
||||
expect(interceptor.hasSpilledToDisk()).toBe(true)
|
||||
// The write stream should receive the full buffer content
|
||||
expect(mockWriteStream.write).toHaveBeenCalledWith(fullOutput)
|
||||
})
|
||||
|
||||
it("should get artifact path from getArtifactPath() method", () => {
|
||||
|
|
@ -264,6 +264,7 @@ describe("OutputInterceptor", () => {
|
|||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small",
|
||||
compressProgressBar: false,
|
||||
})
|
||||
|
||||
const expectedPath = path.join(storageDir, `cmd-${executionId}.txt`)
|
||||
|
|
@ -272,19 +273,20 @@ describe("OutputInterceptor", () => {
|
|||
})
|
||||
|
||||
describe("finalize() method", () => {
|
||||
it("should return preview output for small commands", async () => {
|
||||
it("should return preview output for small commands", () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "echo hello",
|
||||
storageDir,
|
||||
previewSize: "small",
|
||||
compressProgressBar: false,
|
||||
})
|
||||
|
||||
const output = "Hello World\n"
|
||||
interceptor.write(output)
|
||||
|
||||
const result = await interceptor.finalize()
|
||||
const result = interceptor.finalize()
|
||||
|
||||
expect(result.preview).toBe(output)
|
||||
expect(result.totalBytes).toBe(Buffer.byteLength(output, "utf8"))
|
||||
|
|
@ -292,61 +294,61 @@ describe("OutputInterceptor", () => {
|
|||
expect(result.truncated).toBe(false)
|
||||
})
|
||||
|
||||
it("should return PersistedCommandOutput for large commands with head/tail preview", async () => {
|
||||
it("should return PersistedCommandOutput for large commands", () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small", // 5KB = 5120, head=2560, tail=2560
|
||||
previewSize: "small",
|
||||
compressProgressBar: false,
|
||||
})
|
||||
|
||||
const largeOutput = "x".repeat(10000)
|
||||
const largeOutput = "x".repeat(5000)
|
||||
interceptor.write(largeOutput)
|
||||
|
||||
const result = await interceptor.finalize()
|
||||
const result = interceptor.finalize()
|
||||
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.artifactPath).toBe(path.join(storageDir, "cmd-12345.txt"))
|
||||
expect(result.totalBytes).toBe(Buffer.byteLength(largeOutput, "utf8"))
|
||||
// Preview should contain head + omission indicator + tail
|
||||
expect(result.preview).toContain("[...")
|
||||
expect(result.preview).toContain("bytes omitted...]")
|
||||
expect(Buffer.byteLength(result.preview, "utf8")).toBeLessThanOrEqual(2048)
|
||||
})
|
||||
|
||||
it("should close write stream when finalizing", async () => {
|
||||
it("should close write stream when finalizing", () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small",
|
||||
compressProgressBar: false,
|
||||
})
|
||||
|
||||
// Trigger spill (must exceed 5KB = 5120 bytes)
|
||||
interceptor.write("x".repeat(6000))
|
||||
await interceptor.finalize()
|
||||
// Trigger spill
|
||||
interceptor.write("x".repeat(3000))
|
||||
interceptor.finalize()
|
||||
|
||||
expect(mockWriteStream.end).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should include correct metadata (artifactId, size, truncated flag)", async () => {
|
||||
it("should include correct metadata (artifactId, size, truncated flag)", () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small",
|
||||
compressProgressBar: false,
|
||||
})
|
||||
|
||||
// Must exceed 5KB = 5120 bytes to trigger truncation
|
||||
const output = "x".repeat(6000)
|
||||
const output = "x".repeat(5000)
|
||||
interceptor.write(output)
|
||||
|
||||
const result = await interceptor.finalize()
|
||||
const result = interceptor.finalize()
|
||||
|
||||
expect(result).toHaveProperty("preview")
|
||||
expect(result).toHaveProperty("totalBytes", 6000)
|
||||
expect(result).toHaveProperty("totalBytes", 5000)
|
||||
expect(result).toHaveProperty("artifactPath")
|
||||
expect(result).toHaveProperty("truncated", true)
|
||||
expect(result.artifactPath).toMatch(/cmd-12345\.txt$/)
|
||||
|
|
@ -401,6 +403,46 @@ describe("OutputInterceptor", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("Progress bar compression", () => {
|
||||
it("should apply compression when compressProgressBar is true", () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small",
|
||||
compressProgressBar: true,
|
||||
})
|
||||
|
||||
// Output with carriage returns (simulating progress bar)
|
||||
const output = "Progress: 10%\rProgress: 50%\rProgress: 100%\n"
|
||||
interceptor.write(output)
|
||||
|
||||
const result = interceptor.finalize()
|
||||
|
||||
// Preview should be compressed (carriage returns processed)
|
||||
// The processCarriageReturns function should keep only the last line before \r
|
||||
expect(result.preview).not.toBe(output)
|
||||
})
|
||||
|
||||
it("should not apply compression when compressProgressBar is false", () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small",
|
||||
compressProgressBar: false,
|
||||
})
|
||||
|
||||
const output = "Line 1\nLine 2\n"
|
||||
interceptor.write(output)
|
||||
|
||||
const result = interceptor.finalize()
|
||||
expect(result.preview).toBe(output)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getBufferForUI() method", () => {
|
||||
it("should return current buffer for UI updates", () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
|
|
@ -409,6 +451,7 @@ describe("OutputInterceptor", () => {
|
|||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small",
|
||||
compressProgressBar: false,
|
||||
})
|
||||
|
||||
const output = "Hello World"
|
||||
|
|
@ -417,116 +460,22 @@ describe("OutputInterceptor", () => {
|
|||
expect(interceptor.getBufferForUI()).toBe(output)
|
||||
})
|
||||
|
||||
it("should return head + tail buffer after spilling to disk", () => {
|
||||
it("should return truncated buffer after spilling to disk", () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small", // 5KB = 5120, head=2560, tail=2560
|
||||
previewSize: "small",
|
||||
compressProgressBar: false,
|
||||
})
|
||||
|
||||
// Trigger spill
|
||||
const largeOutput = "x".repeat(10000)
|
||||
const largeOutput = "x".repeat(5000)
|
||||
interceptor.write(largeOutput)
|
||||
|
||||
const buffer = interceptor.getBufferForUI()
|
||||
// Buffer for UI is head + tail (no omission indicator for smooth streaming)
|
||||
expect(Buffer.byteLength(buffer, "utf8")).toBeLessThanOrEqual(5120)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Head/Tail split behavior", () => {
|
||||
it("should preserve first 50% and last 50% of output", async () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small", // 5KB = 5120, head=2560, tail=2560
|
||||
})
|
||||
|
||||
// Create identifiable head and tail content
|
||||
const headContent = "HEAD".repeat(750) // 3000 bytes
|
||||
const middleContent = "M".repeat(6000) // 6000 bytes (will be omitted)
|
||||
const tailContent = "TAIL".repeat(750) // 3000 bytes
|
||||
|
||||
interceptor.write(headContent)
|
||||
interceptor.write(middleContent)
|
||||
interceptor.write(tailContent)
|
||||
|
||||
const result = await interceptor.finalize()
|
||||
|
||||
// Should start with HEAD content (first 2560 bytes of head budget)
|
||||
expect(result.preview.startsWith("HEAD")).toBe(true)
|
||||
// Should end with TAIL content (last 2560 bytes)
|
||||
expect(result.preview.endsWith("TAIL")).toBe(true)
|
||||
// Should have omission indicator
|
||||
expect(result.preview).toContain("[...")
|
||||
expect(result.preview).toContain("bytes omitted...]")
|
||||
})
|
||||
|
||||
it("should not add omission indicator when output fits in budget", async () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small", // 5KB
|
||||
})
|
||||
|
||||
const smallOutput = "Hello World\n"
|
||||
interceptor.write(smallOutput)
|
||||
|
||||
const result = await interceptor.finalize()
|
||||
|
||||
// No omission indicator for small output
|
||||
expect(result.preview).toBe(smallOutput)
|
||||
expect(result.preview).not.toContain("[...")
|
||||
})
|
||||
|
||||
it("should handle output that exactly fills head budget", async () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small", // 5KB = 5120, head=2560
|
||||
})
|
||||
|
||||
// Write exactly 2560 bytes (head budget)
|
||||
const exactHeadContent = "x".repeat(2560)
|
||||
interceptor.write(exactHeadContent)
|
||||
|
||||
const result = await interceptor.finalize()
|
||||
|
||||
// Should fit entirely in head, no truncation
|
||||
expect(result.preview).toBe(exactHeadContent)
|
||||
expect(result.truncated).toBe(false)
|
||||
})
|
||||
|
||||
it("should split single large chunk across head and tail", async () => {
|
||||
const interceptor = new OutputInterceptor({
|
||||
executionId: "12345",
|
||||
taskId: "task-1",
|
||||
command: "test",
|
||||
storageDir,
|
||||
previewSize: "small", // 5KB = 5120, head=2560, tail=2560
|
||||
})
|
||||
|
||||
// Write a single chunk larger than preview budget
|
||||
// First 2560 chars go to head, last 2560 chars go to tail
|
||||
const content = "A".repeat(2560) + "B".repeat(4000) + "C".repeat(2560)
|
||||
interceptor.write(content)
|
||||
|
||||
const result = await interceptor.finalize()
|
||||
|
||||
// Head should have A's
|
||||
expect(result.preview.startsWith("A")).toBe(true)
|
||||
// Tail should have C's
|
||||
expect(result.preview.endsWith("C")).toBe(true)
|
||||
// Should have omission indicator
|
||||
expect(result.preview).toContain("[...")
|
||||
expect(Buffer.byteLength(buffer, "utf8")).toBeLessThanOrEqual(2048)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
57
src/integrations/terminal/index.ts
Normal file
57
src/integrations/terminal/index.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* Terminal Output Handling Module
|
||||
*
|
||||
* This module provides utilities for capturing, persisting, and retrieving
|
||||
* command output from terminal executions.
|
||||
*
|
||||
* ## Overview
|
||||
*
|
||||
* When the LLM executes commands via `execute_command`, the output can be
|
||||
* very large (build logs, test output, etc.). To prevent context window
|
||||
* overflow while still allowing access to full output, this module
|
||||
* implements a "persisted output" pattern:
|
||||
*
|
||||
* 1. **OutputInterceptor**: Buffers command output during execution. If
|
||||
* output exceeds a configurable threshold, it "spills" to disk and
|
||||
* keeps only a preview in memory.
|
||||
*
|
||||
* 2. **Artifact Storage**: Full outputs are stored as text files in the
|
||||
* task's `command-output/` directory with names like `cmd-{timestamp}.txt`.
|
||||
*
|
||||
* 3. **ReadCommandOutputTool**: Allows the LLM to retrieve the full output
|
||||
* later via the `read_command_output` tool, with support for search
|
||||
* and pagination.
|
||||
*
|
||||
* ## Data Flow
|
||||
*
|
||||
* ```
|
||||
* execute_command
|
||||
* │
|
||||
* ▼
|
||||
* OutputInterceptor.write() ──► Buffer accumulates
|
||||
* │
|
||||
* ▼ (threshold exceeded)
|
||||
* OutputInterceptor.spillToDisk() ──► Artifact file created
|
||||
* │
|
||||
* ▼
|
||||
* OutputInterceptor.finalize() ──► Returns PersistedCommandOutput
|
||||
* │
|
||||
* ▼
|
||||
* LLM receives preview + artifact_id
|
||||
* │
|
||||
* ▼ (if needs full output)
|
||||
* read_command_output(artifact_id) ──► Full content/search results
|
||||
* ```
|
||||
*
|
||||
* ## Configuration
|
||||
*
|
||||
* Preview size is controlled by `terminalOutputPreviewSize` setting:
|
||||
* - `small`: 2KB preview
|
||||
* - `medium`: 4KB preview (default)
|
||||
* - `large`: 8KB preview
|
||||
*
|
||||
* @module terminal
|
||||
*/
|
||||
|
||||
export { OutputInterceptor } from "./OutputInterceptor"
|
||||
export type { OutputInterceptorOptions } from "./OutputInterceptor"
|
||||
|
|
@ -541,6 +541,7 @@
|
|||
"@types/diff": "^5.2.1",
|
||||
"@types/diff-match-patch": "^1.0.36",
|
||||
"@types/glob": "^8.1.0",
|
||||
"@types/json-stream-stringify": "^2.0.4",
|
||||
"@types/lodash.debounce": "^4.0.9",
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/node": "20.x",
|
||||
|
|
|
|||
|
|
@ -179,6 +179,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
ttsSpeed,
|
||||
soundVolume,
|
||||
telemetrySetting,
|
||||
terminalOutputLineLimit,
|
||||
terminalOutputCharacterLimit,
|
||||
terminalOutputPreviewSize,
|
||||
terminalShellIntegrationTimeout,
|
||||
terminalShellIntegrationDisabled, // Added from upstream
|
||||
|
|
@ -397,6 +399,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
terminalZshOhMy,
|
||||
terminalZshP10k,
|
||||
terminalZdotdir,
|
||||
terminalCompressProgressBar,
|
||||
terminalOutputPreviewSize: terminalOutputPreviewSize ?? "medium",
|
||||
mcpEnabled,
|
||||
maxOpenTabsContext: Math.min(Math.max(0, maxOpenTabsContext ?? 20), 500),
|
||||
|
|
|
|||
|
|
@ -124,6 +124,31 @@ export const TerminalSettings = ({
|
|||
{t("settings:terminal.outputPreviewSize.description")}
|
||||
</div>
|
||||
</SearchableSetting>
|
||||
<SearchableSetting
|
||||
settingId="terminal-compress-progress-bar"
|
||||
section="terminal"
|
||||
label={t("settings:terminal.compressProgressBar.label")}>
|
||||
<VSCodeCheckbox
|
||||
checked={terminalCompressProgressBar ?? true}
|
||||
onChange={(e: any) =>
|
||||
setCachedStateField("terminalCompressProgressBar", e.target.checked)
|
||||
}
|
||||
data-testid="terminal-compress-progress-bar-checkbox">
|
||||
<span className="font-medium">{t("settings:terminal.compressProgressBar.label")}</span>
|
||||
</VSCodeCheckbox>
|
||||
<div className="text-vscode-descriptionForeground text-sm mt-1">
|
||||
<Trans i18nKey="settings:terminal.compressProgressBar.description">
|
||||
<VSCodeLink
|
||||
href={buildDocLink(
|
||||
"features/shell-integration#compress-progress-bar-output",
|
||||
"settings_terminal_compress_progress_bar",
|
||||
)}
|
||||
style={{ display: "inline" }}>
|
||||
{" "}
|
||||
</VSCodeLink>
|
||||
</Trans>
|
||||
</div>
|
||||
</SearchableSetting>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -96,6 +96,10 @@ export interface ExtensionStateContextType extends ExtensionState {
|
|||
setWriteDelayMs: (value: number) => void
|
||||
screenshotQuality?: number
|
||||
setScreenshotQuality: (value: number) => void
|
||||
terminalOutputLineLimit?: number
|
||||
setTerminalOutputLineLimit: (value: number) => void
|
||||
terminalOutputCharacterLimit?: number
|
||||
setTerminalOutputCharacterLimit: (value: number) => void
|
||||
terminalOutputPreviewSize?: "small" | "medium" | "large"
|
||||
setTerminalOutputPreviewSize: (value: "small" | "medium" | "large") => void
|
||||
mcpEnabled: boolean
|
||||
|
|
@ -537,6 +541,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
setState((prevState) => ({ ...prevState, browserViewportSize: value })),
|
||||
setWriteDelayMs: (value) => setState((prevState) => ({ ...prevState, writeDelayMs: value })),
|
||||
setScreenshotQuality: (value) => setState((prevState) => ({ ...prevState, screenshotQuality: value })),
|
||||
setTerminalOutputLineLimit: (value) =>
|
||||
setState((prevState) => ({ ...prevState, terminalOutputLineLimit: value })),
|
||||
setTerminalOutputCharacterLimit: (value) =>
|
||||
setState((prevState) => ({ ...prevState, terminalOutputCharacterLimit: value })),
|
||||
setTerminalOutputPreviewSize: (value) =>
|
||||
setState((prevState) => ({ ...prevState, terminalOutputPreviewSize: value })),
|
||||
setTerminalShellIntegrationTimeout: (value) =>
|
||||
|
|
|
|||
|
|
@ -737,9 +737,9 @@
|
|||
"label": "Command output preview size",
|
||||
"description": "Controls how much command output Roo sees directly. Full output is always saved and accessible when needed.",
|
||||
"options": {
|
||||
"small": "Small (5KB)",
|
||||
"medium": "Medium (10KB)",
|
||||
"large": "Large (20KB)"
|
||||
"small": "Small (2KB)",
|
||||
"medium": "Medium (4KB)",
|
||||
"large": "Large (8KB)"
|
||||
}
|
||||
},
|
||||
"shellIntegrationTimeout": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue