feat: ClawTap v0.2.0

Interactive Prompts:
- Unified InteractivePrompt type across all 3 adapters (Claude/Codex/Gemini)
- InteractivePromptOverlay component with options, text input, countdown
- Gemini + Codex pane monitors detect tool confirmation, ask user, plan approval
- respondInteractivePrompt routing: permission → respondPermission, options → _selectOption
- Claude AskUserQuestion nested questions[0] structure parsing

Cross-AI Review:
- Client-generated reviewId, removed pendingReview state
- FloatingReviewPanel uses CSS display:none instead of unmount (keeps hooks alive)
- Child review sessions default to YOLO/bypass permission mode
- Send back to parent, send to existing/new review, tab switching, end review
- Collapsed review cards with read-only panel for ended reviews
- Full reconnect support: active + ended reviews restore correctly

AskUserQuestion Tool Card UI:
- Dedicated renderer replaces raw JSON display
- Options shown with selected (green) / unselected (gray) indicators
- Free text answers shown in quoted format with green border
- Collapsed summary: question → answer
- Shared parseAskQuestionInput utility (client + server)
- Historical tool results attached via _result on tool_use blocks

Adapter Fixes:
- Session→adapter mapping persisted in SQLite (survives server restart)
- SESSION_CREATED deferred for pendingRekey adapters (Codex/Gemini)
- session-rekeyed handler sends complete SESSION_CREATED with adapter + cwd
- Gemini: auto-accept folder trust, privacy notice, IDE nudge, YOLO * prompt
- Claude: auto-accept bypass permissions confirmation (v2.1.85+)
- Port fallback (EADDRINUSE → try +1), statusLine shell script wrapper

Other:
- Desktop Enter sends / Shift+Enter newline; Mobile Enter newline
- Strip CLAWTAP_REF marker from session list
- Active sessions tab shows adapter badge
- Rename CLAUDE_UI_PASSWORD → CLAWTAP_PASSWORD

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
kuannnn
2026-03-27 14:46:00 +08:00
parent 16f75379af
commit 0fcf66fc22
50 changed files with 2179 additions and 400 deletions
@@ -0,0 +1,403 @@
# Adapter Identity Fix 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:** Fix sessions opening with wrong adapter by deferring initialization until adapter is confirmed (from prop/URL or server SESSION_CREATED).
**Architecture:** Stop guessing adapter from localStorage. If adapter is known (session list click, URL with adapter param), initialize immediately. If unknown (bare URL, push notification), show loading until server tells us via SESSION_CREATED. Server is single source of truth.
**Tech Stack:** React, TypeScript, WebSocket
**Spec:** `docs/superpowers/specs/2026-03-26-adapter-identity-fix-design.md`
---
## File Map
| File | Action | Change |
|---|---|---|
| `src/hooks/useChat.ts` | Modify | Defer init when adapter unknown, handle SESSION_CREATED fully |
| `src/App.tsx` | Modify | URL includes adapter, URL parser reads adapter |
| `src/components/ChatView.tsx` | Modify | Loading state when adapter unknown |
| `server/session-manager.ts` | Modify | SESSION_CREATED includes cwd, handleQuery verifies adapter |
| `src/components/SessionsView.tsx` | Modify | Active sessions badge |
| `src/sw.ts` | Modify | Notification URL includes adapter |
---
### Task 1: useChat — defer initialization when adapter unknown
**Files:**
- Modify: `src/hooks/useChat.ts:114-127` (init), `src/hooks/useChat.ts:169-174` (SESSION_CREATED handler)
- [ ] **Step 1: Change selectedAdapter to nullable, defer prefs init**
Replace lines 114-127:
```typescript
// Current:
const resolvedAdapter = initialAdapter || localStorage.getItem(STORAGE.ADAPTER) || 'claude';
const initialPrefs = loadAdapterPrefs(resolvedAdapter);
const [model, setModel] = useState<string>(initialPrefs.model || '');
const [permissionMode, setPermissionMode] = useState<string>(initialPrefs.permissionMode || 'default');
const [effort, setEffort] = useState<string>(initialPrefs.effort || 'high');
// ...
const [selectedAdapter, setSelectedAdapter] = useState<string>(resolvedAdapter);
```
With:
```typescript
// If adapter is known (from prop or URL), use it immediately.
// If unknown (bare URL, push notification), null → wait for server SESSION_CREATED.
const knownAdapter = initialAdapter || null;
const initialPrefs = knownAdapter ? loadAdapterPrefs(knownAdapter) : null;
const [model, setModel] = useState<string>(initialPrefs?.model || '');
const [permissionMode, setPermissionMode] = useState<string>(initialPrefs?.permissionMode || 'default');
const [effort, setEffort] = useState<string>(initialPrefs?.effort || '');
// ...
const [selectedAdapter, setSelectedAdapter] = useState<string | null>(knownAdapter);
```
**Important:** `selectedAdapter` is now `string | null`. When null, it means "waiting for server to tell us."
- [ ] **Step 2: Update SESSION_CREATED handler to fully initialize**
Replace lines 169-174:
```typescript
case WS.SESSION_CREATED:
setSessionId(msg.sessionId);
if (msg.adapter) {
setSelectedAdapter(msg.adapter);
// Load correct prefs now that we know the adapter
const prefs = loadAdapterPrefs(msg.adapter);
if (!knownAdapter) {
// First time learning adapter — initialize prefs from scratch
setModel(prefs.model || '');
setPermissionMode(msg.permissionMode || prefs.permissionMode || 'default');
setEffort(prefs.effort || '');
} else {
// Adapter was already known — just update permissionMode if server provides it
if (msg.permissionMode) setPermissionMode(msg.permissionMode);
}
} else {
if (msg.permissionMode) setPermissionMode(msg.permissionMode);
}
break;
```
- [ ] **Step 3: Fix all null-safety issues in useChat.ts**
`selectedAdapter` is now `string | null`. Every usage must be guarded:
| Line | Code | Fix |
|---|---|---|
| 149 | `useRef<string>(selectedAdapter)` | → `useRef<string \| null>(selectedAdapter)` |
| 363 | `patchAdapterPrefs(selectedAdapterRef.current, ...)` | → `if (selectedAdapterRef.current) patchAdapterPrefs(...)` |
| 408 | `client.setActiveSession(initialSessionId, selectedAdapter)` | → `client.setActiveSession(initialSessionId, selectedAdapter ?? undefined)` |
| 430 | `wsRef.current.setActiveSession(sessionId, selectedAdapter)` | → `wsRef.current.setActiveSession(sessionId, selectedAdapter ?? undefined)` |
| 436 | `api.adapterConfig(selectedAdapter)` | → `if (selectedAdapter) api.adapterConfig(selectedAdapter)...` |
| 455 | `adapter: selectedAdapter` in actualSend | → add `if (!selectedAdapter) return` at top of actualSend |
| 532 | `patchAdapterPrefs(selectedAdapter, { model })` | → `if (selectedAdapter) patchAdapterPrefs(...)` |
| 549 | `patchAdapterPrefs(selectedAdapter, { permissionMode })` | → `if (selectedAdapter) patchAdapterPrefs(...)` |
- [ ] **Step 4: Fix effort default — keep 'high' not empty string**
Change from the plan's `initialPrefs?.effort || ''` to:
```typescript
const [effort, setEffort] = useState<string>(initialPrefs?.effort || 'high');
```
Empty string effort causes blank label in NewChatView and omits `--effort` flag in CLI.
- [ ] **Step 5: Fix ws.ts — accept null adapter**
`src/lib/ws.ts:72``setActiveSession(sessionId, adapter?: string)` cannot accept `null`. Change to:
```typescript
setActiveSession(sessionId: string | null, adapter?: string | null) {
this.activeSessionId = sessionId;
this.activeAdapter = adapter || null;
}
```
- [ ] **Step 6: TypeScript check**
Run: `npx tsc --noEmit 2>&1 | head -10`
Expected: Zero errors in useChat.ts and ws.ts
- [ ] **Step 7: Commit**
```bash
git add src/hooks/useChat.ts src/lib/ws.ts
git commit -m "refactor: defer useChat init when adapter unknown — wait for server SESSION_CREATED"
```
---
### Task 2: App.tsx — URL includes adapter, parser reads it
**Files:**
- Modify: `src/App.tsx:39-47` (navigateTo), `src/App.tsx:202-217` (URL parser), `src/App.tsx:191-200` (push notification handler)
- [ ] **Step 1: navigateTo includes adapter in URL**
Replace lines 39-47:
```typescript
function navigateTo(view: View) {
persistView(view);
let url = '/';
if (view.name === 'chat' && view.sessionId) {
url = `/?view=chat&session=${view.sessionId}`;
if (view.adapter) url += `&adapter=${view.adapter}`;
} else if (view.name === 'settings') {
url = '/?view=settings';
}
window.history.pushState({ view }, '', url);
}
```
- [ ] **Step 2: URL parser reads adapter from URL**
Replace lines 202-217:
```typescript
useEffect(() => {
if (urlParamsHandled.current || !authed) return;
const params = new URLSearchParams(window.location.search);
const sessionId = params.get('session');
const adapter = params.get('adapter');
const action = params.get('action');
if (sessionId) {
urlParamsHandled.current = true;
openChat(sessionId, undefined, adapter || undefined);
window.history.replaceState({}, '', '/');
} else if (action === 'newchat') {
urlParamsHandled.current = true;
window.history.replaceState({}, '', '/');
}
}, [authed, openChat]);
```
- [ ] **Step 3: Push notification handler — no change needed**
The push notification handler calls `openChat(sessionId)` with no adapter. This is correct — adapter is unknown, useChat will show loading and wait for SESSION_CREATED. The service worker's fallback URL `/?session=${sessionId}` also works (URL parser handles it, adapter will be null → loading → server resolves).
- [ ] **Step 4: Commit**
```bash
git add src/App.tsx
git commit -m "feat: URL includes adapter param, parser reads it for instant adapter resolution"
```
---
### Task 3: ChatView — loading state when adapter unknown
**Files:**
- Modify: `src/components/ChatView.tsx:141-149` (useChat destructuring), add loading check before render
- [ ] **Step 1: Add loading state when selectedAdapter is null**
After the useChat destructuring (line ~149), before the return JSX (line ~487), add:
```typescript
// Waiting for server to tell us the adapter
if (!selectedAdapter) {
return (
<div className="flex flex-col h-dvh bg-bg items-center justify-center">
<LoadingAnimation size="md" label="Connecting..." />
</div>
);
}
```
Import `LoadingAnimation` at the top of the file:
```typescript
import { LoadingAnimation } from './ui/LoadingAnimation';
```
- [ ] **Step 2: Fix ChatView null-safety**
The early return `if (!selectedAdapter)` guarantees all JSX below only runs when `selectedAdapter` is a string. TypeScript narrows it automatically. But verify:
- Line 169: `.filter(a => a !== selectedAdapter)` — safe after guard (narrowed to string)
- Line 440: `<StatusBar selectedAdapter={selectedAdapter}>` — safe (narrowed)
- All `getBrand(selectedAdapter)` calls — safe
**StatusBar.tsx** prop type `selectedAdapter: string` does NOT need to change — it's only rendered after the null guard in ChatView.
**Important**: Any code that runs BEFORE the null guard (e.g., useCallback hooks, useEffect hooks) must guard independently. Check:
- `closeReview` callback — uses `activeReviews`, not `selectedAdapter` → safe
- `openReview` callback — uses `reviewMenuMessageId` → safe
- `handleSendTo` — uses `activeReviews.length` → safe
- [ ] **Step 3: Commit**
```bash
git add src/components/ChatView.tsx
git commit -m "feat: ChatView shows loading when adapter unknown, waits for server"
```
---
### Task 4: Server — SESSION_CREATED includes cwd
**Files:**
- Modify: `server/session-manager.ts:249-261` (sendSessionCreated)
- [ ] **Step 1: Add cwd to SESSION_CREATED payload**
The `sendSessionCreated` function currently sends `sessionId`, `adapter`, `permissionMode`. Add `cwd`:
```typescript
function sendSessionCreated(conn: ClientConnection, adapter: IAdapter, sessionId: string): void {
const sessionObj = adapter.getSession(sessionId) as {
permissionMode?: string;
approvalPolicy?: string;
cwd?: string;
} | null;
const adapterName = sessionAdapterMap.get(sessionId) || sessionAdapters.get(sessionId);
send(conn, {
type: WS.SESSION_CREATED,
sessionId,
adapter: adapterName,
cwd: sessionObj?.cwd,
permissionMode: sessionObj?.permissionMode || sessionObj?.approvalPolicy,
});
}
```
Note: for historical (non-active) sessions, `adapter.getSession(sessionId)` returns null, so `cwd` will be undefined. This is acceptable — the header will show "Session" instead of project name, which is better than showing the wrong project.
- [ ] **Step 2: Commit**
```bash
git add server/session-manager.ts
git commit -m "feat: SESSION_CREATED includes cwd for project name display"
```
---
### Task 5: Server — handleQuery verifies adapter on resumeSession
**Files:**
- Modify: `server/session-manager.ts:293-307` (handleQuery)
- [ ] **Step 1: Verify adapter before resumeSession**
Replace lines 298-305:
```typescript
let handle: { sessionId: string };
if (sessionId) {
// Verify adapter — don't trust client blindly
const resolvedName = await resolveAdapterForSession(sessionId);
const actualAdapter = resolvedName ? getAdapter(resolvedName) : adapter;
const actualName = resolvedName || adapterName || DEFAULT_ADAPTER;
handle = await actualAdapter!.resumeSession(sessionId, cwd as string, { permissionMode });
registerSessionAdapter(handle.sessionId, actualName);
} else {
handle = await adapter.startSession(cwd || process.cwd(), { model, permissionMode });
registerSessionAdapter(handle.sessionId, adapterName || DEFAULT_ADAPTER);
}
registerClient(conn, handle.sessionId);
sendSessionCreated(conn, getAdapter(sessionAdapterMap.get(handle.sessionId) || DEFAULT_ADAPTER)!, handle.sessionId);
```
- [ ] **Step 2: Commit**
```bash
git add server/session-manager.ts
git commit -m "fix: handleQuery verifies adapter on resumeSession — don't trust client blindly"
```
---
### Task 6: Active sessions tab — add adapter badge
**Files:**
- Modify: `src/components/SessionsView.tsx:337-341` (active session card rendering)
- [ ] **Step 1: Add adapter badge to active session cards**
At line ~338, before the firstPrompt text, add the adapter badge:
```typescript
<span className="text-sm text-text truncate flex-1 mr-3">
<span className="inline-block w-2 h-2 rounded-full bg-success mr-1.5 shrink-0" />
{session.adapter && (() => {
const brand = getBrand(session.adapter);
return (
<span
className="text-[10px] font-semibold px-1.5 rounded shrink-0 mr-1"
style={{ color: brand.color, backgroundColor: `${brand.color}20` }}
>
{brand.displayName}
</span>
);
})()}
{session.firstPrompt || session.sessionId}
</span>
```
- [ ] **Step 2: Commit**
```bash
git add src/components/SessionsView.tsx
git commit -m "feat: active sessions tab shows adapter badge"
```
---
### Task 7: Build + E2E Verification
- [ ] **Step 1: TypeScript check**
Run: `npx tsc --noEmit 2>&1 | grep "error TS" | head -10`
Expected: Zero errors
- [ ] **Step 2: Build**
Run: `npm run build 2>&1 | tail -3`
Expected: Clean build
- [ ] **Step 3: E2E — Session list → Gemini historical session**
1. Restart server
2. Open browser, login
3. Navigate to code-tap project → click a Gemini session
4. Verify: Gemini badge in status bar, correct model, messages loaded
- [ ] **Step 4: E2E — Direct URL without adapter**
1. Open `http://localhost:3456/?view=chat&session=<gemini-session-id>`
2. Verify: Loading shown briefly → then correct Gemini adapter + messages
- [ ] **Step 5: E2E — Direct URL with adapter**
1. Open `http://localhost:3456/?view=chat&session=<id>&adapter=gemini`
2. Verify: No loading flash, immediately shows Gemini + messages
- [ ] **Step 6: E2E — Browser refresh on chat page**
1. While viewing a Gemini session, press F5
2. Verify: URL has `&adapter=gemini` → instant load, no flash
- [ ] **Step 7: E2E — Multi-tab sync**
1. Open same Gemini session in two tabs (via session list)
2. Send message from Tab 1
3. Verify Tab 2: user message appears → thinking indicator → response
- [ ] **Step 8: E2E — Active sessions tab**
1. Go to Active tab
2. Verify: each session shows adapter badge (Claude/Codex/Gemini)
- [ ] **Step 9: Final commit + push**
```bash
git push origin main
```
@@ -0,0 +1,195 @@
# Interactive Prompts Implementation Plan (v2)
> **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:** Detect Gemini/Codex interactive prompts from tmux pane, normalize all adapters to a single InteractivePrompt format, and render a unified overlay in the frontend.
**Architecture:** Pane monitors detect prompts and adapters emit interactive-prompt event. Session manager converts ALL prompt events (old Claude hooks + new pane detections) into WS.INTERACTIVE_PROMPT. Frontend uses a single InteractivePromptOverlay component. Response flows back via WS.PROMPT_RESPONSE and adapter sends keystrokes to tmux.
**Tech Stack:** TypeScript, React, WebSocket, tmux pane monitoring
**Spec:** docs/superpowers/specs/2026-03-27-interactive-prompts-design.md
---
## File Map
| File | Action | Change |
|---|---|---|
| server/types/messages.ts | Modify | Add InteractivePrompt, PromptResponse types |
| src/lib/ws-types.ts | Modify | Add INTERACTIVE_PROMPT, PROMPT_RESPONSE, PROMPT_DISMISSED |
| server/adapters/interface.ts | Modify | Add respondInteractivePrompt method |
| server/session-manager.ts | Modify | Convert old events to new format; add interactive-prompt listener; add handlePromptResponse; broadcast PROMPT_DISMISSED |
| server/adapters/gemini/pane-monitor.ts | Modify | Detect 4 prompt types, emit interactive-prompt |
| server/adapters/codex/pane-monitor.ts | Modify | Detect approval + user input, emit interactive-prompt |
| server/adapters/gemini/gemini-tmux-adapter.ts | Modify | Implement respondInteractivePrompt (numbered options + text) |
| server/adapters/codex/codex-tmux-adapter.ts | Modify | Implement respondInteractivePrompt (keyboard shortcuts + text) |
| server/adapters/claude/tmux-adapter.ts | Modify | Implement respondInteractivePrompt (delegates to existing) |
| src/hooks/useChat.ts | Modify | State type to InteractivePrompt; handle INTERACTIVE_PROMPT; add respondPrompt |
| src/components/InteractivePromptOverlay.tsx | Create | Unified overlay: options (buttons), textInput (field), or both (plan mode) |
| src/components/ChatView.tsx | Modify | Replace PermissionOverlay/AskQuestion with InteractivePromptOverlay |
| src/components/FloatingReviewPanel.tsx | Modify | Add prompt handling to ReviewTab |
| server/adapters/gemini/gemini-tmux-adapter.ts | Modify | _waitForReady: privacy notice, multi-folder trust, IDE nudge |
---
### Task 1: Types + WS message types
**Files:**
- Modify: server/types/messages.ts
- Modify: src/lib/ws-types.ts
- Modify: server/adapters/interface.ts
- [ ] **Step 1:** Add InteractivePrompt and PromptResponse types to server/types/messages.ts
- [ ] **Step 2:** Add INTERACTIVE_PROMPT, PROMPT_RESPONSE, PROMPT_DISMISSED to src/lib/ws-types.ts WS enum
- [ ] **Step 3:** Add respondInteractivePrompt(requestId: string, selectedOption?: string, textValue?: string): void {} to IAdapter in server/adapters/interface.ts
- [ ] **Step 4:** Commit
---
### Task 2: Session manager -- unified event conversion + response handling
**Files:**
- Modify: server/session-manager.ts
- [ ] **Step 1:** Replace existing permission-request listener to convert Claude hook data into InteractivePrompt format and broadcast as WS.INTERACTIVE_PROMPT (not WS.PERMISSION_REQUEST)
- [ ] **Step 2:** Replace existing ask-question listener to convert Claude hook data into InteractivePrompt format (detect options vs free text) and broadcast as WS.INTERACTIVE_PROMPT
- [ ] **Step 3:** Add interactive-prompt event listener for Gemini/Codex (already in InteractivePrompt format, just broadcast)
- [ ] **Step 4:** Add WS.PROMPT_RESPONSE case to handleIncomingMessage switch. Create handlePromptResponse function that calls adapter.respondInteractivePrompt and broadcasts WS.PROMPT_DISMISSED to all clients for multi-tab sync.
- [ ] **Step 5:** Commit
---
### Task 3: Gemini pane monitor -- detect prompts
**Files:**
- Modify: server/adapters/gemini/pane-monitor.ts
- [ ] **Step 1:** Add lastPromptId instance variable for dedup. At start of _poll, call _detectPrompt. If detected and different from lastPromptId, emit interactive-prompt event. If no longer detected, reset lastPromptId. Return early (skip streaming text detection while prompt is showing).
- [ ] **Step 2:** Implement _detectPrompt method. Detect 4 types:
- Tool Confirmation: content includes "Action Required" AND numbered options pattern
- AskUser: content includes "Answer Questions"
- Plan Approval: content includes "Approval" AND "Yes" options AND "feedback"
- Loop Detection: content includes "potential loop was detected"
For each, extract title, description, options (via _parseNumberedOptions), and textInput if applicable. Return InteractivePrompt or null.
- [ ] **Step 3:** Implement _parseNumberedOptions helper. Match regex for numbered list items. Return array of { value: String(index), label } where index is 0-based.
- [ ] **Step 4:** Commit
---
### Task 4: Codex pane monitor -- detect prompts
**Files:**
- Modify: server/adapters/codex/pane-monitor.ts
- [ ] **Step 1:** Add lastPromptId + detection at start of _poll (same dedup pattern as Gemini)
- [ ] **Step 2:** Detect command/file/network approval: content includes "(y)" AND "proceed". Parse options with _parseCodexOptions matching (letter) label pattern.
- [ ] **Step 3:** Detect user input: content includes "enter to submit" AND "esc to cancel". Determine if choice or free text.
- [ ] **Step 4:** Implement _parseCodexOptions helper. Match regex for (x) Label format. Return array of { value: letter, label }.
- [ ] **Step 5:** Commit
---
### Task 5: Adapter respondInteractivePrompt implementations
**Files:**
- Modify: server/adapters/gemini/gemini-tmux-adapter.ts
- Modify: server/adapters/codex/codex-tmux-adapter.ts
- Modify: server/adapters/claude/tmux-adapter.ts
- [ ] **Step 1:** Gemini respondInteractivePrompt. If selectedOption: parse as integer index, navigate Down x index + Enter. If textValue: sendKeys text + Enter.
- [ ] **Step 2:** Codex respondInteractivePrompt. If selectedOption: sendKeys with that single character (y/a/p/d/n). If textValue: sendKeys text + Enter.
- [ ] **Step 3:** Claude respondInteractivePrompt. If textValue: delegate to existing respondQuestion. If selectedOption: delegate to existing respondPermission with value as PermissionBehavior.
- [ ] **Step 4:** Commit
---
### Task 6: Frontend -- InteractivePromptOverlay component
**Files:**
- Create: src/components/InteractivePromptOverlay.tsx
- [ ] **Step 1:** Create component with props { prompt: InteractivePrompt, onRespond: (requestId, selectedOption?, textValue?) => void }. Use BottomSheet container. Render:
- Title bar with type badge (permission=orange, question=blue, plan=purple, loop-detected=yellow)
- Description text
- If toolName + toolInput: collapsible tool info card
- If options: vertical button list
- If textInput: text input field + submit button
- If both options AND textInput: buttons above, text input below (plan mode)
- 120s countdown timer
- [ ] **Step 2:** Commit
---
### Task 7: useChat -- InteractivePrompt state + respondPrompt
**Files:**
- Modify: src/hooks/useChat.ts
- [ ] **Step 1:** Add InteractivePrompt type (mirror from server types). Replace PermissionRequest state with InteractivePrompt state.
- [ ] **Step 2:** Replace WS.PERMISSION_REQUEST handler with WS.INTERACTIVE_PROMPT handler. Replace WS.PERMISSION_DISMISSED handler with WS.PROMPT_DISMISSED handler.
- [ ] **Step 3:** Add respondPrompt callback that sends WS.PROMPT_RESPONSE and clears interactivePrompt state.
- [ ] **Step 4:** Update return object: replace permissionRequest with interactivePrompt, add respondPrompt. Keep respondPermission and respondAsk temporarily for any remaining direct callers.
- [ ] **Step 5:** Commit
---
### Task 8: ChatView + ReviewTab -- use InteractivePromptOverlay
**Files:**
- Modify: src/components/ChatView.tsx
- Modify: src/components/FloatingReviewPanel.tsx
- [ ] **Step 1:** ChatView: replace PermissionOverlay/AskQuestion with InteractivePromptOverlay. Update useChat destructuring (interactivePrompt, respondPrompt).
- [ ] **Step 2:** ReviewTab: destructure interactivePrompt + respondPrompt from useChat. Render InteractivePromptOverlay after ChatBody.
- [ ] **Step 3:** Commit
---
### Task 9: Gemini _waitForReady -- remaining startup prompts
**Files:**
- Modify: server/adapters/gemini/gemini-tmux-adapter.ts
- [ ] **Step 1:** Add detection in _waitForReady for:
- Privacy Notice / Terms of Service: dismiss with Esc
- Multi-folder trust: accept default with Enter
- IDE integration nudge: decline (Down + Enter)
All before hasPrompt check, after existing folder trust check.
- [ ] **Step 2:** Commit
---
### Task 10: Build + E2E
- [ ] **Step 1:** TypeScript check
- [ ] **Step 2:** Build
- [ ] **Step 3:** E2E: Gemini tool confirmation (default mode)
- [ ] **Step 4:** E2E: Gemini AskUser
- [ ] **Step 5:** E2E: Codex command approval (suggest mode)
- [ ] **Step 6:** E2E: Claude permissions (backwards compatible)
- [ ] **Step 7:** E2E: Multi-tab dismiss
@@ -0,0 +1,169 @@
# Adapter Identity Fix — Design Spec
**Date**: 2026-03-26
**Status**: Draft
## Problem
When opening a historical session without explicit adapter info (URL direct access, browser refresh, push notification), the frontend guesses the adapter from localStorage and initializes model/permissionMode/effort from the wrong adapter's prefs. The server later corrects the adapter via SESSION_CREATED, but model/prefs are never corrected → wrong UI state.
## Root Cause
`useChat` eagerly initializes everything (adapter, model, permissionMode, effort, adapterConfig) at mount time before knowing the correct adapter. When the adapter is unknown, it guesses wrong.
## Design Principle
**Don't guess. Wait.**
If the adapter is known (from prop or URL) → initialize immediately.
If the adapter is unknown → show loading → wait for server SESSION_CREATED → then initialize.
Server is the single source of truth for adapter identity.
## Changes
### Change 1: useChat — defer initialization until adapter is confirmed
**File:** `src/hooks/useChat.ts`
Current:
```typescript
const resolvedAdapter = initialAdapter || localStorage.getItem(STORAGE.ADAPTER) || 'claude';
const initialPrefs = loadAdapterPrefs(resolvedAdapter);
const [model, setModel] = useState(initialPrefs.model || '');
// ... all initialized immediately with possibly wrong adapter
```
New:
```typescript
// adapter is either known from prop/URL, or null (wait for server)
const [selectedAdapter, setSelectedAdapter] = useState<string | null>(initialAdapter || null);
// model/prefs initialized only after adapter is confirmed
const [model, setModel] = useState<string>('');
const [permissionMode, setPermissionMode] = useState<string>('default');
const [effort, setEffort] = useState<string>('');
```
When SESSION_CREATED arrives:
```typescript
case WS.SESSION_CREATED:
setSessionId(msg.sessionId);
if (msg.adapter) {
setSelectedAdapter(msg.adapter);
// Load correct prefs now that we know the adapter
const prefs = loadAdapterPrefs(msg.adapter);
setModel(prefs.model || '');
setPermissionMode(msg.permissionMode || prefs.permissionMode || 'default');
setEffort(prefs.effort || '');
}
break;
```
If `initialAdapter` was provided (session list click), everything initializes immediately at mount — no waiting, no loading state.
### Change 2: ChatView — show loading when adapter unknown
**File:** `src/components/ChatView.tsx`
If `selectedAdapter` is null (waiting for server), render a loading indicator instead of the chat UI:
```tsx
if (!selectedAdapter) {
return <LoadingAnimation size="md" label="Connecting..." />;
}
```
This only happens for path B (URL) and C (notification). Path A (session list) always has adapter → no loading.
### Change 3: URL includes adapter when available
**File:** `src/App.tsx`
`navigateTo()` includes adapter in URL when known:
```typescript
const url = view.sessionId
? `/?view=chat&session=${view.sessionId}${view.adapter ? `&adapter=${view.adapter}` : ''}`
: '/';
```
URL parser reads adapter from URL:
```typescript
const sessionId = params.get('session');
const adapter = params.get('adapter');
if (sessionId) {
openChat(sessionId, undefined, adapter || undefined);
}
```
Push notification service worker also includes adapter in URL if available.
### Change 4: SERVER SESSION_CREATED includes adapter + cwd
**File:** `server/session-manager.ts`
`sendSessionCreated()` includes `adapter` name (already done) and `cwd`:
```typescript
send(conn, {
type: WS.SESSION_CREATED,
sessionId,
adapter: adapterName,
cwd: sessionObj?.cwd || resolvedCwd,
permissionMode: sessionObj?.permissionMode,
});
```
The `cwd` allows the frontend to show the correct project name in the header even when opened from URL (which doesn't have `cwd`).
### Change 5: handleQuery verifies adapter on resumeSession
**File:** `server/session-manager.ts`
When `handleQuery` receives a QUERY with a `sessionId` (resume path), verify the adapter:
```typescript
if (sessionId) {
const resolvedName = await resolveAdapterForSession(sessionId);
if (resolvedName) adapterName = resolvedName; // override client's adapter with truth
handle = await adapter.resumeSession(sessionId, cwd, { permissionMode });
}
```
### Change 6: Active sessions tab shows adapter badge
**File:** `src/components/SessionsView.tsx`
Add adapter brand badge to active session cards (same pattern as project sessions list).
## Flow After Fix
### Path A (Session List Click):
```
adapter = 'gemini' (from server API) → useChat initializes immediately → no loading
→ WS RECONNECT → server confirms → messages load → render
```
### Path B (URL Direct) / Path C (Push Notification):
```
adapter = null (unknown) → useChat does NOT initialize prefs → ChatView shows loading
→ WS RECONNECT → server SESSION_CREATED { adapter: 'gemini', cwd: '...' }
→ useChat sets adapter, loads correct prefs → loading disappears → messages load → render
```
### Path B with adapter in URL:
```
URL: ?session=UUID&adapter=gemini
adapter = 'gemini' (from URL) → useChat initializes immediately → no loading
→ WS RECONNECT → server confirms → messages load → render
```
## Verification
1. **Session list → Gemini session**: Loads immediately, correct adapter/model/messages
2. **URL `?session=UUID` (no adapter)**: Shows loading → then correct adapter/messages
3. **URL `?session=UUID&adapter=gemini`**: Loads immediately, no flash
4. **Browser refresh on chat**: Adapter from URL → immediate load
5. **Push notification click**: Loading → then correct session
6. **Two tabs same session**: Both show correct adapter, messages sync
7. **Send from Tab A**: Tab B sees user message + thinking + response
8. **Active sessions tab**: Shows adapter badge
9. **New Chat (all adapters)**: Still works correctly
@@ -0,0 +1,196 @@
# Interactive Prompts — Normalized Handling Across All Adapters
**Date**: 2026-03-27
**Status**: Draft
## Problem
Gemini and Codex CLIs show interactive terminal prompts (tool confirmation, ask user question, plan approval, etc.) that ClawTap doesn't detect or handle. The UI freezes with a blinking cursor while the CLI waits for input. Claude's prompts are handled via hooks, but Gemini/Codex have no equivalent hook mechanism for runtime prompts.
## Design Principle
**One normalized format, adapter-agnostic frontend.**
Each adapter detects prompts in its own way (Claude: hooks, Gemini/Codex: pane monitor), but all emit the same `InteractivePrompt` format. Frontend renders based on format, not adapter identity.
## Normalized Types
```typescript
interface InteractivePrompt {
requestId: string;
type: 'permission' | 'question' | 'plan' | 'loop-detected';
title: string;
description: string;
toolName?: string;
toolInput?: any;
options?: { value: string; label: string }[];
textInput?: { placeholder?: string };
}
interface PromptResponse {
requestId: string;
selectedOption?: string;
textValue?: string;
}
```
Render logic:
- `options` only → button list (permission overlay)
- `textInput` only → text input field (ask question)
- Both → buttons + text field (plan mode: approve buttons + feedback)
## Changes
### Change 1: Gemini pane monitor — detect interactive prompts
**File:** `server/adapters/gemini/pane-monitor.ts`
Add prompt detection to the existing poll loop. Each poll, check for known prompt patterns before processing streaming text:
**Tool Confirmation** ("Action Required"):
```
Pattern: content includes "Action Required" AND numbered options (● N.)
Extract: title, description (command/file/tool info), options list
Emit: { type: 'permission', options: [...] }
```
**AskUser** ("Answer Questions"):
```
Pattern: content includes "Answer Questions" AND "> " input prompt
Extract: question text, input type (detect options vs free text)
Emit: { type: 'question', textInput or options }
```
**Plan Approval** ("Approval"):
```
Pattern: content includes "Approval" header with plan content
Extract: plan text, approval options + feedback field
Emit: { type: 'plan', options: [...], textInput: { placeholder: 'Type your feedback...' } }
```
**Loop Detection**:
```
Pattern: content includes "potential loop was detected"
Extract: options
Emit: { type: 'loop-detected', options: [...] }
```
Dedup: track last emitted requestId to avoid re-emitting the same prompt on each poll.
### Change 2: Codex pane monitor — detect interactive prompts
**File:** `server/adapters/codex/pane-monitor.ts`
**Command/File/Network Approval**:
```
Pattern: content includes "(y) Yes, proceed" or "Would you like to run"
Extract: command/file info, available options (y/a/p/d/n)
Emit: { type: 'permission', options: [...] }
```
**Request User Input**:
```
Pattern: content includes "Question" header with "> " input
Extract: question text, options or free text field
Emit: { type: 'question', textInput or options }
```
### Change 3: Gemini adapter — respondPermission / respondQuestion
**File:** `server/adapters/gemini/gemini-tmux-adapter.ts`
Add methods to send keystrokes based on `PromptResponse`:
- **Permission (numbered options):** Navigate Down × N to selected option, press Enter
- **Question (text):** Type answer text, press Enter
- **Question (select):** Navigate to selected option, press Enter
- **Plan:** Select approve option OR type feedback, press Enter
### Change 4: Codex adapter — respondPermission / respondQuestion
**File:** `server/adapters/codex/codex-tmux-adapter.ts`
- **Permission:** Send the keyboard shortcut key (y/a/p/d/n)
- **Question (text):** Type answer text, press Enter
- **Question (select):** Navigate to option, press Enter
### Change 5: Claude adapter — normalize existing events
**File:** `server/adapters/claude/tmux-adapter.ts`
Current `permission-request` and `ask-question` events already work but use adapter-specific format. Map to `InteractivePrompt` format in session-manager.ts event handlers (minimal change — add missing fields).
### Change 6: Session manager — unified event handling
**File:** `server/session-manager.ts`
Current event listeners:
```typescript
adapter.on('permission-request', ...) broadcast WS.PERMISSION_REQUEST
adapter.on('ask-question', ...) broadcast WS.PERMISSION_REQUEST (toolName: 'AskUserQuestion')
```
Change to emit unified format:
```typescript
adapter.on('interactive-prompt', (sessionId, prompt: InteractivePrompt) => {
broadcast(sessionId, { type: WS.INTERACTIVE_PROMPT, ...prompt });
});
```
### Change 7: Frontend — InteractivePromptOverlay component
**File:** `src/components/InteractivePromptOverlay.tsx` (new)
Replaces `PermissionOverlay` and `AskQuestion` with a single adapter-agnostic component:
- Renders `title` + `description`
- If `options` → button list
- If `textInput` → text input field
- If both → buttons + text field (plan mode layout)
- Sends `PromptResponse` back via WS
**File:** `src/components/ChatView.tsx` + `src/components/FloatingReviewPanel.tsx`
Replace `PermissionOverlay` / `AskQuestion` usage with `InteractivePromptOverlay`.
### Change 8: useChat — handle new WS message type
**File:** `src/hooks/useChat.ts`
Replace `WS.PERMISSION_REQUEST` handler with `WS.INTERACTIVE_PROMPT`:
```typescript
case WS.INTERACTIVE_PROMPT:
setInteractivePrompt(msg); // replaces setPermissionRequest
break;
```
### Change 9: Remaining _waitForReady prompts
**File:** `server/adapters/gemini/gemini-tmux-adapter.ts`
Add detection for remaining startup prompts:
- Privacy Notice → send Esc
- Multi-folder trust → send option 1
- IDE integration nudge → send "No"
These are handled in `_waitForReady` (auto-bypass), not sent to frontend.
## What Doesn't Change
- **WS protocol**: Still uses WebSocket for all communication
- **tmux management**: Still uses tmux for CLI process management
- **JSONL watching**: Still uses file watchers for message history
- **Claude hooks**: Still uses hooks for Claude tool events (just normalizes the output format)
## Scope Notes
**Phase 1 (this spec):**
- Gemini: tool confirmation, ask user, plan approval, loop detection
- Codex: command/file/network approval, request user input
- Claude: normalize existing events to new format
- Frontend: InteractivePromptOverlay component
**Phase 2 (future):**
- Diff rendering in file edit permissions
- Codex MCP elicitation forms (structured multi-field forms)
- Codex multi-agent thread approval routing