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:
@@ -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(
|
||||
() => {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user