fix(ui-tui): eradicate every transparent-terminal black-slab trigger

On terminal.background #00000000 xterm paints "drawn blank" and
attribute-styled cells against an opaque black RGB the user never sees
elsewhere. Verified by PTY byte capture + stateful SGR replay that we emit
no black backgrounds — then removed every trigger: the banner's opaque
space-fills, the scrollbar's non-scrollable space column and SGR-dim
track, the placeholder's SGR inverse cursor and dim fallback, and the bold
full-width banner rule. Chrome styling is now explicit truecolor only:
scrollbar thumb rides primary (accent on hover/drag) over a blended track,
the placeholder cursor is a theme-colored chip (48;2 bg + luminance-picked
ink), hints always carry an explicit 38;2 foreground.
This commit is contained in:
Brooklyn Nicholson 2026-07-20 17:37:00 -05:00
parent 28e05a3c85
commit 6929f5f71f
5 changed files with 79 additions and 50 deletions

View file

@ -11,6 +11,7 @@ import { FACES } from '../content/faces.js'
import { VERBS } from '../content/verbs.js'
import { fmtDuration } from '../domain/messages.js'
import { stickyPromptFromViewport } from '../domain/viewport.js'
import { mix } from '../lib/color.js'
import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js'
import { fmtK } from '../lib/text.js'
import { useScrollbarSnapshot, useViewportSnapshot } from '../lib/viewportStore.js'
@ -753,8 +754,14 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps)
const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp
const travel = Math.max(1, vp - thumb)
const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0
const thumbColor = grab !== null ? t.color.primary : hover ? t.color.accent : t.color.border
const trackColor = hover ? t.color.border : t.color.muted
// Thumb rides in the theme's BASE color (primary), brightening to accent
// while hovered/dragged. The track recedes via an EXPLICIT blend toward the
// theme surface — never the SGR dim attribute: dim is terminal-interpreted,
// and on transparent profiles (terminal.background #00000000) xterm renders
// dim cells as half-bright glyphs on an opaque BLACK cell background. That
// was the "black bar / black thumb" on the right edge.
const thumbColor = grab !== null || hover ? t.color.accent : t.color.primary
const trackColor = mix(hover ? t.color.border : t.color.muted, t.color.completionBg, hover ? 0.25 : 0.55)
const jump = (row: number, offset: number) => {
if (!s || !scrollable) {
@ -786,24 +793,24 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps)
}}
width={1}
>
{!scrollable ? (
<Text color={trackColor} dim>
{' \n'.repeat(Math.max(0, vp - 1))}{' '}
</Text>
) : (
{!scrollable ? null : ( // Nothing to scroll → draw nothing. The outer
// width={1} Box still reserves the column so the transcript width
// doesn't jump when content grows scrollable. Previously this drew a
// full-height column of SPACES; on a transparent terminal
// (terminal.background #00000000) those drawn-blank cells composite to
// a black bar (the "big black area" by the scrollbar) — same class as
// the banner's removed opaque fill. A `│` track only appears when
// there's actually something to scroll, which is what reads as "makes
// sense."
<>
{thumbTop > 0 ? (
<Text color={trackColor} dim={!hover}>
{`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`}
</Text>
<Text color={trackColor}>{`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`}</Text>
) : null}
{thumb > 0 ? (
<Text color={thumbColor}>{`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`}</Text>
) : null}
{vp - thumbTop - thumb > 0 ? (
<Text color={trackColor} dim={!hover}>
{`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`}
</Text>
<Text color={trackColor}>{`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`}</Text>
) : null}
</>
)}

View file

@ -17,6 +17,7 @@ import {
inputVisualHeight,
stableComposerColumns
} from '../lib/inputMetrics.js'
import { mix } from '../lib/color.js'
import { PerfPane } from '../lib/perfPane.js'
import { composerPromptText } from '../lib/prompt.js'
@ -418,7 +419,11 @@ const ComposerPane = memo(function ComposerPane({
onPaste={composer.handleTextPaste}
onSubmit={composer.submit}
placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''}
placeholderColor={ui.theme.color.muted}
// Faint = an EXPLICIT color blended toward the theme surface,
// never SGR dim: dim is terminal-interpreted, and on
// transparent profiles it renders as a black slab. The
// surface tracks polarity, so the hint recedes on both.
placeholderColor={mix(ui.theme.color.muted, ui.theme.color.completionBg, 0.45)}
value={composer.input}
voiceRecordKey={composer.voiceRecordKey}
/>

View file

@ -30,8 +30,13 @@ function InlineLoader({ label, t }: { label: string; t: Theme }) {
}
export function ArtLines({ lines }: { lines: [string, string][] }) {
// No `opaque`: the banner is top-level content with nothing behind it, so
// it never needs the opaque space-fill (that's for absolute overlays). On a
// transparent terminal (terminal.background #00000000) the fill's "default
// background" spaces composite to black bars instead of the intended
// see-through — the reported ugly banner. Glyphs paint fine on their own.
return (
<Box flexDirection="column" height={lines.length} opaque width={artWidth(lines)}>
<Box flexDirection="column" height={lines.length} width={artWidth(lines)}>
{lines.map(([c, text], i) => (
<Text color={c} key={i} wrap="truncate-end">
{text}
@ -74,11 +79,18 @@ function CompactBanner({ cols, t }: { cols: number; t: Theme }) {
// -4 keeps a margin so exact-edge rows don't trip terminal pending-wrap.
const w = Math.max(28, cols - 4)
// No `opaque` (see ArtLines): the dashed rules are glyphs and the tagline's
// centering spaces carry the text's own fg style, so every cell paints with
// a real see-through background. The opaque fill was writing default-bg
// spaces that a transparent terminal renders as black bars.
// NOT bold: on Cursor's transparent-background terminal, a full-width run
// of BOLD box-drawing dashes renders with an opaque black cell background
// (the plain-dash rule right below renders clean — pixel-diffed live; the
// only stylistic delta was bold). Bold on short label runs is fine; bold on
// full-width box-drawing rows is what triggers the slab.
return (
<Box flexDirection="column" height={3} marginBottom={1} opaque width={w}>
<Text bold color={t.color.primary}>
{ruleIn(t.brand.name, w)}
</Text>
<Box flexDirection="column" height={3} marginBottom={1} width={w}>
<Text color={t.color.primary}>{ruleIn(t.brand.name, w)}</Text>
<Text color={t.color.muted}>{centerIn(TAG_FULL, w)}</Text>
<Text color={t.color.primary}>{'─'.repeat(w)}</Text>
</Box>

View file

@ -1,6 +1,7 @@
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
import { type RefObject, useState } from 'react'
import { mix } from '../lib/color.js'
import type { Theme } from '../theme.js'
/**
@ -39,8 +40,11 @@ export function OverlayScrollbar({
const vBar = (n: number) => (n > 0 ? `${'│\n'.repeat(n - 1)}` : '')
const thumbBody = `${'┃\n'.repeat(Math.max(0, thumb - 1))}`
const thumbColor = grab !== null ? t.color.primary : t.color.accent
const trackColor = hover ? t.color.border : t.color.muted
// Same scheme as TranscriptScrollbar: base-color thumb, accent on interact,
// track receded via explicit blend (SGR dim renders as a black slab on
// transparent terminals — see TranscriptScrollbar).
const thumbColor = grab !== null || hover ? t.color.accent : t.color.primary
const trackColor = mix(hover ? t.color.border : t.color.muted, t.color.completionBg, hover ? 0.25 : 0.55)
const jump = (row: number, offset: number) => {
if (!s || !scrollable) {
@ -68,24 +72,14 @@ export function OverlayScrollbar({
width={1}
>
{!scrollable ? (
<Text color={trackColor} dim>
{vBar(vp)}
</Text>
<Text color={trackColor}>{vBar(vp)}</Text>
) : (
<>
{thumbTop > 0 ? (
<Text color={trackColor} dim={!hover}>
{vBar(thumbTop)}
</Text>
) : null}
{thumbTop > 0 ? <Text color={trackColor}>{vBar(thumbTop)}</Text> : null}
<Text color={thumbColor}>{thumbBody}</Text>
{below > 0 ? (
<Text color={trackColor} dim={!hover}>
{vBar(below)}
</Text>
) : null}
{below > 0 ? <Text color={trackColor}>{vBar(below)}</Text> : null}
</>
)}
</Box>

View file

@ -31,8 +31,6 @@ const { Box, Text, useStdin, useInput, useStdout, stringWidth, useCursorAdvance,
const ESC = '\x1b'
const INV = `${ESC}[7m`
const INV_OFF = `${ESC}[27m`
const DIM = `${ESC}[2m`
const DIM_OFF = `${ESC}[22m`
const FWD_DEL_RE = new RegExp(`${ESC}\\[3(?:[~$^]|;)`)
const PRINTABLE = /^[ -~\u00a0-\uffff]+$/
const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g')
@ -41,23 +39,39 @@ const MULTI_CLICK_MS = 500
type MinimalEnv = Record<string, string | undefined>
const invert = (s: string) => INV + s + INV_OFF
const dim = (s: string) => DIM + s + DIM_OFF
/** Truecolor foreground wrap; falls back to SGR dim when no hex is given so
* the placeholder can follow the THEME's muted color instead of whatever the
* terminal's default-foreground dim happens to look like. */
/** Truecolor foreground wrap. Always emits an EXPLICIT color never SGR dim
* and never 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 elsewhere, rendering the
* hint as a black slab. A fixed mid-gray fallback is deterministic on every
* host when no theme color is provided. */
const HINT_FALLBACK = '#808080'
const colorizeHint = (s: string, hex?: string) => {
const m = hex ? /^#([0-9a-f]{6})$/i.exec(hex) : null
if (!m) {
return dim(s)
}
const m = /^#([0-9a-f]{6})$/i.exec(hex ?? '') ?? /^#([0-9a-f]{6})$/i.exec(HINT_FALLBACK)!
const n = parseInt(m[1]!, 16)
return `${ESC}[38;2;${(n >> 16) & 0xff};${(n >> 8) & 0xff};${n & 0xff}m${s}${ESC}[39m`
}
/** Synthetic placeholder cursor: an explicit-truecolor chip (hint-colored
* block, luminance-picked ink) instead of SGR inverse. Inverse swaps
* against the terminal's DEFAULT fg/bg, which on transparent profiles is a
* black RGB the user never sees the "cursor" rendered as a black slab. */
const hintCursorCell = (ch: string, hex?: string) => {
const m = /^#([0-9a-f]{6})$/i.exec(hex ?? '') ?? /^#([0-9a-f]{6})$/i.exec(HINT_FALLBACK)!
const n = parseInt(m[1]!, 16)
const r = (n >> 16) & 0xff,
g = (n >> 8) & 0xff,
b = n & 0xff
const ink = 0.2126 * r + 0.7152 * g + 0.0722 * b > 140 ? '0;0;0' : '255;255;255'
return `${ESC}[48;2;${r};${g};${b}m${ESC}[38;2;${ink}m${ch}${ESC}[39m${ESC}[49m`
}
let _seg: Intl.Segmenter | null = null
const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' }))
const STOP_CACHE_MAX = 32
@ -658,10 +672,7 @@ export function TextInput({
}
if (!display && placeholder) {
return (
colorizeHint(invert(placeholder[0] ?? ' '), placeholderColor) +
colorizeHint(placeholder.slice(1), placeholderColor)
)
return hintCursorCell(placeholder[0] ?? ' ', placeholderColor) + colorizeHint(placeholder.slice(1), placeholderColor)
}
if (selected) {