Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/cuddly-cows-sip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"roo-cline": patch
---

I introduced a new method `processCarriageReturns` in `TerminalProcess.ts` to process carriage returns (\r) in terminal output. This method splits the output into lines, handles each line with carriage returns (\r) by retaining only the content after the last carriage return (\r), and preserves escape sequences to avoid breaking terminal formatting. The method is called within `getUnretrievedOutput` to ensure output is processed before being displayed. Additionally, I added comprehensive test cases in `TerminalProcess.test.ts` under a new `describe("processCarriageReturns", ...)` block to validate various scenarios, including basic progress bars, multiple lines with mixed carriage returns (\r) and line feeds (\n), and ANSI escape sequences.

Key implementation details:

- The solution carefully handles special characters and escape sequences to maintain terminal integrity.
- Tradeoff: Slightly increased processing overhead for outputs with carriage returns (\r), but this is negligible compared to the improved user experience.
- I'd like reviewers to pay close attention to the handling of edge cases in `processCarriageReturns` (e.g., lines ending with carriage returns (\r) or mixed content with carriage returns (\r), line feeds (\n), and escape sequences) to ensure no unintended side effects.
2 changes: 1 addition & 1 deletion evals/apps/web/src/lib/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,4 @@ export const formatTokens = (tokens: number) => {
}

export const formatToolUsageSuccessRate = (usage: { attempts: number; failures: number }) =>
usage.attempts === 0 ? '0%' : `${(((usage.attempts - usage.failures) / usage.attempts) * 100).toFixed(1)}%`
usage.attempts === 0 ? "0%" : `${(((usage.attempts - usage.failures) / usage.attempts) * 100).toFixed(1)}%`
50 changes: 26 additions & 24 deletions locales/ca/README.md

Large diffs are not rendered by default.

50 changes: 26 additions & 24 deletions locales/de/README.md

Large diffs are not rendered by default.

50 changes: 26 additions & 24 deletions locales/es/README.md

Large diffs are not rendered by default.

50 changes: 26 additions & 24 deletions locales/fr/README.md

Large diffs are not rendered by default.

50 changes: 26 additions & 24 deletions locales/hi/README.md

Large diffs are not rendered by default.

50 changes: 26 additions & 24 deletions locales/it/README.md

Large diffs are not rendered by default.

50 changes: 26 additions & 24 deletions locales/ja/README.md

Large diffs are not rendered by default.

50 changes: 26 additions & 24 deletions locales/ko/README.md

Large diffs are not rendered by default.

50 changes: 26 additions & 24 deletions locales/pl/README.md

Large diffs are not rendered by default.

50 changes: 26 additions & 24 deletions locales/pt-BR/README.md

Large diffs are not rendered by default.

50 changes: 26 additions & 24 deletions locales/tr/README.md

Large diffs are not rendered by default.

50 changes: 26 additions & 24 deletions locales/vi/README.md

Large diffs are not rendered by default.

50 changes: 26 additions & 24 deletions locales/zh-CN/README.md

Large diffs are not rendered by default.

50 changes: 26 additions & 24 deletions locales/zh-TW/README.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1243,6 +1243,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
showRooIgnoredFiles,
language,
maxReadFileLine,
terminalCompressProgressBar,
} = await this.getState()

const telemetryKey = process.env.POSTHOG_API_KEY
Expand Down Expand Up @@ -1322,10 +1323,11 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
telemetryKey,
machineId,
showRooIgnoredFiles: showRooIgnoredFiles ?? true,
language,
language: language ?? formatLanguage(vscode.env.language),
renderContext: this.renderContext,
maxReadFileLine: maxReadFileLine ?? 500,
settingsImportedAt: this.settingsImportedAt,
terminalCompressProgressBar: terminalCompressProgressBar ?? true,
hasSystemPromptOverride,
}
}
Expand Down Expand Up @@ -1391,6 +1393,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
terminalZshOhMy: stateValues.terminalZshOhMy ?? false,
terminalZshP10k: stateValues.terminalZshP10k ?? false,
terminalZdotdir: stateValues.terminalZdotdir ?? false,
terminalCompressProgressBar: stateValues.terminalCompressProgressBar ?? true,
mode: stateValues.mode ?? defaultModeSlug,
language: stateValues.language ?? formatLanguage(vscode.env.language),
mcpEnabled: stateValues.mcpEnabled ?? true,
Expand Down
7 changes: 7 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,13 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
Terminal.setTerminalZdotdir(message.bool)
}
break
case "terminalCompressProgressBar":
await updateGlobalState("terminalCompressProgressBar", message.bool)
await provider.postStateToWebview()
if (message.bool !== undefined) {
Terminal.setCompressProgressBar(message.bool)
}
break
case "mode":
await provider.handleModeSwitch(message.text as Mode)
break
Expand Down
1 change: 1 addition & 0 deletions src/exports/roo-code.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ type GlobalSettings = {
terminalZshOhMy?: boolean | undefined
terminalZshP10k?: boolean | undefined
terminalZdotdir?: boolean | undefined
terminalCompressProgressBar?: boolean | undefined
rateLimitSeconds?: number | undefined
diffEnabled?: boolean | undefined
fuzzyMatchThreshold?: number | undefined
Expand Down
1 change: 1 addition & 0 deletions src/exports/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ type GlobalSettings = {
terminalZshOhMy?: boolean | undefined
terminalZshP10k?: boolean | undefined
terminalZdotdir?: boolean | undefined
terminalCompressProgressBar?: boolean | undefined
rateLimitSeconds?: number | undefined
diffEnabled?: boolean | undefined
fuzzyMatchThreshold?: number | undefined
Expand Down
193 changes: 193 additions & 0 deletions src/integrations/misc/__tests__/extract-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
stripLineNumbers,
truncateOutput,
applyRunLengthEncoding,
processCarriageReturns,
} from "../extract-text"

describe("addLineNumbers", () => {
Expand Down Expand Up @@ -261,3 +262,195 @@ describe("applyRunLengthEncoding", () => {
expect(applyRunLengthEncoding(input)).toBe(input)
})
})

describe("processCarriageReturns", () => {
it("should return original input if no carriage returns (\r) present", () => {
const input = "Line 1\nLine 2\nLine 3"
expect(processCarriageReturns(input)).toBe(input)
})

it("should process basic progress bar with carriage returns (\r)", () => {
const input = "Progress: [===>---------] 30%\rProgress: [======>------] 60%\rProgress: [==========>] 100%"
const expected = "Progress: [==========>] 100%%"
expect(processCarriageReturns(input)).toBe(expected)
})

it("should handle multi-line outputs with carriage returns (\r)", () => {
const input = "Line 1\rUpdated Line 1\nLine 2\rUpdated Line 2\rFinal Line 2"
const expected = "Updated Line 1\nFinal Line 2 2"
expect(processCarriageReturns(input)).toBe(expected)
})

it("should handle carriage returns (\r) at end of line", () => {
// A carriage return (\r) at the end of a line should be treated as if the cursor is at the start
// with no content following it, so we keep the existing content
const input = "Initial text\rReplacement text\r"
// Depending on terminal behavior:
// Option 1: If last carriage return (\r) is ignored because nothing follows it to replace text
const expected = "Replacement text"
expect(processCarriageReturns(input)).toBe(expected)
})

// Additional test to clarify behavior with a terminal-like example
it("should handle carriage returns (\r) in a way that matches terminal behavior", () => {
// In a real terminal:
// 1. "Hello" is printed
// 2. Carriage return (\r) moves cursor to start of line
// 3. "World" overwrites, becoming "World"
// 4. Carriage return (\r) moves cursor to start again
// 5. Nothing follows, so the line remains "World" (cursor just sitting at start)
const input = "Hello\rWorld\r"
const expected = "World"
expect(processCarriageReturns(input)).toBe(expected)

// Same principle applies to carriage return (\r) + line feed (\n)
// 1. "Line1" is printed
// 2. Carriage return (\r) moves cursor to start
// 3. Line feed (\n) moves to next line, so the line remains "Line1"
expect(processCarriageReturns("Line1\r\n")).toBe("Line1\n")
})

it("should preserve lines without carriage returns (\r)", () => {
const input = "Line 1\nLine 2\rUpdated Line 2\nLine 3"
const expected = "Line 1\nUpdated Line 2\nLine 3"
expect(processCarriageReturns(input)).toBe(expected)
})

it("should handle complex tqdm-like progress bars", () => {
const input =
"10%|██ | 10/100 [00:01<00:09, 10.00it/s]\r20%|████ | 20/100 [00:02<00:08, 10.00it/s]\r100%|██████████| 100/100 [00:10<00:00, 10.00it/s]"
const expected = "100%|██████████| 100/100 [00:10<00:00, 10.00it/s]"
expect(processCarriageReturns(input)).toBe(expected)
})

it("should handle ANSI escape sequences", () => {
const input = "\x1b]633;C\x07Loading\rLoading.\rLoading..\rLoading...\x1b]633;D\x07"
const expected = "Loading...\x1b]633;D\x07"
expect(processCarriageReturns(input)).toBe(expected)
})

it("should handle mixed content with carriage returns (\r) and line feeds (\n)", () => {
const input =
"Step 1: Starting\rStep 1: In progress\rStep 1: Done\nStep 2: Starting\rStep 2: In progress\rStep 2: Done"
const expected = "Step 1: Donerogress\nStep 2: Donerogress"
expect(processCarriageReturns(input)).toBe(expected)
})

it("should handle empty input", () => {
expect(processCarriageReturns("")).toBe("")
})

it("should handle large number of carriage returns (\r) efficiently", () => {
// Create a string with many carriage returns (\r)
let input = ""
for (let i = 0; i < 10000; i++) {
input += `Progress: ${i / 100}%\r`
}
input += "Progress: 100%"

const expected = "Progress: 100%9%"
expect(processCarriageReturns(input)).toBe(expected)
})

// Additional edge cases to stress test processCarriageReturns
it("should handle consecutive carriage returns (\r)", () => {
const input = "Initial\r\r\r\rFinal"
const expected = "Finalal"
expect(processCarriageReturns(input)).toBe(expected)
})

it("should handle carriage returns (\r) at the start of a line", () => {
const input = "\rText after carriage return"
const expected = "Text after carriage return"
expect(processCarriageReturns(input)).toBe(expected)
})

it("should handle only carriage returns (\r)", () => {
const input = "\r\r\r\r"
const expected = ""
expect(processCarriageReturns(input)).toBe(expected)
})

it("should handle carriage returns (\r) with empty strings between them", () => {
const input = "Start\r\r\r\r\rEnd"
const expected = "Endrt"
expect(processCarriageReturns(input)).toBe(expected)
})

it("should handle multiline with carriage returns (\r) at different positions", () => {
const input = "Line1\rLine1Updated\nLine2\nLine3\rLine3Updated\rLine3Final\nLine4"
const expected = "Line1Updated\nLine2\nLine3Finaled\nLine4"
expect(processCarriageReturns(input)).toBe(expected)
})

it("should handle carriage returns (\r) with special characters", () => {
// This test demonstrates our handling of multi-byte characters (like emoji) when they get partially overwritten.
// When a carriage return (\r) causes partial overwrite of a multi-byte character (like an emoji),
// we need to handle this special case to prevent display issues or corruption.
//
// In this example:
// 1. "Line with 🚀 emoji" is printed (note that the emoji is a multi-byte character)
// 2. Carriage return (\r) moves cursor to start of line
// 3. "Line with a" is printed, which partially overwrites the line
// 4. The 'a' character ends at a position that would split the 🚀 emoji
// 5. Instead of creating corrupted output, we insert a space to replace the partial emoji
//
// This behavior mimics terminals that can detect and properly handle these situations
// by replacing partial characters with spaces to maintain text integrity.
const input = "Line with 🚀 emoji\rLine with a"
const expected = "Line with a emoji"
expect(processCarriageReturns(input)).toBe(expected)
})

it("should correctly handle multiple consecutive line feeds (\n) with carriage returns (\r)", () => {
// Another test case for multi-byte character handling during carriage return (\r) overwrites.
// In this case, we're testing with a different emoji and pattern to ensure robustness.
//
// When a new line with an emoji partially overlaps with text from the previous line,
// we need to properly detect surrogate pairs and other multi-byte sequences to avoid
// creating invalid Unicode output.
//
// Note: The expected result might look strange but it's consistent with how real
// terminals process such content - they only overwrite at character boundaries
// and don't attempt to interpret or normalize the resulting text.
const input = "Line with not a emoji\rLine with 🔥 emoji"
const expected = "Line with 🔥 emojioji"
expect(processCarriageReturns(input)).toBe(expected)
})

it("should handle carriage returns (\r) in the middle of non-ASCII text", () => {
// Tests handling of non-Latin text (like Chinese characters)
// Non-ASCII text uses multi-byte encodings, so this test verifies our handling works
// properly with such character sets.
//
// Our implementation ensures we preserve character boundaries and don't create
// invalid sequences when carriage returns (\r) cause partial overwrites.
const input = "你好世界啊\r你好地球"
const expected = "你好地球啊"
expect(processCarriageReturns(input)).toBe(expected)
})

it("should correctly handle complex patterns of alternating carriage returns (\r) and line feeds (\n)", () => {
// Break down the example:
// 1. "Line1" + carriage return (\r) + line feed (\n): carriage return (\r) moves cursor to start of line, line feed (\n) moves to next line, preserving "Line1"
// 2. "Line2" + carriage return (\r): carriage return (\r) moves cursor to start of line
// 3. "Line2Updated" overwrites "Line2"
// 4. Line feed (\n): moves to next line
// 5. "Line3" + carriage return (\r) + line feed (\n): carriage return (\r) moves cursor to start, line feed (\n) moves to next line, preserving "Line3"
const input = "Line1\r\nLine2\rLine2Updated\nLine3\r\n"
const expected = "Line1\nLine2Updated\nLine3\n"
expect(processCarriageReturns(input)).toBe(expected)
})

it("should handle partial overwrites with carriage returns (\r)", () => {
// In this case:
// 1. "Initial text" is printed
// 2. Carriage return (\r) moves cursor to start of line
// 3. "next" is printed, overwriting only the first 4 chars
// 4. Carriage return (\r) moves cursor to start, but nothing follows
// Final result should be "nextial text" (first 4 chars overwritten)
const input = "Initial text\rnext\r"
const expected = "nextial text"
expect(processCarriageReturns(input)).toBe(expected)
})
})
Loading
Loading