mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
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:
parent
eb454919b2
commit
727f6704a7
4 changed files with 91 additions and 5 deletions
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue