Skip to content

Commit aa57ae9

Browse files
authored
Merge pull request #1390 from RooVetGit/cte/terminal-middle-out
Terminal "middle-out": smart truncation for terminal output
2 parents d2c2029 + 7eee3e0 commit aa57ae9

22 files changed

+737
-337
lines changed

src/core/Cline.ts

Lines changed: 42 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import {
2525
addLineNumbers,
2626
stripLineNumbers,
2727
everyLineHasLineNumbers,
28-
truncateOutput,
2928
} from "../integrations/misc/extract-text"
3029
import { TerminalManager, ExitCodeDetails } from "../integrations/terminal/TerminalManager"
3130
import { UrlContentFetcher } from "../services/browser/UrlContentFetcher"
@@ -55,11 +54,12 @@ import { HistoryItem } from "../shared/HistoryItem"
5554
import { ClineAskResponse } from "../shared/WebviewMessage"
5655
import { GlobalFileNames } from "../shared/globalFileNames"
5756
import { defaultModeSlug, getModeBySlug, getFullModeDetails } from "../shared/modes"
57+
import { EXPERIMENT_IDS, experiments as Experiments, ExperimentId } from "../shared/experiments"
5858
import { calculateApiCost } from "../utils/cost"
5959
import { fileExistsAtPath } from "../utils/fs"
6060
import { arePathsEqual, getReadablePath } from "../utils/path"
6161
import { parseMentions } from "./mentions"
62-
import { RooIgnoreController, LOCK_TEXT_SYMBOL } from "./ignore/RooIgnoreController"
62+
import { RooIgnoreController } from "./ignore/RooIgnoreController"
6363
import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseName } from "./assistant-message"
6464
import { formatResponse } from "./prompts/responses"
6565
import { SYSTEM_PROMPT } from "./prompts/system"
@@ -70,7 +70,7 @@ import { BrowserSession } from "../services/browser/BrowserSession"
7070
import { McpHub } from "../services/mcp/McpHub"
7171
import crypto from "crypto"
7272
import { insertGroups } from "./diff/insert-groups"
73-
import { EXPERIMENT_IDS, experiments as Experiments, ExperimentId } from "../shared/experiments"
73+
import { OutputBuilder } from "../integrations/terminal/OutputBuilder"
7474

7575
const cwd =
7676
vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
@@ -912,44 +912,52 @@ export class Cline {
912912
// Tools
913913

914914
async executeCommandTool(command: string): Promise<[boolean, ToolResponse]> {
915+
const { terminalOutputLimit } = (await this.providerRef.deref()?.getState()) ?? {}
916+
915917
const terminalInfo = await this.terminalManager.getOrCreateTerminal(cwd)
916-
terminalInfo.terminal.show() // weird visual bug when creating new terminals (even manually) where there's an empty space at the top.
917-
const process = this.terminalManager.runCommand(terminalInfo, command)
918+
// Weird visual bug when creating new terminals (even manually) where
919+
// there's an empty space at the top.
920+
terminalInfo.terminal.show()
921+
const process = this.terminalManager.runCommand(terminalInfo, command, terminalOutputLimit)
918922

919923
let userFeedback: { text?: string; images?: string[] } | undefined
920924
let didContinue = false
921-
const sendCommandOutput = async (line: string): Promise<void> => {
925+
926+
const sendCommandOutput = async (line: string) => {
922927
try {
923928
const { response, text, images } = await this.ask("command_output", line)
929+
924930
if (response === "yesButtonClicked") {
925-
// proceed while running
931+
// Proceed while running.
926932
} else {
927933
userFeedback = { text, images }
928934
}
935+
929936
didContinue = true
930-
process.continue() // continue past the await
937+
process.continue() // Continue past the await.
931938
} catch {
932-
// This can only happen if this ask promise was ignored, so ignore this error
939+
// This can only happen if this ask promise was ignored, so ignore this error.
933940
}
934941
}
935942

936-
let lines: string[] = []
943+
let completed = false
944+
let exitDetails: ExitCodeDetails | undefined
945+
946+
let builder = new OutputBuilder({ maxSize: terminalOutputLimit })
947+
let output: string | undefined = undefined
948+
937949
process.on("line", (line) => {
938-
lines.push(line)
950+
builder.append(line)
951+
939952
if (!didContinue) {
940953
sendCommandOutput(line)
941954
} else {
942955
this.say("command_output", line)
943956
}
944957
})
945958

946-
let completed = false
947-
let exitDetails: ExitCodeDetails | undefined
948-
process.once("completed", (output?: string) => {
949-
// Use provided output if available, otherwise keep existing result.
950-
if (output) {
951-
lines = output.split("\n")
952-
}
959+
process.once("completed", (buffer?: string) => {
960+
output = buffer
953961
completed = true
954962
})
955963

@@ -965,19 +973,17 @@ export class Cline {
965973

966974
await process
967975

968-
// Wait for a short delay to ensure all messages are sent to the webview
976+
// Wait for a short delay to ensure all messages are sent to the webview.
969977
// This delay allows time for non-awaited promises to be created and
970978
// for their associated messages to be sent to the webview, maintaining
971979
// the correct order of messages (although the webview is smart about
972-
// grouping command_output messages despite any gaps anyways)
980+
// grouping command_output messages despite any gaps anyways).
973981
await delay(50)
974-
975-
const { terminalOutputLineLimit } = (await this.providerRef.deref()?.getState()) ?? {}
976-
const output = truncateOutput(lines.join("\n"), terminalOutputLineLimit)
977-
const result = output.trim()
982+
const result = output || builder.content
978983

979984
if (userFeedback) {
980985
await this.say("user_feedback", userFeedback.text, userFeedback.images)
986+
981987
return [
982988
true,
983989
formatResponse.toolResult(
@@ -991,25 +997,28 @@ export class Cline {
991997

992998
if (completed) {
993999
let exitStatus = "No exit code available"
1000+
9941001
if (exitDetails !== undefined) {
9951002
if (exitDetails.signal) {
9961003
exitStatus = `Process terminated by signal ${exitDetails.signal} (${exitDetails.signalName})`
1004+
9971005
if (exitDetails.coreDumpPossible) {
9981006
exitStatus += " - core dump possible"
9991007
}
10001008
} else {
10011009
exitStatus = `Exit code: ${exitDetails.exitCode}`
10021010
}
10031011
}
1012+
10041013
return [false, `Command executed. ${exitStatus}${result.length > 0 ? `\nOutput:\n${result}` : ""}`]
1005-
} else {
1006-
return [
1007-
false,
1008-
`Command is still running in the user's terminal.${
1009-
result.length > 0 ? `\nHere's the output so far:\n${result}` : ""
1010-
}\n\nYou will be updated on the terminal status and new output in the future.`,
1011-
]
10121014
}
1015+
1016+
return [
1017+
false,
1018+
`Command is still running in the user's terminal.${
1019+
result.length > 0 ? `\nHere's the output so far:\n${result}` : ""
1020+
}\n\nYou will be updated on the terminal status and new output in the future.`,
1021+
]
10131022
}
10141023

10151024
async *attemptApiRequest(previousApiReqIndex: number, retryAttempt: number = 0): ApiStream {
@@ -3495,7 +3504,7 @@ export class Cline {
34953504
terminalDetails += "\n\n# Actively Running Terminals"
34963505
for (const busyTerminal of busyTerminals) {
34973506
terminalDetails += `\n## Original command: \`${busyTerminal.lastCommand}\``
3498-
const newOutput = this.terminalManager.getUnretrievedOutput(busyTerminal.id)
3507+
const newOutput = this.terminalManager.readLine(busyTerminal.id)
34993508
if (newOutput) {
35003509
terminalDetails += `\n### New Output\n${newOutput}`
35013510
} else {
@@ -3507,7 +3516,7 @@ export class Cline {
35073516
if (inactiveTerminals.length > 0) {
35083517
const inactiveTerminalOutputs = new Map<number, string>()
35093518
for (const inactiveTerminal of inactiveTerminals) {
3510-
const newOutput = this.terminalManager.getUnretrievedOutput(inactiveTerminal.id)
3519+
const newOutput = this.terminalManager.readLine(inactiveTerminal.id)
35113520
if (newOutput) {
35123521
inactiveTerminalOutputs.set(inactiveTerminal.id, newOutput)
35133522
}

src/core/mentions/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ import * as vscode from "vscode"
22
import * as path from "path"
33
import { openFile } from "../../integrations/misc/open-file"
44
import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
5-
import { mentionRegexGlobal, formatGitSuggestion, type MentionSuggestion } from "../../shared/context-mentions"
5+
import { mentionRegexGlobal } from "../../shared/context-mentions"
66
import fs from "fs/promises"
77
import { extractTextFromFile } from "../../integrations/misc/extract-text"
88
import { isBinaryFile } from "isbinaryfile"
99
import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
1010
import { getCommitInfo, getWorkingState } from "../../utils/git"
11-
import { getLatestTerminalOutput } from "../../integrations/terminal/get-latest-output"
11+
import { getLatestTerminalOutput } from "../../integrations/terminal/getLatestTerminalOutput"
1212

1313
export async function openMention(mention?: string): Promise<void> {
1414
if (!mention) {

src/core/webview/ClineProvider.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,17 @@ import simpleGit from "simple-git"
1010
import { setPanel } from "../../activate/registerCommands"
1111

1212
import { ApiConfiguration, ApiProvider, ModelInfo, API_CONFIG_KEYS } from "../../shared/api"
13-
import { CheckpointStorage } from "../../shared/checkpoints"
1413
import { findLast } from "../../shared/array"
15-
import { CustomSupportPrompts, supportPrompt } from "../../shared/support-prompt"
14+
import { supportPrompt } from "../../shared/support-prompt"
1615
import { GlobalFileNames } from "../../shared/globalFileNames"
1716
import { SecretKey, GlobalStateKey, SECRET_KEYS, GLOBAL_STATE_KEYS } from "../../shared/globalState"
1817
import { HistoryItem } from "../../shared/HistoryItem"
1918
import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage"
2019
import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage"
21-
import { Mode, CustomModePrompts, PromptComponent, defaultModeSlug, ModeConfig } from "../../shared/modes"
20+
import { Mode, PromptComponent, defaultModeSlug, ModeConfig } from "../../shared/modes"
2221
import { checkExistKey } from "../../shared/checkExistApiConfig"
2322
import { EXPERIMENT_IDS, experiments as Experiments, experimentDefault, ExperimentId } from "../../shared/experiments"
23+
import { TERMINAL_OUTPUT_LIMIT } from "../../shared/terminal"
2424
import { downloadTask } from "../../integrations/misc/export-markdown"
2525
import { openFile, openImage } from "../../integrations/misc/open-file"
2626
import { selectImages } from "../../integrations/misc/process-images"
@@ -1215,7 +1215,6 @@ export class ClineProvider implements vscode.WebviewViewProvider {
12151215
await this.postStateToWebview()
12161216
break
12171217
case "checkpointStorage":
1218-
console.log(`[ClineProvider] checkpointStorage: ${message.text}`)
12191218
const checkpointStorage = message.text ?? "task"
12201219
await this.updateGlobalState("checkpointStorage", checkpointStorage)
12211220
await this.postStateToWebview()
@@ -1249,8 +1248,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
12491248
await this.updateGlobalState("writeDelayMs", message.value)
12501249
await this.postStateToWebview()
12511250
break
1252-
case "terminalOutputLineLimit":
1253-
await this.updateGlobalState("terminalOutputLineLimit", message.value)
1251+
case "terminalOutputLimit":
1252+
await this.updateGlobalState("terminalOutputLimit", message.value)
12541253
await this.postStateToWebview()
12551254
break
12561255
case "mode":
@@ -2134,7 +2133,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
21342133
screenshotQuality,
21352134
preferredLanguage,
21362135
writeDelayMs,
2137-
terminalOutputLineLimit,
2136+
terminalOutputLimit,
21382137
fuzzyMatchThreshold,
21392138
mcpEnabled,
21402139
enableMcpServerCreation,
@@ -2186,7 +2185,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
21862185
screenshotQuality: screenshotQuality ?? 75,
21872186
preferredLanguage: preferredLanguage ?? "English",
21882187
writeDelayMs: writeDelayMs ?? 1000,
2189-
terminalOutputLineLimit: terminalOutputLineLimit ?? 500,
2188+
terminalOutputLimit: terminalOutputLimit ?? TERMINAL_OUTPUT_LIMIT,
21902189
fuzzyMatchThreshold: fuzzyMatchThreshold ?? 1.0,
21912190
mcpEnabled: mcpEnabled ?? true,
21922191
enableMcpServerCreation: enableMcpServerCreation ?? true,
@@ -2334,7 +2333,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
23342333
screenshotQuality: stateValues.screenshotQuality ?? 75,
23352334
fuzzyMatchThreshold: stateValues.fuzzyMatchThreshold ?? 1.0,
23362335
writeDelayMs: stateValues.writeDelayMs ?? 1000,
2337-
terminalOutputLineLimit: stateValues.terminalOutputLineLimit ?? 500,
2336+
terminalOutputLimit: stateValues.terminalOutputLimit ?? TERMINAL_OUTPUT_LIMIT,
23382337
mode: stateValues.mode ?? defaultModeSlug,
23392338
preferredLanguage:
23402339
stateValues.preferredLanguage ??

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

Lines changed: 1 addition & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers, truncateOutput } from "../extract-text"
1+
import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../extract-text"
22

33
describe("addLineNumbers", () => {
44
it("should add line numbers starting from 1 by default", () => {
@@ -101,67 +101,3 @@ describe("stripLineNumbers", () => {
101101
expect(stripLineNumbers(input)).toBe(expected)
102102
})
103103
})
104-
105-
describe("truncateOutput", () => {
106-
it("returns original content when no line limit provided", () => {
107-
const content = "line1\nline2\nline3"
108-
expect(truncateOutput(content)).toBe(content)
109-
})
110-
111-
it("returns original content when lines are under limit", () => {
112-
const content = "line1\nline2\nline3"
113-
expect(truncateOutput(content, 5)).toBe(content)
114-
})
115-
116-
it("truncates content with 20/80 split when over limit", () => {
117-
// Create 25 lines of content
118-
const lines = Array.from({ length: 25 }, (_, i) => `line${i + 1}`)
119-
const content = lines.join("\n")
120-
121-
// Set limit to 10 lines
122-
const result = truncateOutput(content, 10)
123-
124-
// Should keep:
125-
// - First 2 lines (20% of 10)
126-
// - Last 8 lines (80% of 10)
127-
// - Omission indicator in between
128-
const expectedLines = [
129-
"line1",
130-
"line2",
131-
"",
132-
"[...15 lines omitted...]",
133-
"",
134-
"line18",
135-
"line19",
136-
"line20",
137-
"line21",
138-
"line22",
139-
"line23",
140-
"line24",
141-
"line25",
142-
]
143-
expect(result).toBe(expectedLines.join("\n"))
144-
})
145-
146-
it("handles empty content", () => {
147-
expect(truncateOutput("", 10)).toBe("")
148-
})
149-
150-
it("handles single line content", () => {
151-
expect(truncateOutput("single line", 10)).toBe("single line")
152-
})
153-
154-
it("handles windows-style line endings", () => {
155-
// Create content with windows line endings
156-
const lines = Array.from({ length: 15 }, (_, i) => `line${i + 1}`)
157-
const content = lines.join("\r\n")
158-
159-
const result = truncateOutput(content, 5)
160-
161-
// Should keep first line (20% of 5 = 1) and last 4 lines (80% of 5 = 4)
162-
// Split result by either \r\n or \n to normalize line endings
163-
const resultLines = result.split(/\r?\n/)
164-
const expectedLines = ["line1", "", "[...10 lines omitted...]", "", "line12", "line13", "line14", "line15"]
165-
expect(resultLines).toEqual(expectedLines)
166-
})
167-
})

src/integrations/misc/extract-text.ts

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -89,37 +89,3 @@ export function stripLineNumbers(content: string): string {
8989
const lineEnding = content.includes("\r\n") ? "\r\n" : "\n"
9090
return processedLines.join(lineEnding)
9191
}
92-
93-
/**
94-
* Truncates multi-line output while preserving context from both the beginning and end.
95-
* When truncation is needed, it keeps 20% of the lines from the start and 80% from the end,
96-
* with a clear indicator of how many lines were omitted in between.
97-
*
98-
* @param content The multi-line string to truncate
99-
* @param lineLimit Optional maximum number of lines to keep. If not provided or 0, returns the original content
100-
* @returns The truncated string with an indicator of omitted lines, or the original content if no truncation needed
101-
*
102-
* @example
103-
* // With 10 line limit on 25 lines of content:
104-
* // - Keeps first 2 lines (20% of 10)
105-
* // - Keeps last 8 lines (80% of 10)
106-
* // - Adds "[...15 lines omitted...]" in between
107-
*/
108-
export function truncateOutput(content: string, lineLimit?: number): string {
109-
if (!lineLimit) {
110-
return content
111-
}
112-
113-
const lines = content.split("\n")
114-
if (lines.length <= lineLimit) {
115-
return content
116-
}
117-
118-
const beforeLimit = Math.floor(lineLimit * 0.2) // 20% of lines before
119-
const afterLimit = lineLimit - beforeLimit // remaining 80% after
120-
return [
121-
...lines.slice(0, beforeLimit),
122-
`\n[...${lines.length - lineLimit} lines omitted...]\n`,
123-
...lines.slice(-afterLimit),
124-
].join("\n")
125-
}

0 commit comments

Comments
 (0)