feat(tui): paint references in the composer as you type them
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.
This commit is contained in:
committed by
brooklyn!
parent
e69d2fda8a
commit
c1358e45d2
@@ -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')
|
||||
})
|
||||
|
||||
@@ -418,6 +418,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
|
||||
accentColor={ui.theme.color.accent}
|
||||
color={ui.theme.color.text}
|
||||
columns={inputColumns}
|
||||
mouseApiRef={inputMouseRef}
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as Ink from '@hermes/ink'
|
||||
import { type MutableRefObject, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { setInputSelection } from '../app/inputSelectionStore.js'
|
||||
import { highlightMask, highlightsStable } from '../domain/composerHighlights.js'
|
||||
import { readClipboardText, writeClipboardText } from '../lib/clipboard.js'
|
||||
import { cursorLayout, offsetFromPosition } from '../lib/inputMetrics.js'
|
||||
import {
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
import { isTermuxTuiMode } from '../lib/termux.js'
|
||||
|
||||
type InkExt = typeof Ink & {
|
||||
colorize: (str: string, color: string | undefined, type: 'foreground' | 'background') => 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 `<Text color>` 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 `<Text color>`
|
||||
* (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 <Text color>) —
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user