diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 41a90b71..3dd6dde8 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -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 diff --git a/src/web/server.ts b/src/web/server.ts index caa17aba..7e916f8a 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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('', `\n`); + } + } if (!soloSessionId && process.env.CODEMAN_GESTURE === '1') { html = html.replace('', `\n`); if (settings.gestureControlEnabled === true) { diff --git a/test/render-index-html.test.ts b/test/render-index-html.test.ts index 2ae4b6ed..33b6246c 100644 --- a/test/render-index-html.test.ts +++ b/test/render-index-html.test.ts @@ -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 = [ '
', @@ -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 }); diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index 46778536..78485228 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -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