From a0a9aaf8b8c949957930b28d22ff54b55ececaeb Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 17:57:58 -0500 Subject: [PATCH] =?UTF-8?q?refactor(ui-tui):=20DRY=20the=20chrome=20primit?= =?UTF-8?q?ives=20=E2=80=94=20shared=20scrollbarColors=20+=20hintRgb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scrollbarColors(t, hover, grabbed) in overlayPrimitives is now THE scheme for both scrollbars (was duplicated formulas); textInput's hex parsing collapses into one hintRgb helper feeding colorizeHint and hintCursorCell. Comment bloat trimmed. No behavior change — 1243 tests byte-green. --- ui-tui/src/components/appChrome.tsx | 25 ++++---------- ui-tui/src/components/branding.tsx | 10 +++--- ui-tui/src/components/overlayPrimitives.tsx | 15 ++++++++- ui-tui/src/components/overlayScrollbar.tsx | 9 ++--- ui-tui/src/components/textInput.tsx | 37 +++++++++------------ 5 files changed, 44 insertions(+), 52 deletions(-) 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`