From 6929f5f71f36dd12bec13ea6719de679d07b58a3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 17:37:00 -0500 Subject: [PATCH] fix(ui-tui): eradicate every transparent-terminal black-slab trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui-tui/src/components/appChrome.tsx | 33 ++++++++++------- ui-tui/src/components/appLayout.tsx | 7 +++- ui-tui/src/components/branding.tsx | 22 ++++++++--- ui-tui/src/components/overlayScrollbar.tsx | 24 +++++------- ui-tui/src/components/textInput.tsx | 43 ++++++++++++++-------- 5 files changed, 79 insertions(+), 50 deletions(-) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index cd2d4a9ba9de..2f6eb417fc3d 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -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 ? ( - - {' \n'.repeat(Math.max(0, vp - 1))}{' '} - - ) : ( + {!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 ? ( - - {`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`} - + {`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`} ) : null} {thumb > 0 ? ( {`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`} ) : null} {vp - thumbTop - thumb > 0 ? ( - - {`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`} - + {`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`} ) : null} )} diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index fa3e4952dc3f..14d41ac69839 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -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} /> diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 486e4d0b96a4..9f5d191cfd61 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -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 ( - + {lines.map(([c, text], i) => ( {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 ( - - - {ruleIn(t.brand.name, w)} - + + {ruleIn(t.brand.name, w)} {centerIn(TAG_FULL, w)} {'─'.repeat(w)} diff --git a/ui-tui/src/components/overlayScrollbar.tsx b/ui-tui/src/components/overlayScrollbar.tsx index 9b5ba54ffbc7..56e93f3f23e5 100644 --- a/ui-tui/src/components/overlayScrollbar.tsx +++ b/ui-tui/src/components/overlayScrollbar.tsx @@ -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 ? ( - - {vBar(vp)} - + {vBar(vp)} ) : ( <> - {thumbTop > 0 ? ( - - {vBar(thumbTop)} - - ) : null} + {thumbTop > 0 ? {vBar(thumbTop)} : null} {thumbBody} - {below > 0 ? ( - - {vBar(below)} - - ) : null} + {below > 0 ? {vBar(below)} : null} )} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 81f555db3033..4c7ea4f21d63 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -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 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) {