Skip to content

Commit 3c333e3

Browse files
committed
Smart truncation for terminal output
1 parent 2e41376 commit 3c333e3

File tree

4 files changed

+432
-21
lines changed

4 files changed

+432
-21
lines changed
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
interface OutputBuilderOptions {
2+
maxSize?: number // Max size of the buffer (in bytes).
3+
preserveStartPercent?: number // % of `maxSize` to preserve at start.
4+
preserveEndPercent?: number // % of `maxSize` to preserve at end
5+
truncationMessage?: string
6+
}
7+
8+
/**
9+
* OutputBuilder manages terminal output with intelligent middle truncation.
10+
*
11+
* When output exceeds a specified size limit, this class truncates content
12+
* primarily from the middle, preserving both the beginning (command context)
13+
* and the end (recent output) of the buffer for better diagnostic context.
14+
*/
15+
export class OutputBuilder {
16+
public readonly preserveStartSize: number
17+
public readonly preserveEndSize: number
18+
public readonly truncationMessage: string
19+
20+
private startBuffer = ""
21+
private endBuffer = ""
22+
private _bytesProcessed = 0
23+
private _bytesRemoved = 0
24+
private _cursor = 0
25+
26+
constructor({
27+
maxSize = 100 * 1024, // 100KB
28+
preserveStartPercent = 50, // 50% of `maxSize`
29+
preserveEndPercent = 50, // 50% of `maxSize`
30+
truncationMessage = "\n[... OUTPUT TRUNCATED ...]\n",
31+
}: OutputBuilderOptions = {}) {
32+
this.preserveStartSize = Math.floor((preserveStartPercent / 100) * maxSize)
33+
this.preserveEndSize = Math.floor((preserveEndPercent / 100) * maxSize)
34+
35+
if (this.preserveStartSize + this.preserveEndSize > maxSize) {
36+
throw new Error("Invalid configuration: preserve sizes exceed maxSize")
37+
}
38+
39+
this.truncationMessage = truncationMessage
40+
}
41+
42+
append(content: string): this {
43+
if (content.length === 0) {
44+
return this
45+
}
46+
47+
console.log(`[OutputBuilder#append] ${content.length} bytes`)
48+
49+
this._bytesProcessed += content.length
50+
51+
if (!this.isTruncated) {
52+
this.startBuffer += content
53+
54+
const excessBytes = this.startBuffer.length - (this.preserveStartSize + this.preserveEndSize)
55+
56+
if (excessBytes <= 0) {
57+
return this
58+
}
59+
60+
this.endBuffer = this.startBuffer.slice(-this.preserveEndSize)
61+
this.startBuffer = this.startBuffer.slice(0, this.preserveStartSize)
62+
this._bytesRemoved += excessBytes
63+
} else {
64+
// Already in truncation mode; append to `endBuffer`.
65+
this.endBuffer += content
66+
67+
// If `endBuffer` gets too large, trim it.
68+
if (this.endBuffer.length > this.preserveEndSize) {
69+
const excessBytes = this.endBuffer.length - this.preserveEndSize
70+
this.endBuffer = this.endBuffer.slice(excessBytes)
71+
this._bytesRemoved += excessBytes
72+
}
73+
}
74+
75+
return this
76+
}
77+
78+
/**
79+
* Reads unprocessed content from the current cursor position, handling both
80+
* truncated and non-truncated states.
81+
*
82+
* The algorithm handles three cases:
83+
* 1. Non-truncated buffer:
84+
* - Simply returns remaining content from cursor position.
85+
*
86+
* 2. Truncated buffer, cursor in start portion:
87+
* - Returns remaining start content plus all end content.
88+
* - This ensures we don't miss the transition between buffers.
89+
*
90+
* 3. Truncated buffer, cursor in end portion:
91+
* - Adjusts cursor position by subtracting removed bytes and start buffer length.
92+
* - Uses Math.max to prevent negative indices if cursor adjustment overshoots.
93+
* - Returns remaining content from adjusted position in end buffer.
94+
*
95+
* This approach ensures continuous reading even across truncation
96+
* boundaries, while properly tracking position in both start and end
97+
* portions of truncated content.
98+
*/
99+
read() {
100+
let output
101+
102+
if (!this.isTruncated) {
103+
output = this.startBuffer.slice(this.cursor)
104+
} else if (this.cursor < this.startBuffer.length) {
105+
output = this.startBuffer.slice(this.cursor) + this.endBuffer
106+
} else {
107+
output = this.endBuffer.slice(Math.max(this.cursor - this.bytesRemoved - this.startBuffer.length, 0))
108+
}
109+
110+
this._cursor = this.bytesProcessed
111+
112+
return output
113+
}
114+
115+
public reset(content?: string) {
116+
console.log(`[OutputBuilder#reset] ${this.bytesProcessed} bytes processed, ${this.bytesRemoved} bytes removed`)
117+
118+
this.startBuffer = ""
119+
this.endBuffer = ""
120+
this._bytesProcessed = 0
121+
this._bytesRemoved = 0
122+
this._cursor = 0
123+
124+
if (content) {
125+
this.append(content)
126+
}
127+
}
128+
129+
public get content() {
130+
return this.isTruncated ? this.startBuffer + this.truncationMessage + this.endBuffer : this.startBuffer
131+
}
132+
133+
public get size() {
134+
return this.isTruncated
135+
? this.startBuffer.length + this.truncationMessage.length + this.endBuffer.length
136+
: this.startBuffer.length
137+
}
138+
139+
public get isTruncated() {
140+
return this._bytesRemoved > 0
141+
}
142+
143+
public get bytesProcessed() {
144+
return this._bytesProcessed
145+
}
146+
147+
public get bytesRemoved() {
148+
return this._bytesRemoved
149+
}
150+
151+
public get cursor() {
152+
return this._cursor
153+
}
154+
}

src/integrations/terminal/TerminalProcess.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { inspect } from "util"
55

66
import { ExitCodeDetails } from "./TerminalManager"
77
import { TerminalInfo, TerminalRegistry } from "./TerminalRegistry"
8+
import { OutputBuilder } from "./OutputBuilder"
89

910
export interface TerminalProcessEvents {
1011
line: [line: string]
@@ -30,8 +31,7 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
3031
private isListening: boolean = true
3132
private terminalInfo: TerminalInfo | undefined
3233
private lastEmitTime_ms: number = 0
33-
private fullOutput: string = ""
34-
private lastRetrievedIndex: number = 0
34+
private outputBuilder?: OutputBuilder
3535
isHot: boolean = false
3636
private hotTimer: NodeJS.Timeout | null = null
3737

@@ -89,6 +89,8 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
8989
* - OSC 633 ; E ; <commandline> [; <nonce>] ST - Explicitly set command line with optional nonce
9090
*/
9191

92+
this.outputBuilder = new OutputBuilder()
93+
9294
// Process stream data
9395
for await (let data of stream) {
9496
// Check for command output start marker
@@ -98,17 +100,17 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
98100
if (match !== undefined) {
99101
commandOutputStarted = true
100102
data = match
101-
this.fullOutput = "" // Reset fullOutput when command actually starts
103+
this.outputBuilder.reset() // Reset output when command actually starts
102104
} else {
103105
continue
104106
}
105107
}
106108

107109
// Command output started, accumulate data without filtering.
108110
// notice to future programmers: do not add escape sequence
109-
// filtering here: fullOutput cannot change in length (see getUnretrievedOutput),
110-
// and chunks may not be complete so you cannot rely on detecting or removing escape sequences mid-stream.
111-
this.fullOutput += data
111+
// filtering here, and chunks may not be complete so you cannot rely
112+
// on detecting or removing escape sequences mid-stream.
113+
this.outputBuilder.append(data)
112114

113115
// For non-immediately returning commands we want to show loading spinner
114116
// right away but this wouldnt happen until it emits a line break, so
@@ -173,11 +175,12 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
173175

174176
// console.debug("[Terminal Process] raw output: " + inspect(output, { colors: false, breakLength: Infinity }))
175177

176-
// fullOutput begins after C marker so we only need to trim off D marker
178+
// Output begins after C marker so we only need to trim off D marker
177179
// (if D exists, see VSCode bug# 237208):
178-
const match = this.matchBeforeVsceEndMarkers(this.fullOutput)
180+
const match = this.matchBeforeVsceEndMarkers(this.outputBuilder.content)
181+
179182
if (match !== undefined) {
180-
this.fullOutput = match
183+
this.outputBuilder.reset(match)
181184
}
182185

183186
// console.debug(`[Terminal Process] processed output via ${matchSource}: ` + inspect(output, { colors: false, breakLength: Infinity }))
@@ -188,7 +191,10 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
188191
}
189192
this.isHot = false
190193

191-
this.emit("completed", this.removeEscapeSequences(this.fullOutput))
194+
console.log(
195+
`[TerminalProcess#run] ${this.outputBuilder.bytesProcessed} bytes processed, ${this.outputBuilder.bytesRemoved} bytes removed`,
196+
)
197+
this.emit("completed", this.removeEscapeSequences(this.outputBuilder.content))
192198
this.emit("continue")
193199
} else {
194200
terminal.sendText(command, true)
@@ -224,7 +230,8 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
224230
// The final line may lack a carriage return if the program didn't send one.
225231
getUnretrievedOutput(): string {
226232
// Get raw unretrieved output
227-
let outputToProcess = this.fullOutput.slice(this.lastRetrievedIndex)
233+
let outputToProcess = this.outputBuilder?.read() || ""
234+
console.log(`[TerminalProcess#getUnretrievedOutput] ${outputToProcess.length} bytes`)
228235

229236
// Check for VSCE command end markers
230237
const index633 = outputToProcess.indexOf("\x1b]633;D")
@@ -260,7 +267,6 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
260267
}
261268

262269
// Update index and slice output
263-
this.lastRetrievedIndex += endIndex
264270
outputToProcess = outputToProcess.slice(0, endIndex)
265271

266272
// Clean and return output

0 commit comments

Comments
 (0)