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:
Turtle
2026-07-17 16:50:53 +08:00
parent 12b7ac67ba
commit 87e25ef1fe
3 changed files with 66 additions and 31 deletions
+60 -29
View File
@@ -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