From 77e89c7b8e6b9d2f9dc46524f26409d27adde744 Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Thu, 24 Apr 2025 00:06:31 -0700 Subject: [PATCH] feat: compress terminal output with backspace characters Follow-up to #2562 adding support for backspace character compression. Optimizes terminal output by handling backspace characters similar to carriage returns, improving readability of progress spinners and other terminal output that uses backspace for animation. - Added processBackspaces function using efficient indexOf approach - Added comprehensive test suite for backspace handling - Integrated with terminal output compression pipeline Signed-off-by: Eric Wheeler --- .../misc/__tests__/extract-text.test.ts | 64 +++++++++++++++++++ src/integrations/misc/extract-text.ts | 47 ++++++++++++++ src/integrations/terminal/Terminal.ts | 8 ++- 3 files changed, 116 insertions(+), 3 deletions(-) diff --git a/src/integrations/misc/__tests__/extract-text.test.ts b/src/integrations/misc/__tests__/extract-text.test.ts index c6bca0a88d9..04b06cfa836 100644 --- a/src/integrations/misc/__tests__/extract-text.test.ts +++ b/src/integrations/misc/__tests__/extract-text.test.ts @@ -5,6 +5,7 @@ import { truncateOutput, applyRunLengthEncoding, processCarriageReturns, + processBackspaces, } from "../extract-text" describe("addLineNumbers", () => { @@ -229,6 +230,69 @@ describe("truncateOutput", () => { expect(truncateOutput("single line", 10)).toBe("single line") }) + describe("processBackspaces", () => { + it("should handle basic backspace deletion", () => { + const input = "abc\b\bxy" + const expected = "axy" + expect(processBackspaces(input)).toBe(expected) + }) + + it("should handle backspaces at start of input", () => { + const input = "\b\babc" + const expected = "abc" + expect(processBackspaces(input)).toBe(expected) + }) + + it("should handle backspaces with newlines", () => { + const input = "abc\b\n123\b\b" + const expected = "ab\n1" + expect(processBackspaces(input)).toBe(expected) + }) + + it("should handle consecutive backspaces", () => { + const input = "abcdef\b\b\b\bxy" + const expected = "abxy" + expect(processBackspaces(input)).toBe(expected) + }) + + it("should handle backspaces at end of input", () => { + const input = "abc\b\b" + const expected = "a" + expect(processBackspaces(input)).toBe(expected) + }) + + it("should handle mixed backspaces and content", () => { + const input = "abc\bx\byz\b\b123" + const expected = "ab123" + expect(processBackspaces(input)).toBe(expected) + }) + + it("should handle multiple groups of consecutive backspaces", () => { + const input = "abc\b\bdef\b\b\bghi\b\b\b\bjkl" + const expected = "jkl" + expect(processBackspaces(input)).toBe(expected) + }) + + it("should handle backspaces with empty content between them", () => { + const input = "abc\b\b\b\b\b\bdef" + const expected = "def" + expect(processBackspaces(input)).toBe(expected) + }) + + it("should handle complex mixed content with backspaces", () => { + const input = "Loading[\b\b\b\b\b\b\b\bProgress[\b\b\b\b\b\b\b\b\bStatus: \b\b\b\b\b\b\b\bDone!" + // Technically terminal displays "Done!s: [" but we assume \b is destructive as an optimization + const expected = "Done!" + expect(processBackspaces(input)).toBe(expected) + }) + + it("should handle backspaces with special characters", () => { + const input = "abc😀\b\bdef🎉\b\b\bghi" + const expected = "abcdeghi" + expect(processBackspaces(input)).toBe(expected) + }) + }) + it("handles windows-style line endings", () => { // Create content with windows line endings const lines = Array.from({ length: 15 }, (_, i) => `line${i + 1}`) diff --git a/src/integrations/misc/extract-text.ts b/src/integrations/misc/extract-text.ts index 5bbbbf85140..596923d93e4 100644 --- a/src/integrations/misc/extract-text.ts +++ b/src/integrations/misc/extract-text.ts @@ -288,6 +288,53 @@ export function processCarriageReturns(input: string): string { return output } +/** + * Processes backspace characters (\b) in terminal output using index operations. + * Uses indexOf to efficiently locate and handle backspaces. + * + * Technically terminal only moves the cursor and overwrites in-place, + * but we assume \b is destructive as an optimization which is acceptable + * for all progress spinner cases and most terminal output cases. + * + * @param input The terminal output to process + * @returns The processed output with backspaces handled + */ +export function processBackspaces(input: string): string { + let output = "" + let pos = 0 + let bsPos = input.indexOf("\b") + + while (bsPos !== -1) { + // Fast path: exclude char before backspace + output += input.substring(pos, bsPos - 1) + + // Move past backspace + pos = bsPos + 1 + + // Count consecutive backspaces + let count = 0 + while (input[pos] === "\b") { + count++ + pos++ + } + + // Trim output mathematically for consecutive backspaces + if (count > 0 && output.length > 0) { + output = output.substring(0, Math.max(0, output.length - count)) + } + + // Find next backspace + bsPos = input.indexOf("\b", pos) + } + + // Add remaining content + if (pos < input.length) { + output += input.substring(pos) + } + + return output +} + /** * Helper function to process a single line with carriage returns. * Handles the overwrite logic for a line that contains one or more carriage returns (\r). diff --git a/src/integrations/terminal/Terminal.ts b/src/integrations/terminal/Terminal.ts index 5f896c265d5..66eb6c68e17 100644 --- a/src/integrations/terminal/Terminal.ts +++ b/src/integrations/terminal/Terminal.ts @@ -1,7 +1,7 @@ import * as vscode from "vscode" import pWaitFor from "p-wait-for" import { ExitCodeDetails, mergePromise, TerminalProcess, TerminalProcessResultPromise } from "./TerminalProcess" -import { truncateOutput, applyRunLengthEncoding, processCarriageReturns } from "../misc/extract-text" +import { truncateOutput, applyRunLengthEncoding, processCarriageReturns, processBackspaces } from "../misc/extract-text" // Import TerminalRegistry here to avoid circular dependencies const { TerminalRegistry } = require("./TerminalRegistry") @@ -296,9 +296,11 @@ export class Terminal { public static compressTerminalOutput(input: string, lineLimit: number): string { // Apply carriage return processing if the feature is enabled let processedInput = input - if (Terminal.compressProgressBar && input.includes("\r")) { - processedInput = processCarriageReturns(input) + if (Terminal.compressProgressBar) { + processedInput = processCarriageReturns(processedInput) + processedInput = processBackspaces(processedInput) } + return truncateOutput(applyRunLengthEncoding(processedInput), lineLimit) }