diff --git a/src/config/dependency-registry.ts b/src/config/dependency-registry.ts
index bc04c485..5e8ca527 100644
--- a/src/config/dependency-registry.ts
+++ b/src/config/dependency-registry.ts
@@ -90,6 +90,14 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [
usedBy: ['Codex sessions'],
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['codex'], versionArg: '--version' } }],
},
+ {
+ id: 'gemini',
+ label: 'Gemini CLI',
+ category: 'core',
+ required: false,
+ usedBy: ['Gemini sessions'],
+ resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['gemini'], versionArg: '--version' } }],
+ },
{
id: 'libreoffice',
label: 'LibreOffice',
diff --git a/src/ralph-tracker.ts b/src/ralph-tracker.ts
index 15f60466..9fc6a7c8 100644
--- a/src/ralph-tracker.ts
+++ b/src/ralph-tracker.ts
@@ -1059,6 +1059,10 @@ export class RalphTracker extends EventEmitter {
planVersion: this.planTracker.planVersion,
planHistoryLength: this.planTracker.getPlanHistory().length,
completionConfidence: this._lastCompletionConfidence,
+ // Surface the live todo-config so it persists (toState) and reads back into
+ // the Session Options modal (broadcast) — mirrors maxIterations round-trip.
+ maxTodos: this._maxTodos,
+ todoExpirationMinutes: this.todoExpirationMinutes,
};
}
@@ -2345,6 +2349,12 @@ export class RalphTracker extends EventEmitter {
...loopState,
enabled: loopState.enabled ?? false,
};
+ // Restore the per-session todo-config into the live fields used by the hot
+ // paths (eviction cap + expiry). Setters ignore non-positive values.
+ if (typeof loopState.maxTodos === 'number') this.setMaxTodos(loopState.maxTodos);
+ if (typeof loopState.todoExpirationMinutes === 'number') {
+ this.setTodoExpirationMinutes(loopState.todoExpirationMinutes);
+ }
this._todos.clear();
for (const todo of todos) {
this._todos.set(todo.id, {
diff --git a/src/session.ts b/src/session.ts
index 3b45835d..107c413c 100644
--- a/src/session.ts
+++ b/src/session.ts
@@ -157,14 +157,15 @@ function getModeLabel(mode: SessionMode): string {
* that we strip so the browser keeps everything in the main buffer with scrollback
* reachable (the strip runs on both the live stream and the buffer replay).
*
- * Codex and Claude Code are known, controlled TUIs that repaint via cursor
- * positioning, so dropping the alt-screen switch is safe — content stays in the
- * normal buffer. Excluded: `shell` (arbitrary programs like vim/less/htop
- * legitimately need the alt screen) and `opencode` (renders its own TUI that
- * may rely on it). Keep parity with the replay-side strip in session-routes.ts.
+ * Codex, Claude Code, and Gemini are known, controlled (Ink/React) TUIs that
+ * repaint via cursor positioning, so dropping the alt-screen switch is safe —
+ * content stays in the normal buffer. Excluded: `shell` (arbitrary programs like
+ * vim/less/htop legitimately need the alt screen) and `opencode` (renders its own
+ * TUI that may rely on it). Keep parity with the replay-side strip in
+ * session-routes.ts.
*/
export function isAltScreenStripMode(mode: SessionMode): boolean {
- return mode === 'codex' || mode === 'claude';
+ return mode === 'codex' || mode === 'claude' || mode === 'gemini';
}
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts
index 6e288055..956001ed 100644
--- a/src/tmux-manager.ts
+++ b/src/tmux-manager.ts
@@ -720,7 +720,7 @@ function setCodexEnvVars(tmuxCmd: string, muxName: string): void {
* 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 {
+function setGeminiEnvVars(tmuxCmd: string, muxName: string): void {
const sensitiveVars = [
'GEMINI_API_KEY',
'GEMINI_MODEL',
@@ -735,7 +735,7 @@ function setGeminiEnvVars(muxName: string): void {
if (val) {
const escaped = val.replace(/'/g, "'\\''");
try {
- execSync(`tmux setenv -t '${muxName}' ${key} '${escaped}'`, {
+ execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
@@ -929,8 +929,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const exports = [
'export LANG=en_US.UTF-8',
'export LC_ALL=en_US.UTF-8',
- mode === 'codex' ? 'export COLORTERM=truecolor' : 'unset COLORTERM',
- ...(mode === 'codex' ? ['unset NO_COLOR'] : []),
+ mode === 'codex' || mode === 'gemini' ? 'export COLORTERM=truecolor' : 'unset COLORTERM',
+ ...(mode === 'codex' || mode === 'gemini' ? ['unset NO_COLOR'] : []),
'export CODEMAN_MUX=1',
`export CODEMAN_SESSION_ID=${sessionId}`,
`export CODEMAN_MUX_NAME=${muxName}`,
@@ -1034,7 +1034,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* Configure Gemini-specific environment on a tmux session.
*/
private _configureGemini(muxName: string): void {
- setGeminiEnvVars(muxName);
+ setGeminiEnvVars(this.tmux(), muxName);
}
/**
diff --git a/src/types/ralph.ts b/src/types/ralph.ts
index cfc53814..8f66093e 100644
--- a/src/types/ralph.ts
+++ b/src/types/ralph.ts
@@ -90,6 +90,10 @@ export interface RalphTrackerState {
cycleCount: number;
/** Maximum iterations if detected */
maxIterations: number | null;
+ /** Max todos retained for this session before FIFO eviction (persisted; default = global cap) */
+ maxTodos?: number;
+ /** Todo auto-expiry in minutes (persisted; default = global TODO_EXPIRY_MS) */
+ todoExpirationMinutes?: number;
/** Timestamp of last activity */
lastActivity: number;
/** Elapsed hours if detected */
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 5d7f5219..da0babd9 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -29,4 +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';
+export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js';
diff --git a/src/web/public/app.js b/src/web/public/app.js
index ec286205..177fd3fe 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -3004,7 +3004,7 @@ class CodemanApp {
- ${mode === 'shell' ? 'sh' : mode === 'opencode' ? 'oc' : mode === 'codex' ? 'cx' : ''}
+ ${mode === 'shell' ? 'sh' : mode === 'opencode' ? 'oc' : mode === 'codex' ? 'cx' : mode === 'gemini' ? 'gm' : ''}
${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '' + escapeHtml(p.prefix) + ': ' + escapeHtml(p.suffix) + '' : escapeHtml(name); })()}
detached
@@ -3988,7 +3988,9 @@ class CodemanApp {
? 'Kill Tmux & OpenCode'
: session.mode === 'codex'
? 'Kill Tmux & Codex'
- : 'Kill Tmux & Claude Code';
+ : session.mode === 'gemini'
+ ? 'Kill Tmux & Gemini'
+ : 'Kill Tmux & Claude Code';
}
document.getElementById('closeConfirmModal').classList.add('active');
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js
index 33141854..733276b3 100644
--- a/src/web/public/session-ui.js
+++ b/src/web/public/session-ui.js
@@ -653,7 +653,7 @@ Object.assign(CodemanApp.prototype, {
try {
const statusRes = await fetch('/api/gemini/status');
- const status = await statusRes.json();
+ const status = (await statusRes.json()).data;
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');
@@ -674,8 +674,8 @@ Object.assign(CodemanApp.prototype, {
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);
+ if (data.data.sessionId) {
+ await this.selectSession(data.data.sessionId);
}
this.terminal.focus();
@@ -796,6 +796,8 @@ Object.assign(CodemanApp.prototype, {
enabled: ralphState?.loop?.enabled ?? session.ralphLoop?.enabled ?? false,
completionPhrase: ralphState?.loop?.completionPhrase || session.ralphLoop?.completionPhrase || '',
maxIterations: ralphState?.loop?.maxIterations || session.ralphLoop?.maxIterations || 0,
+ maxTodos: ralphState?.loop?.maxTodos || session.ralphLoop?.maxTodos,
+ todoExpirationMinutes: ralphState?.loop?.todoExpirationMinutes || session.ralphLoop?.todoExpirationMinutes,
});
}
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index 5d41b1b5..bf0d3cdc 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -1178,6 +1178,11 @@ body.solo-mode .btn-lifecycle-log {
color: #a855f7;
}
+.session-tab .tab-mode.gemini {
+ background: rgba(138, 180, 248, 0.2);
+ color: #8ab4f8;
+}
+
/* Timer Banner - Compact */
.timer-banner {
display: flex;
diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts
index e138ed99..72a7ac62 100644
--- a/test/routes/system-routes.test.ts
+++ b/test/routes/system-routes.test.ts
@@ -177,7 +177,7 @@ describe('system-routes', () => {
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
- expect(body.message ?? body.error).toBeTruthy();
+ expect(body.success).toBe(false);
});
});
@@ -432,7 +432,7 @@ describe('system-routes', () => {
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
- expect(body.message ?? body.error).toBeTruthy();
+ expect(body.success).toBe(false);
});
it('saves lastUsedCase as partial update without overwriting other settings', async () => {
@@ -530,7 +530,7 @@ describe('system-routes', () => {
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
- expect(body.message ?? body.error).toBeTruthy();
+ expect(body.success).toBe(false);
});
});
@@ -587,7 +587,7 @@ describe('system-routes', () => {
});
expect(res.statusCode).toBe(400);
const body = JSON.parse(res.body);
- expect(body.message ?? body.error).toBeTruthy();
+ expect(body.success).toBe(false);
});
});
diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts
index 07dc1053..c3c9a3d7 100644
--- a/test/run-mode-ui.test.ts
+++ b/test/run-mode-ui.test.ts
@@ -143,3 +143,56 @@ describe('Codex quick start settings', () => {
expect(selected).toEqual(['sess-1']);
});
});
+
+describe('Gemini quick start', () => {
+ // Regression guard for the ApiResponse-envelope unwrap in runGemini(): the
+ // status check must read `.data.available` and the quick-start response must
+ // read `.data.sessionId`. Reading the raw shape (pre-fix) silently bails on
+ // the status check and never selects the new tab — exactly the two blockers
+ // caught in PR #134 review.
+ it('drives runGemini() through the {success,data} envelope and selects the new session', async () => {
+ const elements: Record = {
+ quickStartCase: { value: 'gemini-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 server.ts preSerialization
+ // hook wraps raw 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/gemini/status') return { json: async () => ({ success: true, data: { available: true } }) };
+ if (url === '/api/quick-start')
+ return { json: async () => ({ success: true, data: { sessionId: 'sess-gm' } }) };
+ 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 = () => ({});
+ app.getCaseSettings = () => ({});
+ app.buildEnvOverrides = () => ({});
+ const selected: string[] = [];
+ app.selectSession = async (id: string) => {
+ selected.push(id);
+ };
+
+ await app.runGemini();
+
+ expect(requests.find((req) => req.url === '/api/quick-start')?.body).toMatchObject({
+ caseName: 'gemini-case',
+ mode: 'gemini',
+ geminiConfig: { approvalMode: 'yolo' },
+ });
+ expect(selected).toEqual(['sess-gm']);
+ });
+});