From 2a3e3157d2900c0f4319c66294b6154f11c92bf4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 15:56:22 -0500 Subject: [PATCH] =?UTF-8?q?fix(ui-tui):=20themed=20input=20text=20?= =?UTF-8?q?=E2=80=94=20typed=20text=20tracked=20the=20HOST=20terminal's=20?= =?UTF-8?q?fg,=20not=20the=20skin's?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-repaint's composer gap: flip a light terminal to a dark skin and the input goes black-on-black. The placeholder was already explicit truecolor (theme muted), but TYPED text rendered with no color at all — the terminal's default foreground — in both paint paths: - the Ink render ({rendered}, no color), and - the fast-echo bypass, which writes raw cells straight to stdout. The skin owns the background (OSC-11) but the default fg still belongs to the host terminal's polarity, so any skin/terminal polarity mismatch made input invisible. Every other transcript line already paints theme.color.text (the completed inputBuf rows directly above the composer). Give TextInput a color prop and paint both paths with it: the Ink (chalk re-opens the outer color after the placeholder chips' embedded [39m closes; INV cursor/selection cells never touch fg) and the fast-echo write via colorizeEcho — same explicit-truecolor-only rule as colorizeHint, so the bypass cell can't flash terminal-default before the next frame. All six TextInput sites (composer, prompts, masked, billing ×2, session switcher) pass theme text; no color ⇒ passthrough, unthemed inputs keep the terminal default. Tests: colorizeEcho SGR wrap + passthrough contracts; full ui-tui suite 1338✓; typecheck clean. --- .../src/__tests__/textInputFastEcho.test.ts | 28 ++++++++++++++++++- .../src/components/activeSessionSwitcher.tsx | 8 +++++- ui-tui/src/components/appLayout.tsx | 1 + ui-tui/src/components/billingOverlay.tsx | 3 +- ui-tui/src/components/maskedPrompt.tsx | 9 +++++- ui-tui/src/components/prompts.tsx | 8 +++++- ui-tui/src/components/textInput.tsx | 24 ++++++++++++++-- 7 files changed, 74 insertions(+), 7 deletions(-) diff --git a/ui-tui/src/__tests__/textInputFastEcho.test.ts b/ui-tui/src/__tests__/textInputFastEcho.test.ts index 75ed282a8a0e..d0ccaada60f6 100644 --- a/ui-tui/src/__tests__/textInputFastEcho.test.ts +++ b/ui-tui/src/__tests__/textInputFastEcho.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' -import { canFastAppendShape, canFastBackspaceShape, supportsFastEchoTerminal } from '../components/textInput.js' +import { + canFastAppendShape, + canFastBackspaceShape, + colorizeEcho, + 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 @@ -173,6 +178,27 @@ describe('canFastBackspaceShape', () => { }) }) +describe('colorizeEcho', () => { + // The fast-echo bypass writes raw cells past Ink, so a themed input must + // carry the theme fg explicitly — a default-fg glyph goes invisible when a + // skin repaints the background to the opposite polarity (dark skin on a + // light terminal ⇒ black-on-black). + + it('wraps the write in truecolor fg + reset for a hex theme color', () => { + expect(colorizeEcho('x', '#ff2d95')).toBe('\x1b[38;2;255;45;149mx\x1b[39m') + }) + + it('passes through untouched without a color (unthemed keeps terminal default)', () => { + expect(colorizeEcho('x')).toBe('x') + expect(colorizeEcho('x', undefined)).toBe('x') + }) + + it('passes through on a non-hex color (never emit a garbage SGR)', () => { + expect(colorizeEcho('x', 'red')).toBe('x') + expect(colorizeEcho('x', '#fff')).toBe('x') + }) +}) + describe('supportsFastEchoTerminal', () => { it('disables fast-echo in Apple Terminal', () => { expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe(false) diff --git a/ui-tui/src/components/activeSessionSwitcher.tsx b/ui-tui/src/components/activeSessionSwitcher.tsx index 52668c1d51f3..beb4683f62ec 100644 --- a/ui-tui/src/components/activeSessionSwitcher.tsx +++ b/ui-tui/src/components/activeSessionSwitcher.tsx @@ -866,7 +866,13 @@ export function ActiveSessionSwitcher({ <> prompt › - + diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index d6333eccd4a2..1b4ba084fba0 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -417,6 +417,7 @@ const ComposerPane = memo(function ComposerPane({ {/* Reserve the transcript scrollbar gutter too so typing never rewraps when the scrollbar column repaints. */} Enter a custom amount: {'$'} - + {error && {error}} @@ -852,6 +852,7 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { {'$'} {'> '} - + ) diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx index 614be012aaad..78fade0d2c1f 100644 --- a/ui-tui/src/components/prompts.tsx +++ b/ui-tui/src/components/prompts.tsx @@ -192,7 +192,13 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify {'> '} - + diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index c65fc8c54419..9d35bbdf1331 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -58,6 +58,14 @@ const colorizeHint = (s: string, hex?: string) => { return `${ESC}[38;2;${r};${g};${b}m${s}${ESC}[39m` } +// Typed-text fast-echo must carry the SAME explicit fg the Ink render uses: +// the bypass writes raw cells, and a default-fg glyph goes invisible the +// moment a skin repaints the background to the opposite polarity (a dark +// skin on a light terminal ⇒ black-on-black). No color ⇒ passthrough, so +// unthemed inputs keep the terminal default. +export const colorizeEcho = (s: string, hex?: string) => + /^#[0-9a-f]{6}$/i.test(hex ?? '') ? `${ESC}[38;2;${hintRgb(hex).join(';')}m${s}${ESC}[39m` : s + /** 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) => { @@ -565,6 +573,7 @@ export function TextInput({ voiceRecordKey = DEFAULT_VOICE_RECORD_KEY, placeholder = '', placeholderColor, + color, focus = true }: TextInputProps) { const [cur, setCur] = useState(value.length) @@ -1306,7 +1315,9 @@ export function TextInput({ if (simpleAppend) { const effect = fastAppendEffect(preInsertValue, preInsertCursor, text) - stdout!.write(effect.write) + // Same explicit fg as the Ink render (see the ) — + // the bypass cell must not flash the terminal-default color. + stdout!.write(colorizeEcho(effect.write, color)) // ASCII-printable text advances the physical cursor by exactly // text.length cells (canFastAppendShape rejects non-ASCII, // wide chars, newlines). Notify Ink so the cached displayCursor @@ -1395,7 +1406,14 @@ export function TextInput({ ref={boxRef} width={columns} > - {rendered} + {/* Explicit theme color on the typed text — default fg tracks the HOST + terminal's polarity, not the skin's, so a live dark-skin repaint on a + light terminal would otherwise leave the input black-on-black. chalk + re-opens the outer color after embedded [39m closes (placeholder + chips), and INV cursor/selection cells don't touch fg. */} + + {rendered} + ) } @@ -1416,6 +1434,8 @@ export interface PasteEvent { } interface TextInputProps { + /** Hex color for typed text (theme text); terminal default when omitted. */ + color?: string columns?: number focus?: boolean mask?: string