42861ea7fa
Multi-adapter mobile UI for AI coding assistants. Supports Claude Code, Codex CLI, and Gemini CLI through one interface. Features: - Real-time bidirectional sync via tmux + WebSocket - Cross-AI review (send one AI's output to another for review) - Multi-review tabs with minimize/expand - Push notifications (PWA) with smart session-aware filtering - Three-channel event system (hooks, file watcher, pane monitor) - Voice input, image paste, draft persistence - Terminal-native design (JetBrains Mono, dark theme, pixel art claw) - CLI with --adapter flag on every command - Zero-overhead fire-and-forget hooks
1293 lines
42 KiB
Markdown
1293 lines
42 KiB
Markdown
# Gemini CLI Adapter Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Add a full-featured Gemini CLI adapter to code-tap, providing bidirectional mobile control identical to the existing Claude and Codex adapters.
|
||
|
||
**Architecture:** Pluggable adapter extending `IAdapter` with three event channels (HTTP hooks via bridge script, JSON file watcher, tmux pane monitor). Shared `tmux-manager.ts` extracted to `server/adapters/shared/`. New `JsonWatcher` for Gemini's single-JSON session format.
|
||
|
||
**Tech Stack:** TypeScript, Express, fs.watch, tmux, bash (bridge script)
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-03-26-gemini-adapter-design.md`
|
||
|
||
**Note:** This project has no test runner configured. Steps marked "verify" use manual checks (`npm run dev` + curl). Unit tests are noted as follow-up.
|
||
|
||
---
|
||
|
||
### Task 1: Shared Layer — Move tmux-manager.ts
|
||
|
||
**Files:**
|
||
- Create: `server/adapters/shared/tmux-manager.ts` (copy from claude/)
|
||
- Delete: `server/adapters/claude/tmux-manager.ts`
|
||
- Modify: `server/adapters/claude/tmux-adapter.ts` (import path)
|
||
- Modify: `server/adapters/claude/pane-monitor.ts` (import path)
|
||
- Modify: `server/adapters/codex/codex-tmux-adapter.ts` (import path)
|
||
|
||
- [ ] **Step 1: Create shared/ directory and move the file**
|
||
|
||
```bash
|
||
mkdir -p server/adapters/shared
|
||
git mv server/adapters/claude/tmux-manager.ts server/adapters/shared/tmux-manager.ts
|
||
```
|
||
|
||
- [ ] **Step 2: Update import in `server/adapters/claude/tmux-adapter.ts`**
|
||
|
||
Change:
|
||
```typescript
|
||
import { tmuxManager } from './tmux-manager.js';
|
||
import type { TmuxWindow } from './tmux-manager.js';
|
||
```
|
||
To:
|
||
```typescript
|
||
import { tmuxManager } from '../shared/tmux-manager.js';
|
||
import type { TmuxWindow } from '../shared/tmux-manager.js';
|
||
```
|
||
|
||
- [ ] **Step 3: Update import in `server/adapters/claude/pane-monitor.ts`**
|
||
|
||
Change:
|
||
```typescript
|
||
import { tmuxManager } from './tmux-manager.js';
|
||
```
|
||
To:
|
||
```typescript
|
||
import { tmuxManager } from '../shared/tmux-manager.js';
|
||
```
|
||
|
||
- [ ] **Step 4: Update import in `server/adapters/codex/codex-tmux-adapter.ts`**
|
||
|
||
Change:
|
||
```typescript
|
||
import { tmuxManager } from '../claude/tmux-manager.js';
|
||
```
|
||
To:
|
||
```typescript
|
||
import { tmuxManager } from '../shared/tmux-manager.js';
|
||
```
|
||
|
||
- [ ] **Step 5: Verify — server starts without errors**
|
||
|
||
```bash
|
||
npx tsx server/index.ts
|
||
```
|
||
Expected: Server starts, no import errors. Ctrl+C to stop.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "refactor: move tmux-manager.ts to shared/"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: JsonWatcher — New File Watcher for JSON Sessions
|
||
|
||
**Files:**
|
||
- Create: `server/stores/json-watcher.ts`
|
||
|
||
- [ ] **Step 1: Create `server/stores/json-watcher.ts`**
|
||
|
||
```typescript
|
||
// server/stores/json-watcher.ts
|
||
//
|
||
// Watches a single JSON session file for new messages.
|
||
// Unlike JsonlWatcher (byte-offset for append-only JSONL), this handles
|
||
// Gemini's single-JSON format where the entire file is rewritten on each update.
|
||
//
|
||
// Strategy: fs.watch + stat() size guard + message count/ID tracking + debounce.
|
||
|
||
import fs from 'fs';
|
||
|
||
/** A single message from a Gemini session JSON file */
|
||
export interface GeminiSessionMessage {
|
||
id: string;
|
||
timestamp: string;
|
||
type: 'user' | 'gemini' | 'error' | 'info';
|
||
content: unknown;
|
||
thoughts?: unknown[];
|
||
tokens?: Record<string, number>;
|
||
model?: string;
|
||
toolCalls?: unknown[];
|
||
}
|
||
|
||
/** Top-level structure of a Gemini session JSON file */
|
||
interface GeminiSessionFile {
|
||
sessionId: string;
|
||
projectHash?: string;
|
||
startTime: string;
|
||
lastUpdated: string;
|
||
messages: GeminiSessionMessage[];
|
||
kind?: string;
|
||
summary?: string;
|
||
}
|
||
|
||
export interface JsonWatcherStartOptions {
|
||
skipExisting?: boolean;
|
||
fallbackIntervalMs?: number;
|
||
debounceMs?: number;
|
||
}
|
||
|
||
const SIZE_WARNING_THRESHOLD = 2 * 1024 * 1024; // 2MB
|
||
|
||
export class JsonWatcher {
|
||
filePath: string;
|
||
private _lastSize: number = 0;
|
||
private _lastMessageCount: number = 0;
|
||
private _lastMessageId: string | null = null;
|
||
private _fsWatcher: fs.FSWatcher | null = null;
|
||
private _fallbackInterval: ReturnType<typeof setInterval> | null = null;
|
||
private _debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||
private _debounceMs: number = 50;
|
||
private _polling: boolean = false;
|
||
private _onMessages: ((messages: GeminiSessionMessage[]) => void) | null = null;
|
||
private _onError: ((err: Error) => void) | null = null;
|
||
|
||
constructor(filePath: string) {
|
||
this.filePath = filePath;
|
||
}
|
||
|
||
start({ skipExisting = true, fallbackIntervalMs = 2000, debounceMs = 50 }: JsonWatcherStartOptions = {}): void {
|
||
this._debounceMs = debounceMs;
|
||
|
||
if (skipExisting) {
|
||
// Read current state so we only emit future messages
|
||
try {
|
||
const content = fs.readFileSync(this.filePath, 'utf-8');
|
||
const session: GeminiSessionFile = JSON.parse(content);
|
||
this._lastSize = fs.statSync(this.filePath).size;
|
||
this._lastMessageCount = session.messages.length;
|
||
if (session.messages.length > 0) {
|
||
this._lastMessageId = session.messages[session.messages.length - 1]!.id;
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
// Primary: fs.watch for instant change notification
|
||
try {
|
||
this._fsWatcher = fs.watch(this.filePath, () => this._scheduleDebounce());
|
||
} catch {
|
||
// fs.watch may fail — fallback polling handles it
|
||
}
|
||
|
||
// Fallback: poll every N ms
|
||
this._fallbackInterval = setInterval(() => this._poll(), fallbackIntervalMs);
|
||
|
||
// Immediate first poll (catches messages if skipExisting=false)
|
||
if (!skipExisting) this._poll();
|
||
}
|
||
|
||
stop(): void {
|
||
if (this._fsWatcher) { this._fsWatcher.close(); this._fsWatcher = null; }
|
||
if (this._fallbackInterval) { clearInterval(this._fallbackInterval); this._fallbackInterval = null; }
|
||
if (this._debounceTimer) { clearTimeout(this._debounceTimer); this._debounceTimer = null; }
|
||
}
|
||
|
||
onNewMessages(cb: (messages: GeminiSessionMessage[]) => void): void { this._onMessages = cb; }
|
||
onError(cb: (err: Error) => void): void { this._onError = cb; }
|
||
|
||
/** Force an immediate poll (used by hooks to ensure latest state is read) */
|
||
pollNow(): void { this._poll(); }
|
||
|
||
/** Mark current file position — subsequent polls only return content after this point. */
|
||
markCurrentPosition(): void {
|
||
try {
|
||
const content = fs.readFileSync(this.filePath, 'utf-8');
|
||
const session: GeminiSessionFile = JSON.parse(content);
|
||
this._lastSize = fs.statSync(this.filePath).size;
|
||
this._lastMessageCount = session.messages.length;
|
||
if (session.messages.length > 0) {
|
||
this._lastMessageId = session.messages[session.messages.length - 1]!.id;
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
private _scheduleDebounce(): void {
|
||
if (this._debounceTimer) clearTimeout(this._debounceTimer);
|
||
this._debounceTimer = setTimeout(() => this._poll(), this._debounceMs);
|
||
}
|
||
|
||
private _poll(): void {
|
||
if (this._polling) return;
|
||
this._polling = true;
|
||
try {
|
||
const stats = fs.statSync(this.filePath);
|
||
|
||
// File size unchanged — skip (filters fs.watch false positives)
|
||
if (stats.size === this._lastSize) {
|
||
this._polling = false;
|
||
return;
|
||
}
|
||
|
||
// Performance warning
|
||
if (stats.size > SIZE_WARNING_THRESHOLD) {
|
||
console.warn(`[json-watcher] Session file is ${(stats.size / 1024 / 1024).toFixed(1)}MB: ${this.filePath}`);
|
||
}
|
||
|
||
const content = fs.readFileSync(this.filePath, 'utf-8');
|
||
const session: GeminiSessionFile = JSON.parse(content);
|
||
const messages = session.messages;
|
||
|
||
// No new messages
|
||
if (messages.length <= this._lastMessageCount) {
|
||
// File was rewritten but no new messages (metadata update only)
|
||
this._lastSize = stats.size;
|
||
this._polling = false;
|
||
return;
|
||
}
|
||
|
||
// Verify continuity: if _lastMessageId is set, find its index
|
||
let startIndex = this._lastMessageCount;
|
||
if (this._lastMessageId) {
|
||
// Check if the message at our expected position still matches
|
||
const expectedMsg = messages[this._lastMessageCount - 1];
|
||
if (expectedMsg && expectedMsg.id !== this._lastMessageId) {
|
||
// Messages were reordered/deleted — find actual position
|
||
const foundIndex = messages.findIndex(m => m.id === this._lastMessageId);
|
||
startIndex = foundIndex >= 0 ? foundIndex + 1 : 0;
|
||
}
|
||
}
|
||
|
||
const newMessages = messages.slice(startIndex);
|
||
|
||
// Update tracking state
|
||
this._lastSize = stats.size;
|
||
this._lastMessageCount = messages.length;
|
||
if (messages.length > 0) {
|
||
this._lastMessageId = messages[messages.length - 1]!.id;
|
||
}
|
||
|
||
if (newMessages.length > 0 && this._onMessages) {
|
||
this._onMessages(newMessages);
|
||
}
|
||
} catch (err) {
|
||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT' && this._onError) {
|
||
this._onError(err as Error);
|
||
}
|
||
} finally {
|
||
this._polling = false;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify — TypeScript compiles**
|
||
|
||
```bash
|
||
npx tsc --noEmit server/stores/json-watcher.ts 2>&1 | head -20
|
||
```
|
||
Expected: No errors (or only errors from missing sibling imports, not from this file).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add server/stores/json-watcher.ts && git commit -m "feat(gemini): add JsonWatcher for single-JSON session files"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Gemini Types & Message Utils
|
||
|
||
**Files:**
|
||
- Create: `server/adapters/gemini/message-utils.ts`
|
||
|
||
- [ ] **Step 1: Create `server/adapters/gemini/message-utils.ts`**
|
||
|
||
This file defines Gemini-specific types and content extraction helpers. Reference `server/adapters/claude/message-utils.ts` for the `ContentBlock` type — import it from there or from `server/types/messages.ts`.
|
||
|
||
```typescript
|
||
// server/adapters/gemini/message-utils.ts
|
||
//
|
||
// Gemini-specific types and content extraction helpers.
|
||
// Converts Gemini's JSON message format to the shared ContentBlock format.
|
||
|
||
import type { GeminiSessionMessage } from '../../stores/json-watcher.js';
|
||
|
||
// Import ContentBlock from Claude's message-utils (shared type used by all adapters).
|
||
// Do NOT re-declare — use the canonical definition to avoid type divergence.
|
||
import type { ContentBlock } from '../claude/message-utils.js';
|
||
|
||
/** Gemini tool call from session JSON */
|
||
export interface GeminiToolCall {
|
||
id: string;
|
||
name: string;
|
||
args: Record<string, unknown>;
|
||
result?: Array<{
|
||
functionResponse: {
|
||
id: string;
|
||
name: string;
|
||
response: { output?: string; error?: string };
|
||
};
|
||
}>;
|
||
status: 'success' | 'cancelled' | 'error';
|
||
timestamp: string;
|
||
displayName?: string;
|
||
description?: string;
|
||
renderOutputAsMarkdown?: boolean;
|
||
}
|
||
|
||
/** Extract plain text from Gemini user message content */
|
||
export function extractUserText(content: unknown): string {
|
||
if (typeof content === 'string') return content;
|
||
if (Array.isArray(content)) {
|
||
return content
|
||
.filter((c: any) => c && typeof c.text === 'string')
|
||
.map((c: any) => c.text)
|
||
.join('\n');
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/** Extract plain text from Gemini assistant message content */
|
||
export function extractGeminiText(content: unknown): string {
|
||
if (typeof content === 'string') return content;
|
||
return '';
|
||
}
|
||
|
||
/** Convert Gemini toolCalls to ContentBlock[] (tool_use + tool_result pairs) */
|
||
export function toolCallsToContentBlocks(toolCalls: GeminiToolCall[]): ContentBlock[] {
|
||
const blocks: ContentBlock[] = [];
|
||
for (const tc of toolCalls) {
|
||
// tool_use block
|
||
blocks.push({
|
||
type: 'tool_use',
|
||
id: tc.id,
|
||
name: tc.name,
|
||
input: tc.args,
|
||
});
|
||
// tool_result block (if result exists)
|
||
if (tc.result && tc.result.length > 0) {
|
||
const resp = tc.result[0]!.functionResponse.response;
|
||
blocks.push({
|
||
type: 'tool_result',
|
||
tool_use_id: tc.id,
|
||
content: resp.output || resp.error || '',
|
||
is_error: tc.status === 'error' || tc.status === 'cancelled' || !!resp.error,
|
||
});
|
||
}
|
||
}
|
||
return blocks;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add server/adapters/gemini/message-utils.ts && git commit -m "feat(gemini): add message-utils with types and content extraction"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Transcript Parser
|
||
|
||
**Files:**
|
||
- Create: `server/adapters/gemini/transcript-parser.ts`
|
||
|
||
- [ ] **Step 1: Create `server/adapters/gemini/transcript-parser.ts`**
|
||
|
||
Reference `server/adapters/claude/transcript-parser.ts` for the `ParsedMessage` / `ParseResult` types. The Gemini parser is simpler because tool calls and thinking are embedded in the message JSON.
|
||
|
||
```typescript
|
||
// server/adapters/gemini/transcript-parser.ts
|
||
//
|
||
// Converts Gemini JSON session messages to the shared ParsedMessage format.
|
||
// Much simpler than Claude's parser because Gemini embeds tool calls and
|
||
// thinking directly in each message (no cross-entry tracking needed).
|
||
|
||
import type { GeminiSessionMessage } from '../../stores/json-watcher.js';
|
||
import type { ContentBlock } from '../claude/message-utils.js';
|
||
import {
|
||
extractUserText, extractGeminiText, toolCallsToContentBlocks,
|
||
type GeminiToolCall,
|
||
} from './message-utils.js';
|
||
|
||
/** Parsed message for frontend rendering (shared format across adapters) */
|
||
export interface ParsedMessage {
|
||
id: string;
|
||
role: 'user' | 'assistant' | 'plan';
|
||
content: ContentBlock[]; // Always array — never string (consistent with Claude/Codex)
|
||
adapter?: string;
|
||
}
|
||
|
||
/** Result of parse() */
|
||
export interface ParseResult {
|
||
messages: ParsedMessage[];
|
||
errors: string[]; // Error messages to emit as session-error events
|
||
}
|
||
|
||
/** Token/model info extracted from gemini messages */
|
||
export interface StatusInfo {
|
||
model: string | null;
|
||
tokens: Record<string, number> | null;
|
||
}
|
||
|
||
/** Thinking entry from Gemini message */
|
||
export interface ThoughtEntry {
|
||
subject: string;
|
||
description: string;
|
||
timestamp: string;
|
||
}
|
||
|
||
export class GeminiTranscriptParser {
|
||
private _msgIndex: number = 0;
|
||
|
||
/**
|
||
* Parse Gemini session messages into frontend-ready format.
|
||
* Called incrementally — _msgIndex is NOT reset between calls.
|
||
*/
|
||
parse(messages: GeminiSessionMessage[]): ParseResult {
|
||
const parsed: ParsedMessage[] = [];
|
||
const errors: string[] = [];
|
||
|
||
for (const msg of messages) {
|
||
switch (msg.type) {
|
||
case 'user': {
|
||
const text = extractUserText(msg.content);
|
||
if (!text.trim()) continue;
|
||
const userContent: ContentBlock[] = Array.isArray(msg.content)
|
||
? (msg.content as ContentBlock[])
|
||
: [{ type: 'text', text }];
|
||
parsed.push({
|
||
id: `msg-${this._msgIndex++}`,
|
||
role: 'user',
|
||
content: userContent,
|
||
adapter: 'gemini',
|
||
});
|
||
break;
|
||
}
|
||
case 'gemini': {
|
||
const textContent = extractGeminiText(msg.content);
|
||
const blocks: ContentBlock[] = [];
|
||
|
||
// Add text block if present
|
||
if (textContent) {
|
||
blocks.push({ type: 'text', text: textContent });
|
||
}
|
||
|
||
// Convert embedded toolCalls to ContentBlocks
|
||
if (msg.toolCalls && Array.isArray(msg.toolCalls)) {
|
||
blocks.push(...toolCallsToContentBlocks(msg.toolCalls as GeminiToolCall[]));
|
||
}
|
||
|
||
if (blocks.length === 0) continue;
|
||
|
||
parsed.push({
|
||
id: `msg-${this._msgIndex++}`,
|
||
role: 'assistant',
|
||
content: blocks,
|
||
adapter: 'gemini',
|
||
});
|
||
break;
|
||
}
|
||
case 'error': {
|
||
const errorText = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
|
||
errors.push(errorText);
|
||
break;
|
||
}
|
||
case 'info':
|
||
// Skip internal CLI info messages
|
||
break;
|
||
}
|
||
}
|
||
|
||
return { messages: parsed, errors };
|
||
}
|
||
|
||
/** Extract thinking entries from a gemini message */
|
||
static extractThoughts(msg: GeminiSessionMessage): ThoughtEntry[] {
|
||
if (msg.type !== 'gemini' || !msg.thoughts || !Array.isArray(msg.thoughts)) return [];
|
||
return msg.thoughts as ThoughtEntry[];
|
||
}
|
||
|
||
/** Extract status info (model, tokens) from a gemini message */
|
||
static extractStatus(msg: GeminiSessionMessage): StatusInfo | null {
|
||
if (msg.type !== 'gemini') return null;
|
||
if (!msg.model && !msg.tokens) return null;
|
||
return {
|
||
model: (msg.model as string) || null,
|
||
tokens: (msg.tokens as Record<string, number>) || null,
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add server/adapters/gemini/transcript-parser.ts && git commit -m "feat(gemini): add transcript parser (JSON -> ParsedMessage)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Json Store — Session Discovery
|
||
|
||
**Files:**
|
||
- Create: `server/adapters/gemini/json-store.ts`
|
||
|
||
- [ ] **Step 1: Create `server/adapters/gemini/json-store.ts`**
|
||
|
||
Reference `server/adapters/claude/jsonl-store.ts` for the `SessionInfo` type from `server/types/adapter.ts`. Gemini uses `~/.gemini/projects.json` for project mapping and `~/.gemini/tmp/<name>/chats/` for session files.
|
||
|
||
```typescript
|
||
// server/adapters/gemini/json-store.ts
|
||
//
|
||
// Session discovery for Gemini CLI sessions.
|
||
// Sessions are stored as single JSON files in ~/.gemini/tmp/<project>/chats/
|
||
|
||
import { readFileSync, readdirSync, statSync } from 'fs';
|
||
import { join, basename } from 'path';
|
||
import { homedir } from 'os';
|
||
import type { SessionInfo } from '../../types/adapter.js';
|
||
import { extractUserText } from './message-utils.js';
|
||
|
||
const GEMINI_DIR = join(homedir(), '.gemini');
|
||
const TMP_DIR = join(GEMINI_DIR, 'tmp');
|
||
const PROJECTS_JSON = join(GEMINI_DIR, 'projects.json');
|
||
|
||
/** Read ~/.gemini/projects.json -> { "/abs/path": "project-name" } */
|
||
function readProjectsMapping(): Record<string, string> {
|
||
try {
|
||
const data = JSON.parse(readFileSync(PROJECTS_JSON, 'utf-8'));
|
||
return data.projects || {};
|
||
} catch { return {}; }
|
||
}
|
||
|
||
/** Get project name for a given directory path */
|
||
export function getProjectName(dir: string): string | null {
|
||
const mapping = readProjectsMapping();
|
||
return mapping[dir] || null;
|
||
}
|
||
|
||
/** Get project root path from .project_root file */
|
||
function getProjectRoot(projectName: string): string | null {
|
||
try {
|
||
return readFileSync(join(TMP_DIR, projectName, '.project_root'), 'utf-8').trim();
|
||
} catch { return null; }
|
||
}
|
||
|
||
/** List all project directories in ~/.gemini/tmp/ */
|
||
function listProjectDirs(): string[] {
|
||
try {
|
||
return readdirSync(TMP_DIR).filter(name => {
|
||
try {
|
||
return statSync(join(TMP_DIR, name)).isDirectory() && name !== 'bin';
|
||
} catch { return false; }
|
||
});
|
||
} catch { return []; }
|
||
}
|
||
|
||
/** List session files for a specific project */
|
||
function listSessionFiles(projectName: string): string[] {
|
||
const chatsDir = join(TMP_DIR, projectName, 'chats');
|
||
try {
|
||
return readdirSync(chatsDir)
|
||
.filter(f => f.startsWith('session-') && f.endsWith('.json'))
|
||
.map(f => join(chatsDir, f));
|
||
} catch { return []; }
|
||
}
|
||
|
||
/** Read a session file and extract metadata */
|
||
function readSessionMeta(filePath: string): SessionInfo | null {
|
||
try {
|
||
const content = readFileSync(filePath, 'utf-8');
|
||
const session = JSON.parse(content);
|
||
const messages = session.messages || [];
|
||
|
||
// Find first user message text
|
||
const firstUser = messages.find((m: any) => m.type === 'user');
|
||
const firstPrompt = firstUser ? extractUserText(firstUser.content) : null;
|
||
|
||
// Find latest model from last gemini message
|
||
let model: string | null = null;
|
||
for (let i = messages.length - 1; i >= 0; i--) {
|
||
if (messages[i].type === 'gemini' && messages[i].model) {
|
||
model = messages[i].model;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const stat = statSync(filePath);
|
||
|
||
return {
|
||
sessionId: session.sessionId,
|
||
cwd: '', // Will be set by caller from project root
|
||
lastModified: session.lastUpdated || stat.mtime.toISOString(),
|
||
firstPrompt: firstPrompt ? firstPrompt.slice(0, 200) : null,
|
||
model,
|
||
// NOTE: SessionInfo type does NOT have an 'adapter' field.
|
||
// Adapter identification happens at the API layer, not in the store.
|
||
};
|
||
} catch { return null; }
|
||
}
|
||
|
||
/** Get sessions for a specific directory (or all projects if dir is omitted) */
|
||
export function getSessions(dir?: string, limit: number = 50): SessionInfo[] {
|
||
const sessions: SessionInfo[] = [];
|
||
|
||
if (dir) {
|
||
const projectName = getProjectName(dir);
|
||
if (!projectName) return [];
|
||
const files = listSessionFiles(projectName);
|
||
for (const file of files) {
|
||
const meta = readSessionMeta(file);
|
||
if (meta) { meta.cwd = dir; sessions.push(meta); }
|
||
}
|
||
} else {
|
||
// All projects
|
||
for (const projectName of listProjectDirs()) {
|
||
const projectRoot = getProjectRoot(projectName);
|
||
const files = listSessionFiles(projectName);
|
||
for (const file of files) {
|
||
const meta = readSessionMeta(file);
|
||
if (meta) { meta.cwd = projectRoot || ''; sessions.push(meta); }
|
||
}
|
||
}
|
||
}
|
||
|
||
sessions.sort((a, b) => {
|
||
const ta = a.lastModified ? new Date(a.lastModified).getTime() : 0;
|
||
const tb = b.lastModified ? new Date(b.lastModified).getTime() : 0;
|
||
return tb - ta;
|
||
});
|
||
return sessions.slice(0, limit);
|
||
}
|
||
|
||
/** Find a session file by sessionId across all projects */
|
||
export function findSessionFile(sessionId: string): string | null {
|
||
for (const projectName of listProjectDirs()) {
|
||
const files = listSessionFiles(projectName);
|
||
for (const file of files) {
|
||
try {
|
||
const content = readFileSync(file, 'utf-8');
|
||
const session = JSON.parse(content);
|
||
if (session.sessionId === sessionId) return file;
|
||
} catch { continue; }
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Get all messages from a session file */
|
||
export function getSessionMessages(sessionId: string, dir?: string): { messages: unknown[]; lastModified: string | null } {
|
||
let filePath: string | null = null;
|
||
|
||
if (dir) {
|
||
const projectName = getProjectName(dir);
|
||
if (projectName) {
|
||
const files = listSessionFiles(projectName);
|
||
for (const file of files) {
|
||
try {
|
||
const content = readFileSync(file, 'utf-8');
|
||
const session = JSON.parse(content);
|
||
if (session.sessionId === sessionId) { filePath = file; break; }
|
||
} catch { continue; }
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!filePath) filePath = findSessionFile(sessionId);
|
||
if (!filePath) return { messages: [], lastModified: null };
|
||
|
||
try {
|
||
const content = readFileSync(filePath, 'utf-8');
|
||
const session = JSON.parse(content);
|
||
return {
|
||
messages: session.messages || [],
|
||
lastModified: session.lastUpdated || null,
|
||
};
|
||
} catch { return { messages: [], lastModified: null }; }
|
||
}
|
||
```
|
||
|
||
**Important notes for implementer:**
|
||
- `getSessionMessages()` maps to `IAdapter.getMessages()` — the GeminiAdapter (Task 9) calls `getSessionMessages` internally
|
||
- Add `listDirectory(path?)` export — reuse the pattern from Claude's `jsonl-store.ts` (reads a directory and returns `DirectoryEntry[]`). Gemini projects are rooted in whatever `.project_root` says.
|
||
- All `readFileSync` calls are acceptable for <34KB files. For `findSessionFile()` which scans all projects, consider capping at 100 files.
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add server/adapters/gemini/json-store.ts && git commit -m "feat(gemini): add json-store for session discovery"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Hook Config & Bridge Script
|
||
|
||
**Files:**
|
||
- Create: `server/adapters/gemini/hook-config.ts`
|
||
- Create: `server/adapters/gemini/bridge.sh`
|
||
|
||
- [ ] **Step 1: Create `server/adapters/gemini/bridge.sh`**
|
||
|
||
Copy the bridge script from the spec verbatim. Make it executable.
|
||
|
||
```bash
|
||
cat > server/adapters/gemini/bridge.sh << 'BRIDGE'
|
||
#!/bin/bash
|
||
# Reads JSON from stdin (Gemini hook protocol), POSTs to code-tap server.
|
||
#
|
||
# IMPORTANT: Gemini hooks expect a JSON response on stdout. We must write
|
||
# a response BEFORE backgrounding the curl POST, or Gemini will hang.
|
||
# Exit code 0 = allow (continue), exit code 2 = block.
|
||
ENDPOINT="$1"
|
||
PORT="${CODETAP_PORT:-3456}"
|
||
PROTOCOL="${CODETAP_PROTOCOL:-http}"
|
||
CURL_K=""
|
||
[ "$PROTOCOL" = "https" ] && CURL_K="-k"
|
||
|
||
# Read stdin (Gemini hook JSON payload)
|
||
input=$(cat)
|
||
|
||
# Respond to Gemini immediately
|
||
printf '{}'
|
||
|
||
# Port check: skip curl if server isn't listening
|
||
(echo >/dev/tcp/localhost/$PORT) 2>/dev/null || exit 0
|
||
|
||
# Forward payload to code-tap server asynchronously
|
||
printf '%s' "$input" | curl -sf $CURL_K --connect-timeout 2 --max-time 5 \
|
||
-X POST -H 'Content-Type:application/json' -d @- \
|
||
"${PROTOCOL}://localhost:${PORT}/api/hooks/gemini/${ENDPOINT}" &>/dev/null &
|
||
BRIDGE
|
||
chmod +x server/adapters/gemini/bridge.sh
|
||
```
|
||
|
||
- [ ] **Step 2: Create `server/adapters/gemini/hook-config.ts`**
|
||
|
||
Follow the pattern from `server/adapters/claude/hook-config.ts` — read/write `~/.gemini/settings.json`, use portTag for ownership identification, wrap (don't replace) existing hooks.
|
||
|
||
The hook-config must:
|
||
- Set `CODETAP_PORT` and `CODETAP_PROTOCOL` env vars in the command string
|
||
- Use absolute path to bridge.sh
|
||
- Install hooks for: `BeforeTool`, `AfterTool`, `BeforeAgent`, `AfterAgent`, `SessionStart`, `SessionEnd`
|
||
|
||
```typescript
|
||
// server/adapters/gemini/hook-config.ts
|
||
//
|
||
// Pure filesystem operations for Gemini CLI hook management.
|
||
// Zero runtime dependencies — no EventEmitter, no tmux, no sessions.
|
||
//
|
||
// Key differences from Claude/Codex:
|
||
// - Hooks live in ~/.gemini/settings.json under the "hooks" key
|
||
// - Uses bridge.sh (stdin JSON -> curl POST) instead of direct curl
|
||
// - No statusLine wrapping (Gemini has no statusLine hook)
|
||
|
||
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'fs';
|
||
import { join, dirname, resolve } from 'path';
|
||
import { homedir } from 'os';
|
||
import { fileURLToPath } from 'url';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
|
||
interface HookAction {
|
||
type?: string;
|
||
command?: string;
|
||
timeout?: number;
|
||
}
|
||
|
||
interface HookEntry {
|
||
matcher?: string;
|
||
hooks: HookAction[];
|
||
}
|
||
|
||
interface GeminiSettings {
|
||
hooks?: Record<string, HookEntry[]>;
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
export class GeminiHookConfig {
|
||
port: number | string;
|
||
useHttps: boolean;
|
||
|
||
constructor(port?: number | string, useHttps?: boolean) {
|
||
this.port = port || process.env.PORT || 3456;
|
||
if (useHttps !== undefined) {
|
||
this.useHttps = useHttps;
|
||
} else {
|
||
const codetapDir = join(homedir(), '.codetap');
|
||
this.useHttps = existsSync(join(codetapDir, 'cert.pem')) && existsSync(join(codetapDir, 'key.pem'));
|
||
}
|
||
}
|
||
|
||
install(): void {
|
||
const settingsDir = join(homedir(), '.gemini');
|
||
const settingsPath = join(settingsDir, 'settings.json');
|
||
const { portTag } = this._hookIdentifiers();
|
||
const desiredHooks = this._buildDesiredHooks();
|
||
|
||
try {
|
||
mkdirSync(settingsDir, { recursive: true });
|
||
let existing: GeminiSettings = {};
|
||
try { existing = JSON.parse(readFileSync(settingsPath, 'utf-8')) as GeminiSettings; } catch {}
|
||
|
||
if (!existing.hooks) existing.hooks = {};
|
||
|
||
for (const [event, configs] of Object.entries(desiredHooks)) {
|
||
const existingEntries = existing.hooks[event] || [];
|
||
const filtered = existingEntries.filter(entry => !this._isOurHookEntry(entry, portTag));
|
||
existing.hooks[event] = [...filtered, ...configs];
|
||
}
|
||
|
||
writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
|
||
console.log(`[hooks:gemini] Auto-configured hooks in ${settingsPath}`);
|
||
} catch (err) {
|
||
console.warn(`[hooks:gemini] Failed to auto-configure hooks: ${(err as Error).message}`);
|
||
}
|
||
}
|
||
|
||
uninstall(): void {
|
||
const { portTag } = this._hookIdentifiers();
|
||
const settingsPath = join(homedir(), '.gemini', 'settings.json');
|
||
|
||
try {
|
||
const existing: GeminiSettings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as GeminiSettings;
|
||
|
||
if (existing.hooks) {
|
||
const hookKeys = Object.keys(this._buildDesiredHooks());
|
||
for (const key of hookKeys) {
|
||
const entries = existing.hooks[key];
|
||
if (!Array.isArray(entries)) continue;
|
||
const filtered = entries.filter(entry => !this._isOurHookEntry(entry, portTag));
|
||
if (filtered.length === 0) {
|
||
delete existing.hooks[key];
|
||
} else {
|
||
existing.hooks[key] = filtered;
|
||
}
|
||
}
|
||
if (Object.keys(existing.hooks).length === 0) delete existing.hooks;
|
||
}
|
||
|
||
writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
|
||
console.log(`[hooks:gemini] Removed CodeTap hooks from ${settingsPath}`);
|
||
} catch (err) {
|
||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return;
|
||
console.warn(`[hooks:gemini] Failed to remove hooks: ${(err as Error).message}`);
|
||
}
|
||
}
|
||
|
||
private _hookIdentifiers(): { portTag: string } {
|
||
return { portTag: `CODETAP_PORT=${this.port}` };
|
||
}
|
||
|
||
private _isOurHookEntry(entry: HookEntry, portTag: string): boolean {
|
||
return (entry.hooks || []).some(h => h.command && h.command.includes(portTag));
|
||
}
|
||
|
||
private _buildDesiredHooks(): Record<string, HookEntry[]> {
|
||
const bridgePath = resolve(__dirname, 'bridge.sh');
|
||
const protocol = this.useHttps ? 'https' : 'http';
|
||
const envPrefix = `CODETAP_PORT=${this.port} CODETAP_PROTOCOL=${protocol}`;
|
||
const cmd = (endpoint: string): string => `${envPrefix} ${bridgePath} ${endpoint}`;
|
||
|
||
return {
|
||
SessionStart: [{ hooks: [{ type: 'command', command: cmd('session-start'), timeout: 3 }] }],
|
||
SessionEnd: [{ hooks: [{ type: 'command', command: cmd('session-end'), timeout: 3 }] }],
|
||
BeforeTool: [{ matcher: '*', hooks: [{ type: 'command', command: cmd('before-tool'), timeout: 3 }] }],
|
||
AfterTool: [{ matcher: '*', hooks: [{ type: 'command', command: cmd('after-tool'), timeout: 3 }] }],
|
||
BeforeAgent: [{ hooks: [{ type: 'command', command: cmd('before-agent'), timeout: 3 }] }],
|
||
AfterAgent: [{ hooks: [{ type: 'command', command: cmd('after-agent'), timeout: 3 }] }],
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add server/adapters/gemini/hook-config.ts server/adapters/gemini/bridge.sh && git commit -m "feat(gemini): add hook-config and bridge script"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Pane Monitor
|
||
|
||
**Files:**
|
||
- Create: `server/adapters/gemini/pane-monitor.ts`
|
||
|
||
- [ ] **Step 1: Create `server/adapters/gemini/pane-monitor.ts`**
|
||
|
||
Follow the pattern from `server/adapters/codex/pane-monitor.ts` — takes sessionId, windowId, tmux manager, and EventEmitter. Gemini TUI patterns will need empirical refinement, so start with conservative placeholder patterns similar to Codex.
|
||
|
||
The key difference from Claude/Codex: Gemini already provides thinking in the JSON, so the pane monitor's thinking detection is supplementary (for real-time streaming before JSON is written).
|
||
|
||
```typescript
|
||
// server/adapters/gemini/pane-monitor.ts
|
||
//
|
||
// Polls tmux pane for real-time streaming output from Gemini CLI.
|
||
// Detects: streaming text, thinking indicators.
|
||
// Note: Gemini provides thinking in JSON (thoughts[]) — pane monitor
|
||
// provides real-time streaming BEFORE JSON is written to disk.
|
||
|
||
import { EventEmitter } from 'events';
|
||
|
||
interface TmuxCapture {
|
||
capturePane(windowId: string, lines?: number): Promise<string>;
|
||
}
|
||
|
||
export interface ThinkingInfo {
|
||
text: string;
|
||
detail: string | null;
|
||
}
|
||
|
||
export class GeminiPaneMonitor {
|
||
private sessionId: string;
|
||
private windowId: string;
|
||
private tmux: TmuxCapture;
|
||
private emitter: EventEmitter;
|
||
private interval: ReturnType<typeof setInterval> | null = null;
|
||
private _lastContent: string = '';
|
||
private _lastResponseText: string = '';
|
||
|
||
constructor(
|
||
sessionId: string,
|
||
windowId: string,
|
||
tmuxManager: TmuxCapture,
|
||
emitter: EventEmitter,
|
||
) {
|
||
this.sessionId = sessionId;
|
||
this.windowId = windowId;
|
||
this.tmux = tmuxManager;
|
||
this.emitter = emitter;
|
||
}
|
||
|
||
start(): void {
|
||
if (this.interval) return;
|
||
this.interval = setInterval(() => this._poll(), 500);
|
||
}
|
||
|
||
stop(): void {
|
||
if (this.interval) { clearInterval(this.interval); this.interval = null; }
|
||
}
|
||
|
||
async pollNow(): Promise<void> { await this._poll(); }
|
||
|
||
private async _poll(): Promise<void> {
|
||
try {
|
||
const content = await this.tmux.capturePane(this.windowId);
|
||
if (content === this._lastContent) return;
|
||
this._lastContent = content;
|
||
|
||
// 1. Check for thinking indicator
|
||
const thinking = detectThinking(content);
|
||
if (thinking) {
|
||
this.emitter.emit('thinking', this.sessionId, thinking);
|
||
return;
|
||
}
|
||
|
||
// 2. Extract streaming response text
|
||
const text = extractResponseText(content);
|
||
if (text && text !== this._lastResponseText) {
|
||
this._lastResponseText = text;
|
||
this.emitter.emit('streaming-text', this.sessionId, text);
|
||
}
|
||
} catch {
|
||
// Silently ignore — tmux window may have been killed
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- Detection functions (exported for testing) ---
|
||
|
||
/** Detect Gemini CLI thinking/processing indicators */
|
||
export function detectThinking(content: string): ThinkingInfo | null {
|
||
const lines = content.split('\n');
|
||
const tail = lines.slice(-15);
|
||
for (const line of tail) {
|
||
if (/completed|finished|done|exited/i.test(line)) continue;
|
||
// Gemini uses braille spinners and "Thinking..." text
|
||
const brailleMatch = line.match(/^\s*([⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏])\s+(.+?)\s*$/);
|
||
if (brailleMatch) return { text: brailleMatch[2]!, detail: null };
|
||
const thinkingMatch = line.match(/^\s*(Thinking|Reasoning|Processing|Searching)(\.\.\.)?\s*(?:\((.+?)\))?\s*$/i);
|
||
if (thinkingMatch) return { text: `${thinkingMatch[1]}...`, detail: thinkingMatch[3] || null };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Extract streaming response text from Gemini pane content */
|
||
export function extractResponseText(content: string): string {
|
||
const lines = content.split('\n');
|
||
// Find last user prompt (Gemini uses > or ❯)
|
||
let lastUserPrompt = -1;
|
||
for (let i = lines.length - 1; i >= 0; i--) {
|
||
if (/^\s*[>❯]\s+\S/.test(lines[i]!)) { lastUserPrompt = i; break; }
|
||
}
|
||
if (lastUserPrompt === -1) return '';
|
||
|
||
let responseStart = lastUserPrompt + 1;
|
||
while (responseStart < lines.length && lines[responseStart]!.trim() === '') responseStart++;
|
||
if (responseStart >= lines.length) return '';
|
||
|
||
const responseLines: string[] = [];
|
||
for (let i = responseStart; i < lines.length; i++) {
|
||
const line = lines[i]!;
|
||
if (/^[─━═\-]{5,}/.test(line.trim()) ||
|
||
/^\s*[>❯]\s+\S/.test(line) ||
|
||
/^\s*[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s+/.test(line)) break;
|
||
responseLines.push(line);
|
||
}
|
||
return responseLines.join('\n').trim();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add server/adapters/gemini/pane-monitor.ts && git commit -m "feat(gemini): add pane monitor for streaming text detection"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: GeminiTmuxAdapter — Session Lifecycle
|
||
|
||
**Files:**
|
||
- Create: `server/adapters/gemini/gemini-tmux-adapter.ts`
|
||
|
||
- [ ] **Step 1: Create `server/adapters/gemini/gemini-tmux-adapter.ts`**
|
||
|
||
This is the largest file. Model it closely on `server/adapters/codex/codex-tmux-adapter.ts` since both share the same pattern: session ID discovered from hook, `_pendingHookBodies` for race conditions, JSONL/JSON watcher started on SessionStart hook.
|
||
|
||
Key differences from Codex:
|
||
- Uses `JsonWatcher` instead of `JsonlWatcher`
|
||
- Uses `GeminiTranscriptParser` instead of `CodexTranscriptParser`
|
||
- More hook events (BeforeTool, AfterTool, BeforeAgent, AfterAgent)
|
||
- Permission toggle via Ctrl+Y (binary YOLO toggle)
|
||
- Model switch via `/model <name>` slash command
|
||
|
||
This file is large (~400-500 lines). The implementer should:
|
||
1. Read `server/adapters/codex/codex-tmux-adapter.ts` in full
|
||
2. Copy its structure, adapting for Gemini's specifics
|
||
3. Key methods: `startSession`, `resumeSession`, `sendMessage`, `interrupt`, `switchPermissionMode`, `switchModel`, `handleSessionStart`, `handleBeforeTool`, `handleAfterTool`, `handleBeforeAgent`, `handleAfterAgent`, `handleSessionEnd`
|
||
|
||
The complete implementation is too large for inline code in this plan. The implementer should use the Codex adapter as a template and the spec's Section 7 (Session Lifecycle) for Gemini-specific behavior.
|
||
|
||
- [ ] **Step 2: Verify — file compiles**
|
||
|
||
```bash
|
||
npx tsc --noEmit server/adapters/gemini/gemini-tmux-adapter.ts 2>&1 | head -20
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add server/adapters/gemini/gemini-tmux-adapter.ts && git commit -m "feat(gemini): add tmux adapter for session lifecycle"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: GeminiAdapter — Main Entry Point
|
||
|
||
**Files:**
|
||
- Create: `server/adapters/gemini/index.ts`
|
||
|
||
- [ ] **Step 1: Create `server/adapters/gemini/index.ts`**
|
||
|
||
Model on `server/adapters/codex/index.ts`. Wires the GeminiTmuxAdapter, GeminiHookConfig, and json-store together. Registers HTTP hook routes via `setup(app)`.
|
||
|
||
Key aspects:
|
||
- `static id = 'gemini'`, `static displayName = 'Gemini CLI'`, `static command = 'gemini'`
|
||
- `setup(app)`: register routes for `/api/hooks/gemini/session-start`, `before-tool`, `after-tool`, `before-agent`, `after-agent`, `session-end`
|
||
- Delegate all IAdapter methods to GeminiTmuxAdapter
|
||
- `getModels()`, `getPermissionModes()`, `getCapabilities()` as defined in spec Section 6
|
||
- `installHooks()` / `uninstallHooks()` delegate to GeminiHookConfig
|
||
|
||
Again, too large for inline code. The implementer should use `server/adapters/codex/index.ts` as a template.
|
||
|
||
- [ ] **Step 2: Verify — server starts with gemini adapter loaded**
|
||
|
||
```bash
|
||
npx tsx server/index.ts 2>&1 | head -20
|
||
```
|
||
Expected: Should see `[init] Loaded adapter: gemini` (or no error if gemini CLI not installed — registry skips it).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add server/adapters/gemini/index.ts && git commit -m "feat(gemini): add GeminiAdapter main entry point"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Registration & CLI Integration
|
||
|
||
**Files:**
|
||
- Modify: `server/adapters/init.ts`
|
||
- Modify: `server/adapters/registry.ts`
|
||
- Modify: `bin/hooks-cli.mjs`
|
||
- Modify: `bin/codetap`
|
||
|
||
- [ ] **Step 1: Update `server/adapters/init.ts` — add gemini loader**
|
||
|
||
Add to `LOADERS`:
|
||
```typescript
|
||
gemini: () => import('./gemini/index.js').then(m => m.GeminiAdapter),
|
||
```
|
||
|
||
- [ ] **Step 2: Update `server/adapters/registry.ts` — add gemini to defaults**
|
||
|
||
Change line 29 from:
|
||
```typescript
|
||
: ['claude', 'codex'];
|
||
```
|
||
To:
|
||
```typescript
|
||
: ['claude', 'codex', 'gemini'];
|
||
```
|
||
|
||
- [ ] **Step 3: Update `bin/hooks-cli.mjs` — add GeminiHookConfig**
|
||
|
||
Add import:
|
||
```javascript
|
||
import { GeminiHookConfig } from '../server/adapters/gemini/hook-config.js';
|
||
```
|
||
Add instance:
|
||
```javascript
|
||
const gemini = new GeminiHookConfig();
|
||
```
|
||
Add to install/uninstall:
|
||
```javascript
|
||
if (cmd === 'install') {
|
||
claude.install();
|
||
codex.install();
|
||
gemini.install();
|
||
} else {
|
||
claude.uninstall();
|
||
codex.uninstall();
|
||
gemini.uninstall();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Update `bin/codetap` — add gemini support**
|
||
|
||
Five changes:
|
||
1. `set_adapter()`: add `gemini) ADAPTER="gemini"; ADAPTER_CMD="gemini"; YOLO="--approval-mode yolo" ;;`
|
||
2. Adapter detection: add `*gemini*) SESS_ADAPTER="gemini" ;;`
|
||
3. ANSI label: add `gemini) LABEL="\033[34m[Gemini]\033[0m" ;;`
|
||
4. `--adapter` validation: add `gemini) set_adapter gemini ;;`
|
||
5. Help text (line ~45): update `--adapter <name>` description to `(claude, codex, gemini)`
|
||
|
||
- [ ] **Step 5: Verify — `codetap --help` shows gemini, hooks install works**
|
||
|
||
```bash
|
||
./bin/codetap --help
|
||
node bin/hooks-cli.mjs install 2>&1 | grep gemini
|
||
node bin/hooks-cli.mjs uninstall 2>&1 | grep gemini
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add server/adapters/init.ts server/adapters/registry.ts bin/hooks-cli.mjs bin/codetap && git commit -m "feat(gemini): wire up registration, CLI, and hook management"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Frontend — Adapter Brand & Icon
|
||
|
||
**Files:**
|
||
- Modify: `src/lib/adapter-brands.ts`
|
||
- Modify: `src/components/AdapterIcon.tsx`
|
||
|
||
- [ ] **Step 1: Update `src/lib/adapter-brands.ts`**
|
||
|
||
Extend the `AdapterBrand` type to include `'gemini'` in `iconType`:
|
||
```typescript
|
||
iconType: 'claude' | 'codex' | 'gemini';
|
||
```
|
||
|
||
Add gemini brand to `ADAPTER_BRANDS`:
|
||
```typescript
|
||
gemini: {
|
||
id: 'gemini',
|
||
displayName: 'Gemini',
|
||
provider: 'Google',
|
||
color: '#4285f4',
|
||
colorBg: '#4285f422',
|
||
gradient: 'linear-gradient(135deg, #4285f4, #1a73e8)',
|
||
glow: 'rgba(66,133,244,0.3)',
|
||
iconType: 'gemini',
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 2: Update `src/components/AdapterIcon.tsx`**
|
||
|
||
1. Add `GeminiIcon` component — find the official Google Gemini star SVG from thesvg.org
|
||
2. Refactor the icon selection from if/else to a map:
|
||
|
||
```tsx
|
||
const ICON_MAP: Record<string, React.FC<{ size: number }>> = {
|
||
claude: ClaudeIcon,
|
||
codex: CodexIcon,
|
||
gemini: GeminiIcon,
|
||
};
|
||
|
||
// In AdapterIcon:
|
||
const Icon = ICON_MAP[brand.iconType] || ClaudeIcon;
|
||
return <span className={className} style={{ color: brand.color, display: 'inline-flex' }}><Icon size={size} /></span>;
|
||
```
|
||
|
||
- [ ] **Step 3: Verify — `npm run dev` builds, open browser, check adapter selector shows Gemini**
|
||
|
||
```bash
|
||
npm run dev
|
||
```
|
||
Open http://localhost:5173, check settings → adapter list shows Gemini with blue icon.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/lib/adapter-brands.ts src/components/AdapterIcon.tsx && git commit -m "feat(gemini): add Gemini brand and icon to frontend"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: End-to-End Verification
|
||
|
||
- [ ] **Step 1: Start code-tap server**
|
||
|
||
```bash
|
||
CLAUDE_UI_PASSWORD=test npm run dev
|
||
```
|
||
|
||
- [ ] **Step 2: Verify adapter registration**
|
||
|
||
```bash
|
||
curl -s http://localhost:3456/health | python3 -m json.tool
|
||
curl -sk -X POST http://localhost:3456/api/auth/login -H 'Content-Type: application/json' -d '{"password":"test"}' | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])'
|
||
# Use token for:
|
||
curl -s http://localhost:3456/api/adapters -H 'Authorization: Bearer <token>' | python3 -m json.tool
|
||
```
|
||
Expected: Response includes `{ id: "gemini", displayName: "Gemini CLI", available: true/false, capabilities: {...} }`
|
||
|
||
- [ ] **Step 3: Verify hooks install/uninstall**
|
||
|
||
```bash
|
||
node bin/hooks-cli.mjs install
|
||
cat ~/.gemini/settings.json | python3 -m json.tool
|
||
# Should show hooks.BeforeTool, hooks.AfterTool, etc. with bridge.sh paths
|
||
node bin/hooks-cli.mjs uninstall
|
||
cat ~/.gemini/settings.json | python3 -m json.tool
|
||
# Hooks should be removed
|
||
```
|
||
|
||
- [ ] **Step 4: Verify session listing**
|
||
|
||
```bash
|
||
curl -s http://localhost:3456/api/sessions?adapter=gemini -H 'Authorization: Bearer <token>' | python3 -m json.tool
|
||
```
|
||
Expected: Returns existing Gemini sessions from `~/.gemini/tmp/*/chats/` (if any exist).
|
||
|
||
- [ ] **Step 5: Manual test — full flow (if Gemini CLI is working)**
|
||
|
||
1. Open phone browser → code-tap
|
||
2. Select Gemini adapter
|
||
3. Start new session
|
||
4. Send a prompt
|
||
5. Verify: streaming text appears, thinking shows, tool calls render
|
||
6. Resume session works
|
||
|
||
- [ ] **Step 6: Final commit (if any fixes needed)**
|
||
|
||
```bash
|
||
git add -A && git commit -m "fix(gemini): end-to-end verification fixes"
|
||
```
|