Merge pull request #69616 from NousResearch/bb/tui-input-theme-color

fix(ui-tui): input text goes invisible when a live skin flips the terminal's polarity
This commit is contained in:
brooklyn! 2026-07-22 16:07:42 -05:00 committed by GitHub
commit d6080d4cf7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 74 additions and 7 deletions

View file

@ -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)

View file

@ -866,7 +866,13 @@ export function ActiveSessionSwitcher({
<>
<Box marginTop={1}>
<Text color={t.color.label}>prompt </Text>
<TextInput columns={promptColumns} onChange={setDraft} onSubmit={submitDraft} value={draft} />
<TextInput
color={t.color.text}
columns={promptColumns}
onChange={setDraft}
onSubmit={submitDraft}
value={draft}
/>
</Box>
<OrchestratorHintText segments={orchestratorContextHintSegments(true)} t={t} />
<Text color={t.color.muted} wrap="truncate-end">

View file

@ -417,6 +417,7 @@ const ComposerPane = memo(function ComposerPane({
<Box flexGrow={0} flexShrink={0} height={inputHeight} width={inputColumns}>
{/* Reserve the transcript scrollbar gutter too so typing never rewraps when the scrollbar column repaints. */}
<TextInput
color={ui.theme.color.text}
columns={inputColumns}
mouseApiRef={inputMouseRef}
onChange={composer.updateInput}

View file

@ -340,7 +340,7 @@ function BuyScreen({ ctx, onPatch, s, t }: ScreenProps) {
<Text color={t.color.label}>Enter a custom amount:</Text>
<Box>
<Text color={t.color.label}>{'$'}</Text>
<TextInput columns={20} onChange={setCustom} onSubmit={submitCustom} value={custom} />
<TextInput color={t.color.text} columns={20} onChange={setCustom} onSubmit={submitCustom} value={custom} />
</Box>
{error && <Text color={t.color.error}>{error}</Text>}
<Text />
@ -852,6 +852,7 @@ function AutoReloadScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) {
<Box borderColor={focused ? t.color.accent : t.color.border} borderStyle="round" paddingX={1}>
<Text color={t.color.label}>{'$'}</Text>
<TextInput
color={t.color.text}
columns={16}
focus={focused}
onChange={onChange}

View file

@ -18,7 +18,14 @@ export function MaskedPrompt({ cols = 80, icon, label, onSubmit, sub, t }: Maske
<Box>
<Text color={t.color.label}>{'> '}</Text>
<TextInput columns={Math.max(20, cols - 6)} mask="*" onChange={setValue} onSubmit={onSubmit} value={value} />
<TextInput
color={t.color.text}
columns={Math.max(20, cols - 6)}
mask="*"
onChange={setValue}
onSubmit={onSubmit}
value={value}
/>
</Box>
</Box>
)

View file

@ -192,7 +192,13 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify
<Box>
<Text color={t.color.label}>{'> '}</Text>
<TextInput columns={Math.max(20, cols - 6)} onChange={setCustom} onSubmit={onAnswer} value={custom} />
<TextInput
color={t.color.text}
columns={Math.max(20, cols - 6)}
onChange={setCustom}
onSubmit={onAnswer}
value={custom}
/>
</Box>
<Text color={t.color.muted}>

View file

@ -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 <Text color>) —
// 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}
>
<Text wrap="wrap">{rendered}</Text>
{/* 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. */}
<Text color={color} wrap="wrap">
{rendered}
</Text>
</Box>
)
}
@ -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