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' ? '' : mode === 'opencode' ? '' : mode === 'codex' ? '' : ''} + ${mode === 'shell' ? '' : mode === 'opencode' ? '' : mode === 'codex' ? '' : mode === 'gemini' ? '' : ''} ${(() => { const p = parseSessionPrefix(name); return p && p.suffix ? '' + escapeHtml(p.prefix) + ': ' + escapeHtml(p.suffix) + '' : escapeHtml(name); })()} @@ -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']); + }); +});