fix(tui): make the color palette theme-agnostic
The palette used absolute 256-color indices (`38;5;252`, `48;5;236`, …) that terminals never remap to the active scheme: near-white body text vanished on light backgrounds and fixed dark bubble fills put default foreground text on dark blocks. Rebuild it from the standard 16-color ANSI foregrounds plus SGR attributes, keep body text on the terminal default foreground, replace filled background bubbles with foreground-only left-gutter bars, and use reverse video for the active selection so every grouping reads on any color scheme.
This commit is contained in:
@@ -8,7 +8,6 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { relative, resolve, sep } from 'node:path'
|
||||
import {
|
||||
Box,
|
||||
CombinedAutocompleteProvider,
|
||||
Container,
|
||||
Editor,
|
||||
@@ -172,39 +171,39 @@ interface Palette {
|
||||
italic: (text: string) => string
|
||||
underline: (text: string) => string
|
||||
strike: (text: string) => string
|
||||
userBg: (text: string) => string
|
||||
pendingBg: (text: string) => string
|
||||
successBg: (text: string) => string
|
||||
errorBg: (text: string) => string
|
||||
selectedBg: (text: string) => string
|
||||
/** Reverse video for the active selection; swaps the theme's own fg/bg so it reads on any scheme. */
|
||||
selected: (text: string) => string
|
||||
}
|
||||
|
||||
function ansi(open: string, close: string, enabled: boolean): (text: string) => string {
|
||||
return enabled ? text => `\x1b[${open}m${text}\x1b[${close}m` : text => text
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-agnostic palette built from the standard 16-color ANSI set plus SGR
|
||||
* attributes, which every terminal remaps to its active color scheme. Body
|
||||
* `text` stays the terminal's default foreground so it reads on light and dark
|
||||
* backgrounds alike; grouping uses foreground-only gutter bars and reverse
|
||||
* video rather than fixed background fills.
|
||||
*/
|
||||
function createPalette(enabled: boolean): Palette {
|
||||
return {
|
||||
accent: ansi('38;5;39', '39', enabled),
|
||||
accent2: ansi('38;5;141', '39', enabled),
|
||||
text: ansi('38;5;252', '39', enabled),
|
||||
muted: ansi('38;5;245', '39', enabled),
|
||||
dim: ansi('38;5;240', '39', enabled),
|
||||
success: ansi('38;5;78', '39', enabled),
|
||||
warning: ansi('38;5;221', '39', enabled),
|
||||
error: ansi('38;5;203', '39', enabled),
|
||||
code: ansi('38;5;215', '39', enabled),
|
||||
added: ansi('38;5;78', '39', enabled),
|
||||
removed: ansi('38;5;203', '39', enabled),
|
||||
accent: ansi('94', '39', enabled),
|
||||
accent2: ansi('95', '39', enabled),
|
||||
text: text => text,
|
||||
muted: ansi('90', '39', enabled),
|
||||
dim: ansi('2', '22', enabled),
|
||||
success: ansi('32', '39', enabled),
|
||||
warning: ansi('33', '39', enabled),
|
||||
error: ansi('31', '39', enabled),
|
||||
code: ansi('36', '39', enabled),
|
||||
added: ansi('32', '39', enabled),
|
||||
removed: ansi('31', '39', enabled),
|
||||
bold: ansi('1', '22', enabled),
|
||||
italic: ansi('3', '23', enabled),
|
||||
underline: ansi('4', '24', enabled),
|
||||
strike: ansi('9', '29', enabled),
|
||||
userBg: ansi('48;5;236', '49', enabled),
|
||||
pendingBg: ansi('48;5;235', '49', enabled),
|
||||
successBg: ansi('48;5;22', '49', enabled),
|
||||
errorBg: ansi('48;5;52', '49', enabled),
|
||||
selectedBg: ansi('48;5;24', '49', enabled),
|
||||
selected: ansi('7', '27', enabled),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,9 +295,41 @@ class HeaderComponent implements Component {
|
||||
}
|
||||
}
|
||||
|
||||
class UserMessageComponent extends Box {
|
||||
/**
|
||||
* Groups children behind a colored left-gutter bar (`▌`). Foreground-only, so
|
||||
* it renders legibly on any terminal background — unlike a filled block whose
|
||||
* body text would collide with the theme's default foreground.
|
||||
*/
|
||||
class GutterBox implements Component {
|
||||
protected readonly children: Component[] = []
|
||||
|
||||
constructor(private readonly barFn: (text: string) => string, private readonly paddingY = 1) {}
|
||||
|
||||
addChild(child: Component): void {
|
||||
this.children.push(child)
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
for (const child of this.children) child.invalidate()
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const inner = Math.max(1, width - 2)
|
||||
const body: string[] = []
|
||||
for (const child of this.children) for (const line of child.render(inner)) body.push(line)
|
||||
// Every caller adds a non-empty title/label child, so an all-empty box is unreachable;
|
||||
// the guard preserves Box semantics (render nothing) rather than emitting stray gutter bars.
|
||||
/* v8 ignore next */
|
||||
if (body.length === 0) return []
|
||||
const bar = this.barFn('▌')
|
||||
const pad = Array.from({ length: this.paddingY }, () => '')
|
||||
return [...pad, ...body, ...pad].map(line => `${bar} ${line}`)
|
||||
}
|
||||
}
|
||||
|
||||
class UserMessageComponent extends GutterBox {
|
||||
constructor(text: string, palette: Palette, mdTheme: MarkdownTheme, label = 'You') {
|
||||
super(1, 1, value => palette.userBg(value))
|
||||
super(value => palette.accent(value))
|
||||
this.addChild(new Text(palette.bold(palette.accent(label)), 0, 0))
|
||||
this.addChild(new Markdown(text, 0, 0, mdTheme, { color: value => palette.text(value) }, {
|
||||
preserveOrderedListMarkers: true,
|
||||
@@ -465,10 +496,10 @@ class ToolCardComponent implements Component {
|
||||
const visibleBody = this.expanded || body.length <= this.maxOutputLines
|
||||
? body
|
||||
: [...body.slice(0, this.maxOutputLines), this.palette.dim(`… ${body.length - this.maxOutputLines} more lines (Ctrl+O to expand)`)]
|
||||
const box = new Box(1, visibleBody.length > 0 ? 1 : 0, (value) => {
|
||||
if (this.result === undefined) return this.palette.pendingBg(value)
|
||||
return isError ? this.palette.errorBg(value) : this.palette.successBg(value)
|
||||
})
|
||||
const barFn = this.result === undefined
|
||||
? this.palette.warning
|
||||
: isError ? this.palette.error : this.palette.success
|
||||
const box = new GutterBox(barFn, visibleBody.length > 0 ? 1 : 0)
|
||||
box.addChild(new Text(this.palette.bold(title), 0, 0))
|
||||
if (visibleBody.length > 0) box.addChild(new Text(visibleBody.join('\n'), 0, 0))
|
||||
return box.render(width)
|
||||
@@ -700,7 +731,7 @@ class QuestionDialog implements Component, Focusable {
|
||||
: index === this.selectedIndex ? this.palette.accent('●') : this.palette.dim('○')
|
||||
const description = option.description ? this.palette.muted(` — ${option.description}`) : ''
|
||||
const line = `${cursor} ${mark} ${option.label}${description}`
|
||||
push(index === this.selectedIndex ? this.palette.selectedBg(line) : line)
|
||||
push(index === this.selectedIndex ? this.palette.selected(line) : line)
|
||||
}
|
||||
if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`))
|
||||
push(this.palette.dim(this.question.multiSelect
|
||||
|
||||
Reference in New Issue
Block a user