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