mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
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:
parent
28e05a3c85
commit
6929f5f71f
5 changed files with 79 additions and 50 deletions
|
|
@ -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}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue