Roo-Code/src/integrations/terminal/BaseTerminal.ts
roomote[bot] 37300ef0c5
fix: add character limit to prevent terminal output context explosion (#5777)
* fix: add character limit to prevent terminal output context explosion

- Enhanced truncateOutput function to accept character limits alongside line limits
- Character limits take priority over line limits to prevent context window explosion
- Added terminalOutputCharacterLimit setting (default: 100,000 characters)
- Updated all terminal output processing to use both limits
- Added comprehensive tests for character limit functionality

Fixes #5775

* feat: add terminal output character limit setting to UI

- Add character limit slider to Terminal Settings UI (default: 50,000)
- Update ExtensionStateContext to manage character limit state
- Add validation for positive character limit values
- Add English translation and translations for all 17 supported languages
- Connect UI to backend through proper message handling
- Character limit takes precedence over line limit to prevent memory issues

* fix: update test expectations for character limit edge cases

- Fix multi-byte character test to account for JavaScript's string length behavior
- Fix newline content test to match actual slice behavior
- Tests now correctly validate the truncateOutput function's character limit handling

---------

Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: Daniel Riccio <ricciodaniel98@gmail.com>
2025-07-19 13:26:25 -04:00

317 lines
9.3 KiB
TypeScript

import { truncateOutput, applyRunLengthEncoding, processBackspaces, processCarriageReturns } from "../misc/extract-text"
import { DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT } from "@roo-code/types"
import type {
RooTerminalProvider,
RooTerminal,
RooTerminalCallbacks,
RooTerminalProcess,
RooTerminalProcessResultPromise,
ExitCodeDetails,
} from "./types"
export abstract class BaseTerminal implements RooTerminal {
public readonly provider: RooTerminalProvider
public readonly id: number
public readonly initialCwd: string
public busy: boolean
public running: boolean
protected streamClosed: boolean
public taskId?: string
public process?: RooTerminalProcess
public completedProcesses: RooTerminalProcess[] = []
constructor(provider: RooTerminalProvider, id: number, cwd: string) {
this.provider = provider
this.id = id
this.initialCwd = cwd
this.busy = false
this.running = false
this.streamClosed = false
}
public getCurrentWorkingDirectory(): string {
return this.initialCwd
}
abstract isClosed(): boolean
abstract runCommand(command: string, callbacks: RooTerminalCallbacks): RooTerminalProcessResultPromise
/**
* Sets the active stream for this terminal and notifies the process
* @param stream The stream to set, or undefined to clean up
* @throws Error if process is undefined when a stream is provided
*/
public setActiveStream(stream: AsyncIterable<string> | undefined, pid?: number): void {
if (stream) {
if (!this.process) {
this.running = false
console.warn(
`[Terminal ${this.provider}/${this.id}] process is undefined, so cannot set terminal stream (probably user-initiated non-Roo command)`,
)
return
}
this.running = true
this.streamClosed = false
this.process.emit("shell_execution_started", pid)
this.process.emit("stream_available", stream)
} else {
this.streamClosed = true
}
}
/**
* Handles shell execution completion for this terminal.
* @param exitDetails The exit details of the shell execution
*/
public shellExecutionComplete(exitDetails: ExitCodeDetails) {
this.busy = false
this.running = false
if (this.process) {
// Add to the front of the queue (most recent first).
if (this.process.hasUnretrievedOutput()) {
this.completedProcesses.unshift(this.process)
}
this.process.emit("shell_execution_complete", exitDetails)
this.process = undefined
}
}
public get isStreamClosed(): boolean {
return this.streamClosed
}
/**
* Gets the last executed command
* @returns The last command string or empty string if none
*/
public getLastCommand(): string {
// Return the command from the active process or the most recent process in the queue
if (this.process) {
return this.process.command || ""
} else if (this.completedProcesses.length > 0) {
return this.completedProcesses[0].command || ""
}
return ""
}
/**
* Cleans the process queue by removing processes that no longer have unretrieved output
* or don't belong to the current task
*/
public cleanCompletedProcessQueue(): void {
// Keep only processes with unretrieved output
this.completedProcesses = this.completedProcesses.filter((process) => process.hasUnretrievedOutput())
}
/**
* Gets all processes with unretrieved output
* @returns Array of processes with unretrieved output
*/
public getProcessesWithOutput(): RooTerminalProcess[] {
// Clean the queue first to remove any processes without output
this.cleanCompletedProcessQueue()
return [...this.completedProcesses]
}
/**
* Gets all unretrieved output from both active and completed processes
* @returns Combined unretrieved output from all processes
*/
public getUnretrievedOutput(): string {
let output = ""
// First check completed processes to maintain chronological order
for (const process of this.completedProcesses) {
const processOutput = process.getUnretrievedOutput()
if (processOutput) {
output += processOutput
}
}
// Then check active process for most recent output
const activeOutput = this.process?.getUnretrievedOutput()
if (activeOutput) {
output += activeOutput
}
this.cleanCompletedProcessQueue()
return output
}
public static defaultShellIntegrationTimeout = 5_000
private static shellIntegrationTimeout: number = BaseTerminal.defaultShellIntegrationTimeout
private static shellIntegrationDisabled: boolean = false
private static commandDelay: number = 0
private static powershellCounter: boolean = false
private static terminalZshClearEolMark: boolean = true
private static terminalZshOhMy: boolean = false
private static terminalZshP10k: boolean = false
private static terminalZdotdir: boolean = false
private static compressProgressBar: boolean = true
/**
* Compresses terminal output by applying run-length encoding and truncating to line limit
* @param input The terminal output to compress
* @returns The compressed terminal output
*/
public static setShellIntegrationTimeout(timeoutMs: number): void {
BaseTerminal.shellIntegrationTimeout = timeoutMs
}
public static getShellIntegrationTimeout(): number {
return BaseTerminal.shellIntegrationTimeout
}
public static setShellIntegrationDisabled(disabled: boolean): void {
BaseTerminal.shellIntegrationDisabled = disabled
}
public static getShellIntegrationDisabled(): boolean {
return BaseTerminal.shellIntegrationDisabled
}
/**
* Sets the command delay in milliseconds
* @param delayMs The delay in milliseconds
*/
public static setCommandDelay(delayMs: number): void {
BaseTerminal.commandDelay = delayMs
}
/**
* Gets the command delay in milliseconds
* @returns The command delay in milliseconds
*/
public static getCommandDelay(): number {
return BaseTerminal.commandDelay
}
/**
* Sets whether to use the PowerShell counter workaround
* @param enabled Whether to enable the PowerShell counter workaround
*/
public static setPowershellCounter(enabled: boolean): void {
BaseTerminal.powershellCounter = enabled
}
/**
* Gets whether to use the PowerShell counter workaround
* @returns Whether the PowerShell counter workaround is enabled
*/
public static getPowershellCounter(): boolean {
return BaseTerminal.powershellCounter
}
/**
* Sets whether to clear the ZSH EOL mark
* @param enabled Whether to clear the ZSH EOL mark
*/
public static setTerminalZshClearEolMark(enabled: boolean): void {
BaseTerminal.terminalZshClearEolMark = enabled
}
/**
* Gets whether to clear the ZSH EOL mark
* @returns Whether the ZSH EOL mark clearing is enabled
*/
public static getTerminalZshClearEolMark(): boolean {
return BaseTerminal.terminalZshClearEolMark
}
/**
* Sets whether to enable Oh My Zsh shell integration
* @param enabled Whether to enable Oh My Zsh shell integration
*/
public static setTerminalZshOhMy(enabled: boolean): void {
BaseTerminal.terminalZshOhMy = enabled
}
/**
* Gets whether Oh My Zsh shell integration is enabled
* @returns Whether Oh My Zsh shell integration is enabled
*/
public static getTerminalZshOhMy(): boolean {
return BaseTerminal.terminalZshOhMy
}
/**
* Sets whether to enable Powerlevel10k shell integration
* @param enabled Whether to enable Powerlevel10k shell integration
*/
public static setTerminalZshP10k(enabled: boolean): void {
BaseTerminal.terminalZshP10k = enabled
}
/**
* Gets whether Powerlevel10k shell integration is enabled
* @returns Whether Powerlevel10k shell integration is enabled
*/
public static getTerminalZshP10k(): boolean {
return BaseTerminal.terminalZshP10k
}
/**
* Compresses terminal output by applying run-length encoding and truncating to line and character limits
* @param input The terminal output to compress
* @param lineLimit Maximum number of lines to keep
* @param characterLimit Optional maximum number of characters to keep (defaults to DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT)
* @returns The compressed terminal output
*/
public static compressTerminalOutput(input: string, lineLimit: number, characterLimit?: number): string {
let processedInput = input
if (BaseTerminal.compressProgressBar) {
processedInput = processCarriageReturns(processedInput)
processedInput = processBackspaces(processedInput)
}
// Default character limit to prevent context window explosion
const effectiveCharLimit = characterLimit ?? DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT
return truncateOutput(applyRunLengthEncoding(processedInput), lineLimit, effectiveCharLimit)
}
/**
* Sets whether to enable ZDOTDIR handling for zsh
* @param enabled Whether to enable ZDOTDIR handling
*/
public static setTerminalZdotdir(enabled: boolean): void {
BaseTerminal.terminalZdotdir = enabled
}
/**
* Gets whether ZDOTDIR handling is enabled
* @returns Whether ZDOTDIR handling is enabled
*/
public static getTerminalZdotdir(): boolean {
return BaseTerminal.terminalZdotdir
}
/**
* Sets whether to compress progress bar output by processing carriage returns
* @param enabled Whether to enable progress bar compression
*/
public static setCompressProgressBar(enabled: boolean): void {
BaseTerminal.compressProgressBar = enabled
}
/**
* Gets whether progress bar compression is enabled
* @returns Whether progress bar compression is enabled
*/
public static getCompressProgressBar(): boolean {
return BaseTerminal.compressProgressBar
}
}