fix(tui): stop the composer placeholder from sticking Terminal.app into dim
The placeholder hint and its synthetic cursor chip hand-rolled truecolor escapes ([38;2;r;g;b / [48;2;r;g;b]) and wrote them raw past Ink's depth layer. Legacy Terminal.app has no truecolor parser — it walks compound params one by one, so the literal 2 in 38;2;… lands as SGR 2: dim ON, with no 22m ever emitted. Every frame that painted the placeholder left the terminal's dim attribute stuck, and subsequent cells rendered dimmed until an unrelated bold span's 22m happened to clear it — text randomly flipping dim and back, worst right after the composer empties. Measured on a live resumed session (PTY capture, params interpreted the legacy way): 1026 glyphs painted with stuck dim on main, 0 with the fix. Route both helpers through Ink's own colorize, the same repair colorizeEcho got for the fast-echo path (gray-accent bug) — the escape now downgrades with the terminal's real color depth, and a 256-color terminal gets 38;5;N it can actually parse. Also harden hermes-ink's transitionAnsiCodes for compound SGRs: real tool output ships [1;31m-style sequences whose endCode is [0m, dodging the endCode-based weight detection — parse the params instead (skipping 38/48 extended-color arguments) so a compound bold→dim transition passes through SGR 22 too.
This commit is contained in:
committed by
brooklyn!
parent
d697457f38
commit
2725d3225b
@@ -48,6 +48,33 @@ describe('transitionAnsiCodes weight family', () => {
|
||||
it('unchanged styles emit nothing', () => {
|
||||
expect(transitionAnsiCodes([BOLD, FG_PINK], [BOLD, FG_PINK])).toEqual([])
|
||||
})
|
||||
|
||||
// Real tool output (ls/grep) ships COMPOUND sequences like `[1;31m` whose
|
||||
// endCode is `[0m` — weight detection must parse params, not endCodes.
|
||||
const compound = (params: string) => ({ type: 'ansi' as const, code: `${ESC}[${params}m`, endCode: `${ESC}[0m` })
|
||||
|
||||
it('compound bold → compound dim resets the weight family', () => {
|
||||
expect(codes(transitionAnsiCodes([compound('1;31')], [compound('2;37')]))).toEqual([
|
||||
`${ESC}[22m`,
|
||||
`${ESC}[2;37m`
|
||||
])
|
||||
})
|
||||
|
||||
it('compound bold → compound bold (color change) stays minimal', () => {
|
||||
expect(codes(transitionAnsiCodes([compound('1;31')], [compound('1;32')]))).toEqual([`${ESC}[1;32m`])
|
||||
})
|
||||
|
||||
it('compound weight removal to plain emits the reset', () => {
|
||||
expect(codes(transitionAnsiCodes([compound('1;31')], [FG_GRAY]))).toEqual([`${ESC}[22m`, `${ESC}[38;5;245m`])
|
||||
})
|
||||
|
||||
it('extended-color arguments are not read as weight atoms', () => {
|
||||
// `38;2;r;g;b` / `38;5;N` carry literal 2/5 sub-params: not SGR atoms.
|
||||
const tc = { type: 'ansi' as const, code: `${ESC}[38;2;120;87;109m`, endCode: `${ESC}[39m` }
|
||||
|
||||
expect(codes(transitionAnsiCodes([tc], [FG_GRAY]))).toEqual([`${ESC}[38;5;245m`])
|
||||
expect(codes(transitionAnsiCodes([FG_PINK], [tc]))).toEqual([`${ESC}[38;2;120;87;109m`])
|
||||
})
|
||||
})
|
||||
|
||||
describe('StylePool.transition weight correctness', () => {
|
||||
|
||||
@@ -12,6 +12,12 @@ import { type AnsiCode, diffAnsiCodes } from '@alcalzone/ansi-tokenize'
|
||||
* corruption compounds and sticks — visible as random spans of wrong
|
||||
* weight/brightness ("random dimness/opacity changes") that depend on which
|
||||
* cells happened to change in which order.
|
||||
*
|
||||
* Weight flags hide in two shapes: standalone `[1m`/`[2m` (endCode `[22m`),
|
||||
* and compound sequences from real tool output — `[1;31m` ls/grep style —
|
||||
* whose endCode is `[0m`, dodging any endCode-based check. Both are detected
|
||||
* by parsing the params (skipping 38/48 extended-color arguments, whose
|
||||
* literal `2`/`5` sub-params are not SGR atoms).
|
||||
*/
|
||||
const WEIGHT_END = '\u001b[22m'
|
||||
|
||||
@@ -21,22 +27,61 @@ const WEIGHT_RESET: AnsiCode = {
|
||||
endCode: WEIGHT_END
|
||||
}
|
||||
|
||||
const SGR_PARAMS_RE = /^\u001b\[([0-9;]*)m$/
|
||||
|
||||
/** The bold/dim atoms ('1' / '2') a single SGR sequence turns on. */
|
||||
function weightAtoms(code: AnsiCode): string[] {
|
||||
const match = SGR_PARAMS_RE.exec(code.code)
|
||||
|
||||
if (!match) {
|
||||
return []
|
||||
}
|
||||
|
||||
const parts = (match[1] || '0').split(';')
|
||||
const atoms: string[] = []
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const p = parts[i] || '0'
|
||||
|
||||
if ((p === '38' || p === '48') && i + 1 < parts.length) {
|
||||
// Extended color: consume the argument sub-params so their literal
|
||||
// 2/5 aren't read as weight atoms.
|
||||
i += parts[i + 1] === '5' ? 2 : parts[i + 1] === '2' ? 4 : 0
|
||||
continue
|
||||
}
|
||||
|
||||
if (p === '1' || p === '2') {
|
||||
atoms.push(p)
|
||||
}
|
||||
}
|
||||
|
||||
return atoms
|
||||
}
|
||||
|
||||
const carriesWeight = (code: AnsiCode): boolean => weightAtoms(code).length > 0
|
||||
|
||||
/**
|
||||
* Like `diffAnsiCodes`, but correct for the shared-reset weight family:
|
||||
* when the bold/dim set changes in a way that removes a flag, emit SGR 22
|
||||
* first, then re-apply every weight flag the target style carries.
|
||||
* first, then re-apply every weight-carrying sequence the target style has.
|
||||
*/
|
||||
export function transitionAnsiCodes(from: AnsiCode[], to: AnsiCode[]): AnsiCode[] {
|
||||
const fromWeights = from.filter(code => code.endCode === WEIGHT_END)
|
||||
const toWeights = to.filter(code => code.endCode === WEIGHT_END)
|
||||
const fromAtoms = new Set(from.flatMap(weightAtoms))
|
||||
|
||||
if (fromWeights.length === 0) {
|
||||
if (fromAtoms.size === 0) {
|
||||
// Nothing to un-set; the library's "add what's missing" pass is correct.
|
||||
return diffAnsiCodes(from, to)
|
||||
}
|
||||
|
||||
const toWeightCodes = new Set(toWeights.map(code => code.code))
|
||||
const removesWeight = fromWeights.some(code => !toWeightCodes.has(code.code))
|
||||
const toAtoms = new Set(to.flatMap(weightAtoms))
|
||||
let removesWeight = false
|
||||
|
||||
for (const atom of fromAtoms) {
|
||||
if (!toAtoms.has(atom)) {
|
||||
removesWeight = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!removesWeight) {
|
||||
// from's weights ⊆ to's weights — additions only, library handles it.
|
||||
@@ -44,13 +89,11 @@ export function transitionAnsiCodes(from: AnsiCode[], to: AnsiCode[]): AnsiCode[
|
||||
}
|
||||
|
||||
// A weight flag must be dropped: SGR 22 is the only way (it clears BOTH),
|
||||
// so reset the family and re-apply the target's full weight set. The rest
|
||||
// of the style (colors, italic, underline, …) diffs normally with the
|
||||
// weight family stripped from both sides.
|
||||
const rest = diffAnsiCodes(
|
||||
from.filter(code => code.endCode !== WEIGHT_END),
|
||||
to.filter(code => code.endCode !== WEIGHT_END)
|
||||
)
|
||||
// so reset the family and re-apply the target's weight-carrying sequences
|
||||
// in full (a compound re-asserts its color too — redundant bytes, never
|
||||
// wrong). The rest of the style diffs normally with the weight carriers
|
||||
// stripped from both sides.
|
||||
const rest = diffAnsiCodes(from.filter(code => !carriesWeight(code)), to.filter(code => !carriesWeight(code)))
|
||||
|
||||
return [WEIGHT_RESET, ...rest, ...toWeights]
|
||||
return [WEIGHT_RESET, ...rest, ...to.filter(carriesWeight)]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
canFastAppendShape,
|
||||
canFastBackspaceShape,
|
||||
colorizeEcho,
|
||||
colorizeHint,
|
||||
hintCursorCell,
|
||||
supportsFastEchoTerminal
|
||||
} from '../components/textInput.js'
|
||||
|
||||
@@ -208,6 +210,40 @@ describe('colorizeEcho', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('colorizeHint / hintCursorCell', () => {
|
||||
// The placeholder bypass writes raw bytes past Ink too. Hand-rolling
|
||||
// `38;2;r;g;b` here was WORSE than the gray-accent bug colorizeEcho had:
|
||||
// legacy Terminal.app walks compound params one by one, so the literal `2`
|
||||
// in `38;2;…` landed as SGR 2 (dim ON) with no closing `22m` — every
|
||||
// frame that painted the placeholder left the terminal's dim flag stuck,
|
||||
// and later unstyled cells rendered randomly dimmed. Both helpers must
|
||||
// route through Ink's own colorize so depth downgrades with the terminal.
|
||||
|
||||
it('hint matches Ink exactly, never a hand-rolled truecolor escape', () => {
|
||||
for (const tone of ['#8a8094', '#e77fa3']) {
|
||||
expect(colorizeHint('Try it', tone)).toBe(colorize('Try it', tone, 'foreground'))
|
||||
}
|
||||
})
|
||||
|
||||
it('hint falls back to the neutral gray on junk, still through colorize', () => {
|
||||
expect(colorizeHint('x')).toBe(colorize('x', '#808080', 'foreground'))
|
||||
expect(colorizeHint('x', 'nope')).toBe(colorize('x', '#808080', 'foreground'))
|
||||
})
|
||||
|
||||
it('cursor chip composes bg+fg through colorize only', () => {
|
||||
expect(hintCursorCell('T', '#8a8094')).toBe(
|
||||
colorize(colorize('T', '#ffffff', 'foreground'), '#8a8094', 'background')
|
||||
)
|
||||
})
|
||||
|
||||
it('never emits a raw 38;2/48;2 the depth layer did not choose', () => {
|
||||
// chalk is level 0 under vitest, so ANY escape byte here means the
|
||||
// helper bypassed colorize and hand-rolled the sequence.
|
||||
expect(colorizeHint('x', '#8a8094')).not.toContain('\u001b')
|
||||
expect(hintCursorCell('x', '#8a8094')).not.toContain('\u001b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('supportsFastEchoTerminal', () => {
|
||||
it('disables fast-echo in Apple Terminal', () => {
|
||||
expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe(false)
|
||||
|
||||
@@ -52,7 +52,7 @@ type MinimalEnv = Record<string, string | undefined>
|
||||
|
||||
const invert = (s: string) => INV + s + INV_OFF
|
||||
|
||||
// Placeholder styling is EXPLICIT truecolor only — never SGR dim/inverse:
|
||||
// Placeholder styling is EXPLICIT color only — never SGR dim/inverse:
|
||||
// both are terminal-interpreted relative to the default fg/bg, and on
|
||||
// transparent profiles (terminal.background #00000000) they composite
|
||||
// against a black RGB the user never sees — the hint rendered as a slab.
|
||||
@@ -64,11 +64,15 @@ const hintRgb = (hex?: string): [number, number, number] => {
|
||||
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]
|
||||
}
|
||||
|
||||
const colorizeHint = (s: string, hex?: string) => {
|
||||
const [r, g, b] = hintRgb(hex)
|
||||
const hintHex = (hex?: string): string => (/^#[0-9a-f]{6}$/i.test(hex ?? '') ? hex! : HINT_FALLBACK)
|
||||
|
||||
return `${ESC}[38;2;${r};${g};${b}m${s}${ESC}[39m`
|
||||
}
|
||||
// Through Ink's own `colorize` (see fgSeq below): a hand-rolled 38;2;r;g;b
|
||||
// is worse than unparseable on a non-truecolor terminal — legacy
|
||||
// Terminal.app consumes the params one by one, and the `2` in `38;2;…`
|
||||
// lands as SGR 2 (dim ON) with no `22m` ever emitted. Every subsequent
|
||||
// frame's unstyled cells then paint dim until an unrelated bold span's
|
||||
// `22m` clears it: text randomly dims after the placeholder renders.
|
||||
export const colorizeHint = (s: string, hex?: string) => colorize(s, hintHex(hex), 'foreground')
|
||||
|
||||
/**
|
||||
* The SGR foreground-open sequence for a theme tone, or '' when it has none.
|
||||
@@ -110,12 +114,14 @@ export const colorizeEcho = (s: string, hex?: string) => {
|
||||
}
|
||||
|
||||
/** Synthetic placeholder cursor: a hint-colored chip with luminance-picked
|
||||
* ink, standing in for the hidden hardware cursor (bubbles pattern). */
|
||||
const hintCursorCell = (ch: string, hex?: string) => {
|
||||
* ink, standing in for the hidden hardware cursor (bubbles pattern).
|
||||
* Both halves go through `colorize` so the escapes match the terminal's
|
||||
* real color depth (same hazard as colorizeHint above). */
|
||||
export const hintCursorCell = (ch: string, hex?: string) => {
|
||||
const [r, g, b] = hintRgb(hex)
|
||||
const ink = 0.2126 * r + 0.7152 * g + 0.0722 * b > 140 ? '0;0;0' : '255;255;255'
|
||||
const ink = 0.2126 * r + 0.7152 * g + 0.0722 * b > 140 ? '#000000' : '#ffffff'
|
||||
|
||||
return `${ESC}[48;2;${r};${g};${b}m${ESC}[38;2;${ink}m${ch}${ESC}[39m${ESC}[49m`
|
||||
return colorize(colorize(ch, ink, 'foreground'), hintHex(hex), 'background')
|
||||
}
|
||||
|
||||
let _seg: Intl.Segmenter | null = null
|
||||
|
||||
Reference in New Issue
Block a user