fix(tui): harden Terminal.app render behavior

Avoid Terminal.app paint corruption by disabling fast-echo in that terminal, sanitizing non-SGR control sequences before ANSI rendering, and defaulting Apple Terminal back to the safer 256-color path unless truecolor is explicitly requested.
This commit is contained in:
Brooklyn Nicholson
2026-05-16 22:51:51 -05:00
parent 3b39096904
commit 290bf93104
9 changed files with 214 additions and 10 deletions

View File

@@ -52,6 +52,50 @@ describe('forceTruecolor', () => {
)
})
it('downgrades Apple Terminal when truecolor is only advertised by env', async () => {
await withCleanEnv(
() => {
process.env.TERM_PROGRAM = 'Apple_Terminal'
process.env.COLORTERM = 'truecolor'
process.env.FORCE_COLOR = '3'
},
async () => {
const mod = await import('../lib/forceTruecolor.js?t=downgrade-' + importId++)
expect(
mod.shouldDowngradeAppleTerminalTruecolor({
TERM_PROGRAM: 'Apple_Terminal',
COLORTERM: 'truecolor',
FORCE_COLOR: '3'
} as NodeJS.ProcessEnv)
).toBe(true)
expect(process.env.COLORTERM).toBeUndefined()
expect(process.env.FORCE_COLOR).toBeUndefined()
}
)
})
it('keeps non-Apple terminals untouched when they advertise truecolor', async () => {
await withCleanEnv(
() => {
process.env.TERM_PROGRAM = 'vscode'
process.env.COLORTERM = 'truecolor'
process.env.FORCE_COLOR = '3'
},
async () => {
const mod = await import('../lib/forceTruecolor.js?t=keep-non-apple-' + importId++)
expect(
mod.shouldDowngradeAppleTerminalTruecolor({
TERM_PROGRAM: 'vscode',
COLORTERM: 'truecolor',
FORCE_COLOR: '3'
} as NodeJS.ProcessEnv)
).toBe(false)
expect(process.env.COLORTERM).toBe('truecolor')
expect(process.env.FORCE_COLOR).toBe('3')
}
)
})
it('sets COLORTERM=truecolor and FORCE_COLOR=3 when explicitly enabled', async () => {
await withCleanEnv(
() => {
@@ -79,6 +123,30 @@ describe('forceTruecolor', () => {
)
})
it('lets explicit opt-in keep Apple truecolor advertisement', async () => {
await withCleanEnv(
() => {
process.env.TERM_PROGRAM = 'Apple_Terminal'
process.env.COLORTERM = 'truecolor'
process.env.FORCE_COLOR = '3'
process.env.HERMES_TUI_TRUECOLOR = '1'
},
async () => {
const mod = await import('../lib/forceTruecolor.js?t=apple-explicit-on-' + importId++)
expect(
mod.shouldDowngradeAppleTerminalTruecolor({
TERM_PROGRAM: 'Apple_Terminal',
COLORTERM: 'truecolor',
FORCE_COLOR: '3',
HERMES_TUI_TRUECOLOR: '1'
} as NodeJS.ProcessEnv)
).toBe(false)
expect(process.env.COLORTERM).toBe('truecolor')
expect(process.env.FORCE_COLOR).toBe('3')
}
)
})
it('respects NO_COLOR', async () => {
await withCleanEnv(
() => {

View File

@@ -8,12 +8,15 @@ import {
estimateRows,
estimateTokensRough,
fmtK,
hasAnsi,
isToolTrailResultLine,
lastCotTrailIndex,
parseToolTrailResultLine,
pasteTokenLabel,
sanitizeAnsiForRender,
sameToolTrailGroup,
splitToolDuration,
stripAnsi,
thinkingPreview
} from '../lib/text.js'
@@ -84,6 +87,27 @@ describe('estimateTokensRough', () => {
})
})
describe('ANSI sanitizers', () => {
const ESC = String.fromCharCode(27)
const BEL = String.fromCharCode(7)
it('strips CSI/OSC/control bytes from plain previews', () => {
const sample = `A${ESC}[31mB${ESC}[39m${ESC}[2J${ESC}]0;title${BEL}C${ESC}[?25lD`
expect(stripAnsi(sample)).toBe('ABCD')
})
it('keeps SGR color spans but removes cursor controls for Ansi rendering', () => {
const sample = `A${ESC}[31mB${ESC}[39m${ESC}[2J${ESC}]0;title${BEL}${ESC}[?25lC`
expect(sanitizeAnsiForRender(sample)).toBe(`A${ESC}[31mB${ESC}[39mC`)
})
it('detects non-CSI escape prefixes too', () => {
expect(hasAnsi(`ok${ESC}Ppayload${ESC}\\`)).toBe(true)
})
})
describe('thinkingPreview', () => {
it('adds paragraph breaks before markdown thinking headings', () => {
const raw =

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { canFastAppendShape, canFastBackspaceShape } from '../components/textInput.js'
import { canFastAppendShape, canFastBackspaceShape, supportsFastEchoTerminal } from '../components/textInput.js'
// The fast-echo path bypasses Ink and writes characters directly to stdout
// for the common case of typing plain English at the end of the line. These
@@ -172,3 +172,14 @@ describe('canFastBackspaceShape', () => {
expect(canFastBackspaceShape('hello ', 'hello '.length)).toBe(true)
})
})
describe('supportsFastEchoTerminal', () => {
it('disables fast-echo in Apple Terminal', () => {
expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe(false)
})
it('keeps fast-echo enabled in VS Code and unknown terminals', () => {
expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv)).toBe(true)
expect(supportsFastEchoTerminal({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(true)
})
})