Skip to content

Commit 430b261

Browse files
authored
Merge pull request #228 from SoftwareSavants/overloaded-lockfile-587
fix(terminal): repaint running PTY terminals on dark/light mode flip
2 parents 05e1137 + 83b83ad commit 430b261

8 files changed

Lines changed: 315 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Running PTY terminals now repaint when you flip dark ↔ light mode. xterm v6's WebGL renderer caches its texture atlas, glyph cache, and rectangle-batch buffers per renderer instance, so setting `term.options.theme` updated the option object but the canvas kept painting the palette baked in at construction — old terminals stayed dark in light mode and new terminals stayed light when switching back. The appearance subscriber now disposes the WebGL addon and reloads a fresh instance after writing the new theme, so xterm runs `setRenderer → _fullRefresh` and every visible cell repaints in the new colors. DOM-renderer callers (BtsLogPane, the inert `Terminal.tsx` log view) were unaffected and stay on the simpler subscribe path.
6+
- Terminal panel chrome now flips with the theme: the outer container was baking in `bg-[#0a0a0a]` so the empty state and any gaps around the xterm canvas stayed black even after switching to light mode. Replaced with `bg-surface-0` so it tracks the active theme like the rest of the panel.
57
- Side question (`/btw`) for Claude sessions: ask an ephemeral question without polluting the transcript. Type `/btw <question>` in the composer (auto-completes from the slash-command palette), or - while the agent is streaming and you've scrolled to the bottom of the chat - click the small "Ask sideways /btw" pill that takes the place of the scroll-to-bottom button (same screen real estate, swapped by context). Q/A are not persisted to `output_lines` and never re-enter conversation history on resume; `synthetic` fallbacks are badged distinctly. Wires through a new `ask_side_question` Tauri command on top of the existing control-request plumbing.
68
- Mid-loop API errors (e.g. ConnectionRefused after tools already ran) now surface a `Continue` button instead of `Retry`. It sends `continue` to the resumed session so Claude picks up from the persisted tool results instead of redoing the whole turn from the original prompt. `Retry in new session` is hidden in this case since it would discard the in-flight progress. Fresh-failure turns (no assistant content yet) keep the original Retry / Retry-in-new-session pair
79
- Cmd+Alt+Left/Right now cycles between sessions in the current task when viewing a session (wraps at edges). The shortcut still cycles editor tabs when a file is open, mirroring Ctrl+Tab's view-aware behavior

src/components/ShellTerminal.tsx

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,22 @@ function initialFit(entry: XtermEntry, terminalId: string) {
108108
.catch(() => {})
109109
}
110110

111+
/**
112+
* Replace a terminal's WebGL renderer with a fresh instance carrying the
113+
* current theme. Setting `term.options.theme = newTheme` fires xterm's
114+
* `onChangeColors`, but xterm v6's WebglRenderer keeps its texture atlas,
115+
* cell model, and rectangle-batch buffers across that event — visible cells
116+
* keep painting with the colors baked in at construction time. The canonical
117+
* fix used by VS Code is to swap the renderer entirely; doing that via the
118+
* WebglAddon's lifecycle gives us a clean `setRenderer(...)` → `_fullRefresh()`
119+
* sequence that paints the whole grid in the new palette.
120+
*/
121+
function reloadWebglAddon(entry: import('../store/terminals').XtermEntry): void {
122+
if (!entry.webglFactory) return
123+
entry.webglAddon?.dispose()
124+
entry.webglAddon = entry.webglFactory()
125+
}
126+
111127
/**
112128
* Force-redraw triggers that fix WebGL texture-atlas drift without relying on
113129
* the user resizing the window. VS Code (`xtermTerminal.ts: forceRedraw()`)
@@ -245,10 +261,14 @@ export const ShellTerminal: Component<Props> = (props) => {
245261
existing.term.focus()
246262
})
247263
resizeObserver = attachResizeObserver(terminalRef, existing, props.terminalId)
248-
unsubAppearance = subscribeXtermToAppearance(existing.term, () => {
249-
existing.fitAddon.fit()
250-
existing.term.clearTextureAtlas()
251-
})
264+
unsubAppearance = subscribeXtermToAppearance(
265+
existing.term,
266+
() => {
267+
existing.fitAddon.fit()
268+
existing.term.clearTextureAtlas()
269+
},
270+
() => reloadWebglAddon(existing),
271+
)
252272
unsubLifecycle = attachAtlasLifecycle(existing.term)
253273
return
254274
}
@@ -301,27 +321,43 @@ export const ShellTerminal: Component<Props> = (props) => {
301321
term.write(replay.data)
302322
markSeqWritten(props.terminalId, replay.seq)
303323
}
304-
registerXterm(props.terminalId, term, fitAddon, searchAddon)
305-
306-
try {
307-
const webgl = new WebglAddon()
308-
// VS Code pattern: dispose on context loss so xterm falls back to the
309-
// DOM renderer instead of leaving a dead GL context behind. Critical
310-
// on macOS WebKit (Tauri) where the OS occasionally drops contexts on
311-
// sleep / Mission Control / display switches.
312-
webgl.onContextLoss(() => webgl.dispose())
313-
term.loadAddon(webgl)
314-
} catch {
315-
// WebGL not available — xterm auto-falls back to the DOM renderer.
324+
// Factored so the appearance subscriber can re-create a fresh WebGL
325+
// renderer instance when the theme flips. xterm v6's WebglRenderer caches
326+
// its texture atlas + glyph state across `term.options.theme = ...`
327+
// assignments, so without a swap, running terminals keep painting the
328+
// colors they were started with.
329+
const makeWebgl = (): WebglAddon | undefined => {
330+
try {
331+
const webgl = new WebglAddon()
332+
// VS Code pattern: dispose on context loss so xterm falls back to the
333+
// DOM renderer instead of leaving a dead GL context behind. Critical
334+
// on macOS WebKit (Tauri) where the OS occasionally drops contexts on
335+
// sleep / Mission Control / display switches.
336+
webgl.onContextLoss(() => webgl.dispose())
337+
term.loadAddon(webgl)
338+
return webgl
339+
} catch {
340+
// WebGL not available — xterm auto-falls back to the DOM renderer.
341+
return undefined
342+
}
316343
}
344+
const initialWebgl = makeWebgl()
317345

318-
const entry = { term, fitAddon, searchAddon }
346+
const entry: import('../store/terminals').XtermEntry = {
347+
term, fitAddon, searchAddon, webglAddon: initialWebgl, webglFactory: makeWebgl,
348+
}
349+
// Re-register so the registry sees the WebGL handle alongside the term.
350+
registerXterm(props.terminalId, term, fitAddon, searchAddon, initialWebgl, makeWebgl)
319351
initialFit(entry, props.terminalId)
320352
resizeObserver = attachResizeObserver(terminalRef, entry, props.terminalId)
321-
unsubAppearance = subscribeXtermToAppearance(term, () => {
322-
fitAddon.fit()
323-
term.clearTextureAtlas()
324-
})
353+
unsubAppearance = subscribeXtermToAppearance(
354+
term,
355+
() => {
356+
fitAddon.fit()
357+
term.clearTextureAtlas()
358+
},
359+
() => reloadWebglAddon(entry),
360+
)
325361
unsubLifecycle = attachAtlasLifecycle(term)
326362
})
327363

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, test, expect, vi, beforeEach } from 'vitest'
2+
import { render, cleanup } from '@solidjs/testing-library'
3+
4+
// Stub the terminals store: TerminalPanel reads from it but for this test we
5+
// only care about the chrome around the empty state, so an empty list suffices.
6+
const terminalsMocks = vi.hoisted(() => ({
7+
terminalsForTask: vi.fn(() => []),
8+
activeTerminalId: vi.fn(() => undefined),
9+
setActiveTerminalForTask: vi.fn(),
10+
spawnTerminal: vi.fn(() => Promise.resolve()),
11+
closeTerminal: vi.fn(() => Promise.resolve()),
12+
focusActiveTerminal: vi.fn(),
13+
terminalExitCodes: vi.fn(() => ({})),
14+
isTerminalStopped: vi.fn(() => false),
15+
spawnStartCommand: vi.fn(() => Promise.resolve()),
16+
// Treat the task as hydrated but with no terminals so the createEffect that
17+
// would auto-spawn one is gated by the empty terminals + spawning guard.
18+
isTaskHydrated: vi.fn(() => false),
19+
}))
20+
vi.mock('../store/terminals', () => terminalsMocks)
21+
22+
vi.mock('../store/setup', () => ({ isSetupRunning: vi.fn(() => false) }))
23+
24+
vi.mock('../lib/ipc', () => ({
25+
stopHook: vi.fn(() => Promise.resolve()),
26+
runHook: vi.fn(() => Promise.resolve()),
27+
ptyClose: vi.fn(() => Promise.resolve()),
28+
}))
29+
30+
// xterm needs a real DOM and WebGL — neither of which jsdom supplies. Stub the
31+
// terminal so the panel can mount and we can inspect its outer chrome.
32+
vi.mock('./ShellTerminal', () => ({
33+
ShellTerminal: () => {
34+
const el = document.createElement('div')
35+
el.setAttribute('data-testid', 'shell-terminal')
36+
return el
37+
},
38+
}))
39+
40+
import { TerminalPanel } from './TerminalPanel'
41+
42+
beforeEach(() => {
43+
cleanup()
44+
})
45+
46+
describe('TerminalPanel chrome adapts to the theme', () => {
47+
// Regression: the panel container baked in `bg-[#0a0a0a]`, leaving the empty
48+
// state and any gaps around the xterm canvas stuck on a black background
49+
// even after the user switched to light mode. Surface tokens flip with the
50+
// theme; raw hex does not.
51+
test('outer container uses a surface token, not a hardcoded dark hex', () => {
52+
const { container } = render(() => <TerminalPanel taskId="t-1" />)
53+
const outer = container.firstElementChild as HTMLElement
54+
expect(outer).toBeTruthy()
55+
const cls = outer.className
56+
expect(cls).not.toMatch(/bg-\[#0a0a0a\]/)
57+
expect(cls).toMatch(/bg-surface-0\b/)
58+
})
59+
})

src/components/TerminalPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export const TerminalPanel: Component<Props> = (props) => {
7878
})
7979

8080
return (
81-
<div class="flex flex-col h-full bg-[#0a0a0a]">
81+
<div class="flex flex-col h-full bg-surface-0">
8282
{/* Tab bar */}
8383
<div class="flex items-center px-2 py-1.5 gap-1 bg-surface-1 border-b border-border-subtle overflow-x-auto shrink-0">
8484
<For each={sortedTerminals()}>

src/lib/terminalTheme.test.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
2-
import { subscribeXtermToAppearance } from './terminalTheme'
2+
import { getXtermTheme, subscribeXtermToAppearance } from './terminalTheme'
33
import { applyAppearance, DEFAULT_PREFS } from './theme'
44

55
// Minimal xterm stub - records option writes and refresh/clearTextureAtlas calls.
@@ -55,6 +55,70 @@ describe('subscribeXtermToAppearance', () => {
5555
unsub()
5656
})
5757

58+
test('theme background flips when mode switches between dark and light', () => {
59+
const { term } = makeStubTerm()
60+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
61+
const unsub = subscribeXtermToAppearance(term as any)
62+
63+
applyAppearance({ ...DEFAULT_PREFS, mode: 'dark' })
64+
const darkTheme = (term.options as Record<string, unknown>).theme as { background: string }
65+
66+
applyAppearance({ ...DEFAULT_PREFS, mode: 'light' })
67+
const lightTheme = (term.options as Record<string, unknown>).theme as { background: string }
68+
69+
expect(darkTheme.background).toBeDefined()
70+
expect(lightTheme.background).toBeDefined()
71+
// The whole point of switching modes: the visible terminal canvas color must change.
72+
expect(lightTheme.background.toLowerCase()).not.toBe(darkTheme.background.toLowerCase())
73+
74+
unsub()
75+
})
76+
77+
test('getXtermTheme reads the resolved CSS surface for the current mode', () => {
78+
applyAppearance({ ...DEFAULT_PREFS, mode: 'dark' })
79+
const darkBg = getXtermTheme().background
80+
applyAppearance({ ...DEFAULT_PREFS, mode: 'light' })
81+
const lightBg = getXtermTheme().background
82+
expect(darkBg).toBeDefined()
83+
expect(lightBg).toBeDefined()
84+
expect(lightBg!.toLowerCase()).not.toBe(darkBg!.toLowerCase())
85+
})
86+
87+
// Repro for the user-reported bug: terminals started in dark mode kept
88+
// painting their original palette after switching to light. xterm v6's
89+
// WebGL renderer caches its atlas + cell model across `term.options.theme`
90+
// assignments, so the canonical fix is to dispose-and-reload the renderer.
91+
// The subscriber must call `reloadRenderer` between option writes and the
92+
// post-fit refresh — and only after the new theme is on the term, so the
93+
// fresh renderer activates against it.
94+
test('invokes reloadRenderer after theme is set, before refresh', () => {
95+
const { term, calls } = makeStubTerm()
96+
const reloadRenderer = vi.fn(() => calls.push('reloadRenderer'))
97+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
98+
const unsub = subscribeXtermToAppearance(term as any, undefined, reloadRenderer)
99+
100+
applyAppearance({ ...DEFAULT_PREFS, mode: 'light' })
101+
102+
expect(reloadRenderer).toHaveBeenCalledTimes(1)
103+
const themeIdx = calls.indexOf('set theme')
104+
const reloadIdx = calls.indexOf('reloadRenderer')
105+
const refreshIdx = calls.findIndex(c => c.startsWith('refresh('))
106+
expect(themeIdx).toBeGreaterThanOrEqual(0)
107+
expect(reloadIdx).toBeGreaterThan(themeIdx)
108+
expect(refreshIdx).toBeGreaterThan(reloadIdx)
109+
110+
unsub()
111+
})
112+
113+
test('reloadRenderer is optional — DOM-only callers still work', () => {
114+
const { term } = makeStubTerm()
115+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
116+
const unsub = subscribeXtermToAppearance(term as any)
117+
expect(() => applyAppearance({ ...DEFAULT_PREFS, mode: 'light' })).not.toThrow()
118+
expect(term.refresh).toHaveBeenCalled()
119+
unsub()
120+
})
121+
58122
test('unsub stops further updates', () => {
59123
const { term } = makeStubTerm()
60124
// eslint-disable-next-line @typescript-eslint/no-explicit-any

src/lib/terminalTheme.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,31 @@ export function getXtermFontConfig(): { fontFamily: string; fontSize: number; cu
6969
}
7070
}
7171

72-
/** Subscribe an xterm instance to live appearance updates. Returns cleanup. */
73-
export function subscribeXtermToAppearance(term: XTerm, onResize?: () => void): () => void {
72+
/** Subscribe an xterm instance to live appearance updates. Returns cleanup.
73+
*
74+
* `reloadRenderer`, when provided, is invoked after the new theme has been
75+
* written to xterm's options. WebGL-rendered terminals must use this to swap
76+
* in a fresh renderer instance — xterm v6's WebGL renderer keeps its texture
77+
* atlas, glyph cache, and rectangle-batch buffers alive across theme changes,
78+
* so setting `term.options.theme` updates the option object but the visible
79+
* canvas keeps painting with the old palette. The DOM renderer doesn't have
80+
* this issue (it re-injects CSS on every theme change), so DOM-only callers
81+
* can leave it undefined.
82+
*/
83+
export function subscribeXtermToAppearance(
84+
term: XTerm,
85+
onResize?: () => void,
86+
reloadRenderer?: () => void,
87+
): () => void {
7488
return onAppearanceChanged(() => {
7589
const cfg = getXtermFontConfig()
7690
term.options.theme = getXtermTheme()
7791
term.options.fontFamily = cfg.fontFamily
7892
term.options.fontSize = cfg.fontSize
7993
term.options.cursorBlink = cfg.cursorBlink
94+
// Swap the renderer first so it activates against the just-written theme
95+
// (its constructor caches `themeService.colors` once at activate time).
96+
reloadRenderer?.()
8097
// The WebGL renderer caches a texture atlas keyed on font + theme. Without
8198
// invalidating it, font/color changes don't visibly repaint until the next
8299
// resize. The canvas renderer ignores this method.

src/lib/terminalThemeRepro.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
2+
import { Terminal as XTerm } from '@xterm/xterm'
3+
import { subscribeXtermToAppearance } from './terminalTheme'
4+
import { applyAppearance, DEFAULT_PREFS } from './theme'
5+
6+
// xterm reaches into matchMedia / IntersectionObserver during open() — jsdom
7+
// doesn't ship them. Bare-minimum stubs are enough for the DOM renderer path,
8+
// which is what we hit here since jsdom has no canvas.
9+
beforeAll(() => {
10+
if (!window.matchMedia) {
11+
Object.defineProperty(window, 'matchMedia', {
12+
configurable: true,
13+
value: vi.fn().mockImplementation((query: string) => ({
14+
matches: false,
15+
media: query,
16+
onchange: null,
17+
addEventListener: vi.fn(),
18+
removeEventListener: vi.fn(),
19+
addListener: vi.fn(),
20+
removeListener: vi.fn(),
21+
dispatchEvent: vi.fn(),
22+
})),
23+
})
24+
}
25+
if (!('IntersectionObserver' in window)) {
26+
class IO {
27+
observe() {}
28+
unobserve() {}
29+
disconnect() {}
30+
takeRecords() { return [] }
31+
}
32+
Object.defineProperty(window, 'IntersectionObserver', { configurable: true, value: IO })
33+
}
34+
})
35+
36+
describe('real XTerm responds to appearance changes', () => {
37+
let container: HTMLDivElement
38+
let term: XTerm
39+
40+
beforeEach(() => {
41+
localStorage.clear()
42+
container = document.createElement('div')
43+
Object.defineProperty(container, 'clientWidth', { value: 800, configurable: true })
44+
Object.defineProperty(container, 'clientHeight', { value: 400, configurable: true })
45+
document.body.appendChild(container)
46+
})
47+
48+
afterEach(() => {
49+
term?.dispose()
50+
container.remove()
51+
})
52+
53+
test('rawOptions.theme is replaced when appearance flips dark→light', () => {
54+
applyAppearance({ ...DEFAULT_PREFS, mode: 'dark' })
55+
term = new XTerm({ allowProposedApi: true, theme: { background: '#000000' } })
56+
term.open(container)
57+
const initial = (term.options as unknown as { theme: { background?: string } }).theme
58+
59+
const unsub = subscribeXtermToAppearance(term)
60+
applyAppearance({ ...DEFAULT_PREFS, mode: 'light' })
61+
62+
const after = (term.options as unknown as { theme: { background?: string } }).theme
63+
expect(after).toBeDefined()
64+
expect(after).not.toBe(initial)
65+
expect(after.background?.toLowerCase()).not.toBe(initial.background?.toLowerCase())
66+
unsub()
67+
})
68+
69+
// Reproduces the user-reported bug: terminals started in one mode keep their
70+
// original colors after the user toggles to the other mode. xterm's DOM
71+
// renderer paints the viewport background via the scrollable element's inline
72+
// backgroundColor — so verifying that style flips proves the rendered output
73+
// (not just the option object) tracks the theme.
74+
test('rendered viewport background flips when appearance flips dark→light', () => {
75+
applyAppearance({ ...DEFAULT_PREFS, mode: 'dark' })
76+
term = new XTerm({ allowProposedApi: true })
77+
term.open(container)
78+
79+
// The scrollable element's backgroundColor is set by xterm's Viewport on
80+
// every theme change.
81+
const findScrollable = () => container.querySelector('.xterm-scrollable-element') as HTMLElement | null
82+
const before = findScrollable()?.style.backgroundColor ?? ''
83+
expect(before).toBeTruthy()
84+
85+
const unsub = subscribeXtermToAppearance(term)
86+
applyAppearance({ ...DEFAULT_PREFS, mode: 'light' })
87+
88+
const after = findScrollable()?.style.backgroundColor ?? ''
89+
expect(after).toBeTruthy()
90+
expect(after).not.toBe(before)
91+
92+
unsub()
93+
})
94+
})

0 commit comments

Comments
 (0)