From 727f6704a7da7f2e7b22f463d67c7dbf52334952 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Jul 2026 19:37:10 -0500 Subject: [PATCH] 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. --- skills/hermes-themes/SKILL.md | 2 +- ui-tui/src/__tests__/terminalModes.test.ts | 45 ++++++++++++++++++++- ui-tui/src/app/createGatewayEventHandler.ts | 5 +++ ui-tui/src/lib/terminalModes.ts | 44 +++++++++++++++++++- 4 files changed, 91 insertions(+), 5 deletions(-) diff --git a/skills/hermes-themes/SKILL.md b/skills/hermes-themes/SKILL.md index e64185a542ca..83ad4f0e70c7 100644 --- a/skills/hermes-themes/SKILL.md +++ b/skills/hermes-themes/SKILL.md @@ -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. | diff --git a/ui-tui/src/__tests__/terminalModes.test.ts b/ui-tui/src/__tests__/terminalModes.test.ts index d621f4eef443..1ff7aac60966 100644 --- a/ui-tui/src/__tests__/terminalModes.test.ts +++ b/ui-tui/src/__tests__/terminalModes.test.ts @@ -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) => ({ 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') + }) +}) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index a4c86b601190..7ff42d53d265 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -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 diff --git a/ui-tui/src/lib/terminalModes.ts b/ui-tui/src/lib/terminalModes.ts index 46712a1d902b..32a436ac84fe 100644 --- a/ui-tui/src/lib/terminalModes.ts +++ b/ui-tui/src/lib/terminalModes.ts @@ -25,16 +25,56 @@ type ResettableStream = Pick & { 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 {