diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js
index 0d7ac772..7a43a544 100644
--- a/src/web/public/session-ui.js
+++ b/src/web/public/session-ui.js
@@ -151,17 +151,21 @@ Object.assign(CodemanApp.prototype, {
return this.run();
},
- /** Run using the selected mode (Claude Code or OpenCode) */
+ /** Run using the selected mode (Claude Code, OpenCode, or Codex) */
async run() {
const mode = this._runMode || 'claude';
if (mode === 'opencode') {
return this.runOpenCode();
}
+ if (mode === 'codex') {
+ return this.runCodex();
+ }
return this.runClaude();
},
- /** Get/set the run mode, persisted in localStorage */
- get runMode() { return this._runMode || 'claude'; },
+ // Note: `runMode` is an accessor defined via Object.defineProperty at the bottom of
+ // this file — an object-literal getter here would be flattened to a static value by
+ // Object.assign (it copies values, not accessor descriptors).
setRunMode(mode) {
this._runMode = mode;
@@ -253,7 +257,7 @@ Object.assign(CodemanApp.prototype, {
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
}
if (label) {
- label.textContent = mode === 'opencode' ? 'Run OC' : 'Run';
+ label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : 'Run';
}
},
@@ -580,6 +584,53 @@ Object.assign(CodemanApp.prototype, {
}
},
+ async runCodex() {
+ const caseName = document.getElementById('quickStartCase').value || 'testcase';
+
+ this.terminal.clear();
+ this.terminal.writeln(`\x1b[1;32m Starting Codex session in ${caseName}...\x1b[0m`);
+ this.terminal.writeln('');
+ this.terminal.focus();
+
+ try {
+ const statusRes = await fetch('/api/codex/status');
+ const status = (await statusRes.json()).data;
+ if (!status.available) {
+ this.terminal.writeln('\x1b[1;31m Codex CLI not found.\x1b[0m');
+ this.terminal.writeln('\x1b[90m Install with: npm install -g @openai/codex\x1b[0m');
+ return;
+ }
+
+ const globalSettings = this.loadAppSettingsFromStorage();
+ const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
+ const res = await fetch('/api/quick-start', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ caseName,
+ mode: 'codex',
+ codexConfig: {
+ dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
+ renderMode: 'hybrid',
+ },
+ ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
+ })
+ });
+ const data = await res.json();
+ if (!data.success) throw new Error(data.error || 'Failed to start Codex');
+
+ // Switch to the new session (don't pre-set activeSessionId — selectSession
+ // early-returns when IDs match, skipping buffer load and sendResize)
+ if (data.data.sessionId) {
+ await this.selectSession(data.data.sessionId);
+ }
+
+ this.terminal.focus();
+ } catch (err) {
+ this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
+ }
+ },
+
// ═══════════════════════════════════════════════════════════════
// Session Options Modal
@@ -592,7 +643,7 @@ Object.assign(CodemanApp.prototype, {
this.editingSessionId = sessionId;
// Reset to an appropriate tab — Summary for OpenCode (Respawn/Ralph are Claude-only)
- this.switchOptionsTab(session.mode === 'opencode' ? 'summary' : 'respawn');
+ this.switchOptionsTab(session.mode === 'opencode' || session.mode === 'codex' ? 'summary' : 'respawn');
// Update respawn status display and buttons
const respawnStatus = document.getElementById('sessionRespawnStatus');
@@ -621,7 +672,7 @@ Object.assign(CodemanApp.prototype, {
}
// Hide Claude-specific options for OpenCode sessions
- const isOpenCode = session.mode === 'opencode';
+ const isOpenCode = session.mode === 'opencode' || session.mode === 'codex';
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
claudeOnlyEls.forEach(el => { el.style.display = isOpenCode ? 'none' : ''; });
@@ -1474,3 +1525,14 @@ Object.assign(CodemanApp.prototype, {
setTimeout(() => modal.classList.remove('from-mobile'), 300);
},
});
+
+Object.defineProperty(CodemanApp.prototype, 'runMode', {
+ configurable: true,
+ enumerable: true,
+ get() {
+ return this._runMode || 'claude';
+ },
+ set(mode) {
+ this._runMode = mode === 'opencode' || mode === 'codex' || mode === 'claude' ? mode : 'claude';
+ },
+});
diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js
index fdb61dbd..60f3600e 100644
--- a/src/web/public/settings-ui.js
+++ b/src/web/public/settings-ui.js
@@ -340,6 +340,9 @@ Object.assign(CodemanApp.prototype, {
claudeModeSelect.onchange = () => {
allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none';
};
+ // Codex CLI settings
+ document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked =
+ settings.codexDangerouslyBypassApprovals ?? false;
// Claude Permissions settings
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false;
@@ -1342,6 +1345,8 @@ Object.assign(CodemanApp.prototype, {
// Claude CLI settings
claudeMode: document.getElementById('appSettingsClaudeMode').value,
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
+ // Codex CLI settings
+ codexDangerouslyBypassApprovals: document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked,
// Claude Permissions settings
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
opusContext1mEnabled: document.getElementById('appSettingsOpusContext1m').checked,
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index f1b1c065..787dc8e6 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -1064,6 +1064,11 @@ body.solo-mode .btn-lifecycle-log {
color: #10b981;
}
+.session-tab .tab-mode.codex {
+ background: rgba(168, 85, 247, 0.2);
+ color: #a855f7;
+}
+
/* Timer Banner - Compact */
.timer-banner {
display: flex;
@@ -2745,6 +2750,22 @@ body.solo-mode .btn-lifecycle-log {
color: #a7f3d0;
}
+/* Codex mode colors */
+.btn-toolbar.btn-run.mode-codex,
+.btn-toolbar.btn-run-gear.mode-codex {
+ background: linear-gradient(135deg, #2a0a3e 0%, #350b4d 50%, #400d5e 100%);
+ border-color: rgba(168, 85, 247, 0.5);
+ color: #d8b4fe;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
+}
+.btn-toolbar.btn-run.mode-codex:hover,
+.btn-toolbar.btn-run-gear.mode-codex:hover {
+ background: linear-gradient(135deg, #400d5e 0%, #581c87 50%, #6b21a8 100%);
+ box-shadow: 0 0 12px rgba(168, 85, 247, 0.35), 0 2px 8px rgba(168, 85, 247, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08);
+ border-color: rgba(192, 132, 252, 0.6);
+ color: #e9d5ff;
+}
+
/* Dropdown menu */
.run-mode-menu {
display: none;
@@ -2797,6 +2818,7 @@ body.solo-mode .btn-lifecycle-log {
}
.run-mode-dot.claude { background: #3b82f6; }
.run-mode-dot.opencode { background: #10b981; }
+.run-mode-dot.codex { background: #a855f7; }
.run-mode-sep {
height: 1px;
diff --git a/src/web/routes/ralph-routes.ts b/src/web/routes/ralph-routes.ts
index 615858f4..ab971a51 100644
--- a/src/web/routes/ralph-routes.ts
+++ b/src/web/routes/ralph-routes.ts
@@ -9,7 +9,7 @@ import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
-import { Session } from '../../session.js';
+import { Session, isExternalCliMode } from '../../session.js';
import { RespawnController } from '../../respawn-controller.js';
import { RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, RalphLoopStartSchema } from '../schemas.js';
import { SseEvent } from '../sse-events.js';
@@ -44,9 +44,12 @@ export function registerRalphRoutes(
};
const session = findSessionOrFail(ctx, id);
- // Ralph tracker is not supported for opencode sessions
- if (session.mode === 'opencode') {
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Ralph tracker is not supported for opencode sessions');
+ // Ralph tracker is not supported for external-CLI sessions (opencode/codex)
+ if (isExternalCliMode(session.mode)) {
+ return createErrorResponse(
+ ApiErrorCode.INVALID_INPUT,
+ `Ralph tracker is not supported for ${session.mode} sessions`
+ );
}
// Handle reset first (before other config)
diff --git a/src/web/routes/respawn-routes.ts b/src/web/routes/respawn-routes.ts
index 15749437..834ee3bf 100644
--- a/src/web/routes/respawn-routes.ts
+++ b/src/web/routes/respawn-routes.ts
@@ -11,6 +11,7 @@ import { SseEvent } from '../sse-events.js';
import { findSessionOrFail, autoConfigureRalph, parseBody } from '../route-helpers.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
+import { isExternalCliMode } from '../../session.js';
import {
AI_CHECK_MODEL,
AI_IDLE_CHECK_MAX_CONTEXT,
@@ -88,9 +89,9 @@ export function registerRespawnRoutes(
}
const session = findSessionOrFail(ctx, id);
- // Respawn is not supported for opencode sessions
- if (session.mode === 'opencode') {
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions');
+ // Respawn is not supported for external-CLI sessions (opencode/codex)
+ if (isExternalCliMode(session.mode)) {
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`);
}
// Create or get existing controller
@@ -231,9 +232,9 @@ export function registerRespawnRoutes(
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
}
- // Respawn is not supported for opencode sessions
- if (session.mode === 'opencode') {
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions');
+ // Respawn is not supported for external-CLI sessions (opencode/codex)
+ if (isExternalCliMode(session.mode)) {
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`);
}
try {
@@ -296,9 +297,9 @@ export function registerRespawnRoutes(
const body = reResult.data as { config?: Partial
; durationMinutes?: number };
const session = findSessionOrFail(ctx, id);
- // Respawn is not supported for opencode sessions
- if (session.mode === 'opencode') {
- return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions');
+ // Respawn is not supported for external-CLI sessions (opencode/codex)
+ if (isExternalCliMode(session.mode)) {
+ return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Respawn is not supported for ${session.mode} sessions`);
}
// Check if session is running (has a PID)
diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts
index 5e24be67..60b34cb3 100644
--- a/src/web/routes/session-routes.ts
+++ b/src/web/routes/session-routes.ts
@@ -282,6 +282,17 @@ export function registerSessionRoutes(
}
}
+ // Check Codex availability if requested
+ if (body.mode === 'codex') {
+ const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
+ if (!isCodexAvailable()) {
+ return createErrorResponse(
+ ApiErrorCode.OPERATION_FAILED,
+ 'Codex CLI not found. Install with: npm install -g @openai/codex'
+ );
+ }
+ }
+
// Pre-validate resumeSessionId: check that the conversation file actually exists
// in Claude's projects directory. If not, skip resume to avoid confusing
// "No conversation found" errors from Claude CLI.
@@ -318,9 +329,11 @@ export function registerSessionRoutes(
const model =
mode === 'opencode'
? body.openCodeConfig?.model
- : mode !== 'shell'
- ? modelConfig?.defaultModel || undefined
- : undefined;
+ : mode === 'codex'
+ ? body.codexConfig?.model
+ : mode !== 'shell'
+ ? modelConfig?.defaultModel || undefined
+ : undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig();
const session = new Session({
workingDir,
@@ -333,6 +346,7 @@ export function registerSessionRoutes(
claudeMode: claudeModeConfig.claudeMode,
allowedTools: claudeModeConfig.allowedTools,
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
+ codexConfig: mode === 'codex' ? body.codexConfig : undefined,
resumeSessionId: validatedResumeId,
envOverrides: body.envOverrides,
effort: body.effort,
@@ -1112,6 +1126,7 @@ export function registerSessionRoutes(
caseName = 'testcase',
mode = 'claude',
openCodeConfig,
+ codexConfig,
envOverrides,
effort,
} = parseBody(QuickStartSchema, req.body);
@@ -1127,6 +1142,17 @@ export function registerSessionRoutes(
}
}
+ // Check Codex availability if requested
+ if (mode === 'codex') {
+ const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
+ if (!isCodexAvailable()) {
+ return createErrorResponse(
+ ApiErrorCode.OPERATION_FAILED,
+ 'Codex CLI not found. Install with: npm install -g @openai/codex'
+ );
+ }
+ }
+
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
// external project directories are honoured by quick-start just like regular case routes.
@@ -1179,9 +1205,11 @@ export function registerSessionRoutes(
const qsModel =
mode === 'opencode'
? openCodeConfig?.model
- : mode !== 'shell'
- ? qsModelConfig?.defaultModel || undefined
- : undefined;
+ : mode === 'codex'
+ ? codexConfig?.model
+ : mode !== 'shell'
+ ? qsModelConfig?.defaultModel || undefined
+ : undefined;
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
const session = new Session({
workingDir: casePath,
@@ -1193,6 +1221,7 @@ export function registerSessionRoutes(
claudeMode: qsClaudeModeConfig.claudeMode,
allowedTools: qsClaudeModeConfig.allowedTools,
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
+ codexConfig: mode === 'codex' ? codexConfig : undefined,
envOverrides,
effort,
});
diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts
index 6d593305..e3bd2f49 100644
--- a/src/web/routes/system-routes.ts
+++ b/src/web/routes/system-routes.ts
@@ -341,6 +341,14 @@ export function registerSystemRoutes(
};
});
+ app.get('/api/codex/status', async () => {
+ const { isCodexAvailable, resolveCodexDir } = await import('../../utils/codex-cli-resolver.js');
+ return {
+ available: isCodexAvailable(),
+ path: resolveCodexDir(),
+ };
+ });
+
// ═══════════════════════════════════════════════════════════════
// State & Lifecycle (cleanup, lifecycle log, stats)
// ═══════════════════════════════════════════════════════════════
diff --git a/src/web/schemas.ts b/src/web/schemas.ts
index d1b4b7f0..a1e9c7c2 100644
--- a/src/web/schemas.ts
+++ b/src/web/schemas.ts
@@ -46,7 +46,7 @@ const safePathSchema = z.string().max(1000).refine(isValidWorkingDir, {
// ========== Env Var Allowlist ==========
/** Allowlisted env var key prefixes */
-const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_'];
+const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_'];
/** Env var keys that are always blocked (security-sensitive) */
const BLOCKED_ENV_KEYS = new Set([
@@ -76,7 +76,7 @@ const safeEnvOverridesSchema = z
},
{
message:
- 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_* and OPENCODE_* keys are allowed.',
+ 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, and CODEX_* keys are allowed.',
}
);
@@ -128,9 +128,30 @@ const OpenCodeConfigSchema = z
})
.optional();
+/** Schema for Codex (OpenAI CLI)-specific configuration */
+const CodexConfigSchema = z
+ .object({
+ model: z
+ .string()
+ .max(100)
+ .regex(/^[a-zA-Z0-9._\-/]+$/)
+ .optional(),
+ resumeSessionId: z
+ .string()
+ .max(100)
+ .regex(/^[a-zA-Z0-9_-]+$/)
+ .optional(),
+ dangerouslyBypassApprovals: z.boolean().optional(),
+ renderMode: z
+ .enum(['scrollback', 'hybrid'])
+ .optional()
+ .transform(() => 'hybrid' as const),
+ })
+ .optional();
+
export const CreateSessionSchema = z.object({
workingDir: safePathSchema.optional(),
- mode: z.enum(['claude', 'shell', 'opencode']).optional(),
+ mode: z.enum(['claude', 'shell', 'opencode', 'codex']).optional(),
name: z.string().max(100).optional(),
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
@@ -138,6 +159,7 @@ export const CreateSessionSchema = z.object({
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
modelOverride: z.string().max(50).optional(),
openCodeConfig: OpenCodeConfigSchema,
+ codexConfig: CodexConfigSchema,
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
resumeSessionId: z
.string()
@@ -188,8 +210,9 @@ export const QuickStartSchema = z.object({
.string()
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.')
.optional(),
- mode: z.enum(['claude', 'shell', 'opencode']).optional(),
+ mode: z.enum(['claude', 'shell', 'opencode', 'codex']).optional(),
openCodeConfig: OpenCodeConfigSchema,
+ codexConfig: CodexConfigSchema,
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
@@ -301,6 +324,8 @@ export const SettingsUpdateSchema = z
// Claude CLI settings
claudeMode: z.string().max(50).optional(),
allowedTools: z.string().max(2000).optional(),
+ // Codex CLI settings
+ codexDangerouslyBypassApprovals: z.boolean().optional(),
// CPU priority
nice: z
.object({
diff --git a/src/web/server.ts b/src/web/server.ts
index 67f776c2..3b91feb7 100644
--- a/src/web/server.ts
+++ b/src/web/server.ts
@@ -42,7 +42,7 @@ import { execSync } from 'node:child_process';
import { hostname as getHostname } from 'node:os';
import { dataPath } from '../config/instance.js';
import { EventEmitter } from 'node:events';
-import { Session, type BackgroundTask } from '../session.js';
+import { Session, isExternalCliMode, type BackgroundTask } from '../session.js';
import type { ClaudeMode, SessionState } from '../types.js';
import { RespawnController, RespawnConfig } from '../respawn-controller.js';
import type { TerminalMultiplexer } from '../mux-interface.js';
@@ -1189,8 +1189,8 @@ export class WebServer extends EventEmitter {
this.runSummaryTrackers.set(session.id, summaryTracker);
summaryTracker.recordSessionStarted(session.mode, session.workingDir);
- // Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for opencode sessions)
- if (session.mode !== 'opencode') {
+ // Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for external CLIs)
+ if (!isExternalCliMode(session.mode)) {
session.ralphTracker.setWorkingDir(session.workingDir);
}
@@ -2016,8 +2016,8 @@ export class WebServer extends EventEmitter {
);
}
}
- // Ralph / Todo tracker (not supported for opencode sessions)
- if (session.mode !== 'opencode') {
+ // Ralph / Todo tracker (not supported for external-CLI sessions)
+ if (!isExternalCliMode(session.mode)) {
if (savedState.ralphAutoEnableDisabled) {
session.ralphTracker.disableAutoEnable();
console.log(`[Server] Restored Ralph auto-enable disabled for session ${session.id}`);
@@ -2046,8 +2046,8 @@ export class WebServer extends EventEmitter {
if (savedState.flickerFilterEnabled !== undefined) {
session.flickerFilterEnabled = savedState.flickerFilterEnabled;
}
- // Respawn controller (not supported for opencode sessions)
- if (session.mode !== 'opencode' && savedState.respawnEnabled && savedState.respawnConfig) {
+ // Respawn controller (not supported for external-CLI sessions)
+ if (!isExternalCliMode(session.mode) && savedState.respawnEnabled && savedState.respawnConfig) {
try {
this.restoreRespawnController(session, savedState.respawnConfig, 'state.json');
} catch (err) {
@@ -2056,9 +2056,9 @@ export class WebServer extends EventEmitter {
}
}
- // Fallback: restore respawn from mux-sessions.json if state.json didn't have it (not supported for opencode)
+ // Fallback: restore respawn from mux-sessions.json if state.json didn't have it (not supported for external CLIs)
if (
- session.mode !== 'opencode' &&
+ !isExternalCliMode(session.mode) &&
!this.respawnControllers.has(session.id) &&
muxSession.respawnConfig?.enabled
) {
@@ -2073,9 +2073,9 @@ export class WebServer extends EventEmitter {
}
// Fallback: restore Ralph state from state-inner.json if not already set and not explicitly disabled
- // Ralph tracker is not supported for opencode sessions
+ // Ralph tracker is not supported for external-CLI sessions
if (
- session.mode !== 'opencode' &&
+ !isExternalCliMode(session.mode) &&
!session.ralphTracker.enabled &&
!session.ralphTracker.autoEnableDisabled
) {
@@ -2086,9 +2086,9 @@ export class WebServer extends EventEmitter {
}
}
- // Fallback: auto-detect completion phrase from CLAUDE.md (not supported for opencode)
+ // Fallback: auto-detect completion phrase from CLAUDE.md (not supported for external CLIs)
if (
- session.mode !== 'opencode' &&
+ !isExternalCliMode(session.mode) &&
session.ralphTracker.enabled &&
!session.ralphTracker.loopState.completionPhrase
) {
diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts
new file mode 100644
index 00000000..3624b4f2
--- /dev/null
+++ b/test/run-mode-ui.test.ts
@@ -0,0 +1,135 @@
+/**
+ * @fileoverview Unit tests for the Codex run-mode UI surface in session-ui.js /
+ * settings-ui.js / index.html. Loads the browser modules into a vm sandbox (no
+ * real DOM) and exercises run-mode selection + Codex quick-start wiring.
+ */
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import vm from 'node:vm';
+import { describe, expect, it } from 'vitest';
+
+function loadRunModeHarness() {
+ const elements: Record = {};
+ const storage = new Map();
+ const CodemanApp = function CodemanApp(this: any) {};
+
+ const context = vm.createContext({
+ CodemanApp,
+ VoiceInput: {},
+ localStorage: {
+ getItem: (key: string) => storage.get(key) ?? null,
+ setItem: (key: string, value: string) => storage.set(key, value),
+ },
+ document: {
+ getElementById: (id: string) => elements[id] ?? null,
+ },
+ console,
+ });
+
+ const settingsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/settings-ui.js'), 'utf8');
+ const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
+ vm.runInContext(settingsUi, context, { filename: 'settings-ui.js' });
+ vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
+
+ const runModeMenu = { classList: { remove: () => {} } };
+ const gearBtn = { className: '' };
+ const runBtn = { className: '', nextElementSibling: gearBtn };
+ const runBtnLabel = { textContent: '' };
+ elements.runModeMenu = runModeMenu;
+ elements.runBtn = runBtn;
+ elements.runBtnLabel = runBtnLabel;
+
+ const app = new (CodemanApp as any)();
+ app.loadAppSettingsFromStorage = () => ({});
+ app.saveAppSettingsToStorage = () => {};
+ app._apiPut = () => Promise.resolve();
+
+ return { app, storage, runBtnLabel };
+}
+
+describe('run mode UI', () => {
+ it('updates the visible mode when selecting Claude after server sync set Codex', async () => {
+ const { app, storage, runBtnLabel } = loadRunModeHarness();
+
+ storage.set('codeman_runMode', 'claude');
+ await app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'codex' }));
+ expect(app.runMode).toBe('codex');
+ expect(runBtnLabel.textContent).toBe('Run CX');
+
+ app.setRunMode('claude');
+
+ expect(app.runMode).toBe('claude');
+ expect(runBtnLabel.textContent).toBe('Run');
+ });
+});
+
+describe('Codex quick start settings', () => {
+ it('renders Codex CLI settings in a dedicated app settings tab', () => {
+ const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
+
+ expect(html).toContain('data-tab="settings-codex">Codex CLI');
+
+ const claudeTab = html.match(
+ /([\s\S]*?)/
+ );
+ expect(claudeTab?.[1]).not.toContain('appSettingsCodexDangerouslyBypassApprovals');
+
+ const codexTab = html.match(
+ /
([\s\S]*?)<\/div>\s*/
+ );
+ expect(codexTab?.[1]).toContain('appSettingsCodexDangerouslyBypassApprovals');
+ expect(codexTab?.[1]).not.toContain('appSettingsCodexRenderMode');
+ });
+
+ it('passes global Codex settings into quick-start config for new sessions', async () => {
+ const elements: Record = {
+ quickStartCase: { value: 'codex-case' },
+ };
+ const requests: Array<{ url: string; body?: any }> = [];
+ const CodemanApp = function CodemanApp(this: any) {};
+
+ const context = vm.createContext({
+ CodemanApp,
+ localStorage: {
+ getItem: () => null,
+ setItem: () => {},
+ },
+ document: {
+ getElementById: (id: string) => elements[id] ?? null,
+ },
+ // Mock responses use the real wire shape: the global preSerialization hook in
+ // server.ts wraps route payloads into the { success, data } envelope.
+ fetch: async (url: string, init?: { body?: string }) => {
+ requests.push({ url, body: init?.body ? JSON.parse(init.body) : undefined });
+ if (url === '/api/codex/status') return { json: async () => ({ success: true, data: { available: true } }) };
+ if (url === '/api/quick-start') return { json: async () => ({ success: true, data: { sessionId: 'sess-1' } }) };
+ throw new Error(`unexpected fetch: ${url}`);
+ },
+ console,
+ });
+
+ const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
+ vm.runInContext(sessionUi, context, { filename: 'session-ui.js' });
+
+ const app = new (CodemanApp as any)();
+ app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} };
+ app.loadAppSettingsFromStorage = () => ({
+ codexDangerouslyBypassApprovals: true,
+ });
+ app.getCaseSettings = () => ({});
+ app.buildEnvOverrides = () => ({});
+ const selected: string[] = [];
+ app.selectSession = async (id: string) => {
+ selected.push(id);
+ };
+
+ await app.runCodex();
+
+ expect(requests.find((req) => req.url === '/api/quick-start')?.body).toMatchObject({
+ caseName: 'codex-case',
+ mode: 'codex',
+ codexConfig: { dangerouslyBypassApprovals: true, renderMode: 'hybrid' },
+ });
+ expect(selected).toEqual(['sess-1']);
+ });
+});