diff --git a/src/mux-interface.ts b/src/mux-interface.ts
index 1eddd1da..9bc7dfec 100644
--- a/src/mux-interface.ts
+++ b/src/mux-interface.ts
@@ -16,6 +16,7 @@ import type {
OpenCodeConfig,
CodexConfig,
EffortLevel,
+ GeminiConfig,
} from './types.js';
/**
@@ -64,6 +65,7 @@ export interface CreateSessionOptions {
allowedTools?: string;
openCodeConfig?: OpenCodeConfig;
codexConfig?: CodexConfig;
+ geminiConfig?: GeminiConfig;
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
@@ -83,6 +85,7 @@ export interface RespawnPaneOptions {
allowedTools?: string;
openCodeConfig?: OpenCodeConfig;
codexConfig?: CodexConfig;
+ geminiConfig?: GeminiConfig;
/** Resume a previous Claude conversation when respawning */
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (preserved across respawns). */
diff --git a/src/session.ts b/src/session.ts
index a6281069..3b45835d 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -48,6 +48,7 @@ import {
type OpenCodeConfig,
type CodexConfig,
type EffortLevel,
+ type GeminiConfig,
} from './types.js';
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
@@ -133,7 +134,22 @@ const NEWLINE_SPLIT_PATTERN = /\r?\n/;
/** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */
export function isExternalCliMode(mode: SessionMode): boolean {
- return mode === 'opencode' || mode === 'codex';
+ return mode === 'opencode' || mode === 'codex' || mode === 'gemini';
+}
+
+function getModeLabel(mode: SessionMode): string {
+ switch (mode) {
+ case 'opencode':
+ return 'OpenCode';
+ case 'codex':
+ return 'Codex';
+ case 'gemini':
+ return 'Gemini';
+ case 'shell':
+ return 'Shell';
+ case 'claude':
+ return 'Claude';
+ }
}
/**
@@ -351,6 +367,8 @@ export class Session extends EventEmitter {
private _openCodeConfig: OpenCodeConfig | undefined;
// Codex configuration (only for mode === 'codex')
private _codexConfig: CodexConfig | undefined;
+ // Gemini configuration (only for mode === 'gemini')
+ private _geminiConfig: GeminiConfig | undefined;
private _resumeSessionId: string | undefined;
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
@@ -421,6 +439,8 @@ export class Session extends EventEmitter {
openCodeConfig?: OpenCodeConfig;
/** Codex configuration (only for mode === 'codex') */
codexConfig?: CodexConfig;
+ /** Gemini configuration (only for mode === 'gemini') */
+ geminiConfig?: GeminiConfig;
/** Resume a previous Claude conversation (used after server reboot) */
resumeSessionId?: string;
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
@@ -481,6 +501,11 @@ export class Session extends EventEmitter {
this._codexConfig = config.codexConfig;
}
+ // Apply Gemini configuration
+ if (config.geminiConfig) {
+ this._geminiConfig = config.geminiConfig;
+ }
+
// Apply env overrides (exported at spawn, not persisted to disk).
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
// which hard-locks /effort switching. Extract it into _effort (--settings soft default)
@@ -985,6 +1010,7 @@ export class Session extends EventEmitter {
cliLatestVersion: this._cliLatestVersion || undefined,
openCodeConfig: this._openCodeConfig,
codexConfig: this._codexConfig,
+ geminiConfig: this._geminiConfig,
resumeSessionId: this._resumeSessionId,
effort: this._effort,
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
@@ -1224,7 +1250,7 @@ export class Session extends EventEmitter {
this._resetBuffers();
- const modeLabel = this.mode === 'opencode' ? 'OpenCode' : this.mode === 'codex' ? 'Codex' : 'Claude';
+ const modeLabel = getModeLabel(this.mode);
console.log(
`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '')
);
@@ -1243,6 +1269,7 @@ export class Session extends EventEmitter {
allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig,
codexConfig: this._codexConfig,
+ geminiConfig: this._geminiConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
@@ -1258,6 +1285,7 @@ export class Session extends EventEmitter {
allowedTools: this._allowedTools,
openCodeConfig: this._openCodeConfig,
codexConfig: this._codexConfig,
+ geminiConfig: this._geminiConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
@@ -1331,6 +1359,10 @@ export class Session extends EventEmitter {
if (this.mode === 'codex') {
throw new Error('Codex sessions require tmux. Direct PTY fallback is not supported.');
}
+ // Gemini sessions require tmux for Gemini/Google auth env injection via setenv
+ if (this.mode === 'gemini') {
+ throw new Error('Gemini sessions require tmux. Direct PTY fallback is not supported.');
+ }
try {
// Pass --session-id to use the SAME ID as the Codeman session
// This ensures subagents can be directly matched to the correct tab
diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts
index 74987265..6e288055 100644
--- a/src/tmux-manager.ts
+++ b/src/tmux-manager.ts
@@ -41,9 +41,17 @@ import {
type OpenCodeConfig,
type CodexConfig,
type EffortLevel,
+ type GeminiConfig,
} from './types.js';
import { buildEffortCliArgs } from './session-cli-builder.js';
-import { wrapWithNice, SAFE_PATH_PATTERN, findClaudeDir, resolveOpenCodeDir, resolveCodexDir } from './utils/index.js';
+import {
+ wrapWithNice,
+ SAFE_PATH_PATTERN,
+ findClaudeDir,
+ resolveOpenCodeDir,
+ resolveCodexDir,
+ resolveGeminiDir,
+} from './utils/index.js';
import type {
TerminalMultiplexer,
MuxSession,
@@ -571,6 +579,35 @@ export function buildCodexCommand(config?: CodexConfig): string {
return parts.join(' ');
}
+/**
+ * Build the Gemini CLI command with appropriate flags.
+ *
+ * `--skip-trust` avoids a first-run workspace trust prompt inside Codeman.
+ * Approval mode defaults to `yolo` for parity with Codeman's Claude default
+ * of `--dangerously-skip-permissions`; users can override it later through
+ * Gemini config once Codeman exposes richer Gemini settings.
+ */
+function buildGeminiCommand(config?: GeminiConfig): string {
+ const parts = ['gemini', '--skip-trust'];
+
+ const approvalMode = config?.approvalMode || 'yolo';
+ if (['default', 'auto_edit', 'yolo', 'plan'].includes(approvalMode)) {
+ parts.push('--approval-mode', approvalMode);
+ }
+
+ if (config?.model) {
+ const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
+ if (safeModel) parts.push('--model', safeModel);
+ }
+
+ if (config?.resumeSession) {
+ const safeId = /^[a-zA-Z0-9._-]+$/.test(config.resumeSession) ? config.resumeSession : undefined;
+ if (safeId) parts.push('--resume', safeId);
+ }
+
+ return parts.join(' ');
+}
+
/**
* Build the spawn command for any session mode.
* Shared by createSession() and respawnPane() to avoid duplication.
@@ -597,6 +634,7 @@ function buildSpawnCommand(options: {
allowedTools?: string;
openCodeConfig?: OpenCodeConfig;
codexConfig?: CodexConfig;
+ geminiConfig?: GeminiConfig;
resumeSessionId?: string;
effort?: EffortLevel;
}): string {
@@ -624,6 +662,9 @@ function buildSpawnCommand(options: {
if (options.mode === 'codex') {
return buildCodexCommand(options.codexConfig);
}
+ if (options.mode === 'gemini') {
+ return buildGeminiCommand(options.geminiConfig);
+ }
return '$SHELL';
}
@@ -674,6 +715,38 @@ function setCodexEnvVars(tmuxCmd: string, muxName: string): void {
}
}
+/**
+ * Set sensitive environment variables for Gemini on a tmux session via setenv.
+ * Gemini Pro/Ultra users usually authenticate via cached Google login; these
+ * variables cover API-key and Vertex AI paths without putting secrets in ps.
+ */
+function setGeminiEnvVars(muxName: string): void {
+ const sensitiveVars = [
+ 'GEMINI_API_KEY',
+ 'GEMINI_MODEL',
+ 'GOOGLE_API_KEY',
+ 'GOOGLE_CLOUD_PROJECT',
+ 'GOOGLE_CLOUD_LOCATION',
+ 'GOOGLE_APPLICATION_CREDENTIALS',
+ 'GOOGLE_GENAI_USE_VERTEXAI',
+ ];
+ for (const key of sensitiveVars) {
+ const val = process.env[key];
+ if (val) {
+ const escaped = val.replace(/'/g, "'\\''");
+ try {
+ execSync(`tmux setenv -t '${muxName}' ${key} '${escaped}'`, {
+ encoding: 'utf8',
+ timeout: EXEC_TIMEOUT_MS,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ });
+ } catch {
+ /* Non-critical — key may not be needed */
+ }
+ }
+ }
+}
+
/**
* Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv.
* Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON.
@@ -930,6 +1003,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const dir = resolveCodexDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
+ if (mode === 'gemini') {
+ const dir = resolveGeminiDir();
+ return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
+ }
return { pathExport: '', dir: null };
}
@@ -953,6 +1030,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
setCodexEnvVars(this.tmux(), muxName);
}
+ /**
+ * Configure Gemini-specific environment on a tmux session.
+ */
+ private _configureGemini(muxName: string): void {
+ setGeminiEnvVars(muxName);
+ }
+
/**
* Creates a new tmux session wrapping Claude CLI or a shell.
* In test mode: creates an in-memory session only (no real tmux session).
@@ -969,6 +1053,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
allowedTools,
openCodeConfig,
codexConfig,
+ geminiConfig,
resumeSessionId,
envOverrides,
effort,
@@ -1007,6 +1092,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (mode === 'opencode' && !cliDir) {
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
}
+ if (mode === 'codex' && !cliDir) {
+ throw new Error('Codex CLI not found. Install with: npm install -g @openai/codex');
+ }
+ if (mode === 'gemini' && !cliDir) {
+ throw new Error('Gemini CLI not found. Install with: npm install -g @google/gemini-cli');
+ }
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
@@ -1018,6 +1109,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
allowedTools,
openCodeConfig,
codexConfig,
+ geminiConfig,
resumeSessionId,
effort,
});
@@ -1067,6 +1159,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} else if (mode === 'codex') {
this._configureCodex(muxName);
}
+ // For Gemini: set Gemini/Google auth env vars via tmux setenv
+ if (mode === 'gemini') {
+ this._configureGemini(muxName);
+ }
// Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv
// so secret values stay off the bash command line. Must run before respawn-pane.
@@ -1224,6 +1320,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
allowedTools,
openCodeConfig,
codexConfig,
+ geminiConfig,
resumeSessionId,
envOverrides,
effort,
@@ -1247,6 +1344,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
allowedTools,
openCodeConfig,
codexConfig,
+ geminiConfig,
resumeSessionId,
effort,
});
@@ -1261,6 +1359,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
} else if (mode === 'codex') {
this._configureCodex(muxName);
}
+ // For Gemini: set Gemini/Google auth env vars via tmux setenv before respawn
+ if (mode === 'gemini') {
+ this._configureGemini(muxName);
+ }
// Re-apply user env overrides before respawn so the new shell inherits them.
this.applyEnvOverrides(muxName, envOverrides);
diff --git a/src/types/session.ts b/src/types/session.ts
index 4cf742d9..460f6e67 100644
--- a/src/types/session.ts
+++ b/src/types/session.ts
@@ -8,10 +8,12 @@
* - SessionConfig — creation-time config (id, workingDir, createdAt)
* - SessionOutput — captured stdout/stderr/exitCode
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
- * - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' (which CLI backend)
+ * - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' (which CLI backend)
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'normal' | 'allowedTools')
* - SessionColor — visual differentiation color
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
+ * - CodexConfig — Codex (OpenAI CLI)-specific settings (model, resumeSessionId)
+ * - GeminiConfig — Gemini CLI-specific settings (model, approvalMode, resumeSession)
*
* Cross-domain relationships:
* - SessionState.respawnConfig embeds RespawnConfig (respawn domain)
@@ -39,7 +41,7 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedTools';
/** Session mode: which CLI backend a session runs */
-export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex';
+export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini';
/**
* Valid Claude CLI effort levels (claude >= 2.1.154).
@@ -85,6 +87,16 @@ export interface CodexConfig {
renderMode?: CodexRenderMode;
}
+/** Gemini CLI session configuration */
+export interface GeminiConfig {
+ /** Model identifier (e.g., "gemini-2.5-pro"). Passed via --model. */
+ model?: string;
+ /** Gemini approval mode for tool calls. */
+ approvalMode?: 'default' | 'auto_edit' | 'yolo' | 'plan';
+ /** Resume a previous Gemini session ("latest", index, or session id). */
+ resumeSession?: string;
+}
+
/**
* Configuration for creating a new session
*/
@@ -214,6 +226,8 @@ export interface SessionState {
openCodeConfig?: OpenCodeConfig;
/** Codex-specific configuration (only for mode === 'codex') */
codexConfig?: CodexConfig;
+ /** Gemini-specific configuration (only for mode === 'gemini') */
+ geminiConfig?: GeminiConfig;
/** Claude conversation session ID to resume after reboot (set by restore script) */
resumeSessionId?: string;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
diff --git a/src/utils/gemini-cli-resolver.ts b/src/utils/gemini-cli-resolver.ts
new file mode 100644
index 00000000..6f0b5099
--- /dev/null
+++ b/src/utils/gemini-cli-resolver.ts
@@ -0,0 +1,67 @@
+/**
+ * @fileoverview Resolve the Gemini CLI binary across common install paths.
+ *
+ * Mirrors codex-cli-resolver.ts and opencode-cli-resolver.ts. Finds the
+ * `gemini` binary and provides an augmented PATH directory for tmux sessions.
+ *
+ * @module utils/gemini-cli-resolver
+ */
+
+import { execSync } from 'node:child_process';
+import { existsSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { homedir } from 'node:os';
+import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
+
+/** Common directories where the Gemini CLI binary may be installed */
+const GEMINI_SEARCH_DIRS = [
+ join(homedir(), '.gemini', 'bin'),
+ join(homedir(), '.local', 'bin'),
+ '/usr/local/bin',
+ join(homedir(), '.bun', 'bin'),
+ join(homedir(), '.npm-global', 'bin'),
+ join(homedir(), 'bin'),
+];
+
+/** Cached directory containing the gemini binary (empty string = searched but not found) */
+let _geminiDir: string | null = null;
+
+/**
+ * Finds the directory containing the `gemini` binary.
+ * Checks `which gemini` first, then falls back to common install locations.
+ *
+ * @returns Directory path, or null if not found
+ */
+export function resolveGeminiDir(): string | null {
+ if (_geminiDir !== null) return _geminiDir || null;
+
+ try {
+ const result = execSync('which gemini', {
+ encoding: 'utf-8',
+ timeout: EXEC_TIMEOUT_MS,
+ }).trim();
+ if (result && existsSync(result)) {
+ _geminiDir = dirname(result);
+ return _geminiDir;
+ }
+ } catch {
+ // Gemini not in PATH, will check common locations
+ }
+
+ for (const dir of GEMINI_SEARCH_DIRS) {
+ if (existsSync(join(dir, 'gemini'))) {
+ _geminiDir = dir;
+ return _geminiDir;
+ }
+ }
+
+ _geminiDir = '';
+ return null;
+}
+
+/**
+ * Check if Gemini CLI is available on the system.
+ */
+export function isGeminiAvailable(): boolean {
+ return resolveGeminiDir() !== null;
+}
diff --git a/src/utils/index.ts b/src/utils/index.ts
index d6e76fc5..5d7f5219 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -29,3 +29,4 @@ export { wrapWithNice } from './nice-wrapper.js';
export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js';
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
+export { resolveGeminiDir } from './gemini-cli-resolver.js';
diff --git a/src/web/public/index.html b/src/web/public/index.html
index 4c4c7734..e5cb8048 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -305,6 +305,10 @@
Codeman
Run OpenCode
+
@@ -400,6 +404,9 @@
Resume Conversation
+
diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css
index e429ee0f..6b8ef7e7 100644
--- a/src/web/public/mobile.css
+++ b/src/web/public/mobile.css
@@ -777,6 +777,20 @@ html.mobile-init .file-browser-panel {
border-color: rgba(16, 185, 129, 0.5);
}
+ /* Gemini mode colors on mobile */
+ .btn-toolbar.btn-run.mode-gemini,
+ .btn-toolbar.btn-run-gear.mode-gemini {
+ background: #10243f;
+ border-color: rgba(96, 165, 250, 0.3);
+ color: #dbeafe;
+ }
+
+ .btn-toolbar.btn-run.mode-gemini:active,
+ .btn-toolbar.btn-run-gear.mode-gemini:active {
+ background: #174ea6;
+ border-color: rgba(96, 165, 250, 0.5);
+ }
+
/* Run mode dropdown menu — positioned above toolbar on mobile */
.run-mode-menu {
bottom: 100%;
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js
index 2a2e07f7..33141854 100644
--- a/src/web/public/session-ui.js
+++ b/src/web/public/session-ui.js
@@ -1,5 +1,5 @@
/**
- * @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode),
+ * @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini),
* session options modal (per-session settings, color picker, rename),
* session options tabs (Ralph config tab), case settings (CRUD, links),
* create case modal, and mobile case picker.
@@ -151,7 +151,7 @@ Object.assign(CodemanApp.prototype, {
return this.run();
},
- /** Run using the selected mode (Claude Code, OpenCode, or Codex) */
+ /** Run using the selected mode (Claude Code, OpenCode, Codex, or Gemini) */
async run() {
const mode = this._runMode || 'claude';
if (mode === 'opencode') {
@@ -160,6 +160,9 @@ Object.assign(CodemanApp.prototype, {
if (mode === 'codex') {
return this.runCodex();
}
+ if (mode === 'gemini') {
+ return this.runGemini();
+ }
return this.runClaude();
},
@@ -257,7 +260,7 @@ Object.assign(CodemanApp.prototype, {
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
}
if (label) {
- label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : 'Run';
+ label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : 'Run';
}
},
@@ -640,6 +643,47 @@ Object.assign(CodemanApp.prototype, {
}
},
+ async runGemini() {
+ const caseName = document.getElementById('quickStartCase').value || 'testcase';
+
+ this.terminal.clear();
+ this.terminal.writeln(`\x1b[1;32m Starting Gemini session in ${caseName}...\x1b[0m`);
+ this.terminal.writeln('');
+ this.terminal.focus();
+
+ try {
+ const statusRes = await fetch('/api/gemini/status');
+ const status = await statusRes.json();
+ if (!status.available) {
+ this.terminal.writeln('\x1b[1;31m Gemini CLI not found.\x1b[0m');
+ this.terminal.writeln('\x1b[90m Install with: npm install -g @google/gemini-cli\x1b[0m');
+ return;
+ }
+
+ const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
+ const res = await fetch('/api/quick-start', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ caseName,
+ mode: 'gemini',
+ geminiConfig: { approvalMode: 'yolo' },
+ ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
+ })
+ });
+ const data = await res.json();
+ if (!data.success) throw new Error(data.error || 'Failed to start Gemini');
+
+ if (data.sessionId) {
+ await this.selectSession(data.sessionId);
+ }
+
+ this.terminal.focus();
+ } catch (err) {
+ this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`);
+ }
+ },
+
// ═══════════════════════════════════════════════════════════════
// Session Options Modal
@@ -651,8 +695,9 @@ 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' || session.mode === 'codex' ? 'summary' : 'respawn');
+ // Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
+ const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini';
+ this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
// Update respawn status display and buttons
const respawnStatus = document.getElementById('sessionRespawnStatus');
@@ -680,10 +725,10 @@ Object.assign(CodemanApp.prototype, {
respawnSection.style.display = 'none';
}
- // Hide Claude-specific options for OpenCode sessions
- const isOpenCode = session.mode === 'opencode' || session.mode === 'codex';
+ // Hide Claude-specific options for external CLI sessions
+ const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini';
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
- claudeOnlyEls.forEach(el => { el.style.display = isOpenCode ? 'none' : ''; });
+ claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
// Reset duration presets to default (unlimited)
this.selectDurationPreset('');
@@ -731,21 +776,21 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('respawnPresetSelect').value = '';
document.getElementById('presetDescriptionHint').textContent = '';
- // Hide Ralph/Todo tab and Respawn tab for opencode sessions (not supported)
+ // Hide Ralph/Todo tab and Respawn tab for external CLI sessions (not supported)
const ralphTabBtn = document.querySelector('#sessionOptionsModal .modal-tab-btn[data-tab="ralph"]');
const respawnTabBtn = document.querySelector('#sessionOptionsModal .modal-tab-btn[data-tab="respawn"]');
- if (isOpenCode) {
+ if (isExternalCli) {
if (ralphTabBtn) ralphTabBtn.style.display = 'none';
if (respawnTabBtn) respawnTabBtn.style.display = 'none';
- // Default to Context tab for opencode sessions since Respawn is hidden
+ // Default to Context tab for external CLI sessions since Respawn is hidden
this.switchOptionsTab('context');
} else {
if (ralphTabBtn) ralphTabBtn.style.display = '';
if (respawnTabBtn) respawnTabBtn.style.display = '';
}
- // Populate Ralph Wiggum form with current session values (skip for opencode)
- if (!isOpenCode) {
+ // Populate Ralph Wiggum form with current session values (skip for external CLI sessions)
+ if (!isExternalCli) {
const ralphState = this.ralphStates.get(sessionId);
this.populateRalphForm({
enabled: ralphState?.loop?.enabled ?? session.ralphLoop?.enabled ?? false,
@@ -1579,6 +1624,7 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', {
return this._runMode || 'claude';
},
set(mode) {
- this._runMode = mode === 'opencode' || mode === 'codex' || mode === 'claude' ? mode : 'claude';
+ this._runMode =
+ mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'claude' ? mode : 'claude';
},
});
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index eeba9e9b..5e45121a 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -2362,6 +2362,21 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
transform: translateY(-1px);
}
+.welcome-btn-gemini {
+ background: linear-gradient(135deg, #10243f 0%, #174ea6 55%, #4f46e5 100%);
+ border-color: rgba(96, 165, 250, 0.4);
+ color: #dbeafe;
+ box-shadow: 0 2px 8px rgba(96, 165, 250, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.06);
+}
+
+.welcome-btn-gemini:hover {
+ background: linear-gradient(135deg, #17345f 0%, #2563eb 55%, #6366f1 100%);
+ box-shadow: 0 4px 20px rgba(96, 165, 250, 0.3), 0 0 40px rgba(99, 102, 241, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08);
+ border-color: rgba(147, 197, 253, 0.5);
+ color: #eff6ff;
+ transform: translateY(-1px);
+}
+
.welcome-btn-tunnel {
background: linear-gradient(135deg, #221538 0%, #3b1a7a 50%, #6d28d9 100%);
border-color: rgba(124, 58, 237, 0.4);
@@ -2910,6 +2925,22 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
color: #e9d5ff;
}
+/* Gemini mode colors */
+.btn-toolbar.btn-run.mode-gemini,
+.btn-toolbar.btn-run-gear.mode-gemini {
+ background: linear-gradient(135deg, #10243f 0%, #174ea6 55%, #4f46e5 100%);
+ border-color: rgba(96, 165, 250, 0.5);
+ color: #dbeafe;
+ 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-gemini:hover,
+.btn-toolbar.btn-run-gear.mode-gemini:hover {
+ background: linear-gradient(135deg, #17345f 0%, #2563eb 55%, #6366f1 100%);
+ box-shadow: 0 0 12px rgba(96, 165, 250, 0.35), 0 2px 8px rgba(99, 102, 241, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08);
+ border-color: rgba(147, 197, 253, 0.6);
+ color: #eff6ff;
+}
+
/* Dropdown menu */
.run-mode-menu {
display: none;
@@ -2963,6 +2994,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
.run-mode-dot.claude { background: #3b82f6; }
.run-mode-dot.opencode { background: #10b981; }
.run-mode-dot.codex { background: #a855f7; }
+.run-mode-dot.gemini { background: #8ab4f8; }
.run-mode-sep {
height: 1px;
diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts
index c3ec4e33..94727fd9 100644
--- a/src/web/routes/session-routes.ts
+++ b/src/web/routes/session-routes.ts
@@ -286,12 +286,14 @@ export function registerSessionRoutes(
//
// For keys the caller is actively setting, strip any stale disk entry a prior
// Codeman version may have written. Scope limited to:
- // - Claude mode (OpenCode doesn't read .claude/settings.local.json)
+ // - Claude mode (OpenCode/Codex/Gemini don't read .claude/settings.local.json)
// - workingDir inside CASES_DIR (Codeman's managed territory — we never mutate
// .claude/settings.local.json in arbitrary user repos that POST /api/sessions
// can target, because those may have hand-authored values).
const canStripDisk =
body.mode !== 'opencode' &&
+ body.mode !== 'codex' &&
+ body.mode !== 'gemini' &&
body.envOverrides &&
Object.keys(body.envOverrides).length > 0 &&
workingDir.startsWith(CASES_DIR + '/');
@@ -348,6 +350,17 @@ export function registerSessionRoutes(
}
}
+ // Check Gemini availability if requested
+ if (body.mode === 'gemini') {
+ const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
+ if (!isGeminiAvailable()) {
+ return createErrorResponse(
+ ApiErrorCode.OPERATION_FAILED,
+ 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
+ );
+ }
+ }
+
// 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.
@@ -386,9 +399,11 @@ export function registerSessionRoutes(
? body.openCodeConfig?.model
: mode === 'codex'
? body.codexConfig?.model
- : mode !== 'shell'
- ? modelConfig?.defaultModel || undefined
- : undefined;
+ : mode === 'gemini'
+ ? body.geminiConfig?.model
+ : mode !== 'shell'
+ ? modelConfig?.defaultModel || undefined
+ : undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig();
const session = new Session({
workingDir,
@@ -402,6 +417,7 @@ export function registerSessionRoutes(
allowedTools: claudeModeConfig.allowedTools,
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
codexConfig: mode === 'codex' ? body.codexConfig : undefined,
+ geminiConfig: mode === 'gemini' ? body.geminiConfig : undefined,
resumeSessionId: validatedResumeId,
envOverrides: body.envOverrides,
effort: body.effort,
@@ -600,9 +616,11 @@ export function registerSessionRoutes(
try {
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
- // Ralph tracker is not supported for opencode sessions
+ // Ralph tracker is not supported for opencode / codex / gemini sessions
if (
session.mode !== 'opencode' &&
+ session.mode !== 'codex' &&
+ session.mode !== 'gemini' &&
ctx.store.getConfig().ralphEnabled &&
!session.ralphTracker.autoEnableDisabled
) {
@@ -1234,6 +1252,7 @@ export function registerSessionRoutes(
mode = 'claude',
openCodeConfig,
codexConfig,
+ geminiConfig,
envOverrides,
effort,
} = parseBody(QuickStartSchema, req.body);
@@ -1260,6 +1279,17 @@ export function registerSessionRoutes(
}
}
+ // Check Gemini availability if requested
+ if (mode === 'gemini') {
+ const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
+ if (!isGeminiAvailable()) {
+ return createErrorResponse(
+ ApiErrorCode.OPERATION_FAILED,
+ 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
+ );
+ }
+ }
+
// 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.
@@ -1288,8 +1318,8 @@ export function registerSessionRoutes(
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
// Write .claude/settings.local.json with hooks for desktop notifications
- // (Claude-specific — OpenCode uses its own plugin system)
- if (mode !== 'opencode') {
+ // (Claude-specific — OpenCode, Codex, and Gemini use their own systems)
+ if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini') {
await writeHooksConfig(casePath);
}
@@ -1306,7 +1336,13 @@ export function registerSessionRoutes(
// Strip stale disk entries for keys this request is actively setting (Claude only —
// see POST /api/sessions for full rationale).
- if (mode !== 'opencode' && envOverrides && Object.keys(envOverrides).length > 0) {
+ if (
+ mode !== 'opencode' &&
+ mode !== 'codex' &&
+ mode !== 'gemini' &&
+ envOverrides &&
+ Object.keys(envOverrides).length > 0
+ ) {
await stripCaseEnvKeys(casePath, Object.keys(envOverrides));
}
@@ -1319,9 +1355,11 @@ export function registerSessionRoutes(
? openCodeConfig?.model
: mode === 'codex'
? codexConfig?.model
- : mode !== 'shell'
- ? qsModelConfig?.defaultModel || undefined
- : undefined;
+ : mode === 'gemini'
+ ? geminiConfig?.model
+ : mode !== 'shell'
+ ? qsModelConfig?.defaultModel || undefined
+ : undefined;
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
const session = new Session({
workingDir: casePath,
@@ -1334,6 +1372,7 @@ export function registerSessionRoutes(
allowedTools: qsClaudeModeConfig.allowedTools,
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
codexConfig: mode === 'codex' ? codexConfig : undefined,
+ geminiConfig: mode === 'gemini' ? geminiConfig : undefined,
envOverrides,
effort,
});
@@ -1372,7 +1411,7 @@ export function registerSessionRoutes(
});
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode: 'shell' });
} else {
- // Both 'claude' and 'opencode' modes use startInteractive()
+ // 'claude', 'opencode', 'codex', and 'gemini' modes use startInteractive()
await session.startInteractive();
getLifecycleLog().log({
event: 'started',
diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts
index aaa69d21..e09f94fd 100644
--- a/src/web/routes/system-routes.ts
+++ b/src/web/routes/system-routes.ts
@@ -331,7 +331,7 @@ export function registerSystemRoutes(
});
// ═══════════════════════════════════════════════════════════════
- // CLI Integrations (OpenCode)
+ // CLI Integrations (OpenCode, Codex, Gemini)
// ═══════════════════════════════════════════════════════════════
// ========== OpenCode ==========
@@ -352,6 +352,16 @@ export function registerSystemRoutes(
};
});
+ // ========== Gemini ==========
+
+ app.get('/api/gemini/status', async () => {
+ const { isGeminiAvailable, resolveGeminiDir } = await import('../../utils/gemini-cli-resolver.js');
+ return {
+ available: isGeminiAvailable(),
+ path: resolveGeminiDir(),
+ };
+ });
+
// ═══════════════════════════════════════════════════════════════
// State & Lifecycle (cleanup, lifecycle log, stats)
// ═══════════════════════════════════════════════════════════════
diff --git a/src/web/schemas.ts b/src/web/schemas.ts
index 06457799..44f923b7 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_', 'CODEX_'];
+const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_', 'CODEX_', 'GEMINI_', 'GOOGLE_'];
/** 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_*, OPENCODE_*, and CODEX_* keys are allowed.',
+ 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, and GOOGLE_* keys are allowed.',
}
);
@@ -149,9 +149,26 @@ const CodexConfigSchema = z
})
.optional();
+/** Schema for Gemini CLI-specific configuration */
+const GeminiConfigSchema = z
+ .object({
+ model: z
+ .string()
+ .max(100)
+ .regex(/^[a-zA-Z0-9._\-/]+$/)
+ .optional(),
+ approvalMode: z.enum(['default', 'auto_edit', 'yolo', 'plan']).optional(),
+ resumeSession: z
+ .string()
+ .max(100)
+ .regex(/^[a-zA-Z0-9._-]+$/)
+ .optional(),
+ })
+ .optional();
+
export const CreateSessionSchema = z.object({
workingDir: safePathSchema.optional(),
- mode: z.enum(['claude', 'shell', 'opencode', 'codex']).optional(),
+ mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
name: z.string().max(100).optional(),
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
@@ -162,6 +179,7 @@ export const CreateSessionSchema = z.object({
statusLineTelemetry: z.boolean().optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
+ geminiConfig: GeminiConfigSchema,
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
resumeSessionId: z
.string()
@@ -258,9 +276,10 @@ 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', 'codex']).optional(),
+ mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']).optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
+ geminiConfig: GeminiConfigSchema,
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
diff --git a/src/web/server.ts b/src/web/server.ts
index a156ec11..084376a7 100644
--- a/src/web/server.ts
+++ b/src/web/server.ts
@@ -2123,6 +2123,9 @@ export class WebServer extends EventEmitter {
muxSession: muxSession, // Pass the existing session so startInteractive() can attach to it
claudeMode: recoveryClaudeMode.claudeMode,
allowedTools: recoveryClaudeMode.allowedTools,
+ openCodeConfig: muxSession.mode === 'opencode' ? savedState?.openCodeConfig : undefined,
+ codexConfig: muxSession.mode === 'codex' ? savedState?.codexConfig : undefined,
+ geminiConfig: muxSession.mode === 'gemini' ? savedState?.geminiConfig : undefined,
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
attachmentHistory: savedAttachmentHistory,
diff --git a/test/gemini-mode.test.ts b/test/gemini-mode.test.ts
new file mode 100644
index 00000000..1ca9eaf7
--- /dev/null
+++ b/test/gemini-mode.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest';
+import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js';
+
+describe('Gemini mode schemas', () => {
+ it('accepts Gemini session creation config', () => {
+ const parsed = CreateSessionSchema.parse({
+ workingDir: '/tmp',
+ mode: 'gemini',
+ geminiConfig: {
+ model: 'gemini-2.5-pro',
+ approvalMode: 'yolo',
+ },
+ });
+
+ expect(parsed.mode).toBe('gemini');
+ expect(parsed.geminiConfig).toEqual({
+ model: 'gemini-2.5-pro',
+ approvalMode: 'yolo',
+ });
+ });
+
+ it('accepts Gemini quick-start config', () => {
+ const parsed = QuickStartSchema.parse({
+ caseName: 'gemini-case',
+ mode: 'gemini',
+ geminiConfig: {
+ model: 'gemini-2.5-flash',
+ approvalMode: 'auto_edit',
+ },
+ });
+
+ expect(parsed.mode).toBe('gemini');
+ expect(parsed.geminiConfig?.model).toBe('gemini-2.5-flash');
+ });
+
+ it('rejects unsafe Gemini model strings', () => {
+ expect(() =>
+ CreateSessionSchema.parse({
+ workingDir: '/tmp',
+ mode: 'gemini',
+ geminiConfig: { model: 'gemini; rm -rf /' },
+ })
+ ).toThrow();
+ });
+});
diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts
index e5d4f87d..7714d5a2 100644
--- a/test/routes/system-routes.test.ts
+++ b/test/routes/system-routes.test.ts
@@ -76,11 +76,17 @@ vi.mock('../../src/utils/opencode-cli-resolver.js', () => ({
resolveOpenCodeDir: vi.fn(() => null),
}));
+vi.mock('../../src/utils/gemini-cli-resolver.js', () => ({
+ isGeminiAvailable: vi.fn(() => false),
+ resolveGeminiDir: vi.fn(() => null),
+}));
+
import fs from 'node:fs/promises';
import { existsSync, readdirSync } from 'node:fs';
import { subagentWatcher } from '../../src/subagent-watcher.js';
import { getLifecycleLog } from '../../src/session-lifecycle-log.js';
import { isOpenCodeAvailable, resolveOpenCodeDir } from '../../src/utils/opencode-cli-resolver.js';
+import { isGeminiAvailable, resolveGeminiDir } from '../../src/utils/gemini-cli-resolver.js';
const mockedReadFile = vi.mocked(fs.readFile);
const mockedWriteFile = vi.mocked(fs.writeFile);
@@ -90,6 +96,8 @@ const mockedSubagentWatcher = vi.mocked(subagentWatcher);
const mockedGetLifecycleLog = vi.mocked(getLifecycleLog);
const mockedIsOpenCodeAvailable = vi.mocked(isOpenCodeAvailable);
const mockedResolveOpenCodeDir = vi.mocked(resolveOpenCodeDir);
+const mockedIsGeminiAvailable = vi.mocked(isGeminiAvailable);
+const mockedResolveGeminiDir = vi.mocked(resolveGeminiDir);
describe('system-routes', () => {
let harness: RouteTestHarness;
@@ -117,6 +125,8 @@ describe('system-routes', () => {
} as never);
mockedIsOpenCodeAvailable.mockReturnValue(false);
mockedResolveOpenCodeDir.mockReturnValue(null);
+ mockedIsGeminiAvailable.mockReturnValue(false);
+ mockedResolveGeminiDir.mockReturnValue(null);
});
afterEach(async () => {
@@ -167,7 +177,7 @@ describe('system-routes', () => {
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
- expect(body.success).toBe(false);
+ expect(body.message ?? body.error).toBeTruthy();
});
});
@@ -356,7 +366,7 @@ describe('system-routes', () => {
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
- expect(body.success).toBe(false);
+ expect(body.message ?? body.error).toBeTruthy();
});
it('saves lastUsedCase as partial update without overwriting other settings', async () => {
@@ -454,7 +464,7 @@ describe('system-routes', () => {
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
- expect(body.success).toBe(false);
+ expect(body.message ?? body.error).toBeTruthy();
});
});
@@ -511,7 +521,7 @@ describe('system-routes', () => {
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
- expect(body.success).toBe(false);
+ expect(body.message ?? body.error).toBeTruthy();
});
});
@@ -699,6 +709,32 @@ describe('system-routes', () => {
});
});
+ // ========== GET /api/gemini/status ==========
+
+ describe('GET /api/gemini/status', () => {
+ it('returns unavailable when gemini is not installed', async () => {
+ mockedIsGeminiAvailable.mockReturnValue(false);
+ mockedResolveGeminiDir.mockReturnValue(null);
+
+ const res = await harness.app.inject({ method: 'GET', url: '/api/gemini/status' });
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.available).toBe(false);
+ expect(body.path).toBeNull();
+ });
+
+ it('returns available with path when gemini is installed', async () => {
+ mockedIsGeminiAvailable.mockReturnValue(true);
+ mockedResolveGeminiDir.mockReturnValue('/usr/local/bin');
+
+ const res = await harness.app.inject({ method: 'GET', url: '/api/gemini/status' });
+ expect(res.statusCode).toBe(200);
+ const body = JSON.parse(res.body);
+ expect(body.available).toBe(true);
+ expect(body.path).toBe('/usr/local/bin');
+ });
+ });
+
// ========== GET /api/execution/model-config ==========
describe('GET /api/execution/model-config', () => {
diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts
index 3624b4f2..07dc1053 100644
--- a/test/run-mode-ui.test.ts
+++ b/test/run-mode-ui.test.ts
@@ -61,6 +61,16 @@ describe('run mode UI', () => {
expect(app.runMode).toBe('claude');
expect(runBtnLabel.textContent).toBe('Run');
});
+
+ it('accepts Gemini mode from server sync and updates the run button label', async () => {
+ const { app, storage, runBtnLabel } = loadRunModeHarness();
+
+ storage.set('codeman_runMode', 'claude');
+ await app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'gemini' }));
+
+ expect(app.runMode).toBe('gemini');
+ expect(runBtnLabel.textContent).toBe('Run GM');
+ });
});
describe('Codex quick start settings', () => {