Skip to content

Commit 15e6168

Browse files
committed
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
1 parent 0e0c986 commit 15e6168

29 files changed

+255
-13
lines changed

packages/types/src/global-settings.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ import { languagesSchema } from "./vscode.js"
2222
*/
2323
export const DEFAULT_WRITE_DELAY_MS = 1000
2424

25+
/**
26+
* Default terminal output character limit constant.
27+
* This provides a reasonable default that aligns with typical terminal usage
28+
* while preventing context window explosions from extremely long lines.
29+
*/
30+
export const DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT = 50_000
31+
2532
/**
2633
* GlobalSettings
2734
*/
@@ -228,7 +235,7 @@ export const EVALS_SETTINGS: RooCodeSettings = {
228235
soundVolume: 0.5,
229236

230237
terminalOutputLineLimit: 500,
231-
terminalOutputCharacterLimit: 100000,
238+
terminalOutputCharacterLimit: DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
232239
terminalShellIntegrationTimeout: 30000,
233240
terminalCommandDelay: 0,
234241
terminalPowershellCounter: false,

src/core/environment/getEnvironmentDetails.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import pWaitFor from "p-wait-for"
66
import delay from "delay"
77

88
import type { ExperimentId } from "@roo-code/types"
9+
import { DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT } from "@roo-code/types"
910

1011
import { EXPERIMENT_IDS, experiments as Experiments } from "../../shared/experiments"
1112
import { formatLanguage } from "../../shared/language"
@@ -27,7 +28,7 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
2728
const state = await clineProvider?.getState()
2829
const {
2930
terminalOutputLineLimit = 500,
30-
terminalOutputCharacterLimit = 100000,
31+
terminalOutputCharacterLimit = DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
3132
maxWorkspaceFiles = 200,
3233
} = state ?? {}
3334

src/core/tools/executeCommandTool.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as vscode from "vscode"
44

55
import delay from "delay"
66

7-
import { CommandExecutionStatus } from "@roo-code/types"
7+
import { CommandExecutionStatus, DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT } from "@roo-code/types"
88
import { TelemetryService } from "@roo-code/telemetry"
99

1010
import { Task } from "../task/Task"
@@ -65,7 +65,7 @@ export async function executeCommandTool(
6565
const clineProviderState = await clineProvider?.getState()
6666
const {
6767
terminalOutputLineLimit = 500,
68-
terminalOutputCharacterLimit = 100000,
68+
terminalOutputCharacterLimit = DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
6969
terminalShellIntegrationDisabled = false,
7070
} = clineProviderState ?? {}
7171

@@ -150,7 +150,7 @@ export async function executeCommand(
150150
customCwd,
151151
terminalShellIntegrationDisabled = false,
152152
terminalOutputLineLimit = 500,
153-
terminalOutputCharacterLimit = 100000,
153+
terminalOutputCharacterLimit = DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
154154
commandExecutionTimeout = 0,
155155
}: ExecuteCommandOptions,
156156
): Promise<[boolean, ToolResponse]> {

src/core/webview/ClineProvider.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
openRouterDefaultModelId,
2929
glamaDefaultModelId,
3030
ORGANIZATION_ALLOW_ALL,
31+
DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
3132
} from "@roo-code/types"
3233
import { TelemetryService } from "@roo-code/telemetry"
3334
import { CloudService, getRooCodeApiUrl } from "@roo-code/cloud"
@@ -1494,7 +1495,7 @@ export class ClineProvider
14941495
cachedChromeHostUrl: cachedChromeHostUrl,
14951496
writeDelayMs: writeDelayMs ?? DEFAULT_WRITE_DELAY_MS,
14961497
terminalOutputLineLimit: terminalOutputLineLimit ?? 500,
1497-
terminalOutputCharacterLimit: terminalOutputCharacterLimit ?? 100000,
1498+
terminalOutputCharacterLimit: terminalOutputCharacterLimit ?? DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
14981499
terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout,
14991500
terminalShellIntegrationDisabled: terminalShellIntegrationDisabled ?? false,
15001501
terminalCommandDelay: terminalCommandDelay ?? 0,
@@ -1664,7 +1665,8 @@ export class ClineProvider
16641665
fuzzyMatchThreshold: stateValues.fuzzyMatchThreshold ?? 1.0,
16651666
writeDelayMs: stateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS,
16661667
terminalOutputLineLimit: stateValues.terminalOutputLineLimit ?? 500,
1667-
terminalOutputCharacterLimit: stateValues.terminalOutputCharacterLimit ?? 100000,
1668+
terminalOutputCharacterLimit:
1669+
stateValues.terminalOutputCharacterLimit ?? DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
16681670
terminalShellIntegrationTimeout:
16691671
stateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout,
16701672
terminalShellIntegrationDisabled: stateValues.terminalShellIntegrationDisabled ?? false,

src/core/webview/webviewMessageHandler.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,12 +1049,29 @@ export const webviewMessageHandler = async (
10491049
await provider.postStateToWebview()
10501050
break
10511051
case "terminalOutputLineLimit":
1052-
await updateGlobalState("terminalOutputLineLimit", message.value)
1053-
await provider.postStateToWebview()
1052+
// Validate that the line limit is a positive number
1053+
const lineLimit = message.value
1054+
if (typeof lineLimit === "number" && lineLimit > 0) {
1055+
await updateGlobalState("terminalOutputLineLimit", lineLimit)
1056+
await provider.postStateToWebview()
1057+
} else {
1058+
vscode.window.showErrorMessage(
1059+
t("common:errors.invalid_line_limit") || "Terminal output line limit must be a positive number",
1060+
)
1061+
}
10541062
break
10551063
case "terminalOutputCharacterLimit":
1056-
await updateGlobalState("terminalOutputCharacterLimit", message.value)
1057-
await provider.postStateToWebview()
1064+
// Validate that the character limit is a positive number
1065+
const charLimit = message.value
1066+
if (typeof charLimit === "number" && charLimit > 0) {
1067+
await updateGlobalState("terminalOutputCharacterLimit", charLimit)
1068+
await provider.postStateToWebview()
1069+
} else {
1070+
vscode.window.showErrorMessage(
1071+
t("common:errors.invalid_character_limit") ||
1072+
"Terminal output character limit must be a positive number",
1073+
)
1074+
}
10581075
break
10591076
case "terminalShellIntegrationTimeout":
10601077
await updateGlobalState("terminalShellIntegrationTimeout", message.value)

src/integrations/misc/__tests__/extract-text.spec.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,91 @@ describe("truncateOutput", () => {
407407

408408
expect(result).toBe(expected)
409409
})
410+
411+
describe("edge cases with very small character limits", () => {
412+
it("handles character limit of 1", () => {
413+
const content = "abcdefghijklmnopqrstuvwxyz"
414+
const result = truncateOutput(content, undefined, 1)
415+
416+
// 20% of 1 = 0.2 (floor = 0), so beforeLimit = 0
417+
// afterLimit = 1 - 0 = 1
418+
// Should keep 0 chars from start and 1 char from end
419+
const expected = "\n[...25 characters omitted...]\nz"
420+
expect(result).toBe(expected)
421+
})
422+
423+
it("handles character limit of 2", () => {
424+
const content = "abcdefghijklmnopqrstuvwxyz"
425+
const result = truncateOutput(content, undefined, 2)
426+
427+
// 20% of 2 = 0.4 (floor = 0), so beforeLimit = 0
428+
// afterLimit = 2 - 0 = 2
429+
// Should keep 0 chars from start and 2 chars from end
430+
const expected = "\n[...24 characters omitted...]\nyz"
431+
expect(result).toBe(expected)
432+
})
433+
434+
it("handles character limit of 5", () => {
435+
const content = "abcdefghijklmnopqrstuvwxyz"
436+
const result = truncateOutput(content, undefined, 5)
437+
438+
// 20% of 5 = 1, so beforeLimit = 1
439+
// afterLimit = 5 - 1 = 4
440+
// Should keep 1 char from start and 4 chars from end
441+
const expected = "a\n[...21 characters omitted...]\nwxyz"
442+
expect(result).toBe(expected)
443+
})
444+
445+
it("handles character limit with multi-byte characters", () => {
446+
const content = "🚀🎉🔥💻🌟🎨🎯🎪🎭🎬" // 10 emojis, each is multi-byte
447+
const result = truncateOutput(content, undefined, 10)
448+
449+
// Character limit works on string length, not byte count
450+
// 20% of 10 = 2, 80% of 10 = 8
451+
const expected = "🚀🎉\n[...10 characters omitted...]\n🎨🎯🎪🎭🎬"
452+
expect(result).toBe(expected)
453+
})
454+
455+
it("handles character limit with newlines in content", () => {
456+
const content = "line1\nline2\nline3\nline4\nline5"
457+
const result = truncateOutput(content, undefined, 15)
458+
459+
// Total length is 29 chars (including newlines)
460+
// 20% of 15 = 3, 80% of 15 = 12
461+
const expected = "lin\n[...14 characters omitted...]\ne3\nline4\nline5"
462+
expect(result).toBe(expected)
463+
})
464+
465+
it("handles character limit exactly matching content with omission message", () => {
466+
// Edge case: when the omission message would make output longer than original
467+
const content = "short"
468+
const result = truncateOutput(content, undefined, 10)
469+
470+
// Content is 5 chars, limit is 10, so no truncation needed
471+
expect(result).toBe(content)
472+
})
473+
474+
it("handles character limit smaller than omission message", () => {
475+
const content = "a".repeat(100)
476+
const result = truncateOutput(content, undefined, 3)
477+
478+
// 20% of 3 = 0.6 (floor = 0), so beforeLimit = 0
479+
// afterLimit = 3 - 0 = 3
480+
const expected = "\n[...97 characters omitted...]\naaa"
481+
expect(result).toBe(expected)
482+
})
483+
484+
it("prioritizes character limit even with very high line limit", () => {
485+
const content = "a".repeat(1000)
486+
const result = truncateOutput(content, 999999, 50)
487+
488+
// Character limit should still apply despite high line limit
489+
const expectedStart = "a".repeat(10) // 20% of 50
490+
const expectedEnd = "a".repeat(40) // 80% of 50
491+
const expected = expectedStart + "\n[...950 characters omitted...]\n" + expectedEnd
492+
expect(result).toBe(expected)
493+
})
494+
})
410495
})
411496
})
412497

src/integrations/misc/extract-text.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,16 @@ export function stripLineNumbers(content: string, aggressive: boolean = false):
135135
* When truncation is needed, it keeps 20% of the lines from the start and 80% from the end,
136136
* with a clear indicator of how many lines were omitted in between.
137137
*
138+
* IMPORTANT: Character limit takes precedence over line limit. This is because:
139+
* 1. Character limit provides a hard cap on memory usage and context window consumption
140+
* 2. A single line with millions of characters could bypass line limits and cause issues
141+
* 3. Character limit ensures consistent behavior regardless of line structure
142+
*
143+
* When both limits are specified:
144+
* - If content exceeds character limit, character-based truncation is applied (regardless of line count)
145+
* - If content is within character limit but exceeds line limit, line-based truncation is applied
146+
* - This prevents edge cases where extremely long lines could consume excessive resources
147+
*
138148
* @param content The multi-line string to truncate
139149
* @param lineLimit Optional maximum number of lines to keep. If not provided or 0, no line limit is applied
140150
* @param characterLimit Optional maximum number of characters to keep. If not provided or 0, no character limit is applied
@@ -151,6 +161,12 @@ export function stripLineNumbers(content: string, aggressive: boolean = false):
151161
* // - Keeps first 20% of characters
152162
* // - Keeps last 80% of characters
153163
* // - Adds "[...X characters omitted...]" in between
164+
*
165+
* @example
166+
* // Character limit takes precedence:
167+
* // content = "A".repeat(50000) + "\n" + "B".repeat(50000) // 2 lines, 100,002 chars
168+
* // truncateOutput(content, 10, 40000) // Uses character limit, not line limit
169+
* // Result: First ~8000 chars + "[...60002 characters omitted...]" + Last ~32000 chars
154170
*/
155171
export function truncateOutput(content: string, lineLimit?: number, characterLimit?: number): string {
156172
// If no limits are specified, return original content

src/integrations/terminal/BaseTerminal.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { truncateOutput, applyRunLengthEncoding, processBackspaces, processCarriageReturns } from "../misc/extract-text"
2+
import { DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT } from "@roo-code/types"
23

34
import type {
45
RooTerminalProvider,
@@ -265,7 +266,7 @@ export abstract class BaseTerminal implements RooTerminal {
265266
* Compresses terminal output by applying run-length encoding and truncating to line and character limits
266267
* @param input The terminal output to compress
267268
* @param lineLimit Maximum number of lines to keep
268-
* @param characterLimit Optional maximum number of characters to keep (defaults to 100,000)
269+
* @param characterLimit Optional maximum number of characters to keep (defaults to DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT)
269270
* @returns The compressed terminal output
270271
*/
271272
public static compressTerminalOutput(input: string, lineLimit: number, characterLimit?: number): string {
@@ -277,7 +278,7 @@ export abstract class BaseTerminal implements RooTerminal {
277278
}
278279

279280
// Default character limit to prevent context window explosion
280-
const effectiveCharLimit = characterLimit ?? 100_000
281+
const effectiveCharLimit = characterLimit ?? DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT
281282

282283
return truncateOutput(applyRunLengthEncoding(processedInput), lineLimit, effectiveCharLimit)
283284
}

webview-ui/src/components/settings/SettingsView.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
155155
soundVolume,
156156
telemetrySetting,
157157
terminalOutputLineLimit,
158+
terminalOutputCharacterLimit,
158159
terminalShellIntegrationTimeout,
159160
terminalShellIntegrationDisabled, // Added from upstream
160161
terminalCommandDelay,
@@ -301,6 +302,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
301302
vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs })
302303
vscode.postMessage({ type: "screenshotQuality", value: screenshotQuality ?? 75 })
303304
vscode.postMessage({ type: "terminalOutputLineLimit", value: terminalOutputLineLimit ?? 500 })
305+
vscode.postMessage({ type: "terminalOutputCharacterLimit", value: terminalOutputCharacterLimit ?? 50000 })
304306
vscode.postMessage({ type: "terminalShellIntegrationTimeout", value: terminalShellIntegrationTimeout })
305307
vscode.postMessage({ type: "terminalShellIntegrationDisabled", bool: terminalShellIntegrationDisabled })
306308
vscode.postMessage({ type: "terminalCommandDelay", value: terminalCommandDelay })
@@ -674,6 +676,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
674676
{activeTab === "terminal" && (
675677
<TerminalSettings
676678
terminalOutputLineLimit={terminalOutputLineLimit}
679+
terminalOutputCharacterLimit={terminalOutputCharacterLimit}
677680
terminalShellIntegrationTimeout={terminalShellIntegrationTimeout}
678681
terminalShellIntegrationDisabled={terminalShellIntegrationDisabled}
679682
terminalCommandDelay={terminalCommandDelay}

webview-ui/src/components/settings/TerminalSettings.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { Section } from "./Section"
1818

1919
type TerminalSettingsProps = HTMLAttributes<HTMLDivElement> & {
2020
terminalOutputLineLimit?: number
21+
terminalOutputCharacterLimit?: number
2122
terminalShellIntegrationTimeout?: number
2223
terminalShellIntegrationDisabled?: boolean
2324
terminalCommandDelay?: number
@@ -29,6 +30,7 @@ type TerminalSettingsProps = HTMLAttributes<HTMLDivElement> & {
2930
terminalCompressProgressBar?: boolean
3031
setCachedStateField: SetCachedStateField<
3132
| "terminalOutputLineLimit"
33+
| "terminalOutputCharacterLimit"
3234
| "terminalShellIntegrationTimeout"
3335
| "terminalShellIntegrationDisabled"
3436
| "terminalCommandDelay"
@@ -43,6 +45,7 @@ type TerminalSettingsProps = HTMLAttributes<HTMLDivElement> & {
4345

4446
export const TerminalSettings = ({
4547
terminalOutputLineLimit,
48+
terminalOutputCharacterLimit,
4649
terminalShellIntegrationTimeout,
4750
terminalShellIntegrationDisabled,
4851
terminalCommandDelay,
@@ -129,6 +132,36 @@ export const TerminalSettings = ({
129132
</Trans>
130133
</div>
131134
</div>
135+
<div>
136+
<label className="block font-medium mb-1">
137+
{t("settings:terminal.outputCharacterLimit.label")}
138+
</label>
139+
<div className="flex items-center gap-2">
140+
<Slider
141+
min={1000}
142+
max={100000}
143+
step={1000}
144+
value={[terminalOutputCharacterLimit ?? 50000]}
145+
onValueChange={([value]) =>
146+
setCachedStateField("terminalOutputCharacterLimit", value)
147+
}
148+
data-testid="terminal-output-character-limit-slider"
149+
/>
150+
<span className="w-16">{terminalOutputCharacterLimit ?? 50000}</span>
151+
</div>
152+
<div className="text-vscode-descriptionForeground text-sm mt-1">
153+
<Trans i18nKey="settings:terminal.outputCharacterLimit.description">
154+
<VSCodeLink
155+
href={buildDocLink(
156+
"features/shell-integration#terminal-output-limit",
157+
"settings_terminal_output_character_limit",
158+
)}
159+
style={{ display: "inline" }}>
160+
{" "}
161+
</VSCodeLink>
162+
</Trans>
163+
</div>
164+
</div>
132165
<div>
133166
<VSCodeCheckbox
134167
checked={terminalCompressProgressBar ?? true}

0 commit comments

Comments
 (0)