diff --git a/.changeset/webgl-renderer-toggle.md b/.changeset/webgl-renderer-toggle.md
new file mode 100644
index 00000000..0e620d7a
--- /dev/null
+++ b/.changeset/webgl-renderer-toggle.md
@@ -0,0 +1,5 @@
+---
+"aicodeman": patch
+---
+
+Add a **WebGL Renderer** toggle to Settings → Appearance (desktop). WebGL stays on by default; turning it off forces the DOM renderer for users who hit GPU glitches, without needing the `?nowebgl` URL param. Turning it back on (or `?webgl=force`) clears any stale auto-fallback marker. The existing mobile skip and long-task auto-fallback safety net are unchanged. The skip decision is factored into a pure, unit-tested `shouldSkipWebGL()` helper.
diff --git a/src/web/public/constants.js b/src/web/public/constants.js
index 33b74246..9800617a 100644
--- a/src/web/public/constants.js
+++ b/src/web/public/constants.js
@@ -111,6 +111,36 @@ function evaluateWebGLLongTaskTrip(recent, entries, now, config = WEBGL_FALLBACK
return recent.length >= config.LONGTASK_COUNT;
}
+/**
+ * Pure decision for whether to skip the WebGL renderer at terminal init, and
+ * whether to clear the auto-fallback sticky marker. Keeps the interaction
+ * between device type, URL params, the sticky marker, and the user's settings
+ * toggle in one testable place (terminal-ui.js calls this).
+ *
+ * Precedence (desktop only — mobile always skips):
+ * 1. user toggle OFF -> skip (one-shot opt-out, sticky untouched)
+ * 2. ?nowebgl -> skip (one-shot opt-out, sticky untouched)
+ * 3. ?webgl=force -> enable + clear stale sticky marker
+ * 4. toggle ON / untouched -> respect the auto-fallback sticky marker
+ *
+ * A stored `true` is treated like the untouched default here: the checkbox
+ * ships checked on desktop, so any unrelated settings save stores `true` —
+ * letting it clear the marker would permanently defeat the GPU-stall
+ * auto-fallback safety net. The marker is only retired by ?webgl=force or by
+ * a real OFF->ON toggle flip, which saveAppSettings() detects at save time.
+ *
+ * @param {{deviceType?: string, noWebglParam?: boolean, forceParam?: boolean,
+ * stickyDisabled?: boolean, userPrefEnabled?: (boolean|undefined)}} [input]
+ * @returns {{skip: boolean, clearSticky: boolean}}
+ */
+function shouldSkipWebGL(input = {}) {
+ if (input.deviceType !== 'desktop') return { skip: true, clearSticky: false };
+ if (input.userPrefEnabled === false) return { skip: true, clearSticky: false };
+ if (input.noWebglParam) return { skip: true, clearSticky: false };
+ if (input.forceParam) return { skip: false, clearSticky: true };
+ return { skip: !!input.stickyDisabled, clearSticky: false };
+}
+
// Expose for tests. `const` declarations at the top of a non-module script
// are global lexical bindings but not `window` properties, so explicit
// assignment is the test-visible API surface.
@@ -129,6 +159,7 @@ function shouldAutoWrapTabs(input) {
if (typeof window !== 'undefined') {
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
+ window.shouldSkipWebGL = shouldSkipWebGL;
window.CodemanTabOverflow = {
shouldAutoWrapTabs,
};
diff --git a/src/web/public/index.html b/src/web/public/index.html
index 3f6edd2d..22780dca 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -1011,6 +1011,13 @@
+
+ WebGL Renderer
+
+
Input
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js
index 5d22a22b..eee86f89 100644
--- a/src/web/public/settings-ui.js
+++ b/src/web/public/settings-ui.js
@@ -309,6 +309,11 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false;
document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
+ // WebGL renderer (desktop only — mobile always uses the DOM renderer, so hide
+ // the toggle there so it can't promise something that won't apply).
+ document.getElementById('appSettingsWebglRenderer').checked = settings.webglRendererEnabled ?? defaults.webglRendererEnabled ?? true;
+ const webglItem = document.getElementById('appSettingsWebglRendererItem');
+ if (webglItem) webglItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none';
document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? false;
document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false;
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
@@ -1400,6 +1405,9 @@ Object.assign(CodemanApp.prototype, {
// only takes effect on reload — remember the prior value to decide below.
const _prev = this.loadAppSettingsFromStorage();
const _prevGestureEnabled = (_prev.gestureControlEnabled ?? false) === true;
+ // WebGL toggle: default ON (desktop), so only an explicit stored false counts
+ // as "previously off" — used below to detect a real OFF→ON flip.
+ const _prevWebglEnabled = (_prev.webglRendererEnabled ?? true) === true;
const settings = {
defaultClaudeMdPath: document.getElementById('appSettingsClaudeMdPath').value.trim(),
defaultWorkingDir: document.getElementById('appSettingsDefaultDir').value.trim(),
@@ -1426,6 +1434,7 @@ Object.assign(CodemanApp.prototype, {
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
+ webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
skin: document.getElementById('appSettingsSkin').value,
@@ -1460,6 +1469,15 @@ Object.assign(CodemanApp.prototype, {
this.saveAppSettingsToStorage(settings);
this._updateLocalEchoState();
+ // A real OFF→ON flip of the WebGL toggle retires the GPU-stall auto-fallback
+ // marker so the next reload actually re-tries WebGL. Only the transition
+ // clears it — an incidental save with the checkbox default-checked must NOT
+ // defeat the sticky safety net (shouldSkipWebGL treats stored true like the
+ // untouched default at page load).
+ if (!_prevWebglEnabled && settings.webglRendererEnabled) {
+ try { localStorage.removeItem('codeman-webgl-disabled'); } catch {}
+ }
+
// Save voice settings to localStorage + include in server payload for cross-device sync
const voiceSettings = {
apiKey: document.getElementById('voiceDeepgramKey').value.trim(),
@@ -1570,6 +1588,10 @@ Object.assign(CodemanApp.prototype, {
// Strip device-specific DISPLAY keys so they never sync across devices —
// localEcho/cjk/extendedKeyboard/skin are per-platform, and showPlanUsageLimits
// is per-device too (desktop can show the usage chip while mobile stays hidden).
+ // webglRendererEnabled is per-device as well (renderer choice is GPU-specific,
+ // and syncing would leak mobile's hidden-checkbox false onto desktop); it's
+ // also absent from SettingsUpdateSchema, which is .strict() — sending it
+ // would 400 the whole settings PUT.
// Telemetry COLLECTION is requested out-of-band via statusLineTelemetry (sent on
// ENABLE only, so a device with the chip OFF never strips the exporter that
// another device's chip depends on — see system-routes settings handler).
@@ -1580,6 +1602,7 @@ Object.assign(CodemanApp.prototype, {
skin: _skin,
showPlanUsageLimits: _pul,
showAttachmentsButton: _ahb,
+ webglRendererEnabled: _wgl,
...serverSettings
} = settings;
try {
@@ -1746,6 +1769,7 @@ Object.assign(CodemanApp.prototype, {
ralphTrackerEnabled: false,
tabTwoRows: false,
cjkInputEnabled: false,
+ webglRendererEnabled: false, // mobile always uses the DOM renderer
skin: 'daylight-blue',
};
}
@@ -2118,7 +2142,7 @@ Object.assign(CodemanApp.prototype, {
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
- 'skin', 'showPlanUsageLimits', 'showAttachmentsButton',
+ 'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'webglRendererEnabled',
]);
// The plan-usage chip is a PER-DEVICE display setting (default OFF): desktop
// can show it while mobile stays hidden. It used to sync, so an older
diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js
index d44e5dbf..22b7b5a1 100644
--- a/src/web/public/terminal-ui.js
+++ b/src/web/public/terminal-ui.js
@@ -252,9 +252,6 @@ Object.assign(CodemanApp.prototype, {
// Lazy-loaded: script downloaded only on desktop (saves 244KB on mobile).
this._webglAddon = null;
const _params = new URLSearchParams(location.search);
- if (_params.get('webgl') === 'force') {
- try { localStorage.removeItem('codeman-webgl-disabled'); } catch {}
- }
const _stickyDisabled = (() => {
try {
const raw = localStorage.getItem('codeman-webgl-disabled');
@@ -269,11 +266,25 @@ Object.assign(CodemanApp.prototype, {
return true;
} catch { return false; }
})();
- const skipWebGL =
- MobileDetection.getDeviceType() !== 'desktop' ||
- _params.has('nowebgl') ||
- _stickyDisabled;
- if (_stickyDisabled) {
+ // User's "WebGL Renderer" toggle (Settings > Appearance). undefined = untouched
+ // (desktop default on); false = explicit opt-out; true = explicit opt-in.
+ const _webglSettings = this.loadAppSettingsFromStorage();
+ const _webglDefaults = this.getDefaultSettings();
+ const _webglPref = _webglSettings.webglRendererEnabled ?? _webglDefaults.webglRendererEnabled;
+ const { skip: skipWebGL, clearSticky: _clearWebglSticky } = shouldSkipWebGL({
+ deviceType: MobileDetection.getDeviceType(),
+ noWebglParam: _params.has('nowebgl'),
+ forceParam: _params.get('webgl') === 'force',
+ stickyDisabled: _stickyDisabled,
+ userPrefEnabled: _webglPref,
+ });
+ // Only ?webgl=force retires the auto-fallback marker at init — a stored
+ // toggle ON is incidental (checkbox defaults checked) and must not defeat
+ // the sticky safety net. An OFF→ON flip clears it in saveAppSettings().
+ if (_clearWebglSticky) {
+ try { localStorage.removeItem('codeman-webgl-disabled'); } catch {}
+ }
+ if (skipWebGL && _stickyDisabled) {
console.log('[CRASH-DIAG] WebGL sticky-disabled from prior stalls — DOM renderer in use. Re-enable: ?webgl=force');
}
if (!skipWebGL) {
diff --git a/test/webgl-fallback.test.ts b/test/webgl-fallback.test.ts
index e6d376d8..1a8b6514 100644
--- a/test/webgl-fallback.test.ts
+++ b/test/webgl-fallback.test.ts
@@ -215,4 +215,153 @@ describe('WebGL longtask auto-fallback', () => {
expect(result).toBeNull();
});
});
+
+ describe('shouldSkipWebGL — renderer toggle + fallback precedence', () => {
+ type Input = {
+ deviceType?: string;
+ noWebglParam?: boolean;
+ forceParam?: boolean;
+ stickyDisabled?: boolean;
+ userPrefEnabled?: boolean;
+ };
+ const run = async (input: Input): Promise<{ skip: boolean; clearSticky: boolean }> =>
+ page.evaluate(
+ (i) =>
+ (
+ window as unknown as {
+ shouldSkipWebGL: (x: unknown) => { skip: boolean; clearSticky: boolean };
+ }
+ ).shouldSkipWebGL(i),
+ input
+ );
+
+ it('exposes shouldSkipWebGL on window', async () => {
+ const t = await page.evaluate(() => typeof (window as unknown as { shouldSkipWebGL?: unknown }).shouldSkipWebGL);
+ expect(t).toBe('function');
+ });
+
+ it('mobile always skips, even when the user opted in', async () => {
+ expect(await run({ deviceType: 'mobile', userPrefEnabled: true })).toEqual({
+ skip: true,
+ clearSticky: false,
+ });
+ });
+
+ it('explicit opt-out skips without touching the sticky marker', async () => {
+ expect(await run({ deviceType: 'desktop', userPrefEnabled: false, stickyDisabled: false })).toEqual({
+ skip: true,
+ clearSticky: false,
+ });
+ });
+
+ it('?nowebgl is a one-shot opt-out (sticky untouched)', async () => {
+ expect(await run({ deviceType: 'desktop', noWebglParam: true })).toEqual({
+ skip: true,
+ clearSticky: false,
+ });
+ });
+
+ it('stored toggle ON still respects the sticky marker (incidental saves must not defeat auto-fallback)', async () => {
+ // The checkbox defaults checked on desktop, so any unrelated settings save
+ // stores true — that must NOT act like ?webgl=force and clear the marker.
+ expect(await run({ deviceType: 'desktop', userPrefEnabled: true, stickyDisabled: true })).toEqual({
+ skip: true,
+ clearSticky: false,
+ });
+ });
+
+ it('stored toggle ON enables WebGL when no sticky marker is set', async () => {
+ expect(await run({ deviceType: 'desktop', userPrefEnabled: true, stickyDisabled: false })).toEqual({
+ skip: false,
+ clearSticky: false,
+ });
+ });
+
+ it('?webgl=force enables and clears the sticky marker', async () => {
+ expect(await run({ deviceType: 'desktop', forceParam: true, stickyDisabled: true })).toEqual({
+ skip: false,
+ clearSticky: true,
+ });
+ });
+
+ it('untouched default respects an active sticky marker', async () => {
+ expect(await run({ deviceType: 'desktop', stickyDisabled: true })).toEqual({
+ skip: true,
+ clearSticky: false,
+ });
+ });
+
+ it('untouched default enables WebGL when nothing is disabling it', async () => {
+ expect(await run({ deviceType: 'desktop', stickyDisabled: false })).toEqual({
+ skip: false,
+ clearSticky: false,
+ });
+ });
+ });
+
+ describe('saveAppSettings — per-device WebGL toggle semantics', () => {
+ type SaveHarnessApp = {
+ loadAppSettingsFromStorage: () => Record;
+ saveAppSettingsToStorage: (s: Record) => void;
+ saveAppSettings: () => Promise;
+ _apiPut: (path: string, body: Record) => Promise;
+ saveModelConfigFromSettings: () => Promise;
+ _syncPushPreferences: () => void;
+ };
+
+ /**
+ * Drive the real saveAppSettings() in the page with the settings PUT stubbed
+ * out. Seeds the stored blob (via saveAppSettingsToStorage so the in-memory
+ * cache stays coherent), sets the sticky marker + checkbox, saves, and
+ * reports what happened to the marker and the captured server payload.
+ */
+ const runSave = async (opts: { storedPref: boolean | undefined }) =>
+ page.evaluate(async (o) => {
+ const app = (window as unknown as { app: SaveHarnessApp }).app;
+ const prevStored = { ...app.loadAppSettingsFromStorage() };
+ const origPut = app._apiPut;
+ const origModel = app.saveModelConfigFromSettings;
+ const origSync = app._syncPushPreferences;
+ let captured: Record | null = null;
+ try {
+ const seeded = { ...prevStored };
+ if (o.storedPref === undefined) delete seeded.webglRendererEnabled;
+ else seeded.webglRendererEnabled = o.storedPref;
+ app.saveAppSettingsToStorage(seeded);
+ localStorage.setItem('codeman-webgl-disabled', JSON.stringify({ at: Date.now() }));
+ (document.getElementById('appSettingsWebglRenderer') as HTMLInputElement).checked = true;
+ app._apiPut = async (_path, body) => {
+ captured = body;
+ return { ok: true };
+ };
+ app.saveModelConfigFromSettings = async () => {};
+ app._syncPushPreferences = () => {};
+ await app.saveAppSettings();
+ return {
+ markerCleared: localStorage.getItem('codeman-webgl-disabled') === null,
+ payloadHasWebglKey: captured ? 'webglRendererEnabled' in captured : null,
+ };
+ } finally {
+ app._apiPut = origPut;
+ app.saveModelConfigFromSettings = origModel;
+ app._syncPushPreferences = origSync;
+ localStorage.removeItem('codeman-webgl-disabled');
+ app.saveAppSettingsToStorage(prevStored);
+ }
+ }, opts);
+
+ it('a real OFF→ON flip clears the sticky marker and keeps the key off the settings PUT', async () => {
+ const result = await runSave({ storedPref: false });
+ expect(result.markerCleared).toBe(true);
+ expect(result.payloadHasWebglKey).toBe(false);
+ });
+
+ it('a save with the toggle merely default-checked leaves the sticky marker alone', async () => {
+ // No stored OFF → this is the "unrelated settings save stores true" case;
+ // clearing here would permanently defeat the GPU-stall auto-fallback.
+ const result = await runSave({ storedPref: undefined });
+ expect(result.markerCleared).toBe(false);
+ expect(result.payloadHasWebglKey).toBe(false);
+ });
+ });
});