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

@ -12,6 +12,7 @@ import {
compactPreview,
hasAnsi,
isPasteBackedText,
sanitizeAnsiForRender,
stripAnsi
} from '../lib/text.js'
import type { Theme } from '../theme.js'
@ -85,13 +86,14 @@ export const MessageLine = memo(function MessageLine({
if (msg.role === 'tool') {
const maxChars = Math.max(24, cols - 14)
const stripped = hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text
const safeAnsi = hasAnsi(msg.text) ? sanitizeAnsiForRender(msg.text) : msg.text
const preview = compactPreview(stripped, maxChars) || '(empty tool result)'
return (
<Box alignSelf="flex-start" borderColor={t.color.muted} borderStyle="round" marginLeft={3} paddingX={1}>
{hasAnsi(msg.text) ? (
<Text wrap="truncate-end">
<Ansi>{msg.text}</Ansi>
<Ansi>{safeAnsi}</Ansi>
</Text>
) : (
<Text color={t.color.muted} wrap="truncate-end">
@ -129,13 +131,13 @@ export const MessageLine = memo(function MessageLine({
{msg.text.length.toLocaleString()} chars
</Text>
</Box>
{systemOpen && <Ansi>{msg.text}</Ansi>}
{systemOpen && <Ansi>{sanitizeAnsiForRender(msg.text)}</Ansi>}
</Box>
)
}
if (msg.role !== 'user' && hasAnsi(msg.text)) {
return <Ansi>{msg.text}</Ansi>
return <Ansi>{sanitizeAnsiForRender(msg.text)}</Ansi>
}
if (msg.role === 'assistant') {

View file

@ -283,6 +283,12 @@ export function canFastBackspaceShape(current: string, cursor: number, columns?:
return ASCII_PRINTABLE_RE.test(removed)
}
export function supportsFastEchoTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
// Terminal.app still shows paint/cursor artifacts under the fast-echo
// bypass path. Fall back to the normal Ink render path there.
return (env.TERM_PROGRAM ?? '').trim() !== 'Apple_Terminal'
}
function renderWithCursor(value: string, cursor: number) {
const pos = Math.max(0, Math.min(cursor, value.length))
@ -559,7 +565,7 @@ export function TextInput({
}, 16)
}
const canFastEchoBase = () => focus && termFocus && !selected && !mask && !!stdout?.isTTY
const canFastEchoBase = () => supportsFastEchoTerminal() && focus && termFocus && !selected && !mask && !!stdout?.isTTY
const canFastAppend = (current: string, cursor: number, text: string) =>
canFastEchoBase() && canFastAppendShape(current, cursor, text, columns, lineWidthRef.current)