Merge PR #140 from crawlsys/feat/webgl-renderer-toggle: WebGL renderer toggle in settings

Includes review fixes (per-device setting + sticky-marker semantics); merged locally because the org-owned fork rejects maintainer pushes.
This commit is contained in:
Codeman maintainer
2026-07-12 13:22:53 +02:00
6 changed files with 236 additions and 9 deletions
+5
View File
@@ -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.
+31
View File
@@ -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,
};
+7
View File
@@ -1011,6 +1011,13 @@
<option value="og">OG Codeman</option>
</select>
</div>
<div class="settings-item" id="appSettingsWebglRendererItem" title="Use the GPU-accelerated WebGL terminal renderer (desktop only). Turn off to force the DOM renderer if you hit GPU glitches. Codeman also auto-falls-back to the DOM renderer after repeated GPU stalls.">
<span class="settings-item-label">WebGL Renderer</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsWebglRenderer">
<span class="slider"></span>
</label>
</div>
<!-- Input Section -->
<div class="settings-section-header">Input</div>
<div class="settings-item settings-item-multiline" title="Shows typed characters instantly via overlay while forwarding keystrokes to the server in the background. Enables Tab completion, preserves input across tab switches, and protects against session crashes. Recommended for mobile and high-latency connections.">
+25 -1
View File
@@ -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
+19 -8
View File
@@ -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) {
+149
View File
@@ -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<string, unknown>;
saveAppSettingsToStorage: (s: Record<string, unknown>) => void;
saveAppSettings: () => Promise<void>;
_apiPut: (path: string, body: Record<string, unknown>) => Promise<unknown>;
saveModelConfigFromSettings: () => Promise<void>;
_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<string, unknown> | 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);
});
});
});