feat(themes): TUI paints its own background from the skin (OSC 11)

The TUI inherited the terminal's background; now a skin's `background` paints the
whole surface via OSC 11 when a skin is applied, and clears back to the terminal
default (OSC 111) on revert and on exit (ridden in through resetTerminalModes).
Opt-in: a skin with no `background` leaves the terminal untouched, and the
restore only fires if we actually painted. Desktop already themed its own bg;
this closes the loop so Hermes owns its background on every surface.
This commit is contained in:
Brooklyn Nicholson 2026-07-21 19:37:10 -05:00
parent eb454919b2
commit 727f6704a7
4 changed files with 91 additions and 5 deletions

View file

@ -48,7 +48,7 @@ palette from these; the TUI and CLI read the terminal-oriented ones directly.
| Key | Drives |
|---|---|
| `background` | Base surface — GUI + TUI status bar seed. Set it. |
| `background` | Base surface. Paints the whole TUI (OSC 11) + seeds the GUI. Set it. |
| `ui_accent` / `banner_accent` | Brand accent: buttons, rings, primary. |
| `banner_title` | Headings / primary text. |
| `banner_text` / `ui_text` | Body foreground. |

View file

@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { resetTerminalModes, TERMINAL_MODE_RESET } from '../lib/terminalModes.js'
import { resetTerminalModes, setTerminalBackground, TERMINAL_MODE_RESET } from '../lib/terminalModes.js'
describe('terminal mode reset', () => {
it('includes common sticky input modes', () => {
@ -60,3 +60,44 @@ describe('terminal mode reset', () => {
}
})
})
describe('terminal background (OSC 11)', () => {
const tty = (write: ReturnType<typeof vi.fn>) => ({ isTTY: true, write }) as unknown as NodeJS.WriteStream
const written = (fn: (s: NodeJS.WriteStream) => void): string => {
const write = vi.fn()
fn(tty(write))
return (write.mock.calls[0]?.[0] as string) ?? ''
}
// Leave the module's "painted" flag clean so the exact-match reset test above
// (and other files) never see a stray background restore.
afterEach(() => setTerminalBackground('', tty(vi.fn())))
it('paints the terminal default background from a valid hex', () => {
expect(written(s => setTerminalBackground('#08201F', s))).toBe('\x1b]11;#08201F\x07')
})
it('ignores an invalid hex and non-TTY streams', () => {
expect(written(s => setTerminalBackground('teal', s))).toBe('')
const write = vi.fn()
setTerminalBackground('#08201f', { isTTY: false, write } as unknown as NodeJS.WriteStream)
expect(write).not.toHaveBeenCalled()
})
it('appends the background restore to the exit reset once painted, not before', () => {
expect(written(resetTerminalModes)).not.toContain('\x1b]111\x07')
setTerminalBackground('#101010', tty(vi.fn()))
expect(written(resetTerminalModes)).toContain('\x1b]111\x07')
})
it('clears back to the terminal default when the next skin has no background', () => {
setTerminalBackground('#123456', tty(vi.fn()))
expect(written(s => setTerminalBackground('', s))).toBe('\x1b]111\x07')
// Cleared: a later reset no longer restores.
expect(written(resetTerminalModes)).not.toContain('\x1b]111\x07')
})
})

View file

@ -18,6 +18,7 @@ import { isTodoDone } from '../lib/liveProgress.js'
import { openExternalUrl } from '../lib/openExternalUrl.js'
import { rpcErrorMessage } from '../lib/rpc.js'
import { topLevelSubagents } from '../lib/subagentTree.js'
import { setTerminalBackground } from '../lib/terminalModes.js'
import { formatAbandonedClarify, formatToolCall, stripAnsi } from '../lib/text.js'
import { bootSeededPin, invalidateBootBackground, writeBootTheme } from '../lib/themeBoot.js'
import { defaultThemeForCurrentBackground, detectLightMode, fromSkin, type Theme } from '../theme.js'
@ -119,6 +120,10 @@ const themesEqual = (a: Theme, b: Theme) => {
const applySkin = (s: GatewaySkin) => {
lastSkin = s
commitTheme(themeForSkin(s))
// Paint the whole terminal from the skin's `background` (empty ⇒ restore the
// terminal default), so Hermes owns its background instead of inheriting it.
// Opt-in: a skin with no `background` leaves the terminal untouched.
setTerminalBackground(s.colors?.background ?? '')
}
/** Re-derive the theme from current detection signals (env overrides, cached

View file

@ -25,16 +25,56 @@ type ResettableStream = Pick<NodeJS.WriteStream, 'isTTY' | 'write'> & {
fd?: number
}
// OSC 11 sets the terminal's DEFAULT background — so the whole TUI, not just
// rendered text, takes the skin color. OSC 111 restores the terminal's own
// default. We only reset when we actually painted, so a user who never uses a
// skin background keeps their terminal untouched.
const HEX_RE = /^#[0-9a-f]{6}$/i
const OSC_RESET_BACKGROUND = '\x1b]111\x07'
let _backgroundPainted = false
/**
* Paint the terminal's default background from a skin (`hex`), or clear it back
* to the terminal default when `hex` is empty/invalid (a skin with no
* `background`, e.g. reverting to `default`). Runtime writes go through the async
* stream so they order cleanly with Ink's frames; the exit-time restore rides
* `resetTerminalModes` (writeSync). No-op off a TTY.
*/
export function setTerminalBackground(hex: string, stream: ResettableStream = process.stdout): void {
if (!stream.isTTY) {
return
}
if (HEX_RE.test(hex)) {
try {
stream.write(`\x1b]11;${hex}\x07`)
_backgroundPainted = true
} catch {
// Terminal that can't take it just keeps its background.
}
} else if (_backgroundPainted) {
try {
stream.write(OSC_RESET_BACKGROUND)
_backgroundPainted = false
} catch {
// ignore
}
}
}
export function resetTerminalModes(stream: ResettableStream = process.stdout): boolean {
if (!stream.isTTY) {
return false
}
// Append the background restore only if we painted one, so a normal session
// never resets a terminal it didn't touch.
const reset = _backgroundPainted ? TERMINAL_MODE_RESET + OSC_RESET_BACKGROUND : TERMINAL_MODE_RESET
const fd = typeof stream.fd === 'number' ? stream.fd : stream === process.stdout ? 1 : undefined
if (fd !== undefined) {
try {
writeSync(fd, TERMINAL_MODE_RESET)
writeSync(fd, reset)
return true
} catch {
@ -43,7 +83,7 @@ export function resetTerminalModes(stream: ResettableStream = process.stdout): b
}
try {
stream.write(TERMINAL_MODE_RESET)
stream.write(reset)
return true
} catch {