diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx
index 2f6eb417fc3d..556770394d38 100644
--- a/ui-tui/src/components/appChrome.tsx
+++ b/ui-tui/src/components/appChrome.tsx
@@ -11,13 +11,14 @@ 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'
import type { Theme } from '../theme.js'
import type { Msg, Usage } from '../types.js'
+import { scrollbarColors } from './overlayPrimitives.js'
+
const FACE_TICK_MS = 2500
const HEART_COLORS = ['#ff5fa2', '#ff4d6d']
@@ -754,14 +755,7 @@ 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
- // 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 { thumb: thumbColor, track: trackColor } = scrollbarColors(t, hover, grab !== null)
const jump = (row: number, offset: number) => {
if (!s || !scrollable) {
@@ -793,15 +787,10 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps)
}}
width={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."
+ {/* Nothing to scroll → draw nothing (the width={1} Box still reserves
+ the column). Drawn-blank cells composite to a black bar on
+ transparent terminals — same class as the removed opaque fills. */}
+ {!scrollable ? null : (
<>
{thumbTop > 0 ? (
{`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`}
diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx
index 87006a5e0c97..2bafc0c6ae5a 100644
--- a/ui-tui/src/components/branding.tsx
+++ b/ui-tui/src/components/branding.tsx
@@ -232,12 +232,10 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
const lineBudget = Math.max(12, w - 2)
const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s)
- // Hierarchy: category labels lead in the theme's label tone; member lists
- // recede in the midpoint of muted and text. Anchoring on MUTED (a
- // mid-luminance family tone) keeps the fade readable on both poles even
- // when polarity detection is wrong — blending toward the SURFACE instead
- // made the fade invisible whenever text was already pale (light-rendered
- // default: cream toward white = nothing).
+ // Hierarchy: labels lead in the label tone; member lists recede in the
+ // muted/text midpoint. Anchoring on MUTED (mid-luminance by construction)
+ // keeps the fade readable on both poles even when polarity detection is
+ // wrong — surface-relative blends go invisible when text is already pale.
const listFade = mix(t.color.muted, t.color.text, 0.5)
// ── Local collapse state for each section ──
diff --git a/ui-tui/src/components/overlayPrimitives.tsx b/ui-tui/src/components/overlayPrimitives.tsx
index a001011e5f78..1884841d5d25 100644
--- a/ui-tui/src/components/overlayPrimitives.tsx
+++ b/ui-tui/src/components/overlayPrimitives.tsx
@@ -3,9 +3,22 @@ import { Text, useInput } from '@hermes/ink'
import { type ReactNode, useState } from 'react'
import type { UsageModelData } from '../gatewayTypes.js'
-import { liftForContrast } from '../lib/color.js'
+import { liftForContrast, mix } from '../lib/color.js'
import type { Theme } from '../theme.js'
+/**
+ * THE scrollbar treatment (transcript + overlays): thumb rides the theme
+ * base, accent while interacting; track recedes via an explicit blend toward
+ * the surface. Never SGR dim — terminal-interpreted, it renders as a black
+ * slab on transparent profiles (terminal.background #00000000).
+ */
+export function scrollbarColors(t: Theme, hover: boolean, grabbed: boolean): { thumb: string; track: string } {
+ return {
+ thumb: grabbed || hover ? t.color.accent : t.color.primary,
+ track: mix(hover ? t.color.border : t.color.muted, t.color.completionBg, hover ? 0.25 : 0.55)
+ }
+}
+
export interface MenuRowSpec {
color?: string
label: string
diff --git a/ui-tui/src/components/overlayScrollbar.tsx b/ui-tui/src/components/overlayScrollbar.tsx
index 56e93f3f23e5..d12b147a8954 100644
--- a/ui-tui/src/components/overlayScrollbar.tsx
+++ b/ui-tui/src/components/overlayScrollbar.tsx
@@ -1,9 +1,10 @@
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'
+import { scrollbarColors } from './overlayPrimitives.js'
+
/**
* Mouse-draggable scrollbar bound to a `ScrollBox` ref. Re-renders off the
* parent `tick` so accordions / async content can resize the thumb without a
@@ -40,11 +41,7 @@ export function OverlayScrollbar({
const vBar = (n: number) => (n > 0 ? `${'│\n'.repeat(n - 1)}│` : '')
const thumbBody = `${'┃\n'.repeat(Math.max(0, thumb - 1))}┃`
- // 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 { thumb: thumbColor, track: trackColor } = scrollbarColors(t, hover, grab !== null)
const jump = (row: number, offset: number) => {
if (!s || !scrollable) {
diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx
index 4c7ea4f21d63..c049ce078ff2 100644
--- a/ui-tui/src/components/textInput.tsx
+++ b/ui-tui/src/components/textInput.tsx
@@ -40,33 +40,28 @@ type MinimalEnv = Record
const invert = (s: string) => INV + s + INV_OFF
-/** 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. */
+// Placeholder styling is EXPLICIT truecolor only — never SGR dim/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 — the hint rendered as a slab.
const HINT_FALLBACK = '#808080'
-const colorizeHint = (s: 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 hintRgb = (hex?: string): [number, number, number] => {
+ const n = parseInt((/^#([0-9a-f]{6})$/i.exec(hex ?? '')?.[1] ?? HINT_FALLBACK.slice(1)) as string, 16)
- return `${ESC}[38;2;${(n >> 16) & 0xff};${(n >> 8) & 0xff};${n & 0xff}m${s}${ESC}[39m`
+ return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]
}
-/** 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 colorizeHint = (s: string, hex?: string) => {
+ const [r, g, b] = hintRgb(hex)
+
+ return `${ESC}[38;2;${r};${g};${b}m${s}${ESC}[39m`
+}
+
+/** Synthetic placeholder cursor: a hint-colored chip with luminance-picked
+ * ink, standing in for the hidden hardware cursor (bubbles pattern). */
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 [r, g, b] = hintRgb(hex)
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`