feat(settings): show the Codex CLI tab only where codex is installed

Both settings on the App Settings "Codex CLI" tab (bypass approvals, animated
status effects) are handed to `codex` at launch, so on an instance where the
binary does not resolve the tab offers choices nothing can act on. Gate it on
availability instead.

renderIndexHtml injects window.__codemanCodexAvailable, mirroring the existing
gesture-availability flag, and settings-ui.js hides the tab button when it is
absent. Injected rather than fetched on modal open so the tab cannot flicker in
and back out; isCodexAvailable() memoizes its PATH probe, so the per-render cost
is nil. Installing codex later needs a restart, exactly like the
/api/codex/status route that already backs the Run menu. Solo popups skip the
probe since they have no settings modal.

Only the tab BUTTON is toggled. The panel already carries
.modal-tab-content.hidden unless it is the selected tab and openAppSettings()
always reopens on Display, so an unreachable button keeps the panel unreachable.
The inputs stay in the DOM and are still populated and read back on save, so a
user without codex cannot silently wipe the codex preferences of an instance
that has it. Animations stay off by default for new local Codex sessions.

Verified in a browser on this host, which has no codex: the flag is absent, the
Codex tab is hidden while the other tabs are unaffected, and saving App Settings
with the tab hidden leaves codexAnimationsEnabled/codexDangerouslyBypassApprovals
untouched. With the flag forced on, the tab appears, its panel opens, and
toggling the visible slider persists. The openAppSettings coupling test was
checked to fail when the call is removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-05 01:14:53 +02:00
parent ddc267c6ff
commit 816d900857
4 changed files with 119 additions and 1 deletions
+23 -1
View File
@@ -373,11 +373,15 @@ Object.assign(CodemanApp.prototype, {
claudeModeSelect.onchange = () => {
allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none';
};
// Codex CLI settings
// Codex CLI settings. The inputs are always populated (and always read back
// by saveAppSettings), even when the tab is hidden below, so a user without
// codex installed can never silently wipe the codex prefs of an instance
// that does have it.
document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked =
settings.codexDangerouslyBypassApprovals ?? false;
document.getElementById('appSettingsCodexAnimations').checked =
settings.codexAnimationsEnabled ?? false;
this._applyCodexSettingsVisibility();
// Claude Permissions settings
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
document.getElementById('appSettingsClaudeModel').value = settings.claudeModel ?? '';
@@ -487,6 +491,24 @@ Object.assign(CodemanApp.prototype, {
this.activeFocusTrap.activate();
},
/**
* Show the App Settings "Codex CLI" tab only on instances where the codex
* binary actually resolves. Both settings on it (approval bypass, animated
* status effects) are passed to `codex` at launch, so on a box without codex
* the tab is a promise nothing can keep.
*
* Availability comes from `window.__codemanCodexAvailable`, injected by
* renderIndexHtml (same shape as the gesture flag) so the tab never flickers
* in and back out. Only the tab BUTTON is toggled: the panel already carries
* `.modal-tab-content.hidden` unless it is the selected tab, and
* openAppSettings() always reopens on Display, so an unreachable button is
* enough to keep the panel unreachable.
*/
_applyCodexSettingsVisibility() {
const btn = document.querySelector('#appSettingsModal .modal-tab-btn[data-tab="settings-codex"]');
if (btn) btn.style.display = window.__codemanCodexAvailable ? '' : 'none';
},
switchSettingsTab(tabName) {
const modal = document.getElementById('appSettingsModal');
// Toggle active class on tab buttons
+13
View File
@@ -1288,6 +1288,19 @@ export class WebServer extends EventEmitter {
// actual on/off. We expose `__codemanGestureAvailable` so the settings UI can
// show the toggle only when the feature is available, and inject the bundle
// (served same-origin from /gesture/, so 'self' covers it) only when enabled.
// Codex CLI settings tab: the App Settings "Codex CLI" tab only means anything
// when the codex binary is actually installed, so expose availability and let
// settings-ui.js hide the tab otherwise (same shape as the gesture flag below).
// Injected rather than fetched so the tab never flickers in and back out on
// modal open. `isCodexAvailable()` memoizes its PATH probe, so this is cheap
// per render; installing codex afterwards needs a server restart, exactly like
// the /api/codex/status route that backs the Run menu.
if (!soloSessionId) {
const { isCodexAvailable } = await import('../utils/codex-cli-resolver.js');
if (isCodexAvailable()) {
html = html.replace('</head>', `<script>window.__codemanCodexAvailable=true;</script>\n</head>`);
}
}
if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') {
html = html.replace('</head>', `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
if (settings.gestureControlEnabled === true) {
+31
View File
@@ -12,6 +12,16 @@
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { isCodexAvailable } from '../src/utils/codex-cli-resolver.js';
// renderIndexHtml probes the real PATH for `codex`, which would make the codex
// assertions below depend on whatever happens to be installed on the machine
// running the suite. Default to "not installed" so every OTHER test in this file
// renders exactly as before, and opt in per test.
vi.mock('../src/utils/codex-cli-resolver.js', () => ({
isCodexAvailable: vi.fn(() => false),
resolveCodexDir: vi.fn(() => null),
}));
const TEMPLATE = [
'<head>',
@@ -86,6 +96,27 @@ describe('WebServer.renderIndexHtml', () => {
expect(html).toContain('gesture-codeman.js');
});
it('exposes codex availability so the App Settings Codex tab can be gated', async () => {
vi.mocked(isCodexAvailable).mockReturnValue(true);
const { server } = makeServer({});
const html = await render(server);
expect(html).toContain('window.__codemanCodexAvailable=true');
});
it('omits the codex flag entirely when the codex binary does not resolve', async () => {
vi.mocked(isCodexAvailable).mockReturnValue(false);
const { server } = makeServer({});
const html = await render(server);
expect(html).not.toContain('__codemanCodexAvailable');
});
it('skips the codex probe for a solo window, which has no settings modal', async () => {
vi.mocked(isCodexAvailable).mockReturnValue(true);
const { server } = makeServer({});
const html = await render(server, 'sess-123');
expect(html).not.toContain('__codemanCodexAvailable');
});
it('does not expose gesture at all when CODEMAN_GESTURE is unset', async () => {
delete process.env.CODEMAN_GESTURE;
const { server } = makeServer({ gestureControlEnabled: true });
+52
View File
@@ -293,6 +293,58 @@ describe('Codex quick start settings', () => {
expect(codexTab?.[1]).not.toContain('appSettingsCodexRenderMode');
});
describe('Codex CLI tab visibility', () => {
// Both settings on the tab are handed to `codex` at launch, so on an instance
// where the binary does not resolve the tab is a promise nothing can keep.
// renderIndexHtml injects window.__codemanCodexAvailable; this pins the client
// half. Coupled test: it drives the REAL settings-ui.js against a stub button,
// so deleting the call in openAppSettings() is what it is meant to catch.
function loadSettingsUi(codexAvailable: boolean | undefined) {
const codexTabBtn = { dataset: { tab: 'settings-codex' }, style: { display: 'PRISTINE' } };
const CodemanApp = function CodemanApp(this: any) {};
const context: any = vm.createContext({
CodemanApp,
MobileDetection: { getDeviceType: () => 'desktop', isTouchDevice: () => false, isHandheldDevice: () => false },
localStorage: { getItem: () => null, setItem: () => {} },
document: {
getElementById: () => null,
querySelector: (sel: string) => (sel.includes('[data-tab="settings-codex"]') ? codexTabBtn : null),
},
console,
});
context.window = context;
if (codexAvailable !== undefined) context.__codemanCodexAvailable = codexAvailable;
const settingsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' });
return { app: new (CodemanApp as any)(), codexTabBtn };
}
it('hides the Codex tab when the codex binary is not available', () => {
const { app, codexTabBtn } = loadSettingsUi(false);
app._applyCodexSettingsVisibility();
expect(codexTabBtn.style.display).toBe('none');
});
it('hides the Codex tab when the availability flag was never injected', () => {
const { app, codexTabBtn } = loadSettingsUi(undefined);
app._applyCodexSettingsVisibility();
expect(codexTabBtn.style.display).toBe('none');
});
it('shows the Codex tab when codex is available', () => {
const { app, codexTabBtn } = loadSettingsUi(true);
app._applyCodexSettingsVisibility();
expect(codexTabBtn.style.display).toBe('');
});
it('applies the gating from openAppSettings, not just in isolation', () => {
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
const open = src.slice(src.indexOf('\n openAppSettings() {'));
const body = open.slice(0, open.indexOf('\n },'));
expect(body).toContain('_applyCodexSettingsVisibility()');
});
});
it('passes global Codex settings into quick-start config for new sessions', async () => {
const elements: Record<string, any> = {
quickStartCase: { value: 'codex-case' },