Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/jump-web/src/terminal-colors.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
44 changes: 44 additions & 0 deletions apps/jump-web/src/terminal-colors.ts
Original file line number Diff line number Diff line change
@@ -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'
}
27 changes: 25 additions & 2 deletions apps/jump-web/src/terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -273,6 +274,7 @@ export function TerminalView({
const compositionSuppressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const mobileCopyModeRef = useRef(mobileCopyMode)
const mobileCopyAnchorRef = useRef<TerminalCell | null>(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.
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -1022,6 +1042,8 @@ export function TerminalView({
disposePasteHandler()
disposeMobileHandler()
xtGetTcapDisposable.dispose()
oscBackgroundDisposable.dispose()
oscBackgroundRestoreDisposable.dispose()
osc52Disposable.dispose()
dataDisposable.dispose()
scrollDisposable.dispose()
Expand Down Expand Up @@ -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(() => {
Expand All @@ -1077,6 +1099,7 @@ export function TerminalView({
setWsState('connecting')

setTermLoading(true)
applyTerminalBackground()

function forceReconnect() {
if (disposed.current) return
Expand Down Expand Up @@ -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(() => {
Expand Down
13 changes: 8 additions & 5 deletions cli/jump/internal/ptyserver/ptyserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand Down
55 changes: 55 additions & 0 deletions cli/jump/internal/ptyserver/ptyserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
97 changes: 97 additions & 0 deletions cli/jump/internal/ptyserver/terminal_colors.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading