From 8c2755006a610b213237f7563658738b7b92b3e5 Mon Sep 17 00:00:00 2001 From: sting8k Date: Sun, 31 May 2026 00:54:05 +0700 Subject: [PATCH] fix(web): preserve dynamic terminal background --- apps/jump-web/src/terminal-colors.test.ts | 27 ++++++ apps/jump-web/src/terminal-colors.ts | 44 +++++++++ apps/jump-web/src/terminal.tsx | 27 +++++- cli/jump/internal/ptyserver/ptyserver.go | 13 ++- cli/jump/internal/ptyserver/ptyserver_test.go | 55 +++++++++++ .../internal/ptyserver/terminal_colors.go | 97 +++++++++++++++++++ .../ptyserver/terminal_colors_test.go | 45 +++++++++ docs/TEST_MATRIX.md | 1 + .../web-terminal-dynamic-background.md | 59 +++++++++++ 9 files changed, 361 insertions(+), 7 deletions(-) create mode 100644 apps/jump-web/src/terminal-colors.test.ts create mode 100644 apps/jump-web/src/terminal-colors.ts create mode 100644 cli/jump/internal/ptyserver/terminal_colors.go create mode 100644 cli/jump/internal/ptyserver/terminal_colors_test.go create mode 100644 docs/stories/web-terminal-dynamic-background.md diff --git a/apps/jump-web/src/terminal-colors.test.ts b/apps/jump-web/src/terminal-colors.test.ts new file mode 100644 index 0000000..222e444 --- /dev/null +++ b/apps/jump-web/src/terminal-colors.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { buildTerminalOptions } from './settings-schema' +import { resolvedTerminalBackground, terminalBackgroundFromOsc } from './terminal-colors' + +describe('terminal dynamic colors', () => { + it('parses OSC 11 rgb background colors', () => { + expect(terminalBackgroundFromOsc('rgb:11/22/33')).toBe('#112233') + expect(terminalBackgroundFromOsc('rgb:1111/2222/3333')).toBe('#112233') + expect(terminalBackgroundFromOsc('rgb:0/f/8')).toBe('#00ff88') + }) + + it('parses hex background colors', () => { + expect(terminalBackgroundFromOsc('#ABCDEF')).toBe('#abcdef') + expect(terminalBackgroundFromOsc('#ace')).toBe('#aaccee') + }) + + it('ignores OSC reports and invalid colors', () => { + expect(terminalBackgroundFromOsc('?')).toBeNull() + expect(terminalBackgroundFromOsc('')).toBeNull() + expect(terminalBackgroundFromOsc('rgb:xx/22/33')).toBeNull() + }) + + it('uses the configured terminal theme background as the fallback', () => { + const options = buildTerminalOptions(null, { background: '#123456' }) + expect(resolvedTerminalBackground(options)).toBe('#123456') + }) +}) diff --git a/apps/jump-web/src/terminal-colors.ts b/apps/jump-web/src/terminal-colors.ts new file mode 100644 index 0000000..fec502f --- /dev/null +++ b/apps/jump-web/src/terminal-colors.ts @@ -0,0 +1,44 @@ +import { DEFAULT_THEME_COLORS } from './config' +import type { ResolvedTerminalOptions } from './settings-schema' + +function normalizeHexColor(value: string): string | null { + const text = value.trim() + if (/^#[0-9a-f]{6}$/i.test(text)) return text.toLowerCase() + if (/^#[0-9a-f]{3}$/i.test(text)) { + const [, r, g, b] = text.toLowerCase() + return `#${r}${r}${g}${g}${b}${b}` + } + return null +} + +function oscHexComponentToByte(component: string): string | null { + if (!/^[0-9a-f]{1,4}$/i.test(component)) return null + const value = Number.parseInt(component, 16) + const max = Math.pow(16, component.length) - 1 + const byte = Math.round((value / max) * 255) + return byte.toString(16).padStart(2, '0') +} + +export function terminalBackgroundFromOsc(data: string): string | null { + const text = data.trim() + if (!text || text === '?') return null + + const hex = normalizeHexColor(text) + if (hex) return hex + + const match = /^rgb:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})$/i.exec(text) + if (!match) return null + + const r = oscHexComponentToByte(match[1]) + const g = oscHexComponentToByte(match[2]) + const b = oscHexComponentToByte(match[3]) + if (!r || !g || !b) return null + return `#${r}${g}${b}` +} + +export function resolvedTerminalBackground(options: ResolvedTerminalOptions): string { + const configured = options.theme.background + return typeof configured === 'string' && configured.trim() + ? configured.trim() + : DEFAULT_THEME_COLORS.background ?? '#000000' +} diff --git a/apps/jump-web/src/terminal.tsx b/apps/jump-web/src/terminal.tsx index 91783db..cca2e2a 100644 --- a/apps/jump-web/src/terminal.tsx +++ b/apps/jump-web/src/terminal.tsx @@ -10,6 +10,7 @@ import { DEFAULT_THEME_COLORS, type ResolvedKeybind } from './config' import { attachMobileInputHandler } from './mobile-input' import { createReplayBuffer } from './replay' import { createTerminalIO, type TerminalIOPerfEvent, type TerminalSize } from './terminal-io' +import { resolvedTerminalBackground, terminalBackgroundFromOsc } from './terminal-colors' import { addPageResumeListener } from './page-resume' import { decideViewportResize, sameSize } from './terminal-resize' import { MOCK_BY_ID } from './mock-data/index' @@ -273,6 +274,7 @@ export function TerminalView({ const compositionSuppressTimerRef = useRef | null>(null) const mobileCopyModeRef = useRef(mobileCopyMode) const mobileCopyAnchorRef = useRef(null) + const terminalBackgroundFallbackRef = useRef(resolvedTerminalBackground(terminalOptions)) // True once the terminal's font is downloaded; gates xterm mount. // See the preload effect below for why this matters. @@ -308,6 +310,7 @@ export function TerminalView({ ctrlArmedRef.current = ctrlArmed altArmedRef.current = altArmed mobileCopyModeRef.current = mobileCopyMode + terminalBackgroundFallbackRef.current = resolvedTerminalBackground(terminalOptions) const queueResize = useCallback((size: TerminalSize) => { termIoRef.current?.requestResize(size, termEpochRef.current) @@ -321,6 +324,12 @@ export function TerminalView({ termIoRef.current?.enqueueMany(chunks, termEpochRef.current, onWritten) }, []) + const applyTerminalBackground = useCallback((background?: string | null) => { + const shell = shellRef.current + if (!shell) return + shell.style.setProperty('--terminal-bg', background?.trim() || terminalBackgroundFallbackRef.current) + }, []) + const resetResizeEchoGate = useCallback(() => { const gate = resizeEchoGateRef.current if (gate.timer !== null) clearTimeout(gate.timer) @@ -581,6 +590,8 @@ export function TerminalView({ if (!containerRef.current || USE_MOCK || !fontReady) return disposed.current = false + applyTerminalBackground() + // Add non-serializable options that can't live in JSON config. const term = new Terminal({ ...terminalOptions, @@ -599,6 +610,15 @@ export function TerminalView({ // XTGETTCAP can be treated as image data and leave xterm's write callback // stuck, keeping the Web UI on Vim's alternate screen until refresh. const xtGetTcapDisposable = term.parser.registerDcsHandler({ intermediates: '+', final: 'q' }, () => true) + const oscBackgroundDisposable = term.parser.registerOscHandler(11, (data) => { + const background = terminalBackgroundFromOsc(data) + if (background) applyTerminalBackground(background) + return false + }) + const oscBackgroundRestoreDisposable = term.parser.registerOscHandler(111, () => { + applyTerminalBackground() + return false + }) // Detect plain-text URLs in terminal output and make them clickable. term.loadAddon(new WebLinksAddon()) term.open(containerRef.current) @@ -1022,6 +1042,8 @@ export function TerminalView({ disposePasteHandler() disposeMobileHandler() xtGetTcapDisposable.dispose() + oscBackgroundDisposable.dispose() + oscBackgroundRestoreDisposable.dispose() osc52Disposable.dispose() dataDisposable.dispose() scrollDisposable.dispose() @@ -1052,7 +1074,7 @@ export function TerminalView({ termRef.current = null termIoRef.current = null } - }, [onCtrlConsumed, onInputReady, fontReady]) + }, [applyTerminalBackground, onCtrlConsumed, onInputReady, fontReady]) // WebSocket connection (reconnects when session.id changes). useEffect(() => { @@ -1077,6 +1099,7 @@ export function TerminalView({ setWsState('connecting') setTermLoading(true) + applyTerminalBackground() function forceReconnect() { if (disposed.current) return @@ -1229,7 +1252,7 @@ export function TerminalView({ wsRef.current?.close() wsRef.current = null } - }, [fitAndResize, queueData, queueMany, queueResize, releaseResizeEchoGate, resetResizeEchoGate, session.id, fontReady]) + }, [applyTerminalBackground, fitAndResize, queueData, queueMany, queueResize, releaseResizeEchoGate, resetResizeEchoGate, session.id, fontReady]) useEffect(() => { diff --git a/cli/jump/internal/ptyserver/ptyserver.go b/cli/jump/internal/ptyserver/ptyserver.go index 3dc73d8..77715d8 100644 --- a/cli/jump/internal/ptyserver/ptyserver.go +++ b/cli/jump/internal/ptyserver/ptyserver.go @@ -228,9 +228,10 @@ type Server struct { ptyRows uint16 // last applied PTY rows (guarded by mu) cursorHidden bool // tracks DECTCEM via callback (guarded by mu) activeModes map[ansi.Mode]bool // app-enabled terminal modes replayed on reconnect (guarded by mu) - screenPending []byte // raw PTY data not yet fed to screen (guarded by mu) - lastClientLeft time.Time // when the last WS client disconnected (guarded by mu) - suppressActivityUntil time.Time // ignores redraws caused by reconnect resize/shrink (guarded by mu) + terminalColors terminalColorTracker + screenPending []byte // raw PTY data not yet fed to screen (guarded by mu) + lastClientLeft time.Time // when the last WS client disconnected (guarded by mu) + suppressActivityUntil time.Time // ignores redraws caused by reconnect resize/shrink (guarded by mu) done chan struct{} // closed when child exits ptyDone chan struct{} // closed when readPTY finishes draining @@ -867,11 +868,12 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { // the scrollback history followed by the visible screen as ANSI // sequences with style diffing. // - // Sequence: BSU → input modes → reset → scrollback + screen → cursor → ESU + // Sequence: BSU → input modes → reset → terminal colors → scrollback + screen → cursor → ESU s.mu.Lock() s.drainScreenLocked() renderStart := time.Now() modeSeq := s.terminalModeReplayLocked() + colorSeq := s.terminalColors.backgroundReplaySeq() snapshot := renderScreen(s.screen) cursorSeq := "\x1b[?25h" // show cursor (default) if s.cursorHidden { @@ -883,7 +885,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { bsu := "\x1b[?2026h" // Begin Synchronized Update resetSeq := "\x1b[r\x1b[H\x1b[2J\x1b[3J" // Reset scroll region + cursor home + erase display + erase scrollback esu := "\x1b[?2026l" // End Synchronized Update - frame := []byte(bsu + modeSeq + resetSeq + snapshot + cursorPos + cursorSeq + esu) + frame := []byte(bsu + modeSeq + resetSeq + colorSeq + snapshot + cursorPos + cursorSeq + esu) s.perf.observeSnapshotRender(len(frame), time.Since(renderStart)) s.clients[client] = struct{}{} s.lastClientLeft = time.Time{} // reset: we have an active viewer @@ -1140,6 +1142,7 @@ func (s *Server) readPTY() { // processScreen in the background). Snapshot the client list // atomically so new clients always see their replay frame first. s.mu.Lock() + s.terminalColors.write(data) s.screenPending = append(s.screenPending, data...) localOut := s.localOut clients := make([]*wsClient, 0, len(s.clients)) diff --git a/cli/jump/internal/ptyserver/ptyserver_test.go b/cli/jump/internal/ptyserver/ptyserver_test.go index 5a3cabb..6188101 100644 --- a/cli/jump/internal/ptyserver/ptyserver_test.go +++ b/cli/jump/internal/ptyserver/ptyserver_test.go @@ -207,6 +207,61 @@ func TestDebugPerfEndpointReportsTerminalStats(t *testing.T) { } } +func TestPTYServerReconnectSnapshotReplaysTerminalBackground(t *testing.T) { + sockPath := filepath.Join(t.TempDir(), "test.sock") + + srv, err := New(Config{ + Command: []string{"bash", "-c", "printf '\\033]11;rgb:12/34/56\\007READY\\n'; sleep 1"}, + Cwd: "/tmp", + Listener: mustBindSocket(t, sockPath), + SocketPath: sockPath, + }) + if err != nil { + t.Fatalf("new server: %v", err) + } + defer srv.Shutdown() + + deadline := time.After(3 * time.Second) + for { + srv.mu.Lock() + replaySeq := srv.terminalColors.backgroundReplaySeq() + srv.mu.Unlock() + if replaySeq == "\x1b]11;rgb:12/34/56\x07" { + break + } + select { + case <-deadline: + t.Fatalf("timeout waiting for terminal background replay seq, got %q", replaySeq) + case <-time.After(20 * time.Millisecond): + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + conn, _, err := websocket.Dial(ctx, "ws://localhost/", &websocket.DialOptions{ + HTTPClient: &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", sockPath) + }, + }, + }, + }) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close(websocket.StatusNormalClosure, "") + + _, frame, err := conn.Read(ctx) + if err != nil { + t.Fatalf("read snapshot: %v", err) + } + if !bytes.Contains(frame, []byte("\x1b]11;rgb:12/34/56\x07")) { + t.Fatalf("snapshot did not replay terminal background: %q", string(frame)) + } +} + func TestPTYServerResize(t *testing.T) { sockPath := filepath.Join(t.TempDir(), "test.sock") diff --git a/cli/jump/internal/ptyserver/terminal_colors.go b/cli/jump/internal/ptyserver/terminal_colors.go new file mode 100644 index 0000000..51e3138 --- /dev/null +++ b/cli/jump/internal/ptyserver/terminal_colors.go @@ -0,0 +1,97 @@ +package ptyserver + +import "bytes" + +const ( + oscBEL = byte(0x07) + esc = byte(0x1b) +) + +type terminalColorTracker struct { + pending []byte + bgSeq string +} + +func (t *terminalColorTracker) write(data []byte) { + if len(data) == 0 { + return + } + + combined := data + if len(t.pending) > 0 { + combined = make([]byte, 0, len(t.pending)+len(data)) + combined = append(combined, t.pending...) + combined = append(combined, data...) + t.pending = nil + } + + for i := 0; i < len(combined); { + payloadStart, ok := oscPayloadStart(combined, i) + if !ok { + i++ + continue + } + + payloadEnd, seqEnd, found := oscTerminator(combined, payloadStart) + if !found { + t.pending = append(t.pending[:0], combined[i:]...) + return + } + + t.applyOSC(combined[payloadStart:payloadEnd]) + i = seqEnd + } + + if len(combined) > 0 && combined[len(combined)-1] == esc { + t.pending = append(t.pending[:0], esc) + } +} + +func (t *terminalColorTracker) backgroundReplaySeq() string { + if t.bgSeq != "" { + return t.bgSeq + } + return "\x1b]111\x07" +} + +func (t *terminalColorTracker) applyOSC(payload []byte) { + sep := bytes.IndexByte(payload, ';') + id := payload + value := []byte(nil) + if sep >= 0 { + id = payload[:sep] + value = payload[sep+1:] + } + + switch string(id) { + case "11": + if sep < 0 || len(value) == 0 || bytes.Equal(value, []byte("?")) { + return + } + t.bgSeq = "\x1b]11;" + string(value) + "\x07" + case "111": + t.bgSeq = "" + } +} + +func oscPayloadStart(data []byte, i int) (int, bool) { + if data[i] == 0x9d { + return i + 1, true + } + if data[i] == esc && i+1 < len(data) && data[i+1] == ']' { + return i + 2, true + } + return 0, false +} + +func oscTerminator(data []byte, start int) (payloadEnd int, seqEnd int, ok bool) { + for i := start; i < len(data); i++ { + if data[i] == oscBEL { + return i, i + 1, true + } + if data[i] == esc && i+1 < len(data) && data[i+1] == '\\' { + return i, i + 2, true + } + } + return 0, 0, false +} diff --git a/cli/jump/internal/ptyserver/terminal_colors_test.go b/cli/jump/internal/ptyserver/terminal_colors_test.go new file mode 100644 index 0000000..140a973 --- /dev/null +++ b/cli/jump/internal/ptyserver/terminal_colors_test.go @@ -0,0 +1,45 @@ +package ptyserver + +import "testing" + +func TestTerminalColorTrackerBackgroundReplay(t *testing.T) { + var tracker terminalColorTracker + + if got := tracker.backgroundReplaySeq(); got != "\x1b]111\x07" { + t.Fatalf("empty replay seq = %q, want OSC 111 restore", got) + } + + tracker.write([]byte("before\x1b]11;rgb:12/34/56\x07after")) + if got := tracker.backgroundReplaySeq(); got != "\x1b]11;rgb:12/34/56\x07" { + t.Fatalf("set replay seq = %q", got) + } + + tracker.write([]byte("\x1b]111\x07")) + if got := tracker.backgroundReplaySeq(); got != "\x1b]111\x07" { + t.Fatalf("restore replay seq = %q, want OSC 111 restore", got) + } +} + +func TestTerminalColorTrackerHandlesSplitOSC(t *testing.T) { + var tracker terminalColorTracker + + tracker.write([]byte("\x1b]11;rgb:12")) + if got := tracker.backgroundReplaySeq(); got != "\x1b]111\x07" { + t.Fatalf("partial OSC changed replay seq: %q", got) + } + + tracker.write([]byte("/34/56\x07")) + if got := tracker.backgroundReplaySeq(); got != "\x1b]11;rgb:12/34/56\x07" { + t.Fatalf("split set replay seq = %q", got) + } +} + +func TestTerminalColorTrackerIgnoresReportsAndKeepsPreviousColor(t *testing.T) { + var tracker terminalColorTracker + tracker.write([]byte("\x1b]11;#123456\x07")) + tracker.write([]byte("\x1b]11;?\x07")) + + if got := tracker.backgroundReplaySeq(); got != "\x1b]11;#123456\x07" { + t.Fatalf("report changed replay seq: %q", got) + } +} diff --git a/docs/TEST_MATRIX.md b/docs/TEST_MATRIX.md index 798dee1..203cdb3 100644 --- a/docs/TEST_MATRIX.md +++ b/docs/TEST_MATRIX.md @@ -34,6 +34,7 @@ implemented until tests or validation evidence exist. | `docs/stories/web-release-update-badge.md` | Web UI `...` menu shows an informational GitHub latest-release update row when `jumpd` health reports `update_available`, with no auto-update or session mutation | yes | yes | no | yes | implemented | `pnpm --filter @jump/web test -- release-updates.test.ts store.test.ts --runInBand`; `pnpm --filter @jump/web lint`; `pnpm --filter @jump/web build`; `TMPDIR=/tmp GOWORK=$PWD/go.work go test ./services/jumpd/internal/update`; `git diff --check` | | `docs/stories/product-rename-jump/` | Product identity is hard-renamed from `gmux` to `jump` across binaries, paths, modules, docs, and runtime defaults with no old-path fallback | yes | yes | no | yes | implemented | `TMPDIR=/tmp GOWORK=$PWD/go.work go test ./packages/paths/... ./packages/workspace/... ./packages/relayproto/... ./packages/scrollback/... ./packages/adapter/... ./cli/jump/... ./services/jump-relayd/... ./services/jumpd/...`; `pnpm --filter @jump/web lint`; `pnpm --filter @jump/web test`; `pnpm --filter @jump/web build`; `TMPDIR=/tmp GOWORK=$PWD/go.work go build -o /tmp/jump-verify/jump ./cli/jump/cmd/jump`; `TMPDIR=/tmp GOWORK=$PWD/go.work go build -o /tmp/jump-verify/jumpd ./services/jumpd/cmd/jumpd`; `TMPDIR=/tmp GOWORK=$PWD/go.work go build -o /tmp/jump-verify/jump-relayd ./services/jump-relayd/cmd/jump-relayd`; `pnpm --filter @jump/protocol build`; `pnpm --filter @jump/protocol test`; `pnpm --filter @jump/protocol lint`; `source "$HOME/.nvm/nvm.sh" && nvm use 23 && pnpm --filter @jump/website build`; workflow script tests; `git diff --check`; old-name/public URL text checks | | `docs/stories/webui-theme-preferences.md` | Web UI theme switching ships `default`, SpacetimeDB-inspired `spacetime`, Command Center `vercel`, Signal HUD `hud`, Slate Noir `slate-noir`, and Zerobyte-inspired `zerobyte` chrome themes, applies cached client-side appearance before app mount, and persists `appearance.theme_id` through jumpd-managed server state | yes | yes | no | no | implemented | `TMPDIR=/tmp GOWORK=$PWD/go.work go test ./services/jumpd/internal/webprefs ./services/jumpd/cmd/jumpd`; `corepack pnpm --filter @jump/web test -- appearance.test.ts store.test.ts --runInBand`; `corepack pnpm --filter @jump/web lint`; `corepack pnpm --filter @jump/web build`; `git diff --check` | +| `docs/stories/web-terminal-dynamic-background.md` | Web UI terminal wrapper surfaces use the resolved terminal background fallback, mirror OSC 11 runtime background changes, restore on OSC 111, reset per session, and receive pre-existing OSC 11 state in reconnect snapshots | yes | no | no | yes | implemented | `corepack pnpm --filter @jump/web test -- terminal-colors.test.ts` (Vitest ran the full `@jump/web` suite: 26 files, 409 tests); `TMPDIR=/tmp GOWORK=$PWD/go.work go test ./cli/jump/internal/ptyserver ./services/jumpd/internal/wsproxy ./services/jumpd/cmd/jumpd`; `corepack pnpm --filter @jump/web lint`; `corepack pnpm --filter @jump/web build`; local reinstall/restart; Chrome headless pre-attach OSC 11 check reported `--terminal-bg: #123456` | ## Evidence Rules diff --git a/docs/stories/web-terminal-dynamic-background.md b/docs/stories/web-terminal-dynamic-background.md new file mode 100644 index 0000000..96f94e6 --- /dev/null +++ b/docs/stories/web-terminal-dynamic-background.md @@ -0,0 +1,59 @@ +# Web Terminal Dynamic Background + +## Status + +implemented + +## Lane + +normal + +## Product Contract + +The Web UI terminal background should match the terminal palette instead of the Web UI chrome theme. Terminal wrapper surfaces such as padding, empty grid area, and scrollable overflow use the configured terminal `theme.background` as their fallback. When terminal output changes the xterm background at runtime with OSC 11, those wrapper surfaces mirror that dynamic background immediately. OSC 111 restores the wrapper fallback to the configured terminal background. + +This behavior is browser-local. It does not change PTY output, daemon APIs, relay protocol, session persistence, or Web UI chrome theme switching. + +## Relevant Product Docs + +- `docs/ARCHITECTURE.md` — browser app owns browser protocol/API contracts and must not couple to daemon internals. +- `docs/stories/webui-theme-preferences.md` — Web UI chrome themes must not mutate `settings.jsonc` / `theme.jsonc` terminal palette behavior. + +## Acceptance Criteria + +- Terminal shell/container background uses the resolved terminal `theme.background` fallback, including user-provided `~/.config/jump/theme.jsonc` backgrounds. +- OSC 11 runtime background changes from terminal applications are mirrored into the terminal shell/container CSS background without page refresh. +- OSC 111 runtime background restore returns the terminal shell/container CSS background to the resolved terminal fallback. +- Unknown, report-only, or invalid OSC color payloads do not change the wrapper background. +- Switching sessions resets the wrapper background to the resolved terminal fallback before replay/live output can apply that session's OSC colors. +- New browser attaches receive the runner's latest pre-existing OSC 11 background state in the reconnect snapshot, so colors emitted before Web UI attach do not require a manual refresh or new live output. + +## Design Notes + +- `apps/jump-web/src/terminal.tsx` keeps xterm as the parser/source of truth and registers fall-through OSC handlers for 11 and 111. Returning `false` preserves xterm's built-in dynamic color handling. +- `apps/jump-web/src/terminal-colors.ts` only normalizes supported OSC color payloads into CSS hex colors for wrapper surfaces. +- The browser fix is client-side because `pi-droid-styling` emits OSC 11/111 through PTY output and jumpd already transports that output. +- The runner tracks the latest OSC 11/111 background state and includes it in reconnect snapshots before rendered screen content. This keeps initial attach/reconnect behavior aligned with live WebSocket output without making jumpd interpret terminal escape sequences. + +## Validation + +| Layer | Expected proof | +| --- | --- | +| Unit | Browser OSC color parsing, terminal background fallback tests, and runner OSC 11/111 snapshot replay tests. | +| Integration | Not required; daemon/API contracts are unchanged. | +| E2E | Not required for this slice; xterm consumes the same replay/live PTY output path. | +| Platform | Web lint/build smoke. | + +## Harness Delta + +None. + +## Evidence + +- `corepack pnpm --filter @jump/web test -- terminal-colors.test.ts` passed; Vitest ran the full `@jump/web` suite: 26 files, 409 tests. +- `TMPDIR=/tmp GOWORK=$PWD/go.work go test -v ./cli/jump/internal/ptyserver -run 'TestTerminalColorTracker|TestPTYServerReconnectSnapshotReplaysTerminalBackground'` passed. +- `TMPDIR=/tmp GOWORK=$PWD/go.work go test ./cli/jump/internal/ptyserver ./services/jumpd/internal/wsproxy ./services/jumpd/cmd/jumpd` passed. +- `corepack pnpm --filter @jump/web lint` passed. +- `corepack pnpm --filter @jump/web build` passed. +- `./scripts/build.sh && install -m 755 bin/jump bin/jumpd bin/jump-relayd "$HOME/.local/bin/"` completed and `jumpd status` reported `jumpd 1.15.0 (ready)`. +- Manual Chrome headless attach to a fresh session that emitted OSC 11 before attach (`sess-4bca95d9`) reported `.terminal-shell --terminal-bg` as `#123456` without any live injection.