Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/webgl-renderer-toggle.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 26 additions & 0 deletions src/web/public/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,31 @@ 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. user toggle ON / ?webgl=force -> enable + clear stale sticky marker
* 4. untouched (default on) -> respect the auto-fallback sticky marker
*
* @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 };
const pref = input.userPrefEnabled; // true | false | undefined (default on)
if (pref === false) return { skip: true, clearSticky: false };
if (input.noWebglParam) return { skip: true, clearSticky: false };
if (pref === true || 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.
Expand All @@ -129,6 +154,7 @@ function shouldAutoWrapTabs(input) {
if (typeof window !== 'undefined') {
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
window.shouldSkipWebGL = shouldSkipWebGL;
window.CodemanTabOverflow = {
shouldAutoWrapTabs,
};
Expand Down
7 changes: 7 additions & 0 deletions src/web/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,13 @@ <h3>App Settings</h3>
<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.">
Expand Down
7 changes: 7 additions & 0 deletions src/web/public/settings-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1377,6 +1382,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,
Expand Down Expand Up @@ -1694,6 +1700,7 @@ Object.assign(CodemanApp.prototype, {
ralphTrackerEnabled: false,
tabTwoRows: false,
cjkInputEnabled: false,
webglRendererEnabled: false, // mobile always uses the DOM renderer
skin: 'daylight-blue',
};
}
Expand Down
25 changes: 17 additions & 8 deletions src/web/public/terminal-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,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');
Expand All @@ -263,11 +260,23 @@ 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,
});
// Explicit opt-in (toggle ON) or ?webgl=force retires a stale auto-fallback marker.
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) {
Expand Down
76 changes: 76 additions & 0 deletions test/webgl-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,4 +215,80 @@ 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('explicit opt-in enables and clears a stale sticky marker', async () => {
expect(await run({ deviceType: 'desktop', userPrefEnabled: true, stickyDisabled: true })).toEqual({
skip: false,
clearSticky: true,
});
});

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,
});
});
});
});