Skip to content
Open
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
62 changes: 55 additions & 7 deletions common/src/util/__tests__/string.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'bun:test'

import { pluralize } from '../string'
import { pluralize, stripAnsi, stripColors } from '../string'

describe('pluralize', () => {
it('should handle singular and plural cases correctly', () => {
Expand Down Expand Up @@ -53,13 +53,13 @@ describe('pluralize', () => {
expect(pluralize(2, 'person')).toBe('2 people')
expect(pluralize(2, 'child')).toBe('2 children')
expect(pluralize(2, 'mouse')).toBe('2 mice')

// -ex/-ix → -ices (no reliable rule, must be hardcoded)
expect(pluralize(2, 'index')).toBe('2 indices')
expect(pluralize(2, 'vertex')).toBe('2 vertices')
expect(pluralize(2, 'matrix')).toBe('2 matrices')
expect(pluralize(2, 'appendix')).toBe('2 appendices')

// Latin -um → -a
expect(pluralize(2, 'datum')).toBe('2 data')
expect(pluralize(2, 'medium')).toBe('2 media')
Expand Down Expand Up @@ -123,7 +123,7 @@ describe('pluralize', () => {
expect(pluralize(2, 'data')).toBe('2 data')
expect(pluralize(2, 'metadata')).toBe('2 metadata')
expect(pluralize(2, 'feedback')).toBe('2 feedback')

// Other words ending in -s that don't change
expect(pluralize(2, 'series')).toBe('2 series')
expect(pluralize(2, 'chassis')).toBe('2 chassis')
Expand All @@ -135,7 +135,7 @@ describe('pluralize', () => {
expect(pluralize(2, 'hero')).toBe('2 heroes')
expect(pluralize(2, 'echo')).toBe('2 echoes')
expect(pluralize(2, 'veto')).toBe('2 vetoes')

// Tech terms that just add -s
expect(pluralize(2, 'photo')).toBe('2 photos')
expect(pluralize(2, 'video')).toBe('2 videos')
Expand All @@ -160,11 +160,11 @@ describe('pluralize', () => {
expect(pluralize(2, 'shelf')).toBe('2 shelves')
expect(pluralize(2, 'self')).toBe('2 selves')
expect(pluralize(2, 'leaf')).toBe('2 leaves')

// -fe to -ves
expect(pluralize(2, 'knife')).toBe('2 knives')
expect(pluralize(2, 'life')).toBe('2 lives')

// Tech/design terms that just add -s
expect(pluralize(2, 'proof')).toBe('2 proofs') // mathematical proofs
expect(pluralize(2, 'brief')).toBe('2 briefs') // design briefs
Expand Down Expand Up @@ -237,3 +237,51 @@ describe('pluralize', () => {
})
})

describe('stripAnsi', () => {
it('strips ANSI color escape sequences', () => {
expect(stripAnsi('\u001b[31;1mred text\u001b[0m')).toBe('red text')
expect(stripAnsi('\u001b[38;5;196mcolored\u001b[0m normal')).toBe(
'colored normal',
)
})

it('strips CSI control sequences (line clears, cursor movement, visibility)', () => {
expect(stripAnsi('building...\u001b[2K\r')).toBe('building...\r')
expect(stripAnsi('\u001b[?25lhidden\u001b[?25h')).toBe('hidden')
expect(stripAnsi('\u001b[1A\u001b[2Jcleared')).toBe('cleared')
})

it('strips OSC sequences terminated by BEL (\\u0007)', () => {
expect(stripAnsi('\u001b]0;my window title\u0007window')).toBe('window')
expect(stripAnsi('prefix\u001b]2;tab title\u0007suffix')).toBe(
'prefixsuffix',
)
})

it('strips OSC sequences terminated by String Terminator (ST, ESC \\)', () => {
expect(stripAnsi('\u001b]0;my window title\u001b\\window')).toBe('window')
expect(stripAnsi('prefix\u001b]2;tab title\u001b\\suffix')).toBe(
'prefixsuffix',
)
})

it('strips 2-character Fe escape sequences', () => {
expect(stripAnsi('\u001bMreverse index')).toBe('reverse index')
expect(stripAnsi('\u001bNsingle shift 2')).toBe('single shift 2')
})

it('preserves plain text without escape sequences', () => {
expect(stripAnsi('hello world 123 !@#$%^&*()')).toBe(
'hello world 123 !@#$%^&*()',
)
})
})

describe('stripColors', () => {
it('strips only ANSI color codes while leaving other sequences alone', () => {
expect(stripColors('\u001b[31mred\u001b[0m')).toBe('red')
expect(stripColors('status: \u001b[2K\rline')).toBe(
'status: \u001b[2K\rline',
)
})
})
3 changes: 2 additions & 1 deletion common/src/util/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,8 @@ export function stripColors(str: string): string {
return str.replace(ansiColorsRegex, '')
}

const ansiRegex = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x1B]*\x1B\\?)/g
const ansiRegex =
/\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1B]*(?:\x07|\x1B\\)|[@-Z\\-_])/g
export function stripAnsi(str: string): string {
return str.replace(ansiRegex, '')
}
Expand Down
45 changes: 45 additions & 0 deletions sdk/src/__tests__/run-terminal-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,51 @@ describe('BoundedOutputBuffer', () => {
expect(output.format()).toStartWith('chunk-0000')
expect(output.format()).toEndWith('chunk-0999')
})

test('strips non-color ANSI control sequences such as line clears and cursor controls', () => {
const output = new BoundedOutputBuffer(100)
output.append('building...\u001b[2K\r')
output.append('\u001b[?25lprogress\u001b[?25h')
output.append('\u001b[1A\u001b[2Jdone')

expect(output.format()).toBe('building...\rprogressdone')
expect(output.format()).not.toContain('\u001b[')
})

test('strips fully-terminated OSC sequences and preserves trailing text', () => {
const output = new BoundedOutputBuffer(100)
// OSC sequence terminated by BEL with text after it in same chunk
output.append('building...\u001b]0;my window title\u0007done')
// OSC sequence terminated by String Terminator (ST, ESC \)
output.append(' \u001b]2;another title\u001b\\completed')
// OSC sequence terminated by BEL as the final chunk
output.append('\u001b]0;final title\u0007')

expect(output.format()).toBe('building...done completed')
expect(output.format()).not.toContain('\u001b]')
expect(output.format()).not.toContain('my window title')
expect(output.format()).not.toContain('another title')
expect(output.format()).not.toContain('final title')
})

test('buffers and strips split ANSI control sequences across chunk boundaries', () => {
const output = new BoundedOutputBuffer(100)
// Split CSI
output.append('step 1\u001b[2')
output.append('K-cleared')
output.append(' \u001b[?25')
output.append('h-visible')
// Split OSC across chunks terminated by BEL
output.append(' \u001b]0;tit')
output.append('le\u0007-osc-bel')
// Split OSC across chunks terminated by ST split between ESC and backslash
output.append(' \u001b]0;title2\u001b')
output.append('\\-osc-st')

expect(output.format()).toBe('step 1-cleared -visible -osc-bel -osc-st')
expect(output.format()).not.toContain('\u001b[')
expect(output.format()).not.toContain('\u001b]')
})
})

describe('terminal command process diagnostics', () => {
Expand Down
32 changes: 15 additions & 17 deletions sdk/src/tools/run-terminal-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,17 @@ import type {
} from 'child_process'
import type { Readable } from 'stream'

import { stripColors } from '../../../common/src/util/string'
import { stripAnsi } from '../../../common/src/util/string'
import { getSystemProcessEnv } from '../env'
import {
createWindowsBashNotFoundError,
findWindowsBash,
} from './windows-bash'
import { createWindowsBashNotFoundError, findWindowsBash } from './windows-bash'

import type { CodebuffToolOutput } from '../../../common/src/tools/list'

const COMMAND_OUTPUT_LIMIT = 50_000
const TRUNCATION_MARKER = '\n[...TRUNCATED DUE TO LENGTH...]\n'
const MAX_PENDING_COLOR_SEQUENCE_LENGTH = 32
const INCOMPLETE_COLOR_SEQUENCE_REGEX = /\x1B\[[0-9;]*$/
const MAX_PENDING_ESCAPE_SEQUENCE_LENGTH = 32
const INCOMPLETE_ESCAPE_SEQUENCE_REGEX =
/\x1B(?:\[[0-?]*[ -/]*|\][^\x07\x1B]*(?:\x1B)?)?$/
// Grace period between SIGTERM and SIGKILL for commands that trap or ignore
// SIGTERM.
const KILL_ESCALATION_MS = 1500
Expand Down Expand Up @@ -64,7 +62,7 @@ export class BoundedOutputBuffer {
private head = ''
private tail = ''
private truncated = false
private pendingColorSequence = ''
private pendingEscapeSequence = ''
private readonly headLimit: number
private readonly tailLimit: number

Expand All @@ -80,19 +78,19 @@ export class BoundedOutputBuffer {
append(value: string): void {
if (!value) return

let normalized = this.pendingColorSequence + value
this.pendingColorSequence = ''
const incompleteColorSequence = normalized.match(
INCOMPLETE_COLOR_SEQUENCE_REGEX,
let normalized = this.pendingEscapeSequence + value
this.pendingEscapeSequence = ''
const incompleteEscapeSequence = normalized.match(
INCOMPLETE_ESCAPE_SEQUENCE_REGEX,
)?.[0]
if (
incompleteColorSequence &&
incompleteColorSequence.length <= MAX_PENDING_COLOR_SEQUENCE_LENGTH
incompleteEscapeSequence &&
incompleteEscapeSequence.length <= MAX_PENDING_ESCAPE_SEQUENCE_LENGTH
) {
this.pendingColorSequence = incompleteColorSequence
normalized = normalized.slice(0, -incompleteColorSequence.length)
this.pendingEscapeSequence = incompleteEscapeSequence
normalized = normalized.slice(0, -incompleteEscapeSequence.length)
}
normalized = stripColors(normalized)
normalized = stripAnsi(normalized)
if (!normalized) return

if (!this.truncated) {
Expand Down
Loading