From c1358e45d26c1c340c04659258e582c45630409c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 18 Aug 2026 08:37:21 -0500 Subject: [PATCH] feat(tui): paint references in the composer as you type them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer renders one flat string, so a reference only became visible after sending. It now wears the theme accent live, through both the cursor and selection renderers; a masked input is a password and never highlights. Two things the fast-echo bypass needed. It writes only the new cells, so a keystroke that RECOLORS existing ones — `]` closing a token, a second `/` demoting `/usr` to a path — has to take the Ink path instead. And its own escape went through Ink's colorize rather than a hand-rolled truecolor sequence: `38;2;` is unparseable on a 256-color terminal, where the accent fell back to the default foreground and read gray. --- .../src/__tests__/textInputFastEcho.test.ts | 15 +- ui-tui/src/components/appLayout.tsx | 1 + ui-tui/src/components/textInput.tsx | 149 +++++++++++++++--- 3 files changed, 136 insertions(+), 29 deletions(-) diff --git a/ui-tui/src/__tests__/textInputFastEcho.test.ts b/ui-tui/src/__tests__/textInputFastEcho.test.ts index d0ccaada60..21550327e5 100644 --- a/ui-tui/src/__tests__/textInputFastEcho.test.ts +++ b/ui-tui/src/__tests__/textInputFastEcho.test.ts @@ -1,3 +1,4 @@ +import { colorize } from '@hermes/ink' import { describe, expect, it } from 'vitest' import { @@ -184,8 +185,16 @@ describe('colorizeEcho', () => { // 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('matches Ink exactly, never a hand-rolled truecolor escape', () => { + // The bypass and the Ink render paint the same cells, so they must agree + // byte-for-byte at whatever depth the terminal supports. Hand-rolling + // `38;2;r;g;b` shipped an escape a 256-color terminal (Apple Terminal) + // cannot parse: the accent fell back to the default fg and read GRAY. + // Asserted as an equality rather than a literal because chalk resolves + // its depth at import time — under vitest that's level 0 (no color). + for (const tone of ['#ff2d95', '#e77fa3', 'ansi256(211)']) { + expect(colorizeEcho('x', tone)).toBe(colorize('x', tone, 'foreground')) + } }) it('passes through untouched without a color (unthemed keeps terminal default)', () => { @@ -193,7 +202,7 @@ describe('colorizeEcho', () => { expect(colorizeEcho('x', undefined)).toBe('x') }) - it('passes through on a non-hex color (never emit a garbage SGR)', () => { + it('passes through on a non-color value (never emit a garbage SGR)', () => { expect(colorizeEcho('x', 'red')).toBe('x') expect(colorizeEcho('x', '#fff')).toBe('x') }) diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 569c969a11..3b5ef135c0 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -418,6 +418,7 @@ const ComposerPane = memo(function ComposerPane({ {/* Reserve the transcript scrollbar gutter too so typing never rewraps when the scrollbar column repaints. */} string stringWidth: (s: string) => number useCursorAdvance: () => (dx: number, dy?: number) => void useDeclaredCursor: (a: { line: number; column: number; active: boolean }) => (el: any) => void @@ -25,8 +27,18 @@ type InkExt = typeof Ink & { const ink = Ink as unknown as InkExt -const { Box, Text, useStdin, useInput, useStdout, stringWidth, useCursorAdvance, useDeclaredCursor, useTerminalFocus } = - ink +const { + Box, + Text, + useStdin, + useInput, + useStdout, + stringWidth, + colorize, + useCursorAdvance, + useDeclaredCursor, + useTerminalFocus +} = ink const ESC = '\x1b' const INV = `${ESC}[7m` @@ -58,13 +70,44 @@ const colorizeHint = (s: string, hex?: string) => { return `${ESC}[38;2;${r};${g};${b}m${s}${ESC}[39m` } +/** + * The SGR foreground-open sequence for a theme tone, or '' when it has none. + * + * Goes through Ink's own `colorize` rather than hand-rolling `38;2;r;g;b`. + * These bytes are written raw, past Ink — but Ink's `` renders + * through chalk, which downgrades to the terminal's real depth (Apple Terminal + * is 256-color, and takes a bespoke rich-8-bit path). A hand-rolled truecolor + * escape is unparseable there, so the glyph falls back to the default fg and + * the accent reads GRAY. Sharing the renderer's own function is the only way + * the bypass and the Ink path can't drift. + * + * Handles `ansi256(N)` for free — the shape the palette quantizer rewrites + * theme foregrounds to on exactly those limited-palette terminals. + */ +const fgSeq = (tone?: string): string => { + const value = (tone ?? '').trim() + + if (!value) { + return '' + } + + // Colorize a sentinel and keep the OPEN half, so the depth decision stays + // Ink's rather than being re-derived here. + const [open = ''] = colorize('\u0000', value, 'foreground').split('\u0000') + + return open +} + // 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 +export const colorizeEcho = (s: string, hex?: string) => { + const open = fgSeq(hex) + + return open ? `${open}${s}${ESC}[39m` : s +} /** Synthetic placeholder cursor: a hint-colored chip with luminance-picked * ink, standing in for the hidden hardware cursor (bubbles pattern). */ @@ -634,34 +677,66 @@ export function supportsFastEchoTerminal(env: NodeJS.ProcessEnv = process.env): return true } -function renderWithCursor(value: string, cursor: number) { - const pos = Math.max(0, Math.min(cursor, value.length)) +/** + * `value` with the accent opened and closed around each highlighted run. + * + * `mask` is indexed against the WHOLE composer string, so a slice passes its + * `offset` to stay aligned. `[39m` closes back to the outer `` + * (chalk re-opens it), leaving prose on the theme's text tone. + */ +function paintHighlights(value: string, accentOpen: string, mask: boolean[] | null, offset = 0) { + if (!accentOpen || !mask) { + return value + } - let out = '', - done = false + let out = '' + let on = false for (const { segment, index } of seg().segment(value)) { - if (!done && index >= pos) { - out += invert(index === pos && segment !== '\n' ? segment : ' ') - done = true + const want = !!mask[offset + index] - if (index === pos && segment !== '\n') { - continue - } + if (want !== on) { + out += want ? accentOpen : `${ESC}[39m` + on = want } out += segment } - return done ? out : out + invert(' ') + return on ? `${out}${ESC}[39m` : out } -function renderWithSelection(value: string, start: number, end: number) { +function renderWithCursor(value: string, cursor: number, accentOpen = '', mask: boolean[] | null = null) { + const pos = Math.max(0, Math.min(cursor, value.length)) + const under = [...seg().segment(value.slice(pos))][0]?.segment + // The cursor cell is inverted, not accented: inverse swaps fg/bg, so an + // accent under the block would fight it rather than show through. + const cell = under && under !== '\n' ? under : ' ' + const tail = under && under !== '\n' ? pos + under.length : pos + + return ( + paintHighlights(value.slice(0, pos), accentOpen, mask) + + invert(cell) + + paintHighlights(value.slice(tail), accentOpen, mask, tail) + ) +} + +function renderWithSelection( + value: string, + start: number, + end: number, + accentOpen = '', + mask: boolean[] | null = null +) { if (start >= end) { - return value + return paintHighlights(value, accentOpen, mask) } - return value.slice(0, start) + invert(value.slice(start, end) || ' ') + value.slice(end) + return ( + paintHighlights(value.slice(0, start), accentOpen, mask) + + invert(paintHighlights(value.slice(start, end), accentOpen, mask, start) || ' ') + + paintHighlights(value.slice(end), accentOpen, mask, end) + ) } function useFwdDelete(active: boolean) { @@ -704,6 +779,7 @@ export function TextInput({ voiceRecordKey = DEFAULT_VOICE_RECORD_KEY, placeholder = '', placeholderColor, + accentColor, color, focus = true }: TextInputProps) { @@ -810,9 +886,15 @@ export function TextInput({ // character rendered inverse-muted, so the glyph stays legible under the // "cursor" and the block never renders as a host-colored solid slab. The // hardware cursor is hidden for this state (see hideHardwareCursor). + // `/work`, `@file:src/a.ts`, and `[[ Image 1 ]]` wear in the composer the + // accent they wear once sent. A masked input is a password, never a + // reference, so it never highlights. + const accentOpen = mask ? '' : fgSeq(accentColor) + const highlights = useMemo(() => (accentOpen ? highlightMask(display) : null), [accentOpen, display]) + const rendered = useMemo(() => { if (!focus) { - return display || colorizeHint(placeholder, placeholderColor) + return display ? paintHighlights(display, accentOpen, highlights) : colorizeHint(placeholder, placeholderColor) } if (!display && placeholder) { @@ -822,11 +904,13 @@ export function TextInput({ } if (selected) { - return renderWithSelection(display, selected.start, selected.end) + return renderWithSelection(display, selected.start, selected.end, accentOpen, highlights) } - return nativeCursor ? display || ' ' : renderWithCursor(display, cur) - }, [cur, display, focus, nativeCursor, placeholder, placeholderColor, selected]) + return nativeCursor + ? paintHighlights(display, accentOpen, highlights) || ' ' + : renderWithCursor(display, cur, accentOpen, highlights) + }, [accentOpen, cur, display, focus, highlights, nativeCursor, placeholder, placeholderColor, selected]) useEffect(() => { const ownEcho = self.current && value === vRef.current @@ -976,10 +1060,19 @@ export function TextInput({ supportsFastEchoTerminal() && focus && termFocus && !selected && !mask && !!stdout?.isTTY const canFastAppend = (current: string, cursor: number, text: string) => - canFastEchoBase() && canFastAppendShape(current, cursor, text, columns, lineWidthRef.current) + canFastEchoBase() && + canFastAppendShape(current, cursor, text, columns, lineWidthRef.current) && + // Typing can RE-COLOR cells already on screen: `]` closing a `[[ token ]]`, + // or a second `/` demoting `/usr` to a path. The bypass only writes the new + // cells, so anything that repaints old ones must take the Ink path. + (!accentOpen || highlightsStable(current, current.slice(0, cursor) + text + current.slice(cursor))) const canFastBackspace = (current: string, cursor: number) => - !inkRepaintedRef.current && canFastEchoBase() && canFastBackspaceShape(current, cursor, columns) + !inkRepaintedRef.current && + canFastEchoBase() && + canFastBackspaceShape(current, cursor, columns) && + // Deleting can re-color survivors too (erasing `]` re-opens the token). + (!accentOpen || highlightsStable(current, current.slice(0, prevPos(current, cursor)) + current.slice(cursor))) const commit = ( next: string, @@ -1546,8 +1639,10 @@ export function TextInput({ if (simpleAppend) { const effect = fastAppendEffect(preInsertValue, preInsertCursor, text) // 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)) + // the bypass cell must not flash the terminal-default color. A + // character landing inside a `/skill` / `@ref` / `[[ token ]]` + // takes the accent, matching what Ink would have painted. + stdout!.write(colorizeEcho(effect.write, highlightMask(v)[preInsertCursor] ? accentColor : color)) // A real character was just fast-echoed to the screen, so the // terminal baseline is synced again — clear any pending Ink-repaint // fast-echo suppression so normal backspace fast-echo resumes. @@ -1674,6 +1769,8 @@ export interface PasteEvent { } interface TextInputProps { + /** Hex/ansi256 tone for `/skill`, `@ref`, and `[[ token ]]` spans. */ + accentColor?: string /** Hex color for typed text (theme text); terminal default when omitted. */ color?: string columns?: number