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
36 changes: 32 additions & 4 deletions src/ink-win.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,42 @@
const BSU = '\x1b[?2026h'
const ESU = '\x1b[?2026l'
// Begin/End Synchronized Update (DEC private mode 2026); exported so callers
// can emit them; on Windows the filter below strips them from every write, so
// even a concatenated BSU+payload write cannot reach ConPTY (#195).
export const BSU = '\x1b[?2026h'
export const ESU = '\x1b[?2026l'
let patched = false

// split/join removes every occurrence and is hot-path cheap because the
// includes() gate below runs first.
export function stripSyncUpdateEscapes(chunk: string): string {
return chunk.split(BSU).join('').split(ESU).join('')
}

export function patchStdoutForWindows(): void {
if (process.platform !== 'win32' || patched) return
patched = true

const origWrite = process.stdout.write.bind(process.stdout)
process.stdout.write = function (chunk: unknown, ...args: unknown[]): boolean {
if (chunk === BSU || chunk === ESU) return true
return (origWrite as Function)(chunk, ...args)
// Non-string chunks pass straight through unchanged; Buffers never carry
// these escapes in this codebase, so scanning them is not worth the copy.
if (typeof chunk !== 'string') {
return (origWrite as Function)(chunk, ...args)
}
// Neither escape present: pass straight through.
if (!chunk.includes(BSU) && !chunk.includes(ESU)) {
return (origWrite as Function)(chunk, ...args)
}
const stripped = stripSyncUpdateEscapes(chunk)
if (stripped.length > 0) {
return (origWrite as Function)(stripped, ...args)
}
// The chunk was swallowed entirely. The old exact-match filter dropped the
// callback too, which could wedge a callback-style writer; invoke it
// asynchronously so a caller awaiting the callback never hangs.
const last = args[args.length - 1]
if (typeof last === 'function') {
queueMicrotask(() => (last as () => void)())
}
return true
} as typeof process.stdout.write
}
42 changes: 42 additions & 0 deletions tests/ink-win.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest'
import { BSU, ESU, stripSyncUpdateEscapes, patchStdoutForWindows } from '../src/ink-win.js'

describe('stripSyncUpdateEscapes', () => {
it('strips an exact BSU chunk to empty', () => {
expect(stripSyncUpdateEscapes(BSU)).toBe('')
})

it('strips an exact ESU chunk to empty', () => {
expect(stripSyncUpdateEscapes(ESU)).toBe('')
})

it('strips a leading BSU from a concatenated clear write', () => {
// #863 regression shape: the clear sequence glued to a BSU used to slip
// through raw and hang Windows ConPTY.
expect(stripSyncUpdateEscapes(BSU + '\x1b[2J\x1b[H')).toBe('\x1b[2J\x1b[H')
})

it('strips a trailing ESU, and both ends at once', () => {
expect(stripSyncUpdateEscapes('x' + ESU)).toBe('x')
expect(stripSyncUpdateEscapes(BSU + 'x' + ESU)).toBe('x')
})

it('removes every occurrence when escapes appear multiple times', () => {
expect(stripSyncUpdateEscapes(BSU + 'a' + BSU + 'b' + ESU + 'c' + ESU)).toBe('abc')
})

it('leaves a string without escapes untouched (same reference-equal content)', () => {
const plain = 'status line \x1b[2J'
expect(stripSyncUpdateEscapes(plain)).toBe(plain)
})
})

describe('patchStdoutForWindows', () => {
it('is a no-op off win32: process.stdout.write stays reference-identical', () => {
// Skip on actual Windows runners, where the patch legitimately applies.
if (process.platform === 'win32') return
const before = process.stdout.write
patchStdoutForWindows()
expect(process.stdout.write).toBe(before)
})
})