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

View File

@@ -1122,7 +1122,7 @@ export interface TuiConfig {
}
```
Source: [`packages/ui/tui/src/index.ts:100`](../packages/ui/tui/src/index.ts)
Source: [`packages/ui/tui/src/index.ts:99`](../packages/ui/tui/src/index.ts)
## `@deepseek-ai/dsh-user-approval`

View File

@@ -20,7 +20,7 @@ While the agent is running, editor submissions call `agent.steer()`; otherwise t
| `questionDialogWidth` | `72` | Question-overlay width in columns |
| `questionDialogMaxHeight` | `20` | Question-overlay maximum rows |
| `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker |
| `color` | `true` | Apply the built-in ANSI palette |
| `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) |
| `title` | `DeepSeek Harness` | Terminal window title |
```yaml
@@ -35,6 +35,10 @@ While the agent is running, editor submissions call `agent.steer()`; otherwise t
Startup fails before mounting when either process stream is not a TTY. While waiting for its configured agent, the front door also observes retained and live `agent/start-failed` notifications; a matching failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR.
## Color
The palette uses the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so it stays readable on light and dark backgrounds alike. Body text keeps the terminal's default foreground rather than a fixed shade. Grouped regions (user prompts, tool cards) use a colored left-gutter bar instead of a filled background block, and the question overlay's active row uses reverse video; both are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling.
## Model Experience
### Interactive prompt input

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