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
+1 -1
View File
@@ -1,2 +1,2 @@
CLAUDE_UI_PASSWORD=your-password-here CLAWTAP_PASSWORD=your-password-here
PORT=3456 PORT=3456
+2 -2
View File
@@ -35,7 +35,7 @@ Real-time sync. Cross-AI review. Push notifications.
```bash ```bash
npm install -g @kuannnn/clawtap npm install -g @kuannnn/clawtap
export CLAUDE_UI_PASSWORD=your-password export CLAWTAP_PASSWORD=your-password
clawtap clawtap
``` ```
@@ -192,7 +192,7 @@ The app icon badge shows how many sessions have unread notifications. Entering a
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `CLAUDE_UI_PASSWORD` | *(required)* | Login password | | `CLAWTAP_PASSWORD` | *(required)* | Login password |
| `PORT` | `3456` | Server port | | `PORT` | `3456` | Server port |
HTTPS is enabled automatically when `~/.clawtap/cert.pem` and `~/.clawtap/key.pem` exist. Otherwise the server runs on HTTP. Tailscale Serve is the easiest path to HTTPS. HTTPS is enabled automatically when `~/.clawtap/cert.pem` and `~/.clawtap/key.pem` exist. Otherwise the server runs on HTTP. Tailscale Serve is the easiest path to HTTPS.
+5 -5
View File
@@ -217,20 +217,20 @@ ensure_server() {
fi fi
# Password is required # Password is required
if [ -z "$CLAUDE_UI_PASSWORD" ]; then if [ -z "$CLAWTAP_PASSWORD" ]; then
echo "ClawTap server not running." echo "ClawTap server not running."
echo "" echo ""
echo "Set a password and try again:" echo "Set a password and try again:"
echo " export CLAUDE_UI_PASSWORD=your-password" echo " export CLAWTAP_PASSWORD=your-password"
echo " clawtap" echo " clawtap"
echo "" echo ""
echo "Or start the server separately:" echo "Or start the server separately:"
echo " CLAUDE_UI_PASSWORD=your-password npm start" echo " CLAWTAP_PASSWORD=your-password npm start"
exit 1 exit 1
fi fi
echo "Starting ClawTap server on port $PORT..." echo "Starting ClawTap server on port $PORT..."
CLAUDE_UI_PASSWORD="$CLAUDE_UI_PASSWORD" PORT="$PORT" \ CLAWTAP_PASSWORD="$CLAWTAP_PASSWORD" PORT="$PORT" \
nohup npx tsx "$SERVER_DIR/server/index.ts" >"$HOME/.clawtap/server.log" 2>&1 & nohup npx tsx "$SERVER_DIR/server/index.ts" >"$HOME/.clawtap/server.log" 2>&1 &
SERVER_PID=$! SERVER_PID=$!
@@ -252,7 +252,7 @@ ensure_server
# Authenticate with the ClawTap server API # Authenticate with the ClawTap server API
get_auth_token() { get_auth_token() {
local BODY local BODY
BODY=$(printf '%s' "$CLAUDE_UI_PASSWORD" | python3 -c 'import sys,json; print(json.dumps({"password": sys.stdin.read()}))' 2>/dev/null) BODY=$(printf '%s' "$CLAWTAP_PASSWORD" | python3 -c 'import sys,json; print(json.dumps({"password": sys.stdin.read()}))' 2>/dev/null)
curl -sk -X POST "${PROTOCOL}://localhost:${PORT}/api/auth/login" \ curl -sk -X POST "${PROTOCOL}://localhost:${PORT}/api/auth/login" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "$BODY" 2>/dev/null | \ -d "$BODY" 2>/dev/null | \
@@ -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
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@kuannnn/clawtap", "name": "@kuannnn/clawtap",
"version": "0.1.1", "version": "0.2.0",
"description": "Use Claude Code from your phone. Real-time mobile UI synced with your desktop terminal via tmux.", "description": "Use Claude Code from your phone. Real-time mobile UI synced with your desktop terminal via tmux.",
"type": "module", "type": "module",
"bin": { "bin": {
@@ -80,4 +80,4 @@
"vite": "^6.3.2", "vite": "^6.3.2",
"vite-plugin-pwa": "^1.2.0" "vite-plugin-pwa": "^1.2.0"
} }
} }
+44 -29
View File
@@ -30,14 +30,11 @@ interface HookIdentifiers {
interface ClaudeSettings { interface ClaudeSettings {
hooks?: Record<string, HookEntry[]>; hooks?: Record<string, HookEntry[]>;
statusLine?: { type: string; command: string }; statusLine?: { type: string; command: string };
_clawtapOriginalStatusLine?: string; _clawtapOriginalStatusLine?: string; // legacy, cleaned up on uninstall
[key: string]: unknown; [key: string]: unknown;
} }
export class ClaudeHookConfig { export class ClaudeHookConfig {
/** Shared between install() wrapper construction and _extractOriginalFromWrapper() */
private static readonly WRAPPER_TAIL = `fi; printf '%s' "$input" | `;
port: number | string; port: number | string;
useHttps: boolean; useHttps: boolean;
@@ -79,16 +76,14 @@ export class ClaudeHookConfig {
existing.hooks[event] = [...filtered, ...configs]; existing.hooks[event] = [...filtered, ...configs];
} }
// Wrap statusLine to also POST to our server (non-blocking). // Insert our statusLine script into the pipe chain (if not already there).
// - Has custom statusLine → wrap it (POST + original coexist) // Our script is a passthrough: reads stdin, POSTs to server (background), outputs stdin.
// - Has custom statusLine → pipe through our script first
// - No custom statusLine → don't touch it, preserve Claude Code built-in // - No custom statusLine → don't touch it, preserve Claude Code built-in
const wrapperScript = this._ensureStatusLineScript(statuslineUrl);
const existingCmd = existing.statusLine?.command || ''; const existingCmd = existing.statusLine?.command || '';
if (existingCmd && !existingCmd.includes(`:${port}/api/hooks/claude/statusline`)) { if (existingCmd && !existingCmd.includes(wrapperScript)) {
existing._clawtapOriginalStatusLine = existingCmd; existing.statusLine = { type: 'command', command: `${wrapperScript} | ${existingCmd}` };
const portCheck = this._portCheckCmd();
const curlK = this.useHttps ? ' -k' : '';
const wrapperCmd = `input=$(cat); if ${portCheck}; then printf '%s' "$input" | curl -sf${curlK} -X POST -H 'Content-Type:application/json' -d @- ${statuslineUrl} &>/dev/null & ${ClaudeHookConfig.WRAPPER_TAIL}${existingCmd}`;
existing.statusLine = { type: 'command', command: wrapperCmd };
console.log(`[hooks] Wrapped statusLine to POST to ${statuslineUrl}`); console.log(`[hooks] Wrapped statusLine to POST to ${statuslineUrl}`);
} }
@@ -129,19 +124,23 @@ export class ClaudeHookConfig {
if (Object.keys(existing.hooks).length === 0) delete existing.hooks; if (Object.keys(existing.hooks).length === 0) delete existing.hooks;
} }
// --- Restore statusLine (independent of hooks) --- // --- Restore statusLine: remove our script from the pipe chain ---
// Restore original statusLine: try extraction from wrapper first (most reliable), const wrapperScript = this._statusLineScriptPath();
// then fall back to backup field, then delete only if truly no original existed. if (existing.statusLine?.command?.includes(wrapperScript)) {
if (existing.statusLine?.command?.includes(portTag)) { // Remove our script + pipe from the command string
const original = this._extractOriginalFromWrapper(existing.statusLine.command); const restored = existing.statusLine.command
if (original) { .replace(`${wrapperScript} | `, '')
existing.statusLine = { type: 'command', command: original }; .replace(wrapperScript, '')
} else if (existing._clawtapOriginalStatusLine) { .replace(/\s*\|\s*$/, '') // trailing pipe
existing.statusLine = { type: 'command', command: existing._clawtapOriginalStatusLine }; .replace(/^\s*\|\s*/, '') // leading pipe
.trim();
if (restored) {
existing.statusLine = { type: 'command', command: restored };
} else { } else {
delete existing.statusLine; delete existing.statusLine;
} }
} }
// Clean up legacy backup field from old versions
delete existing._clawtapOriginalStatusLine; delete existing._clawtapOriginalStatusLine;
writeFileSync(settingsPath, JSON.stringify(existing, null, 2)); writeFileSync(settingsPath, JSON.stringify(existing, null, 2));
@@ -160,14 +159,30 @@ export class ClaudeHookConfig {
}; };
} }
/** Extract the original statusLine command from our wrapper using WRAPPER_TAIL. */ /** Path to our statusLine wrapper script */
private _extractOriginalFromWrapper(cmd: string): string | null { private _statusLineScriptPath(): string {
const tail = ClaudeHookConfig.WRAPPER_TAIL; return join(homedir(), '.clawtap', 'hooks', 'claude-statusline.sh');
const idx = cmd.lastIndexOf(tail); }
if (idx < 0) return null;
const original = cmd.substring(idx + tail.length).trim(); /** Create or update the statusLine wrapper script */
if (!original || original.includes('/api/hooks/claude')) return null; private _ensureStatusLineScript(statuslineUrl: string): string {
return original; const scriptPath = this._statusLineScriptPath();
const scriptDir = join(homedir(), '.clawtap', 'hooks');
mkdirSync(scriptDir, { recursive: true });
const portCheck = this._portCheckCmd();
const curlInsecure = this.useHttps ? ' -k' : '';
const script = `#!/bin/bash
input=$(cat)
# POST to ClawTap server (non-blocking, skip if server not running)
if ${portCheck}; then
printf '%s' "$input" | curl -sf${curlInsecure} --connect-timeout 2 --max-time 5 -X POST -H 'Content-Type:application/json' -d @- ${statuslineUrl} &>/dev/null &
fi
# Pass through to stdout
printf '%s' "$input"
`;
writeFileSync(scriptPath, script, { mode: 0o755 });
return scriptPath;
} }
private _isOurHookEntry(entry: HookEntry, portTag: string): boolean { private _isOurHookEntry(entry: HookEntry, portTag: string): boolean {
+2 -1
View File
@@ -79,10 +79,10 @@ export class ClaudeAdapter extends IAdapter {
} }
setup(app: Express): void { setup(app: Express): void {
this.installHooks();
this._registerHookRoutes(app); this._registerHookRoutes(app);
} }
setHookPort(port: number | string): void { this._hookConfig.port = port; }
installHooks(): void { this._hookConfig.install(); } installHooks(): void { this._hookConfig.install(); }
uninstallHooks(): void { this._hookConfig.uninstall(); } uninstallHooks(): void { this._hookConfig.uninstall(); }
@@ -206,6 +206,7 @@ export class ClaudeAdapter extends IAdapter {
async switchPermissionMode(sid: string, mode: string): Promise<boolean> { return this._tmux.switchPermissionMode(sid, mode); } async switchPermissionMode(sid: string, mode: string): Promise<boolean> { return this._tmux.switchPermissionMode(sid, mode); }
respondPermission(reqId: string, behavior: PermissionBehavior): void { this._tmux.respondPermission(reqId, behavior); } respondPermission(reqId: string, behavior: PermissionBehavior): void { this._tmux.respondPermission(reqId, behavior); }
async respondQuestion(reqId: string, answer: string): Promise<void> { return this._tmux.respondQuestion(reqId, answer); } async respondQuestion(reqId: string, answer: string): Promise<void> { return this._tmux.respondQuestion(reqId, answer); }
respondInteractivePrompt(reqId: string, opt?: string, text?: string): void { this._tmux.respondInteractivePrompt(reqId, opt, text); }
releaseAllPending(sid: string): void { this._tmux.releaseAllPending(sid); } releaseAllPending(sid: string): void { this._tmux.releaseAllPending(sid); }
resolveAllPendingAs(sid: string, behavior: PermissionBehavior): void { this._tmux.resolveAllPendingAs(sid, behavior); } resolveAllPendingAs(sid: string, behavior: PermissionBehavior): void { this._tmux.resolveAllPendingAs(sid, behavior); }
+18 -2
View File
@@ -139,6 +139,7 @@ export async function getMessages(sessionId: string, dir?: string): Promise<GetM
try { try {
const messages: unknown[] = []; const messages: unknown[] = [];
const subToolMap: Map<string, SubToolBlock[]> = new Map(); // parentToolUseId → sub-tool blocks const subToolMap: Map<string, SubToolBlock[]> = new Map(); // parentToolUseId → sub-tool blocks
const toolUseIndex: Map<string, ContentBlock> = new Map(); // tool_use id → content block
const stream = createReadStream(filePath); const stream = createReadStream(filePath);
const rl = createInterface({ input: stream, crlfDelay: Infinity }); const rl = createInterface({ input: stream, crlfDelay: Infinity });
try { try {
@@ -162,9 +163,24 @@ export async function getMessages(sessionId: string, dir?: string): Promise<GetM
if (entry.type === 'assistant') { if (entry.type === 'assistant') {
if (isNoResponseMessage(text)) continue; if (isNoResponseMessage(text)) continue;
messages.push(entry.message); messages.push(entry.message);
// Index tool_use blocks for O(1) result attachment
if (Array.isArray(content)) {
for (const block of content as ContentBlock[]) {
if (block.type === 'tool_use' && block.id) toolUseIndex.set(block.id, block);
}
}
} else if (entry.type === 'user') { } else if (entry.type === 'user') {
// Skip messages containing tool results (not needed for display) // Attach tool results to their matching tool_use blocks
if (Array.isArray(content) && content.some((b: ContentBlock) => b.type === 'tool_result')) continue; const toolResults = Array.isArray(content)
? (content as ContentBlock[]).filter((b: ContentBlock) => b.type === 'tool_result' && b.tool_use_id)
: [];
if (toolResults.length > 0) {
for (const block of toolResults) {
const match = toolUseIndex.get(block.tool_use_id as string);
if (match) match._result = block;
}
continue;
}
// Skip system/CLI messages (empty text, system patterns) // Skip system/CLI messages (empty text, system patterns)
if (isSystemMessage(text, content)) continue; if (isSystemMessage(text, content)) continue;
// Convert "Implement the following plan:" messages to plan type // Convert "Implement the following plan:" messages to plan type
+39
View File
@@ -611,6 +611,28 @@ export class TmuxAdapter extends EventEmitter {
} }
} }
respondInteractivePrompt(requestId: string, selectedOption?: string, textValue?: string): void {
if (textValue != null) {
this.respondQuestion(requestId, textValue);
} else if (selectedOption != null) {
// Permission behaviors are named ('allow', 'allow_session', 'deny')
// Question options are numeric indices ('0', '1', '2')
const isPermission = ['allow', 'allow_session', 'deny'].includes(selectedOption);
if (isPermission) {
this.respondPermission(requestId, selectedOption as any);
} else {
// Numeric index — validate before consuming the pending entry
const index = parseInt(selectedOption);
if (isNaN(index)) return;
const pending = this._permissions.resolveQuestion(requestId);
if (!pending) return;
const session = this.sessions.get(pending.sessionId);
if (!session) return;
this._selectOption(session.windowId, index).catch(() => {});
}
}
}
/** /**
* Respond to the CLI's plan approval selector. * Respond to the CLI's plan approval selector.
* Options: 0=bypass (auto-accept edits), 1=manually approve, 2=text feedback * Options: 0=bypass (auto-accept edits), 1=manually approve, 2=text feedback
@@ -876,6 +898,23 @@ export class TmuxAdapter extends EventEmitter {
if (attempt <= 3 || attempt % 5 === 0) { if (attempt <= 3 || attempt % 5 === 0) {
console.log(`[adapter] waitForReady #${attempt}: window=${windowId} prompt=${hasPrompt} lines=${lineCount}`); console.log(`[adapter] waitForReady #${attempt}: window=${windowId} prompt=${hasPrompt} lines=${lineCount}`);
} }
// Auto-accept bypass permissions confirmation prompt (Claude v2.1.85+).
// Detect by structure (numbered selection list) + context (bypass permissions).
const isSelectionPrompt = /\s+\d+\./.test(content);
const isBypassPrompt = /[Bb]ypass\s+[Pp]ermissions/.test(content);
if (isSelectionPrompt && isBypassPrompt) {
const acceptMatch = content.match(/(\d+)\.\s+Yes/);
const acceptOption = acceptMatch ? parseInt(acceptMatch[1]) : 2;
console.log(`[adapter] Bypass permissions prompt detected, selecting option ${acceptOption}`);
for (let i = 1; i < acceptOption; i++) {
await tmuxManager.sendControl(windowId, 'Down');
await new Promise<void>(r => setTimeout(r, 50));
}
await tmuxManager.sendControl(windowId, 'Enter');
await new Promise<void>(r => setTimeout(r, 500));
continue;
}
if (hasPrompt && lineCount >= 3) { if (hasPrompt && lineCount >= 3) {
console.log(`[adapter] CLI ready for ${windowId} in ${Date.now() - start}ms`); console.log(`[adapter] CLI ready for ${windowId} in ${Date.now() - start}ms`);
await new Promise<void>(r => setTimeout(r, 300)); await new Promise<void>(r => setTimeout(r, 300));
+19 -1
View File
@@ -19,6 +19,7 @@ import type { ReconnectState } from '../../types/adapter.js';
import type { ActiveSessionInfo } from '../interface.js'; import type { ActiveSessionInfo } from '../interface.js';
import { isLargeContent } from '../interface.js'; import { isLargeContent } from '../interface.js';
import { PermissionManager } from '../../permission-manager.js'; import { PermissionManager } from '../../permission-manager.js';
import { findActiveSession } from '../shared/find-active-session.js';
import { readFile } from 'fs/promises'; import { readFile } from 'fs/promises';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -149,7 +150,7 @@ export class CodexTmuxAdapter extends EventEmitter {
this._startMonitor(finalId, windowId); this._startMonitor(finalId, windowId);
return { sessionId: finalId }; return { sessionId: finalId, pendingRekey: finalId === tempKey };
} }
async resumeSession(sessionId: string, cwd: string, options: QueryOptions = {}): Promise<{ sessionId: string }> { async resumeSession(sessionId: string, cwd: string, options: QueryOptions = {}): Promise<{ sessionId: string }> {
@@ -606,6 +607,23 @@ export class CodexTmuxAdapter extends EventEmitter {
await tmuxManager.sendKeys(session.windowId, answer, true); await tmuxManager.sendKeys(session.windowId, answer, true);
} }
respondInteractivePrompt(requestId: string, selectedOption?: string, textValue?: string): void {
const pending = this._permissions.resolvePermission(requestId)
|| this._permissions.resolveQuestion(requestId);
const sessionId = pending?.sessionId || findActiveSession(this.sessions);
if (!sessionId) return;
const session = this.sessions.get(sessionId);
if (!session) return;
if (selectedOption != null) {
// Codex uses single-key shortcuts (y, a, p, d, n)
tmuxManager.sendKeys(session.windowId, selectedOption, false).catch(() => {});
}
if (textValue != null) {
tmuxManager.sendKeys(session.windowId, textValue, true).catch(() => {});
}
}
/** Release all pending requests for a session (e.g., when Mobile disconnects). */ /** Release all pending requests for a session (e.g., when Mobile disconnects). */
releaseAllPending(sessionId: string): void { releaseAllPending(sessionId: string): void {
this._permissions.dismissAll(sessionId); this._permissions.dismissAll(sessionId);
+2 -1
View File
@@ -87,10 +87,10 @@ export class CodexAdapter extends IAdapter {
} }
setup(app: Express): void { setup(app: Express): void {
this.installHooks();
this._registerHookRoutes(app); this._registerHookRoutes(app);
} }
setHookPort(port: number | string): void { this._hookConfig.port = port; }
installHooks(): void { this._hookConfig.install(); } installHooks(): void { this._hookConfig.install(); }
uninstallHooks(): void { this._hookConfig.uninstall(); } uninstallHooks(): void { this._hookConfig.uninstall(); }
@@ -164,6 +164,7 @@ export class CodexAdapter extends IAdapter {
async switchPermissionMode(sid: string, mode: string): Promise<boolean> { return this._tmux.switchPermissionMode(sid, mode); } async switchPermissionMode(sid: string, mode: string): Promise<boolean> { return this._tmux.switchPermissionMode(sid, mode); }
respondPermission(reqId: string, behavior: PermissionBehavior): void { this._tmux.respondPermission(reqId, behavior); } respondPermission(reqId: string, behavior: PermissionBehavior): void { this._tmux.respondPermission(reqId, behavior); }
async respondQuestion(reqId: string, answer: string): Promise<void> { return this._tmux.respondQuestion(reqId, answer); } async respondQuestion(reqId: string, answer: string): Promise<void> { return this._tmux.respondQuestion(reqId, answer); }
respondInteractivePrompt(reqId: string, opt?: string, text?: string): void { this._tmux.respondInteractivePrompt(reqId, opt, text); }
releaseAllPending(sid: string): void { this._tmux.releaseAllPending(sid); } releaseAllPending(sid: string): void { this._tmux.releaseAllPending(sid); }
resolveAllPendingAs(sid: string, behavior: PermissionBehavior): void { this._tmux.resolveAllPendingAs(sid, behavior); } resolveAllPendingAs(sid: string, behavior: PermissionBehavior): void { this._tmux.resolveAllPendingAs(sid, behavior); }
+2 -1
View File
@@ -3,6 +3,7 @@ import { join } from 'path';
import { homedir } from 'os'; import { homedir } from 'os';
import { createReadStream } from 'fs'; import { createReadStream } from 'fs';
import { createInterface } from 'readline'; import { createInterface } from 'readline';
import { stripMarker } from '../shared/content-utils.js';
import { CodexTranscriptParser } from './transcript-parser.js'; import { CodexTranscriptParser } from './transcript-parser.js';
import type { CodexJsonlEntry } from './transcript-parser.js'; import type { CodexJsonlEntry } from './transcript-parser.js';
import type { DirectoryEntry, MessagesResult } from '../interface.js'; import type { DirectoryEntry, MessagesResult } from '../interface.js';
@@ -202,7 +203,7 @@ export async function getSessions(dir?: string, limit?: number): Promise<Session
cwd, cwd,
lastModified: entry.ts * 1000, // Convert to ms timestamp lastModified: entry.ts * 1000, // Convert to ms timestamp
firstPrompt: entry.text firstPrompt: entry.text
? entry.text.replace(/^(?:\[CLAWTAP_REF:[^\]]+\]|\d+\])(?:\\n|\n)?/, '').slice(0, 200) ? stripMarker(entry.text).slice(0, 200)
: null, : null,
model, model,
}; };
+96 -1
View File
@@ -13,6 +13,16 @@
// that will be refined through empirical testing with the actual Codex TUI. // that will be refined through empirical testing with the actual Codex TUI.
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { InteractivePrompt } from '../../types/messages.js';
function simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash).toString(36);
}
/** Minimal interface for the tmux manager dependency */ /** Minimal interface for the tmux manager dependency */
interface TmuxCapture { interface TmuxCapture {
@@ -42,6 +52,7 @@ export class CodexPaneMonitor {
private interval: ReturnType<typeof setInterval> | null = null; private interval: ReturnType<typeof setInterval> | null = null;
private _lastContent: string = ''; private _lastContent: string = '';
private _lastResponseText: string = ''; private _lastResponseText: string = '';
private lastPromptId: string | null = null;
constructor( constructor(
sessionId: string, sessionId: string,
@@ -84,7 +95,19 @@ export class CodexPaneMonitor {
if (content === this._lastContent) return; if (content === this._lastContent) return;
this._lastContent = content; this._lastContent = content;
// 1. Check for approval prompt (highest priority — blocks everything) // 0. Check for interactive prompt (highest priority)
const interactivePrompt = this._detectPrompt(content);
if (interactivePrompt) {
if (interactivePrompt.requestId !== this.lastPromptId) {
this.lastPromptId = interactivePrompt.requestId;
this.emitter.emit('interactive-prompt', this.sessionId, interactivePrompt);
}
return; // Don't process streaming while prompt is showing
} else if (this.lastPromptId) {
this.lastPromptId = null;
}
// 1. Check for approval prompt (legacy — kept for backwards compat)
const approval = detectApprovalPrompt(content); const approval = detectApprovalPrompt(content);
if (approval) { if (approval) {
this.emitter.emit('approval-prompt', this.sessionId, approval); this.emitter.emit('approval-prompt', this.sessionId, approval);
@@ -108,6 +131,78 @@ export class CodexPaneMonitor {
// Silently ignore — tmux window may have been killed // Silently ignore — tmux window may have been killed
} }
} }
/**
* Detect an interactive prompt in the Codex CLI pane content.
* Returns an InteractivePrompt if one is detected, null otherwise.
*/
private _detectPrompt(content: string): InteractivePrompt | null {
// Command/File/Network Approval: "(y)" with proceed/run/make patterns
if (
content.includes('(y)') &&
(/proceed/i.test(content) || /Would you like to run/i.test(content) || /Would you like to make/i.test(content))
) {
const options = this._parseCodexOptions(content);
const lines = content.split('\n');
const tail = lines.slice(-20);
const promptLine = tail.find(l => /proceed|\brun\b|\bmake\b/i.test(l)) || 'Approve action';
const description = tail.join('\n').trim();
return {
requestId: `codex-perm-${simpleHash(description)}`,
promptType: 'permission',
title: typeof promptLine === 'string' ? promptLine.trim() : 'Approve action',
description,
options: options.length > 0 ? options : [
{ value: 'y', label: 'Yes' },
{ value: 'n', label: 'No' },
],
};
}
// User Input: "enter to submit" AND "esc to cancel" (but NOT approval patterns)
if (
/enter to submit/i.test(content) &&
/esc to cancel/i.test(content) &&
!content.includes('(y)')
) {
const lines = content.split('\n');
const tail = lines.slice(-20);
const options = this._parseCodexOptions(content);
const description = tail.join('\n').trim();
if (options.length > 0) {
return {
requestId: `codex-ask-${simpleHash(description)}`,
promptType: 'question',
title: 'User Input',
description,
options,
};
}
return {
requestId: `codex-ask-${simpleHash(description)}`,
promptType: 'question',
title: 'User Input',
description,
textInput: { placeholder: 'Type your response...' },
};
}
return null;
}
/**
* Parse Codex-style options from content.
* Matches patterns like "(y) Yes" or "(a) Always approve".
*/
private _parseCodexOptions(content: string): { value: string; label: string }[] {
const results: { value: string; label: string }[] = [];
const regex = /\(([a-z])\)\s+(.+?)(?:\n|$)/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
results.push({ value: match[1]!, label: match[2]!.trim() });
}
return results;
}
} }
// ============================================================================= // =============================================================================
+95 -10
View File
@@ -19,6 +19,7 @@ import type { ReconnectState } from '../../types/adapter.js';
import type { ActiveSessionInfo } from '../interface.js'; import type { ActiveSessionInfo } from '../interface.js';
import { isLargeContent } from '../interface.js'; import { isLargeContent } from '../interface.js';
import { PermissionManager } from '../../permission-manager.js'; import { PermissionManager } from '../../permission-manager.js';
import { findActiveSession } from '../shared/find-active-session.js';
import { readFile } from 'fs/promises'; import { readFile } from 'fs/promises';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -116,12 +117,13 @@ export class GeminiTmuxAdapter extends EventEmitter {
// === Session Lifecycle === // === Session Lifecycle ===
async startSession(cwd: string, options: QueryOptions = {}): Promise<{ sessionId: string }> { async startSession(cwd: string, options: QueryOptions = {}): Promise<{ sessionId: string; pendingRekey?: boolean }> {
const mode = options.permissionMode || 'default'; const mode = options.permissionMode || 'default';
const parts = ['gemini', '--approval-mode', this._toCliApprovalMode(mode)]; const parts = ['gemini', '--approval-mode', this._toCliApprovalMode(mode)];
if (options.model) parts.push('-m', options.model); if (options.model) parts.push('-m', options.model);
const tempName = `gemini-${Date.now()}`; const tempName = `gemini-${Date.now()}`;
console.log(`[gemini-tmux] startSession: tempName=${tempName} cwd=${cwd} mode=${mode}`);
const windowId = await tmuxManager.createWindow(tempName, cwd, parts.join(' ')); const windowId = await tmuxManager.createWindow(tempName, cwd, parts.join(' '));
// Register session BEFORE _waitForReady — SessionStart hook fires during // Register session BEFORE _waitForReady — SessionStart hook fires during
@@ -137,14 +139,16 @@ export class GeminiTmuxAdapter extends EventEmitter {
const rekeyed = [...this.sessions.entries()].find(([, s]) => s.windowId === windowId)?.[0]; const rekeyed = [...this.sessions.entries()].find(([, s]) => s.windowId === windowId)?.[0];
if (rekeyed) { if (rekeyed) {
finalId = rekeyed; finalId = rekeyed;
console.log(`[gemini-tmux] startSession: rekeyed ${tempName}${rekeyed}`);
} else { } else {
console.warn(`[gemini-tmux] Session ${tempName} vanished during startup (windowId=${windowId})`); console.warn(`[gemini-tmux] Session ${tempName} vanished during startup (windowId=${windowId})`);
} }
} }
console.log(`[gemini-tmux] startSession: finalId=${finalId} pendingRekey=${finalId === tempName}`);
this._startMonitor(finalId, windowId); this._startMonitor(finalId, windowId);
return { sessionId: finalId }; return { sessionId: finalId, pendingRekey: finalId === tempName };
} }
async resumeSession(sessionId: string, cwd: string, options: QueryOptions = {}): Promise<{ sessionId: string }> { async resumeSession(sessionId: string, cwd: string, options: QueryOptions = {}): Promise<{ sessionId: string }> {
@@ -581,6 +585,27 @@ export class GeminiTmuxAdapter extends EventEmitter {
await tmuxManager.sendKeys(session.windowId, answer, true); await tmuxManager.sendKeys(session.windowId, answer, true);
} }
respondInteractivePrompt(requestId: string, selectedOption?: string, textValue?: string): void {
const pending = this._permissions.resolvePermission(requestId)
|| this._permissions.resolveQuestion(requestId);
// For pane-monitor-detected prompts, there may be no pending entry — find session from active sessions
const sessionId = pending?.sessionId || findActiveSession(this.sessions);
if (!sessionId) return;
const session = this.sessions.get(sessionId);
if (!session) return;
if (selectedOption != null) {
const index = parseInt(selectedOption);
if (!isNaN(index)) {
// Gemini numbered options: navigate Down × index, then Enter
this._selectNumberedOption(session.windowId, index).catch(() => {});
}
}
if (textValue != null) {
tmuxManager.sendKeys(session.windowId, textValue, true).catch(() => {});
}
}
/** Release all pending requests for a session */ /** Release all pending requests for a session */
releaseAllPending(sessionId: string): void { releaseAllPending(sessionId: string): void {
this._permissions.dismissAll(sessionId); this._permissions.dismissAll(sessionId);
@@ -598,6 +623,14 @@ export class GeminiTmuxAdapter extends EventEmitter {
} }
} }
private async _selectNumberedOption(windowId: string, targetIndex: number): Promise<void> {
for (let i = 0; i < targetIndex; i++) {
await tmuxManager.sendControl(windowId, 'Down');
await new Promise<void>(r => setTimeout(r, 50));
}
await tmuxManager.sendControl(windowId, 'Enter');
}
// === Cleanup === // === Cleanup ===
async destroy(): Promise<void> { async destroy(): Promise<void> {
@@ -662,7 +695,7 @@ export class GeminiTmuxAdapter extends EventEmitter {
* Wait for Gemini CLI to be ready. * Wait for Gemini CLI to be ready.
* Polls tmux pane content until a prompt indicator appears. * Polls tmux pane content until a prompt indicator appears.
*/ */
private async _waitForReady(windowId: string, timeoutMs: number = 30000): Promise<void> { private async _waitForReady(windowId: string, timeoutMs: number = 60000): Promise<void> {
const start = Date.now(); const start = Date.now();
let attempt = 0; let attempt = 0;
while (Date.now() - start < timeoutMs) { while (Date.now() - start < timeoutMs) {
@@ -670,17 +703,69 @@ export class GeminiTmuxAdapter extends EventEmitter {
try { try {
const content = await tmuxManager.capturePane(windowId); const content = await tmuxManager.capturePane(windowId);
const lines = content.split('\n'); const lines = content.split('\n');
// Gemini shows > or similar prompt indicator // Gemini shows > (default), * (yolo), or as prompt indicator
const hasPrompt = lines.some(l => /^\s*[>]/.test(l)); const hasPrompt = lines.some(l => /^\s*[>*]/.test(l));
const lineCount = lines.filter(l => l.trim()).length;
if (attempt <= 3 || attempt % 5 === 0) { if (hasPrompt) {
console.log(`[gemini-tmux] waitForReady #${attempt}: window=${windowId} prompt=${hasPrompt} lines=${lineCount}`);
}
if (hasPrompt && lineCount >= 2) {
console.log(`[gemini-tmux] CLI ready for ${windowId} in ${Date.now() - start}ms`); console.log(`[gemini-tmux] CLI ready for ${windowId} in ${Date.now() - start}ms`);
await new Promise<void>(r => setTimeout(r, 300)); await new Promise<void>(r => setTimeout(r, 300));
return; return;
} }
// Privacy Notice or Terms of Service popup — dismiss with Esc
if (!hasPrompt && (content.includes('Privacy Notice') ||
(content.includes('Terms of Service') && !content.includes('trust the files')))) {
console.log('[gemini-tmux] Privacy/ToS notice detected, dismissing');
await tmuxManager.sendControl(windowId, 'Escape');
await new Promise<void>(r => setTimeout(r, 500));
continue;
}
// Multi-folder trust dialog ("trust the following folders")
if (!hasPrompt && content.includes('trust the following folders')) {
console.log('[gemini-tmux] Multi-folder trust detected, accepting');
await tmuxManager.sendControl(windowId, 'Enter');
await new Promise<void>(r => setTimeout(r, 1000));
continue;
}
// IDE integration nudge — decline
if (!hasPrompt && content.includes('Do you want to connect') && content.includes('Gemini CLI')) {
console.log('[gemini-tmux] IDE nudge detected, declining');
await tmuxManager.sendControl(windowId, 'Down');
await new Promise<void>(r => setTimeout(r, 50));
await tmuxManager.sendControl(windowId, 'Enter');
await new Promise<void>(r => setTimeout(r, 500));
continue;
}
// Auto-accept folder trust prompt (Gemini asks on first use in a directory).
// Only runs when prompt is NOT yet visible.
if (content.includes('trust the files') && content.includes('Trust folder')) {
const parentMatch = content.match(/(\d+)\.\s+Trust parent folder/);
if (parentMatch) {
const targetOption = parseInt(parentMatch[1]);
console.log(`[gemini-tmux] Folder trust prompt detected, selecting option ${targetOption} (Trust parent folder)`);
for (let i = 1; i < targetOption; i++) {
await tmuxManager.sendControl(windowId, 'Down');
await new Promise<void>(r => setTimeout(r, 50));
}
} else {
console.log(`[gemini-tmux] Folder trust prompt detected, accepting default (Trust folder)`);
}
await tmuxManager.sendControl(windowId, 'Enter');
await new Promise<void>(r => setTimeout(r, 1000));
continue;
}
if (attempt <= 3 || attempt % 5 === 0) {
const lineCount = lines.filter(l => l.trim()).length;
console.log(`[gemini-tmux] waitForReady #${attempt}: window=${windowId} prompt=${hasPrompt} lines=${lineCount}`);
if (lineCount > 0) {
const nonEmpty = lines.filter(l => l.trim());
console.log(`[gemini-tmux] waitForReady content: first="${nonEmpty[0]?.substring(0, 60)}" last="${nonEmpty[nonEmpty.length - 1]?.substring(0, 60)}"`);
}
}
} catch (err) { } catch (err) {
console.log(`[gemini-tmux] waitForReady #${attempt}: ERROR ${(err as Error).message}`); console.log(`[gemini-tmux] waitForReady #${attempt}: ERROR ${(err as Error).message}`);
} }
+2 -1
View File
@@ -73,10 +73,10 @@ export class GeminiAdapter extends IAdapter {
} }
setup(app: Express): void { setup(app: Express): void {
this.installHooks();
this._registerHookRoutes(app); this._registerHookRoutes(app);
} }
setHookPort(port: number | string): void { this._hookConfig.port = port; }
installHooks(): void { this._hookConfig.install(); } installHooks(): void { this._hookConfig.install(); }
uninstallHooks(): void { this._hookConfig.uninstall(); } uninstallHooks(): void { this._hookConfig.uninstall(); }
@@ -167,6 +167,7 @@ export class GeminiAdapter extends IAdapter {
async switchPermissionMode(sid: string, mode: string): Promise<boolean> { return this._tmux.switchPermissionMode(sid, mode); } async switchPermissionMode(sid: string, mode: string): Promise<boolean> { return this._tmux.switchPermissionMode(sid, mode); }
respondPermission(reqId: string, behavior: PermissionBehavior): void { this._tmux.respondPermission(reqId, behavior); } respondPermission(reqId: string, behavior: PermissionBehavior): void { this._tmux.respondPermission(reqId, behavior); }
async respondQuestion(reqId: string, answer: string): Promise<void> { return this._tmux.respondQuestion(reqId, answer); } async respondQuestion(reqId: string, answer: string): Promise<void> { return this._tmux.respondQuestion(reqId, answer); }
respondInteractivePrompt(reqId: string, opt?: string, text?: string): void { this._tmux.respondInteractivePrompt(reqId, opt, text); }
releaseAllPending(sid: string): void { this._tmux.releaseAllPending(sid); } releaseAllPending(sid: string): void { this._tmux.releaseAllPending(sid); }
resolveAllPendingAs(sid: string, behavior: PermissionBehavior): void { this._tmux.resolveAllPendingAs(sid, behavior); } resolveAllPendingAs(sid: string, behavior: PermissionBehavior): void { this._tmux.resolveAllPendingAs(sid, behavior); }
+4 -2
View File
@@ -2,6 +2,7 @@ import { readdirSync, readFileSync, statSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { homedir } from 'os'; import { homedir } from 'os';
import { extractUserText } from './message-utils.js'; import { extractUserText } from './message-utils.js';
import { stripMarker } from '../shared/content-utils.js';
import type { DirectoryEntry } from '../interface.js'; import type { DirectoryEntry } from '../interface.js';
import type { SessionInfo } from '../../types/adapter.js'; import type { SessionInfo } from '../../types/adapter.js';
@@ -123,7 +124,7 @@ export function getSessions(dir?: string, limit?: number): SessionInfo[] {
if (m.type === 'user' && m.content != null) { if (m.type === 'user' && m.content != null) {
const text = extractUserText(m.content); const text = extractUserText(m.content);
if (text.trim()) { if (text.trim()) {
firstPrompt = text.slice(0, 200); firstPrompt = stripMarker(text).slice(0, 200);
break; break;
} }
} }
@@ -223,7 +224,6 @@ export function getSessionMessages(
const projectName = getProjectName(dir); const projectName = getProjectName(dir);
if (projectName) { if (projectName) {
const chatsDir = join(GEMINI_TMP_DIR, projectName, 'chats'); const chatsDir = join(GEMINI_TMP_DIR, projectName, 'chats');
// Try exact match first, then scan
try { try {
const files = readdirSync(chatsDir); const files = readdirSync(chatsDir);
for (const file of files) { for (const file of files) {
@@ -245,6 +245,8 @@ export function getSessionMessages(
// chats dir not readable // chats dir not readable
} }
} }
// Fallback: project name mapping failed — scan all projects
if (!filePath) filePath = findSessionFile(sessionId);
} else { } else {
filePath = findSessionFile(sessionId); filePath = findSessionFile(sessionId);
} }
+124 -2
View File
@@ -16,6 +16,16 @@
// that will be refined through empirical testing with the actual Gemini CLI. // that will be refined through empirical testing with the actual Gemini CLI.
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { InteractivePrompt } from '../../types/messages.js';
function simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash).toString(36);
}
/** Minimal interface for the tmux manager dependency */ /** Minimal interface for the tmux manager dependency */
interface TmuxCapture { interface TmuxCapture {
@@ -44,6 +54,7 @@ export class GeminiPaneMonitor {
private interval: ReturnType<typeof setInterval> | null = null; private interval: ReturnType<typeof setInterval> | null = null;
private _lastContent: string = ''; private _lastContent: string = '';
private _lastResponseText: string = ''; private _lastResponseText: string = '';
private lastPromptId: string | null = null;
constructor( constructor(
sessionId: string, sessionId: string,
@@ -86,6 +97,20 @@ export class GeminiPaneMonitor {
if (content === this._lastContent) return; if (content === this._lastContent) return;
this._lastContent = content; this._lastContent = content;
const lines = content.split('\n');
// 0. Check for interactive prompt (highest priority)
const prompt = this._detectPrompt(content, lines);
if (prompt) {
if (prompt.requestId !== this.lastPromptId) {
this.lastPromptId = prompt.requestId;
this.emitter.emit('interactive-prompt', this.sessionId, prompt);
}
return; // Don't process streaming while prompt is showing
} else if (this.lastPromptId) {
this.lastPromptId = null;
}
// 1. Check for thinking indicator // 1. Check for thinking indicator
const thinking = detectThinking(content); const thinking = detectThinking(content);
if (thinking) { if (thinking) {
@@ -103,6 +128,103 @@ export class GeminiPaneMonitor {
// Silently ignore — tmux window may have been killed // Silently ignore — tmux window may have been killed
} }
} }
/**
* Detect an interactive prompt in the Gemini CLI pane content.
* Returns an InteractivePrompt if one is detected, null otherwise.
*/
private _detectPrompt(content: string, _lines: string[]): InteractivePrompt | null {
// Tool Confirmation: "Action Required" with numbered options
if (content.includes('Action Required') && /●\s+\d+\./.test(content)) {
const description = this._extractBetween(content, 'Action Required', '●');
const options = this._parseNumberedOptions(content);
return {
requestId: `gemini-perm-${simpleHash(description)}`,
promptType: 'permission',
title: 'Action Required',
description: description.trim(),
options,
};
}
// Plan Approval: "Approval" with "Yes" and "feedback"
if (content.includes('Approval') && /Yes/.test(content) && /feedback/i.test(content)) {
const description = this._extractBetween(content, 'Approval', '●');
const options = this._parseNumberedOptions(content);
return {
requestId: `gemini-plan-${simpleHash(description)}`,
promptType: 'plan',
title: 'Plan Approval',
description: description.trim(),
options,
textInput: { placeholder: 'Provide feedback...' },
};
}
// AskUser: "Answer Questions"
if (content.includes('Answer Questions')) {
const description = this._extractBetween(content, 'Answer Questions', '●');
const options = this._parseNumberedOptions(content);
if (options.length > 0) {
return {
requestId: `gemini-ask-${simpleHash(description)}`,
promptType: 'question',
title: 'Answer Questions',
description: description.trim(),
options,
};
}
return {
requestId: `gemini-ask-${simpleHash(description)}`,
promptType: 'question',
title: 'Answer Questions',
description: description.trim(),
textInput: { placeholder: 'Type your answer...' },
};
}
// Loop Detection: "potential loop was detected"
if (content.includes('potential loop was detected')) {
const options = this._parseNumberedOptions(content);
return {
requestId: `gemini-loop-${simpleHash('loop-detected')}`,
promptType: 'loop-detected',
title: 'Loop Detected',
description: 'A potential loop was detected.',
options,
};
}
return null;
}
/**
* Parse numbered options from Gemini CLI content.
* Matches patterns like "● 1. Allow this action" or "1. Allow this action".
* Returns 0-based index values.
*/
private _parseNumberedOptions(content: string): { value: string; label: string }[] {
const results: { value: string; label: string }[] = [];
const regex = /(?:●\s+)?(\d+)\.\s+(.+?)(?:\n|$)/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
const index = parseInt(match[1]!, 10);
results.push({ value: String(index - 1), label: match[2]!.trim() });
}
return results;
}
/**
* Extract text between two markers in the content.
*/
private _extractBetween(content: string, start: string, end: string): string {
const startIdx = content.indexOf(start);
if (startIdx === -1) return '';
const afterStart = startIdx + start.length;
const endIdx = content.indexOf(end, afterStart);
if (endIdx === -1) return content.slice(afterStart).trim();
return content.slice(afterStart, endIdx).trim();
}
} }
// ============================================================================= // =============================================================================
@@ -178,7 +300,7 @@ export function extractResponseText(content: string): string {
// Gemini user prompt patterns (conservative): // Gemini user prompt patterns (conservative):
// - ">" or "" at start of line followed by user text // - ">" or "" at start of line followed by user text
// - "user:" prefix // - "user:" prefix
if (/^\s*[>]\s+\S/.test(line) || /^\s*user:\s/i.test(line)) { if (/^\s*[>*]\s+\S/.test(line) || /^\s*user:\s/i.test(line)) {
lastUserPrompt = i; lastUserPrompt = i;
break; break;
} }
@@ -204,7 +326,7 @@ export function extractResponseText(content: string): string {
// Horizontal rules // Horizontal rules
/^[─━═\-]{5,}/.test(line.trim()) || /^[─━═\-]{5,}/.test(line.trim()) ||
// New user prompt // New user prompt
/^\s*[>]\s+\S/.test(line) || /^\s*[>*]\s+\S/.test(line) ||
// Spinner/thinking indicators (braille set) // Spinner/thinking indicators (braille set)
/^\s*[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s*/.test(line) /^\s*[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s*/.test(line)
) { ) {
+5 -1
View File
@@ -92,7 +92,7 @@ export class IAdapter extends EventEmitter {
// --- Session Lifecycle --- // --- Session Lifecycle ---
async startSession(cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> { throw new Error('Not implemented: startSession'); } async startSession(cwd: string, options?: QueryOptions): Promise<{ sessionId: string; pendingRekey?: boolean }> { throw new Error('Not implemented: startSession'); }
async resumeSession(sessionId: string, cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> { throw new Error('Not implemented: resumeSession'); } async resumeSession(sessionId: string, cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> { throw new Error('Not implemented: resumeSession'); }
async attachSession(sessionId: string, cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> { throw new Error('Not implemented: attachSession'); } async attachSession(sessionId: string, cwd: string, options?: QueryOptions): Promise<{ sessionId: string }> { throw new Error('Not implemented: attachSession'); }
async destroySession(sessionId: string): Promise<void> { throw new Error('Not implemented: destroySession'); } async destroySession(sessionId: string): Promise<void> { throw new Error('Not implemented: destroySession'); }
@@ -155,10 +155,14 @@ export class IAdapter extends EventEmitter {
// --- Hooks --- // --- Hooks ---
/** Update the port used by hooks (called when port fallback changes the port). */
setHookPort(port: number | string): void {}
/** Install adapter-specific hooks (e.g., write to CLI settings). No server needed. */ /** Install adapter-specific hooks (e.g., write to CLI settings). No server needed. */
installHooks(): void {} installHooks(): void {}
/** Remove adapter-specific hooks. No server needed. */ /** Remove adapter-specific hooks. No server needed. */
uninstallHooks(): void {} uninstallHooks(): void {}
/** Respond to an interactive prompt (permission, question, plan, etc). */
respondInteractivePrompt(requestId: string, selectedOption?: string, textValue?: string): void {}
// --- Lifecycle --- // --- Lifecycle ---
+8
View File
@@ -75,6 +75,14 @@ export function initAll(app: Express): Map<string, IAdapter> {
return adapters; return adapters;
} }
/** Install hooks with confirmed port (called after server.listen succeeds) */
export function installAllHooks(port: number | string): void {
for (const [, adapter] of adapters) {
adapter.setHookPort(port);
adapter.installHooks();
}
}
export function getAll(): Map<string, IAdapter> { export function getAll(): Map<string, IAdapter> {
return adapters; return adapters;
} }
@@ -0,0 +1,18 @@
/** Parse Claude's AskUserQuestion nested input structure into a flat format */
export function parseAskQuestionInput(input: any): {
question: string;
header?: string;
options?: { label: string; value: string }[];
} {
const q = input?.questions?.[0] || input || {};
const question = q.question || q.text || input?.question || input?.text || '';
const header = q.header;
const rawOpts = q.options || input?.options;
const options = Array.isArray(rawOpts) && rawOpts.length > 0
? rawOpts.map((o: any, i: number) => ({
value: typeof o === 'string' ? String(i) : (o.value ?? String(i)),
label: typeof o === 'string' ? o : (o.label || o.text || `Option ${i + 1}`),
}))
: undefined;
return { question, header, options };
}
+6
View File
@@ -0,0 +1,6 @@
/** Strip [CLAWTAP_REF:...] and [CODETAP_REF:...] markers from message text. */
const MARKER_REGEX = /^(?:\[(?:CLAWTAP_REF|CODETAP_REF):[^\]]+\]|\d+\])(?:\\n|\n)?/;
export function stripMarker(text: string): string {
return text.replace(MARKER_REGEX, '');
}
@@ -0,0 +1,20 @@
/**
* Find the session most likely showing an interactive prompt.
* Checks for actively processing sessions first, then falls back to most recent.
*/
export function findActiveSession(
sessions: Map<string, { isProcessing: boolean; lastActivity: number | null }>
): string | null {
for (const [id, session] of sessions) {
if (session.isProcessing) return id;
}
let latest: string | null = null;
let latestTime = 0;
for (const [id, session] of sessions) {
if (session.lastActivity && session.lastActivity > latestTime) {
latestTime = session.lastActivity;
latest = id;
}
}
return latest;
}
+3 -3
View File
@@ -22,12 +22,12 @@ export interface AppConfig {
} }
export function loadConfig(): AppConfig { export function loadConfig(): AppConfig {
const password = process.env.CLAUDE_UI_PASSWORD; const password = process.env.CLAWTAP_PASSWORD;
if (!password) { if (!password) {
throw new Error( throw new Error(
'CLAUDE_UI_PASSWORD is required.\n' + 'CLAWTAP_PASSWORD is required.\n' +
'Set it and try again:\n' + 'Set it and try again:\n' +
' export CLAUDE_UI_PASSWORD=your-password' ' export CLAWTAP_PASSWORD=your-password'
); );
} }
+25
View File
@@ -61,6 +61,11 @@ export function initDB(config: AppConfig): void {
); );
CREATE INDEX IF NOT EXISTS idx_reviews_parent ON session_reviews(parent_cli_session_id); CREATE INDEX IF NOT EXISTS idx_reviews_parent ON session_reviews(parent_cli_session_id);
CREATE TABLE IF NOT EXISTS session_adapters (
session_id TEXT PRIMARY KEY,
adapter TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS saved_instructions ( CREATE TABLE IF NOT EXISTS saved_instructions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
label TEXT NOT NULL, label TEXT NOT NULL,
@@ -122,6 +127,8 @@ interface PreparedStatements {
instructionCreate: BetterSqlite3.Statement; instructionCreate: BetterSqlite3.Statement;
instructionGetAll: BetterSqlite3.Statement; instructionGetAll: BetterSqlite3.Statement;
instructionDelete: BetterSqlite3.Statement; instructionDelete: BetterSqlite3.Statement;
sessionAdapterSet: BetterSqlite3.Statement;
sessionAdapterGet: BetterSqlite3.Statement;
} }
let _stmts: PreparedStatements | null = null; let _stmts: PreparedStatements | null = null;
@@ -202,6 +209,12 @@ function stmts(): PreparedStatements {
instructionDelete: d.prepare( instructionDelete: d.prepare(
`DELETE FROM saved_instructions WHERE id = ?` `DELETE FROM saved_instructions WHERE id = ?`
), ),
sessionAdapterSet: d.prepare(
`INSERT OR REPLACE INTO session_adapters (session_id, adapter) VALUES (?, ?)`
),
sessionAdapterGet: d.prepare(
`SELECT adapter FROM session_adapters WHERE session_id = ?`
),
}; };
} }
return _stmts; return _stmts;
@@ -284,6 +297,18 @@ export const preferences = {
// --- Session Review Operations --- // --- Session Review Operations ---
// --- Session → Adapter Mapping (persists across restarts) ---
export const sessionAdapters = {
set(sessionId: string, adapter: string): void {
stmts().sessionAdapterSet.run(sessionId, adapter);
},
get(sessionId: string): string | null {
const row = stmts().sessionAdapterGet.get(sessionId) as { adapter: string } | undefined;
return row?.adapter ?? null;
},
};
let _childIdCache: Set<string> | null = null; let _childIdCache: Set<string> | null = null;
export const sessionReviews = { export const sessionReviews = {
+42 -11
View File
@@ -12,7 +12,7 @@ import {
authMiddleware, authMiddleware,
} from './auth.js'; } from './auth.js';
import './adapters/init.js'; import './adapters/init.js';
import { initAll, listAvailable, get as getAdapter, getAll as getAllAdapters, cleanupAll, DEFAULT_ADAPTER } from './adapters/registry.js'; import { initAll, installAllHooks, listAvailable, get as getAdapter, getAll as getAllAdapters, cleanupAll, DEFAULT_ADAPTER } from './adapters/registry.js';
import { initPush, getVapidPublicKey, saveSubscription, removeSubscription, getPendingSessions } from './push.js'; import { initPush, getVapidPublicKey, saveSubscription, removeSubscription, getPendingSessions } from './push.js';
import { import {
setupSessionManager, setupSessionManager,
@@ -25,7 +25,7 @@ import {
import { WebSocketTransport } from './transport/websocket-transport.js'; import { WebSocketTransport } from './transport/websocket-transport.js';
import { loadConfig } from './config.js'; import { loadConfig } from './config.js';
import type { AppConfig } from './config.js'; import type { AppConfig } from './config.js';
import { initDB, closeDB, sessionReviews, savedInstructions } from './db.js'; import { initDB, closeDB, sessionReviews, sessionAdapters, savedInstructions } from './db.js';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -99,6 +99,10 @@ async function start(): Promise<void> {
) )
); );
const allSessions = results.flat(); const allSessions = results.flat();
// Persist session→adapter mapping so server knows which adapter owns each session
for (const s of allSessions) {
if (s.sessionId && s.adapter) sessionAdapters.set(s.sessionId, s.adapter);
}
allSessions.sort((a, b) => { allSessions.sort((a, b) => {
const aTime = typeof a.lastModified === 'number' ? a.lastModified : new Date(a.lastModified || 0).getTime(); const aTime = typeof a.lastModified === 'number' ? a.lastModified : new Date(a.lastModified || 0).getTime();
const bTime = typeof b.lastModified === 'number' ? b.lastModified : new Date(b.lastModified || 0).getTime(); const bTime = typeof b.lastModified === 'number' ? b.lastModified : new Date(b.lastModified || 0).getTime();
@@ -251,9 +255,9 @@ async function start(): Promise<void> {
// Register a review after the child session is already created via QUERY // Register a review after the child session is already created via QUERY
app.post('/api/reviews/register', authMiddleware, async (req: Request, res: Response) => { app.post('/api/reviews/register', authMiddleware, async (req: Request, res: Response) => {
try { try {
const { parentCliSessionId, childSessionId, targetAdapter, anchorMessageId, prompt, title } = req.body; const { reviewId, parentCliSessionId, childSessionId, targetAdapter, anchorMessageId, prompt, title } = req.body;
if (!parentCliSessionId || !childSessionId) { if (!reviewId || !parentCliSessionId || !childSessionId) {
return res.status(400).json({ error: 'parentCliSessionId and childSessionId required' }); return res.status(400).json({ error: 'reviewId, parentCliSessionId and childSessionId required' });
} }
// Find which adapter owns the parent session // Find which adapter owns the parent session
@@ -262,7 +266,6 @@ async function start(): Promise<void> {
if (a.getSession(parentCliSessionId)) { parentAdapterName = name; break; } if (a.getSession(parentCliSessionId)) { parentAdapterName = name; break; }
} }
const reviewId = randomUUID();
sessionReviews.create(reviewId, parentCliSessionId, childSessionId, targetAdapter, parentAdapterName, anchorMessageId, prompt, title); sessionReviews.create(reviewId, parentCliSessionId, childSessionId, targetAdapter, parentAdapterName, anchorMessageId, prompt, title);
// Ensure adapter mapping exists for the child session // Ensure adapter mapping exists for the child session
@@ -436,21 +439,49 @@ async function start(): Promise<void> {
} }
}); });
// Initialize all adapters (registers hook routes, configures CLI hooks) // Register adapter routes (before listen — routes don't depend on port)
initAll(app); initAll(app);
setupSessionManager(); setupSessionManager();
// --- Initialize and Listen --- // --- Find available port and Listen ---
await initAuth(config); await initAuth(config);
initPush(config); initPush(config);
writeFileSync(config.paths.pid, String(process.pid));
const protocol = config.https ? 'https' : 'http'; const protocol = config.https ? 'https' : 'http';
server.listen(config.port, '0.0.0.0', () => { const actualPort = await new Promise<number>((resolve, reject) => {
console.log(`ClawTap running on ${protocol}://0.0.0.0:${config.port}${config.https ? ' (HTTPS)' : ''}`); const maxRetries = 10;
let attempt = 0;
function tryListen(port: number) {
const onError = (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE' && attempt < maxRetries) {
attempt++;
const nextPort = port + 1;
console.log(`Port ${port} in use, trying ${nextPort}...`);
server.close(() => tryListen(nextPort));
} else {
reject(err);
}
};
server.once('error', onError);
server.listen(port, '0.0.0.0', () => {
server.removeListener('error', onError);
resolve(port);
});
}
tryListen(config.port);
}); });
// Update config with actual port (may differ if fallback occurred)
config.port = actualPort;
// Install hooks AFTER port is confirmed (hooks embed the port in CLI configs)
installAllHooks(actualPort);
writeFileSync(config.paths.pid, String(process.pid));
console.log(`ClawTap running on ${protocol}://0.0.0.0:${actualPort}${config.https ? ' (HTTPS)' : ''}`);
// --- Graceful Shutdown --- // --- Graceful Shutdown ---
async function shutdown(signal: string): Promise<void> { async function shutdown(signal: string): Promise<void> {
+127 -34
View File
@@ -5,7 +5,8 @@ import type { ClientMessage, QueryOptions, PermissionBehavior } from './types/me
import { sendPush, incrementPending, clearPending, getPendingSessions } from './push.js'; import { sendPush, incrementPending, clearPending, getPendingSessions } from './push.js';
import { basename } from 'path'; import { basename } from 'path';
import type { ClientConnection } from './transport/client-connection.js'; import type { ClientConnection } from './transport/client-connection.js';
import { sessionReviews } from './db.js'; import { sessionReviews, sessionAdapters } from './db.js';
import { parseAskQuestionInput } from './adapters/shared/ask-question-utils.js';
/** Push notification options */ /** Push notification options */
interface PushOptions { interface PushOptions {
@@ -94,14 +95,48 @@ export function setupSessionManager(): void {
triggerPush(adapter, sessionId, { title: 'Claude finished', body: 'Turn complete', tagPrefix: 'idle' }); triggerPush(adapter, sessionId, { title: 'Claude finished', body: 'Turn complete', tagPrefix: 'idle' });
}); });
adapter.on('permission-request', (sessionId: string, data: { toolName?: string; [key: string]: unknown }) => { adapter.on('permission-request', (sessionId: string, data: { requestId?: string; toolName?: string; input?: any; [key: string]: unknown }) => {
broadcast(sessionId, { type: WS.PERMISSION_REQUEST, ...data }); // Convert legacy Claude hook event to InteractivePrompt format
broadcast(sessionId, {
type: WS.INTERACTIVE_PROMPT,
requestId: data.requestId || `perm-${Date.now()}`,
promptType: 'permission',
title: 'Permission Request',
description: `${data.toolName || 'Tool'} wants to execute`,
toolName: data.toolName,
toolInput: data.input,
options: [
{ value: 'allow', label: 'Allow' },
{ value: 'allow_session', label: 'Allow All' },
{ value: 'deny', label: 'Deny' },
],
});
triggerPush(adapter, sessionId, { title: 'Permission needed', body: data.toolName || 'tool', tagPrefix: 'perm' }); triggerPush(adapter, sessionId, { title: 'Permission needed', body: data.toolName || 'tool', tagPrefix: 'perm' });
}); });
adapter.on('ask-question', (sessionId: string, data: { toolName?: string; [key: string]: unknown }) => { adapter.on('ask-question', (sessionId: string, data: { requestId?: string; toolName?: string; input?: any; [key: string]: unknown }) => {
broadcast(sessionId, { type: WS.PERMISSION_REQUEST, ...data }); const parsed = parseAskQuestionInput(data.input || {});
triggerPush(adapter, sessionId, { title: 'Question from Claude', body: 'Waiting for answer', tagPrefix: 'ask' }); broadcast(sessionId, {
type: WS.INTERACTIVE_PROMPT,
requestId: data.requestId || `ask-${Date.now()}`,
promptType: 'question',
title: parsed.header || 'Question',
description: parsed.question,
toolName: 'AskUserQuestion',
toolInput: data.input || {},
options: parsed.options,
textInput: parsed.options ? undefined : { placeholder: 'Enter your response...' },
});
triggerPush(adapter, sessionId, { title: 'Question', body: questionText.substring(0, 50) || 'Waiting for answer', tagPrefix: 'ask' });
});
adapter.on('interactive-prompt', (sessionId: string, prompt: any) => {
broadcast(sessionId, { type: WS.INTERACTIVE_PROMPT, ...prompt });
const pushTitle = prompt.promptType === 'permission' ? 'Permission needed'
: prompt.promptType === 'question' ? 'Question'
: prompt.promptType === 'plan' ? 'Plan approval'
: 'Action needed';
triggerPush(adapter, sessionId, { title: pushTitle, body: prompt.title || '', tagPrefix: 'prompt' });
}); });
adapter.on('status-update', (sessionId: string, status: Record<string, unknown>) => { adapter.on('status-update', (sessionId: string, status: Record<string, unknown>) => {
@@ -181,15 +216,12 @@ export function setupSessionManager(): void {
} }
// Update any active reviews that reference the old key as child (FIX 3) // Update any active reviews that reference the old key as child (FIX 3)
sessionReviews.updateChildCliId(oldKey, newKey); sessionReviews.updateChildCliId(oldKey, newKey);
// Send updated SESSION_CREATED so frontend knows the real ID // Send SESSION_CREATED with the real UUID — for pendingRekey adapters,
// this is the ONLY SESSION_CREATED the client receives.
const resolvedAdapter = getAdapter(adapterName || DEFAULT_ADAPTER); const resolvedAdapter = getAdapter(adapterName || DEFAULT_ADAPTER);
if (resolvedAdapter && clients) { if (resolvedAdapter && clients) {
for (const conn of clients) { for (const conn of clients) {
send(conn, { sendSessionCreated(conn, resolvedAdapter, newKey);
type: WS.SESSION_CREATED,
sessionId: newKey,
permissionMode: (resolvedAdapter.getSession(newKey) as any)?.permissionMode || (resolvedAdapter.getSession(newKey) as any)?.approvalPolicy,
});
} }
} }
}); });
@@ -204,20 +236,60 @@ export function setupSessionManager(): void {
// === Helper: resolve adapter for a session === // === Helper: resolve adapter for a session ===
function getAdapterForSession(conn: ClientConnection, sessionId?: string): { adapter: IAdapter | undefined; sid: string } { /**
* Resolve which adapter owns a session.
* 1. In-memory map (fastest, covers active sessions)
* 2. SQLite (populated when /api/sessions is fetched, survives restarts)
* 3. Probe each adapter with getMessages (one-time cost, then cached)
* Returns null if no adapter recognizes the session.
*/
async function resolveAdapterForSession(sessionId: string): Promise<string | null> {
// 1. Memory
const mapped = sessionAdapterMap.get(sessionId);
if (mapped) return mapped;
// 2. SQLite
const persisted = sessionAdapters.get(sessionId);
if (persisted) {
sessionAdapterMap.set(sessionId, persisted);
return persisted;
}
// 3. Probe each adapter — runs once per unknown session, then cached
for (const [name, adapter] of getAllAdapters()) {
try {
const { messages } = await adapter.getMessages(sessionId);
if (messages.length > 0) {
sessionAdapterMap.set(sessionId, name);
sessionAdapters.set(sessionId, name);
return name;
}
} catch (err) {
console.warn(`[resolveAdapter] probe ${name} for ${sessionId.slice(0, 8)} failed:`, (err as Error).message);
}
}
return null;
}
async function getAdapterForSession(conn: ClientConnection, sessionId?: string): Promise<{ adapter: IAdapter | undefined; sid: string }> {
const sid = sessionId || conn.sessionId || ''; const sid = sessionId || conn.sessionId || '';
const name = sessionAdapterMap.get(sid) || DEFAULT_ADAPTER; const name = await resolveAdapterForSession(sid);
return { adapter: getAdapter(name), sid }; return { adapter: name ? getAdapter(name) : undefined, sid };
} }
function sendSessionCreated(conn: ClientConnection, adapter: IAdapter, sessionId: string): void { function sendSessionCreated(conn: ClientConnection, adapter: IAdapter, sessionId: string): void {
const sessionObj = adapter.getSession(sessionId) as { const sessionObj = adapter.getSession(sessionId) as {
permissionMode?: string; permissionMode?: string;
approvalPolicy?: string; approvalPolicy?: string;
cwd?: string;
} | null; } | null;
const adapterName = sessionAdapterMap.get(sessionId) || sessionAdapters.get(sessionId);
send(conn, { send(conn, {
type: WS.SESSION_CREATED, type: WS.SESSION_CREATED,
sessionId, sessionId,
adapter: adapterName,
cwd: sessionObj?.cwd,
permissionMode: sessionObj?.permissionMode || sessionObj?.approvalPolicy, permissionMode: sessionObj?.permissionMode || sessionObj?.approvalPolicy,
}); });
} }
@@ -238,13 +310,18 @@ export async function handleIncomingMessage(conn: ClientConnection, msg: ClientM
case WS.ABORT: case WS.ABORT:
return handleAbort(conn, msg.sessionId as string | undefined); return handleAbort(conn, msg.sessionId as string | undefined);
case WS.RECONNECT: case WS.RECONNECT:
return handleReconnect(conn, msg.sessionId as string | undefined, msg.adapter as string | undefined); return handleReconnect(conn, msg.sessionId as string | undefined);
case WS.SET_PERMISSION_MODE: case WS.SET_PERMISSION_MODE:
return handleSetPermissionMode(conn, msg.sessionId as string, msg.mode as string); return handleSetPermissionMode(conn, msg.sessionId as string, msg.mode as string);
case WS.SET_MODEL: case WS.SET_MODEL:
return handleSetModel(conn, msg.sessionId as string, msg.model as string); return handleSetModel(conn, msg.sessionId as string, msg.model as string);
case WS.PLAN_RESPONSE: case WS.PLAN_RESPONSE:
return handlePlanResponse(conn, msg.sessionId as string, msg.optionIndex as number, msg.text as string | undefined); return handlePlanResponse(conn, msg.sessionId as string, msg.optionIndex as number, msg.text as string | undefined);
case WS.PROMPT_RESPONSE:
return handlePromptResponse(conn, msg.requestId as string, {
selectedOption: msg.selectedOption as string | undefined,
textValue: msg.textValue as string | undefined,
});
default: default:
conn.send({ type: 'error', error: `Unknown message type: ${msg.type}` }); conn.send({ type: 'error', error: `Unknown message type: ${msg.type}` });
} }
@@ -253,20 +330,29 @@ export async function handleIncomingMessage(conn: ClientConnection, msg: ClientM
// === Message Handlers === // === Message Handlers ===
export async function handleQuery(conn: ClientConnection, prompt: string, options: QueryOptions): Promise<void> { export async function handleQuery(conn: ClientConnection, prompt: string, options: QueryOptions): Promise<void> {
const { cwd, model, sessionId, permissionMode, images, adapter: adapterName } = options; let { cwd, model, sessionId, permissionMode, images, adapter: adapterName } = options;
const adapter = getAdapter(adapterName || DEFAULT_ADAPTER); let adapter = getAdapter(adapterName || DEFAULT_ADAPTER);
if (!adapter) throw new Error(`Unknown adapter: ${adapterName}`); if (!adapter) throw new Error(`Unknown adapter: ${adapterName}`);
let handle: { sessionId: string }; let handle: { sessionId: string; pendingRekey?: boolean };
if (sessionId) { if (sessionId) {
const resolvedName = await resolveAdapterForSession(sessionId);
if (resolvedName && resolvedName !== (adapterName || DEFAULT_ADAPTER)) {
adapter = getAdapter(resolvedName)!;
adapterName = resolvedName;
}
handle = await adapter.resumeSession(sessionId, cwd as string, { permissionMode }); handle = await adapter.resumeSession(sessionId, cwd as string, { permissionMode });
} else { } else {
handle = await adapter.startSession(cwd || process.cwd(), { model, permissionMode }); handle = await adapter.startSession(cwd || process.cwd(), { model, permissionMode });
} }
sessionAdapterMap.set(handle.sessionId, adapterName || DEFAULT_ADAPTER); registerSessionAdapter(handle.sessionId, adapterName || DEFAULT_ADAPTER);
registerClient(conn, handle.sessionId); registerClient(conn, handle.sessionId);
sendSessionCreated(conn, adapter, handle.sessionId); // Adapters with pendingRekey (Codex/Gemini) don't get SESSION_CREATED here —
// the session-rekeyed handler sends it after rekey with the real UUID.
if (!handle.pendingRekey) {
sendSessionCreated(conn, adapter, handle.sessionId);
}
// Send the message (images sent as text description for now) // Send the message (images sent as text description for now)
let messageText = prompt; let messageText = prompt;
@@ -280,8 +366,8 @@ export async function handleQuery(conn: ClientConnection, prompt: string, option
await adapter.sendMessage(handle.sessionId, messageText, { clientId: conn.clientId }); await adapter.sendMessage(handle.sessionId, messageText, { clientId: conn.clientId });
} }
export function handlePermissionResponse(conn: ClientConnection, requestId: string, response: { behavior: PermissionBehavior; alwaysAllow?: boolean }): void { export async function handlePermissionResponse(conn: ClientConnection, requestId: string, response: { behavior: PermissionBehavior; alwaysAllow?: boolean }): Promise<void> {
const { adapter, sid } = getAdapterForSession(conn); const { adapter, sid } = await getAdapterForSession(conn);
if (adapter) { if (adapter) {
adapter.respondPermission(requestId, response.behavior); adapter.respondPermission(requestId, response.behavior);
broadcast(sid, { type: WS.PERMISSION_DISMISSED, requestId }); broadcast(sid, { type: WS.PERMISSION_DISMISSED, requestId });
@@ -289,20 +375,28 @@ export function handlePermissionResponse(conn: ClientConnection, requestId: stri
} }
export async function handleAskResponse(conn: ClientConnection, requestId: string, answers: string): Promise<void> { export async function handleAskResponse(conn: ClientConnection, requestId: string, answers: string): Promise<void> {
const { adapter, sid } = getAdapterForSession(conn); const { adapter, sid } = await getAdapterForSession(conn);
if (adapter) { if (adapter) {
adapter.respondQuestion(requestId, answers); adapter.respondQuestion(requestId, answers);
broadcast(sid, { type: WS.PERMISSION_DISMISSED, requestId }); broadcast(sid, { type: WS.PERMISSION_DISMISSED, requestId });
} }
} }
export async function handlePromptResponse(conn: ClientConnection, requestId: string, response: { selectedOption?: string; textValue?: string }): Promise<void> {
const { adapter, sid } = await getAdapterForSession(conn);
if (adapter) {
adapter.respondInteractivePrompt(requestId, response.selectedOption, response.textValue);
broadcast(sid, { type: WS.PROMPT_DISMISSED, requestId });
}
}
export async function handleAbort(conn: ClientConnection, sessionId?: string): Promise<void> { export async function handleAbort(conn: ClientConnection, sessionId?: string): Promise<void> {
const { adapter, sid } = getAdapterForSession(conn, sessionId); const { adapter, sid } = await getAdapterForSession(conn, sessionId);
if (sid && adapter) await adapter.interrupt(sid); if (sid && adapter) await adapter.interrupt(sid);
} }
export async function handlePlanResponse(conn: ClientConnection, sessionId: string, optionIndex: number, text?: string): Promise<void> { export async function handlePlanResponse(conn: ClientConnection, sessionId: string, optionIndex: number, text?: string): Promise<void> {
const { adapter, sid } = getAdapterForSession(conn, sessionId); const { adapter, sid } = await getAdapterForSession(conn, sessionId);
if (!sid || !adapter) return; if (!sid || !adapter) return;
await adapter.respondPlan(sid, optionIndex, text); await adapter.respondPlan(sid, optionIndex, text);
// Broadcast synthetic user message so plan card transitions to read-only on ALL clients // Broadcast synthetic user message so plan card transitions to read-only on ALL clients
@@ -312,17 +406,17 @@ export async function handlePlanResponse(conn: ClientConnection, sessionId: stri
broadcast(sid, { type: WS.MESSAGE_COMPLETE, messages: [{ role: 'user', content: msg }] }); broadcast(sid, { type: WS.MESSAGE_COMPLETE, messages: [{ role: 'user', content: msg }] });
} }
export async function handleReconnect(conn: ClientConnection, sessionId?: string, adapterHint?: string): Promise<void> { export async function handleReconnect(conn: ClientConnection, sessionId?: string): Promise<void> {
if (!sessionId) return; if (!sessionId) return;
// Resolve rekey alias (Codex temp key → real UUID)
const resolvedId = rekeyAliases.get(sessionId) || sessionId; const resolvedId = rekeyAliases.get(sessionId) || sessionId;
const adapterName = sessionAdapterMap.get(resolvedId) || adapterHint || DEFAULT_ADAPTER; const adapterName = await resolveAdapterForSession(resolvedId);
if (!adapterName) return;
const adapter = getAdapter(adapterName); const adapter = getAdapter(adapterName);
if (!adapter) return; if (!adapter) return;
registerClient(conn, sessionId); // registerClient also resolves alias internally registerClient(conn, sessionId);
sessionAdapterMap.set(resolvedId, adapterName); sessionAdapterMap.set(resolvedId, adapterName);
// Clear pending push notifications for this session and update badge (only if there were pending) // Clear pending push notifications for this session and update badge (only if there were pending)
@@ -374,8 +468,6 @@ export async function handleReconnect(conn: ClientConnection, sessionId?: string
const childAdapterObj = getAdapter(review.child_adapter); const childAdapterObj = getAdapter(review.child_adapter);
if (!childAdapterObj) continue; if (!childAdapterObj) continue;
// Check if child session still exists in adapter's in-memory Map.
// If not (server restarted or windows killed), mark review as ended.
if (!childAdapterObj.getSession(review.child_cli_session_id)) { if (!childAdapterObj.getSession(review.child_cli_session_id)) {
sessionReviews.endReview(review.id); sessionReviews.endReview(review.id);
continue; continue;
@@ -397,14 +489,14 @@ export async function handleReconnect(conn: ClientConnection, sessionId?: string
} }
export async function handleSetModel(conn: ClientConnection, sessionId: string, model: string): Promise<void> { export async function handleSetModel(conn: ClientConnection, sessionId: string, model: string): Promise<void> {
const { adapter, sid } = getAdapterForSession(conn, sessionId); const { adapter, sid } = await getAdapterForSession(conn, sessionId);
if (adapter && sid) { if (adapter && sid) {
await adapter.switchModel(sid, model); await adapter.switchModel(sid, model);
} }
} }
export async function handleSetPermissionMode(conn: ClientConnection, sessionId: string, mode: string): Promise<void> { export async function handleSetPermissionMode(conn: ClientConnection, sessionId: string, mode: string): Promise<void> {
const { adapter, sid } = getAdapterForSession(conn, sessionId); const { adapter, sid } = await getAdapterForSession(conn, sessionId);
if (!sid || !adapter) return; if (!sid || !adapter) return;
const success = await adapter.switchPermissionMode(sid, mode); const success = await adapter.switchPermissionMode(sid, mode);
@@ -512,4 +604,5 @@ export function getClientCount(sessionId: string): number {
export function registerSessionAdapter(sessionId: string, adapterName: string): void { export function registerSessionAdapter(sessionId: string, adapterName: string): void {
sessionAdapterMap.set(sessionId, adapterName); sessionAdapterMap.set(sessionId, adapterName);
sessionAdapters.set(sessionId, adapterName);
} }
+17
View File
@@ -65,3 +65,20 @@ export interface SessionStatus {
model: string; model: string;
cost: number; cost: number;
} }
export interface InteractivePrompt {
requestId: string;
promptType: 'permission' | 'question' | 'plan' | 'loop-detected';
title: string;
description: string;
toolName?: string;
toolInput?: any;
options?: { value: string; label: string }[];
textInput?: { placeholder?: string };
}
export interface PromptResponse {
requestId: string;
selectedOption?: string;
textValue?: string;
}
+4
View File
@@ -30,6 +30,10 @@ export const WS = {
CLIENT_ID: 'client-id', CLIENT_ID: 'client-id',
PENDING_NOTIFICATIONS: 'pending-notifications', PENDING_NOTIFICATIONS: 'pending-notifications',
ERROR: 'error', ERROR: 'error',
// Interactive Prompts (unified permission/question/plan overlay)
INTERACTIVE_PROMPT: 'interactive-prompt',
PROMPT_RESPONSE: 'prompt-response',
PROMPT_DISMISSED: 'prompt-dismissed',
// Cross-AI Review // Cross-AI Review
REVIEW_STARTED: 'review-started', REVIEW_STARTED: 'review-started',
REVIEW_ENDED: 'review-ended', REVIEW_ENDED: 'review-ended',
+9 -6
View File
@@ -38,11 +38,13 @@ function persistView(view: View) {
function navigateTo(view: View) { function navigateTo(view: View) {
persistView(view); persistView(view);
const url = view.name === 'chat' && view.sessionId let url = '/';
? `/?view=chat&session=${view.sessionId}` if (view.name === 'chat' && view.sessionId) {
: view.name === 'settings' url = `/?view=chat&session=${view.sessionId}`;
? '/?view=settings' if (view.adapter) url += `&adapter=${view.adapter}`;
: '/'; } else if (view.name === 'settings') {
url = '/?view=settings';
}
window.history.pushState({ view }, '', url); window.history.pushState({ view }, '', url);
} }
@@ -208,7 +210,8 @@ export function App() {
const action = params.get('action'); const action = params.get('action');
if (sessionId) { if (sessionId) {
urlParamsHandled.current = true; urlParamsHandled.current = true;
openChat(sessionId); const adapter = params.get('adapter');
openChat(sessionId, undefined, adapter || undefined);
window.history.replaceState({}, '', '/'); window.history.replaceState({}, '', '/');
} else if (action === 'newchat') { } else if (action === 'newchat') {
urlParamsHandled.current = true; urlParamsHandled.current = true;
-64
View File
@@ -1,64 +0,0 @@
import { useState } from 'react';
import { Button } from './ui/button';
import { Send } from 'lucide-react';
export function AskQuestion({ toolUseId, input, onRespond }: {
toolUseId: string; input: any; onRespond: (toolUseId: string, response: string) => void;
}) {
const [customText, setCustomText] = useState('');
const [showCustom, setShowCustom] = useState(false);
const [answered, setAnswered] = useState(false);
// SDK AskUserQuestion uses questions[0].question/options structure
const firstQ = input?.questions?.[0];
const question = firstQ?.question || input?.question || input?.text || 'Choose an option';
const options: Array<{ value: string; label: string; description?: string }> = firstQ?.options || input?.options || input?.choices || [];
function select(value: string) { if (answered) return; setAnswered(true); onRespond(toolUseId, value); }
function submitCustom() { if (!customText.trim() || answered) return; setAnswered(true); onRespond(toolUseId, customText.trim()); }
if (answered) return (
<div className="mb-3">
<p className="text-text-dim text-sm italic">Question answered</p>
</div>
);
return (
<div className="mb-3">
<p className="text-sm font-medium font-mono text-text mb-3">{question}</p>
<div className="space-y-2">
{options.map((opt, i) => (
<Button
key={i}
variant="outline"
onClick={() => select(opt.value || opt.label)}
className="w-full justify-start text-left h-auto py-3 px-4"
>
<div>
<div className="text-sm font-medium">{opt.label || opt.value}</div>
{opt.description && <div className="text-text-dim text-xs mt-0.5">{opt.description}</div>}
</div>
</Button>
))}
{!showCustom ? (
<Button variant="ghost" onClick={() => setShowCustom(true)} className="w-full text-sm">
Other...
</Button>
) : (
<div className="flex gap-2">
<input
value={customText}
onChange={(e) => setCustomText(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && submitCustom()}
placeholder="Type your answer..."
className="flex-1 bg-bg border border-border rounded-md px-3 py-2 text-text text-sm focus:outline-none focus:border-accent"
autoFocus
/>
<Button variant="ghost" size="icon" onClick={submitCustom} disabled={!customText.trim()}>
<Send className="size-4" />
</Button>
</div>
)}
</div>
</div>
);
}
+1 -1
View File
@@ -146,7 +146,7 @@ export function ChatBody({
: isLastAssistant && interrupted ? 'interrupted' : isLastAssistant && interrupted ? 'interrupted'
: 'success'; : 'success';
elements.push( elements.push(
<ToolCallCard key={tool.id} toolName={tool.name} input={tool.input} status={status?.status || fallbackStatus} result={status?.result} />, <ToolCallCard key={tool.id} toolName={tool.name} input={tool.input} status={status?.status || fallbackStatus} result={status?.result || tool._result} />,
); );
} }
} }
+70 -91
View File
@@ -1,9 +1,8 @@
import React, { useState, useRef, useEffect, useCallback, useMemo, Fragment, type RefObject } from 'react'; import React, { useState, useRef, useEffect, useCallback, useMemo, Fragment, type RefObject } from 'react';
import { useChat } from '../hooks/useChat'; import { useChat } from '../hooks/useChat';
import { PLAN_OPTION } from '../lib/ws-types'; import { PLAN_OPTION } from '../lib/ws-types';
import { PermissionOverlay } from './PermissionOverlay'; import { InteractivePromptOverlay } from './InteractivePromptOverlay';
import { StatusBar } from './StatusBar'; import { StatusBar } from './StatusBar';
import { AskQuestion } from './AskQuestion';
import { PlanMode } from './PlanMode'; import { PlanMode } from './PlanMode';
import { ChatBody } from './ChatBody'; import { ChatBody } from './ChatBody';
import { FloatingReviewPanel, type ReviewPanelHandle } from './FloatingReviewPanel'; import { FloatingReviewPanel, type ReviewPanelHandle } from './FloatingReviewPanel';
@@ -11,11 +10,11 @@ import { ReviewActionMenu } from './ReviewActionMenu';
import { SendToExistingSheet } from './SendToExistingSheet'; import { SendToExistingSheet } from './SendToExistingSheet';
import { CollapsedReviewCard } from './CollapsedReviewCard'; import { CollapsedReviewCard } from './CollapsedReviewCard';
import { BlockMarker } from './BlockMarker'; import { BlockMarker } from './BlockMarker';
import { BottomSheet } from './BottomSheet';
import { api } from '../lib/api'; import { api } from '../lib/api';
import { getBrand } from '../lib/adapter-brands'; import { getBrand } from '../lib/adapter-brands';
import { extractTextFromBlocks } from '../lib/content-utils'; import { extractTextFromBlocks } from '../lib/content-utils';
import { patchAdapterPrefs } from '../lib/adapter-prefs'; import { patchAdapterPrefs } from '../lib/adapter-prefs';
import { LoadingAnimation } from './ui/LoadingAnimation';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
import { ChevronLeft, Copy, Check, X } from 'lucide-react'; import { ChevronLeft, Copy, Check, X } from 'lucide-react';
@@ -140,11 +139,11 @@ export function ChatView({
const { const {
messages, toolStatuses, streaming, pendingResponse, wsStatus, sessionId, liveStatus, messages, toolStatuses, streaming, pendingResponse, wsStatus, sessionId, liveStatus,
interrupted, sessionStatus, adapterConfig, selectedAdapter, permissionRequest, model, permissionMode, interrupted, sessionStatus, adapterConfig, selectedAdapter, interactivePrompt, model, permissionMode,
queuedMessage, clearQueuedMessage, queuedMessage, clearQueuedMessage,
activeReviews, setActiveReviews, activeReviewPanel, setActiveReviewPanel, activeReviews, setActiveReviews, activeReviewPanel, setActiveReviewPanel,
historyReview, setHistoryReview, historyReview, setHistoryReview,
sendMessage, respondPermission, respondAsk, respondPlan, abort, sendMessage, respondPrompt, respondPlan, abort,
updateModel, updatePermissionMode, updateModel, updatePermissionMode,
} = useChat(initialSessionId, cwd, adapter, initialPrompt); } = useChat(initialSessionId, cwd, adapter, initialPrompt);
@@ -189,22 +188,13 @@ export function ChatView({
// Shared cleanup for ending/closing an active review // Shared cleanup for ending/closing an active review
const closeReview = useCallback(async (reviewId?: string) => { const closeReview = useCallback(async (reviewId?: string) => {
// Empty reviewId means the pending tab's close button — just cancel it
if (reviewId === '') {
setPendingReview(null);
return;
}
const targetId = reviewId || activeReviews[0]?.reviewId; const targetId = reviewId || activeReviews[0]?.reviewId;
if (!targetId) return; if (!targetId) return;
const lastMsg = messages[messages.length - 1];
const endAnchorMessageId = lastMsg?.id || undefined;
try { await api.endReview(targetId, endAnchorMessageId); } catch {}
setActiveReviews(prev => prev.filter(r => r.reviewId !== targetId)); setActiveReviews(prev => prev.filter(r => r.reviewId !== targetId));
setHistoryReview(null); setHistoryReview(null);
setPendingReview(null); const lastMsg = messages[messages.length - 1];
const endAnchorMessageId = lastMsg?.id || undefined;
try { await api.endReview(targetId, endAnchorMessageId); } catch {}
}, [activeReviews, messages]); }, [activeReviews, messages]);
// Close history panel only (does not affect active review) // Close history panel only (does not affect active review)
@@ -227,6 +217,7 @@ export function ChatView({
// New reviews added — batch into a single setReviews call // New reviews added — batch into a single setReviews call
const newReviews = activeReviews.filter(r => !prevIds.has(r.reviewId)); const newReviews = activeReviews.filter(r => !prevIds.has(r.reviewId));
if (newReviews.length > 0) { if (newReviews.length > 0) {
console.log(`[reviewSync] ${newReviews.length} new review(s):`, newReviews.map(r => `${r.reviewId.slice(0,8)} anchor=${r.anchorMessageId}`));
setReviews(prev => { setReviews(prev => {
const existingIds = new Set(prev.map(r => r.id)); const existingIds = new Set(prev.map(r => r.id));
const toAdd = newReviews const toAdd = newReviews
@@ -303,23 +294,29 @@ export function ChatView({
const [saveToast, setSaveToast] = useState<{ instruction: string; label: string } | null>(null); const [saveToast, setSaveToast] = useState<{ instruction: string; label: string } | null>(null);
// Pending review: waiting for child session to be created (not yet in activeReviews)
const [pendingReview, setPendingReview] = useState<{
childAdapter: string;
anchorMessageId: string;
reviewTitle: string;
prompt: string;
} | null>(null);
const openReview = useCallback((adapter: string, model: string, prompt: string, title: string) => { const openReview = useCallback((adapter: string, model: string, prompt: string, title: string) => {
const anchorId = reviewMenuMessageId; const anchorId = reviewMenuMessageId;
setReviewMenuMessageId(null); setReviewMenuMessageId(null);
if (!anchorId) return; if (!anchorId) return;
const reviewId = crypto.randomUUID();
console.log(`[openReview] creating review=${reviewId.slice(0,8)} adapter=${adapter} anchor=${anchorId} prompt=${prompt?.substring(0, 30)}`);
patchAdapterPrefs(adapter, { model }); patchAdapterPrefs(adapter, { model });
setHistoryReview(null); setHistoryReview(null);
setPendingReview({ childAdapter: adapter, anchorMessageId: anchorId, reviewTitle: title, prompt }); setActiveReviews(prev => {
console.log(`[openReview] activeReviews before: ${prev.length} entries, ids: ${prev.map(r => r.reviewId.slice(0,8)).join(',')}`);
return [...prev, {
reviewId,
childSessionId: '',
childCliSessionId: '',
childAdapter: adapter,
anchorMessageId: anchorId,
reviewTitle: title,
prompt,
permissionMode: adapter === 'gemini' ? 'yolo' : 'bypassPermissions',
}];
});
setActiveReviewPanel('expanded'); setActiveReviewPanel('expanded');
}, [reviewMenuMessageId, cwd]); }, [reviewMenuMessageId]);
const handleDirectSend = useCallback((adapter: string, model: string) => { const handleDirectSend = useCallback((adapter: string, model: string) => {
const anchorMsg = messages.find(m => m.id === reviewMenuMessageId); const anchorMsg = messages.find(m => m.id === reviewMenuMessageId);
@@ -437,7 +434,7 @@ export function ChatView({
permissionMode={permissionMode} permissionMode={permissionMode}
sessionStatus={sessionStatus} sessionStatus={sessionStatus}
adapterConfig={adapterConfig} adapterConfig={adapterConfig}
selectedAdapter={selectedAdapter} selectedAdapter={selectedAdapter!}
streaming={streaming} streaming={streaming}
onModelChange={updateModel} onModelChange={updateModel}
onPermissionModeChange={updatePermissionMode} onPermissionModeChange={updatePermissionMode}
@@ -447,40 +444,38 @@ export function ChatView({
const isHistoryPanel = !!historyReview; const isHistoryPanel = !!historyReview;
// Use ref so onSessionCreatedCallback always reads the latest pendingReview const activeReviewsRef = useRef(activeReviews);
// (prevents stale closure if a second review is opened while the first is still pending) activeReviewsRef.current = activeReviews;
const pendingReviewRef = useRef(pendingReview);
pendingReviewRef.current = pendingReview;
const onSessionCreatedCallback = useCallback(async (childSid: string) => { const onSessionCreatedCallback = useCallback(async (reviewId: string, childSid: string) => {
const pending = pendingReviewRef.current; console.log(`[onSessionCreated] reviewId=${reviewId.slice(0,8)} childSid=${childSid.slice(0,8)} parentSid=${sessionId?.slice(0,8)}`);
if (!sessionId || !pending) return; if (!sessionId) return;
const review = activeReviewsRef.current.find(r => r.reviewId === reviewId);
if (!review) { console.log(`[onSessionCreated] review not found in activeReviews`); return; }
try { try {
const result = await api.registerReview( console.log(`[onSessionCreated] calling api.registerReview...`);
sessionId, await api.registerReview({
childSid, reviewId,
pending.childAdapter, parentCliSessionId: sessionId,
pending.anchorMessageId, childSessionId: childSid,
pending.prompt, targetAdapter: review.childAdapter,
pending.reviewTitle, anchorMessageId: review.anchorMessageId || '',
); prompt: review.prompt || '',
setActiveReviews(prev => { title: review.reviewTitle || '',
if (prev.some(r => r.reviewId === result.reviewId)) return prev;
return [...prev, {
reviewId: result.reviewId,
childSessionId: childSid,
childCliSessionId: childSid,
childAdapter: pending.childAdapter,
anchorMessageId: pending.anchorMessageId,
reviewTitle: pending.reviewTitle,
}];
}); });
} catch (err) { } catch (err) {
console.error('Failed to register review:', err); console.error('Failed to register review:', err);
} }
setPendingReview(null);
}, [sessionId]); }, [sessionId]);
if (!selectedAdapter) {
return (
<div className="flex flex-col h-dvh bg-bg items-center justify-center">
<LoadingAnimation size="md" label="Connecting..." />
</div>
);
}
return ( return (
<div className="flex flex-col h-dvh bg-bg relative overflow-hidden"> <div className="flex flex-col h-dvh bg-bg relative overflow-hidden">
{/* Header — auto-hides when scrolling up to view history */} {/* Header — auto-hides when scrolling up to view history */}
@@ -516,31 +511,25 @@ export function ChatView({
className="flex-1" className="flex-1"
/> />
{/* Floating review panel — active reviews (tabbed) + pending review */} {/* Floating review panel — CSS-hidden when minimized to keep hooks alive */}
{activeReviewPanel === 'expanded' && (activeReviews.length > 0 || pendingReview) && ( {activeReviews.length > 0 && (
<FloatingReviewPanel <div style={{ display: activeReviewPanel === 'expanded' ? 'contents' : 'none' }}>
ref={reviewPanelRef} <FloatingReviewPanel
reviews={[ ref={reviewPanelRef}
...activeReviews.map(r => ({ reviews={activeReviews.map(r => ({
reviewId: r.reviewId, reviewId: r.reviewId,
childSessionId: r.childSessionId, childSessionId: r.childSessionId,
childAdapter: r.childAdapter, childAdapter: r.childAdapter,
reviewTitle: r.reviewTitle, reviewTitle: r.reviewTitle,
})), prompt: r.prompt,
// Pending review: no reviewId yet, triggers session creation in ReviewTab permissionMode: r.permissionMode,
...(pendingReview ? [{ }))}
reviewId: '', onEnd={(reviewId) => closeReview(reviewId)}
childSessionId: '', onMinimize={() => setActiveReviewPanel('minimized')}
childAdapter: pendingReview.childAdapter, cwd={cwd}
reviewTitle: pendingReview.reviewTitle, onSessionCreated={onSessionCreatedCallback}
}] : []), />
]} </div>
onEnd={(reviewId) => closeReview(reviewId)}
onMinimize={() => setActiveReviewPanel('minimized')}
initialPrompt={pendingReview?.prompt || undefined}
cwd={cwd}
onSessionCreated={onSessionCreatedCallback}
/>
)} )}
{/* Floating review panel — read-only history view */} {/* Floating review panel — read-only history view */}
@@ -590,23 +579,13 @@ export function ChatView({
</div> </div>
)} )}
{/* Permission / Ask overlays */} {/* Interactive prompt overlay (permissions, questions, plan approval, etc.) */}
{permissionRequest && permissionRequest.toolName === 'AskUserQuestion' ? ( {interactivePrompt && (
<BottomSheet visible zIndex="z-40" backdropClassName="bg-black/60" className="p-6" showHandle={false}> <InteractivePromptOverlay
<AskQuestion prompt={interactivePrompt}
toolUseId={permissionRequest.requestId} onRespond={respondPrompt}
input={permissionRequest.input}
onRespond={(requestId: string, response: string) => respondAsk(requestId, response)}
/>
</BottomSheet>
) : permissionRequest ? (
<PermissionOverlay
request={permissionRequest}
onAllow={() => respondPermission(permissionRequest.requestId, 'allow')}
onAllowAll={() => respondPermission(permissionRequest.requestId, 'allow_session')}
onDeny={(msg?: string) => respondPermission(permissionRequest.requestId, 'deny', msg)}
/> />
) : null} )}
</div> </div>
); );
} }
+23 -14
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef, useCallback, forwardRef, useImperativeHandle } from 'react'; import React, { useState, useEffect, useRef, useCallback, forwardRef, useImperativeHandle } from 'react';
import { useChat } from '../hooks/useChat'; import { useChat } from '../hooks/useChat';
import { ChatBody } from './ChatBody'; import { ChatBody } from './ChatBody';
import { InteractivePromptOverlay } from './InteractivePromptOverlay';
import { getBrand } from '@/lib/adapter-brands'; import { getBrand } from '@/lib/adapter-brands';
import { extractTextFromBlocks } from '@/lib/content-utils'; import { extractTextFromBlocks } from '@/lib/content-utils';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
@@ -12,15 +13,16 @@ export interface ReviewEntry {
childSessionId: string; childSessionId: string;
childAdapter: string; childAdapter: string;
reviewTitle?: string; reviewTitle?: string;
prompt?: string;
permissionMode?: string;
} }
interface ReviewPanelProps { interface ReviewPanelProps {
reviews: ReviewEntry[]; reviews: ReviewEntry[];
onEnd: (reviewId: string) => void; onEnd: (reviewId: string) => void;
onMinimize: () => void; onMinimize: () => void;
initialPrompt?: string;
cwd?: string; cwd?: string;
onSessionCreated?: (childSessionId: string) => void; onSessionCreated?: (reviewId: string, childSessionId: string) => void;
readOnly?: boolean; readOnly?: boolean;
} }
@@ -30,23 +32,24 @@ export interface ReviewPanelHandle {
// ===== ReviewTab (one per review, keeps useChat hook alive) ===== // ===== ReviewTab (one per review, keeps useChat hook alive) =====
const ReviewTab = React.memo(function ReviewTab({ review, cwd, initialPrompt, onSessionCreated, isActive, readOnly, sendRef }: { const ReviewTab = React.memo(function ReviewTab({ review, cwd, onSessionCreated, isActive, readOnly, sendRef }: {
review: ReviewEntry; review: ReviewEntry;
cwd?: string; cwd?: string;
initialPrompt?: string; onSessionCreated?: (reviewId: string, sid: string) => void;
onSessionCreated?: (sid: string) => void;
isActive: boolean; isActive: boolean;
readOnly?: boolean; readOnly?: boolean;
sendRef?: React.MutableRefObject<Map<string, (text: string) => void>>; sendRef?: React.MutableRefObject<Map<string, (text: string) => void>>;
}) { }) {
const { const {
messages, streaming, liveStatus, toolStatuses, messages, streaming, pendingResponse, liveStatus, toolStatuses,
sendMessage, abort, sessionId: chatSessionId, sendMessage, abort, sessionId: chatSessionId,
interactivePrompt, respondPrompt,
} = useChat( } = useChat(
review.childSessionId || undefined, review.childSessionId || undefined,
cwd, cwd,
review.childAdapter, review.childAdapter,
initialPrompt, review.prompt,
review.permissionMode,
); );
// Notify parent when child session is created // Notify parent when child session is created
@@ -54,9 +57,9 @@ const ReviewTab = React.memo(function ReviewTab({ review, cwd, initialPrompt, on
useEffect(() => { useEffect(() => {
if (chatSessionId && !review.childSessionId && onSessionCreated && !sessionCreatedRef.current) { if (chatSessionId && !review.childSessionId && onSessionCreated && !sessionCreatedRef.current) {
sessionCreatedRef.current = true; sessionCreatedRef.current = true;
onSessionCreated(chatSessionId); onSessionCreated(review.reviewId, chatSessionId);
} }
}, [chatSessionId, review.childSessionId, onSessionCreated]); }, [chatSessionId, review.childSessionId, onSessionCreated, review.reviewId]);
// Register sendMessage in parent's ref map for sendToReview // Register sendMessage in parent's ref map for sendToReview
useEffect(() => { useEffect(() => {
@@ -89,6 +92,7 @@ const ReviewTab = React.memo(function ReviewTab({ review, cwd, initialPrompt, on
<ChatBody <ChatBody
messages={messages} messages={messages}
streaming={streaming} streaming={streaming}
pendingResponse={pendingResponse}
liveStatus={liveStatus} liveStatus={liveStatus}
toolStatuses={toolStatuses || new Map()} toolStatuses={toolStatuses || new Map()}
onSend={sendMessage} onSend={sendMessage}
@@ -100,6 +104,12 @@ const ReviewTab = React.memo(function ReviewTab({ review, cwd, initialPrompt, on
inputPlaceholder={`Reply to ${brand.displayName} review...`} inputPlaceholder={`Reply to ${brand.displayName} review...`}
className="flex-1" className="flex-1"
/> />
{interactivePrompt && (
<InteractivePromptOverlay
prompt={interactivePrompt}
onRespond={respondPrompt}
/>
)}
</div> </div>
); );
}); });
@@ -107,7 +117,7 @@ const ReviewTab = React.memo(function ReviewTab({ review, cwd, initialPrompt, on
// ===== Main Panel ===== // ===== Main Panel =====
export const FloatingReviewPanel = forwardRef<ReviewPanelHandle, ReviewPanelProps>( export const FloatingReviewPanel = forwardRef<ReviewPanelHandle, ReviewPanelProps>(
function FloatingReviewPanel({ reviews, onEnd, onMinimize, initialPrompt, cwd, onSessionCreated, readOnly }, ref) { function FloatingReviewPanel({ reviews, onEnd, onMinimize, cwd, onSessionCreated, readOnly }, ref) {
const [activeTabIndex, setActiveTabIndex] = useState(Math.max(0, reviews.length - 1)); const [activeTabIndex, setActiveTabIndex] = useState(Math.max(0, reviews.length - 1));
// Keep activeTabIndex in bounds // Keep activeTabIndex in bounds
@@ -162,7 +172,7 @@ export const FloatingReviewPanel = forwardRef<ReviewPanelHandle, ReviewPanelProp
const tabActive = i === activeTabIndex; const tabActive = i === activeTabIndex;
return ( return (
<div <div
key={r.reviewId || `tab-${i}`} key={r.reviewId}
className="flex items-center gap-0.5 text-xs whitespace-nowrap" className="flex items-center gap-0.5 text-xs whitespace-nowrap"
style={{ style={{
color: tabActive ? b.color : '#71717a', color: tabActive ? b.color : '#71717a',
@@ -229,11 +239,10 @@ export const FloatingReviewPanel = forwardRef<ReviewPanelHandle, ReviewPanelProp
{/* Tabs — ALL rendered to keep hooks alive, only active one visible via CSS */} {/* Tabs — ALL rendered to keep hooks alive, only active one visible via CSS */}
{reviews.map((r, i) => ( {reviews.map((r, i) => (
<ReviewTab <ReviewTab
key={r.reviewId || `pending-${i}`} key={r.reviewId}
review={r} review={r}
cwd={cwd} cwd={cwd}
initialPrompt={i === reviews.length - 1 ? initialPrompt : undefined} onSessionCreated={!r.childSessionId ? onSessionCreated : undefined}
onSessionCreated={i === reviews.length - 1 ? onSessionCreated : undefined}
isActive={i === activeTabIndex} isActive={i === activeTabIndex}
readOnly={readOnly} readOnly={readOnly}
sendRef={sendRefs} sendRef={sendRefs}
+165
View File
@@ -0,0 +1,165 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { BottomSheet } from './BottomSheet';
import { Send } from 'lucide-react';
export interface InteractivePromptData {
requestId: string;
promptType: string; // 'permission' | 'question' | 'plan' | 'loop-detected'
title: string;
description: string;
toolName?: string;
toolInput?: any;
options?: { value: string; label: string }[];
textInput?: { placeholder?: string };
}
interface Props {
prompt: InteractivePromptData;
onRespond: (requestId: string, selectedOption?: string, textValue?: string) => void;
}
const BADGE_COLORS: Record<string, string> = {
permission: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
question: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
plan: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
'loop-detected': 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
};
function formatToolInput(toolName: string, input: any): string {
if (!input) return '';
if (toolName === 'Bash' && input.command) return input.command;
if (input.file_path) return input.file_path;
if (input.pattern) return input.pattern;
if (input.path) return input.path;
if (input.command) return input.command;
const str = JSON.stringify(input, null, 2);
return str.length > 300 ? str.slice(0, 300) + '...' : str;
}
export function InteractivePromptOverlay({ prompt, onRespond }: Props) {
const [textValue, setTextValue] = useState('');
const [countdown, setCountdown] = useState(120);
const onRespondRef = useRef(onRespond);
onRespondRef.current = onRespond;
const requestIdRef = useRef(prompt.requestId);
requestIdRef.current = prompt.requestId;
// 120s countdown for permission type
useEffect(() => {
if (prompt.promptType !== 'permission') return;
setCountdown(120);
const timer = setInterval(() => {
setCountdown((c) => {
if (c <= 1) {
onRespondRef.current(requestIdRef.current, 'deny');
return 0;
}
return c - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [prompt.requestId, prompt.promptType]);
// Reset text when prompt changes
useEffect(() => {
setTextValue('');
}, [prompt.requestId]);
const handleOptionClick = useCallback((value: string) => {
onRespond(prompt.requestId, value);
}, [onRespond, prompt.requestId]);
const handleTextSubmit = useCallback(() => {
if (!textValue.trim()) return;
onRespond(prompt.requestId, undefined, textValue.trim());
}, [onRespond, prompt.requestId, textValue]);
const badgeClass = BADGE_COLORS[prompt.promptType] || BADGE_COLORS.question;
return (
<BottomSheet
visible
onClose={() => onRespond(prompt.requestId, 'deny')}
zIndex="z-40"
backdropClassName="backdrop-blur-sm"
className="p-5"
showHandle={false}
>
{/* Title bar */}
<div className="flex items-center justify-between mb-4">
<span className={`text-xs font-medium px-2 py-0.5 rounded border ${badgeClass}`}>
{prompt.title || prompt.promptType}
</span>
{prompt.promptType === 'permission' && (
<span className="text-xs text-text-dim font-mono">{countdown}s</span>
)}
</div>
{/* Description */}
{prompt.description && (
<p className="text-sm text-text mb-3 whitespace-pre-wrap">{prompt.description}</p>
)}
{/* Tool info card */}
{prompt.toolName && (
<div className="bg-bg rounded-md p-3 mb-4 max-h-40 overflow-y-auto">
<div className="flex items-center gap-2 mb-1">
<Badge variant="mono">{prompt.toolName}</Badge>
</div>
{prompt.toolInput && (
<pre className="font-mono text-xs text-text whitespace-pre-wrap break-all">
{formatToolInput(prompt.toolName, prompt.toolInput)}
</pre>
)}
</div>
)}
{/* Options — vertical button list */}
{prompt.options && prompt.options.length > 0 && (
<div className="flex flex-col gap-2 mb-3">
{prompt.options.map((opt, i) => (
<Button
key={i}
variant={i === 0 ? 'default' : i === prompt.options!.length - 1 ? 'ghost' : 'secondary'}
onClick={() => handleOptionClick(opt.value)}
className="w-full"
>
{opt.label}
</Button>
))}
</div>
)}
{/* Text input */}
{prompt.textInput && (
<div className="flex gap-2">
<textarea
value={textValue}
onChange={(e) => setTextValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleTextSubmit();
}
}}
placeholder={prompt.textInput.placeholder || 'Type your response...'}
className="flex-1 bg-bg border border-border rounded-md px-3 py-2 text-text text-sm focus:outline-none focus:border-accent resize-none min-h-[60px]"
autoFocus={!prompt.options}
rows={2}
/>
<Button
variant="ghost"
size="icon"
onClick={handleTextSubmit}
disabled={!textValue.trim()}
className="self-end"
>
<Send className="size-4" />
</Button>
</div>
)}
</BottomSheet>
);
}
-68
View File
@@ -1,68 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import type { PermissionRequest } from '../hooks/useChat';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { BottomSheet } from './BottomSheet';
function formatInput(toolName: string, input: any): string {
if (toolName === 'Bash' && input?.command) return input.command;
if (input?.file_path) return input.file_path;
if (input?.pattern) return input.pattern;
if (input?.path) return input.path;
if (input?.command) return input.command;
return JSON.stringify(input, null, 2).slice(0, 300);
}
export function PermissionOverlay({ request, onAllow, onAllowAll, onDeny }: {
request: PermissionRequest; onAllow: () => void; onAllowAll: () => void; onDeny: (message?: string) => void;
}) {
const [countdown, setCountdown] = useState(120);
const onDenyRef = useRef(onDeny);
onDenyRef.current = onDeny;
useEffect(() => {
setCountdown(120);
const timer = setInterval(() => {
setCountdown((c) => {
if (c <= 1) { onDenyRef.current('Permission timed out'); return 0; }
return c - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [request.requestId]);
return (
<BottomSheet
visible
onClose={() => onDeny('Dismissed')}
zIndex="z-40"
backdropClassName="backdrop-blur-sm"
className="p-5"
showHandle={false}
>
<div className="flex items-center justify-between mb-4">
<Badge variant="mono">{request.toolName}</Badge>
<span className="text-xs text-text-dim font-mono">{countdown}s</span>
</div>
{request.decisionReason && (
<p className="text-text-dim text-sm mb-3">{request.decisionReason}</p>
)}
<div className="bg-bg rounded-md p-3 mb-4 max-h-40 overflow-y-auto">
<pre className="font-mono text-xs text-text whitespace-pre-wrap break-all">
{formatInput(request.toolName, request.input)}
</pre>
</div>
<div className="flex flex-col gap-2">
<Button variant="default" onClick={onAllow} className="w-full">
Allow
</Button>
<Button variant="secondary" onClick={onAllowAll} className="w-full">
Allow all for this session
</Button>
<Button variant="ghost" onClick={() => onDeny()} className="w-full">
Deny
</Button>
</div>
</BottomSheet>
);
}
+8
View File
@@ -337,6 +337,14 @@ export function SessionsView({
<div className="flex items-center justify-between mb-0.5"> <div className="flex items-center justify-between mb-0.5">
<span className="text-sm text-text truncate flex-1 mr-3"> <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" /> <span className="inline-block w-2 h-2 rounded-full bg-success mr-1.5 shrink-0" />
{session.adapter && (
<span
className="text-[10px] font-semibold px-1.5 rounded shrink-0 mr-1"
style={{ color: getBrand(session.adapter).color, backgroundColor: `${getBrand(session.adapter).color}20` }}
>
{getBrand(session.adapter).displayName}
</span>
)}
{session.firstPrompt || session.sessionId} {session.firstPrompt || session.sessionId}
</span> </span>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
+1 -1
View File
@@ -21,7 +21,7 @@ export function SettingsView({ onBack }: { onBack: () => void }) {
useEffect(() => { useEffect(() => {
api.adapters().then(setAdapters).catch(() => {}); api.adapters().then(setAdapters).catch(() => {});
fetch('/api/health') fetch('/health')
.then(r => r.json()) .then(r => r.json())
.then((data: { version: string }) => setVersion(data.version)) .then((data: { version: string }) => setVersion(data.version))
.catch(() => {}); .catch(() => {});
+9
View File
@@ -260,6 +260,15 @@ export function ShimmerInput({ onSend, onStop, disabled, streaming, interrupted,
ref={textareaRef} ref={textareaRef}
value={text} value={text}
onChange={(e) => { setText(e.target.value); handleInput(); }} onChange={(e) => { setText(e.target.value); handleInput(); }}
onKeyDown={(e) => {
// Desktop: Enter sends, Shift+Enter newline
// Mobile: Enter always newline (send via button)
if (e.key === 'Enter' && !('ontouchstart' in window) && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
enterKeyHint="enter"
placeholder={isRecording && interimText ? interimText : imageFile ? "Add a message (optional)..." : interrupted ? "What should Claude do instead?" : placeholderProp || "Send a message..."} placeholder={isRecording && interimText ? interimText : imageFile ? "Add a message (optional)..." : interrupted ? "What should Claude do instead?" : placeholderProp || "Send a message..."}
rows={1} rows={1}
className={cn( className={cn(
+51 -3
View File
@@ -1,7 +1,8 @@
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { Loader2, Check, X, Ban, ChevronDown, ChevronUp } from 'lucide-react'; import { Loader2, Check, X, Ban, ChevronDown, ChevronUp } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { parseAskQuestionInput } from '@/lib/ask-question-utils';
import { DiffViewer } from './DiffViewer'; import { DiffViewer } from './DiffViewer';
const STATUS_CONFIG: Record<string, { icon: React.ReactNode }> = { const STATUS_CONFIG: Record<string, { icon: React.ReactNode }> = {
@@ -11,7 +12,19 @@ const STATUS_CONFIG: Record<string, { icon: React.ReactNode }> = {
interrupted: { icon: <Ban className="size-4 text-text-dim" /> }, interrupted: { icon: <Ban className="size-4 text-text-dim" /> },
}; };
function toolSummary(toolName: string, input: any): string { function matchesAskOption(answer: string | null, o: { label: string; value?: string }): boolean {
return !!answer && (o.label === answer || o.value === answer || answer.includes(o.label));
}
function toolSummary(toolName: string, input: any, result?: any): string {
if (toolName === 'AskUserQuestion') {
const { question, options } = parseAskQuestionInput(input);
const rawAnswer = getResultText(result);
const matchedOpt = options?.find(o => matchesAskOption(rawAnswer, o));
const answer = matchedOpt?.label || (rawAnswer && rawAnswer.length > 60 ? null : rawAnswer);
const q = question.length > 40 ? question.slice(0, 40) + '...' : question;
return answer ? `${q}${answer.length > 30 ? answer.slice(0, 30) + '...' : answer}` : q;
}
if (toolName === 'Bash' && input?.command) { if (toolName === 'Bash' && input?.command) {
return input.command.length > 60 ? input.command.slice(0, 60) + '...' : input.command; return input.command.length > 60 ? input.command.slice(0, 60) + '...' : input.command;
} }
@@ -57,9 +70,13 @@ export function ToolCallCard({ toolName, input, status, result }: {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [showFullDiff, setShowFullDiff] = useState(false); const [showFullDiff, setShowFullDiff] = useState(false);
const { icon } = STATUS_CONFIG[status]; const { icon } = STATUS_CONFIG[status];
const summary = toolSummary(toolName, input); const summary = toolSummary(toolName, input, result);
const isDiff = hasDiff(toolName, input); const isDiff = hasDiff(toolName, input);
const isNewFile = hasNewFile(toolName); const isNewFile = hasNewFile(toolName);
const askData = useMemo(
() => toolName === 'AskUserQuestion' ? parseAskQuestionInput(input) : null,
[toolName, input],
);
return ( return (
<> <>
@@ -154,6 +171,37 @@ export function ToolCallCard({ toolName, input, status, result }: {
)} )}
</div> </div>
); );
if (askData) {
const { question, options } = askData;
const answer = resultStr;
const isOptionAnswer = options?.some(o => matchesAskOption(answer, o));
return (
<div className="max-h-48 overflow-y-auto font-sans">
<div className="flex items-start gap-1.5 mb-1">
<span className="text-purple-400">{'\u2753'}</span>
<span className="text-text font-semibold text-[13px]">{question}</span>
</div>
{options && (
<div className="flex flex-col gap-1 mt-2 ml-5">
{options.map((o, i) => {
const selected = matchesAskOption(answer, o) || (!!answer && String(i) === answer);
return (
<div key={i} className={cn('flex items-center gap-1.5', selected ? 'opacity-100' : 'opacity-40')}>
<span className={cn('text-[10px]', selected ? 'text-green-500' : 'text-zinc-500')}>{selected ? '\u25CF' : '\u25CB'}</span>
<span className={cn('text-[12px]', selected ? 'text-green-500' : 'text-zinc-500')}>{o.label}</span>
</div>
);
})}
</div>
)}
{answer && !isOptionAnswer && (
<div className="mt-2 ml-5 border-l-2 border-green-500 pl-2.5">
<span className="text-[12px] text-green-500">{'\u300C'}{answer}{'\u300D'}</span>
</div>
)}
</div>
);
}
return ( return (
<> <>
<div className="text-text-dim mb-1">Input:</div> <div className="text-text-dim mb-1">Input:</div>
+117 -35
View File
@@ -5,6 +5,7 @@ import { WS } from '../lib/ws-types';
import { api } from '../lib/api'; import { api } from '../lib/api';
import { patchAdapterPrefs, loadAdapterPrefs } from '../lib/adapter-prefs'; import { patchAdapterPrefs, loadAdapterPrefs } from '../lib/adapter-prefs';
import { stripMarker } from '@/lib/content-utils'; import { stripMarker } from '@/lib/content-utils';
import { parseAskQuestionInput } from '@/lib/ask-question-utils';
export type ChatMessage = { export type ChatMessage = {
id?: string; id?: string;
@@ -19,6 +20,17 @@ export type PermissionRequest = {
decisionReason?: string; decisionReason?: string;
}; };
export type InteractivePrompt = {
requestId: string;
promptType: string;
title: string;
description: string;
toolName?: string;
toolInput?: any;
options?: { value: string; label: string }[];
textInput?: { placeholder?: string };
};
export type ToolStatus = { export type ToolStatus = {
toolUseId: string; toolUseId: string;
toolName: string; toolName: string;
@@ -91,9 +103,11 @@ export interface ReviewInfo {
childAdapter: string; childAdapter: string;
anchorMessageId?: string; anchorMessageId?: string;
reviewTitle?: string; reviewTitle?: string;
prompt?: string;
permissionMode?: string;
} }
export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?: string, initialPrompt?: string) { export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?: string, initialPrompt?: string, initialPermissionMode?: string) {
// --- State --- // --- State ---
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streamingText, setStreamingText] = useState<string>(''); const [streamingText, setStreamingText] = useState<string>('');
@@ -108,23 +122,23 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
const [pendingResponse, setPendingResponse] = useState(false); const [pendingResponse, setPendingResponse] = useState(false);
const [wsStatus, setWsStatus] = useState<WsStatus>('disconnected'); const [wsStatus, setWsStatus] = useState<WsStatus>('disconnected');
const [sessionId, setSessionId] = useState<string | null>(initialSessionId || null); const [sessionId, setSessionId] = useState<string | null>(initialSessionId || null);
const [permissionRequest, setPermissionRequest] = useState<PermissionRequest | null>(null); const [interactivePrompt, setInteractivePrompt] = useState<InteractivePrompt | null>(null);
// True when the most recent turn was interrupted — used for input placeholder // True when the most recent turn was interrupted — used for input placeholder
const [interrupted, setInterrupted] = useState(false); const [interrupted, setInterrupted] = useState(false);
// Resolve adapter + prefs once, share across state initializers // If adapter is known (from prop or URL), use immediately. Otherwise null → wait for server.
const resolvedAdapter = initialAdapter || localStorage.getItem(STORAGE.ADAPTER) || 'claude'; const knownAdapter = initialAdapter || null;
const initialPrefs = loadAdapterPrefs(resolvedAdapter); const initialPrefs = knownAdapter ? loadAdapterPrefs(knownAdapter) : null;
const [model, setModel] = useState<string>(initialPrefs.model || ''); const [model, setModel] = useState<string>(initialPrefs?.model || '');
const [permissionMode, setPermissionMode] = useState<string>(initialPrefs.permissionMode || 'default'); const [permissionMode, setPermissionMode] = useState<string>(initialPermissionMode || initialPrefs?.permissionMode || 'default');
const [effort, setEffort] = useState<string>(initialPrefs.effort || 'high'); const [effort, setEffort] = useState<string>(initialPrefs?.effort || 'high');
const [sessionStatus, setSessionStatus] = useState<{ const [sessionStatus, setSessionStatus] = useState<{
contextPercent: number | null; contextPercent: number | null;
model: string | null; model: string | null;
cost: number | null; cost: number | null;
} | null>(null); } | null>(null);
const [queuedMessage, setQueuedMessage] = useState<string | null>(null); const [queuedMessage, setQueuedMessage] = useState<string | null>(null);
const [selectedAdapter, setSelectedAdapter] = useState<string>(resolvedAdapter); const [selectedAdapter, setSelectedAdapter] = useState<string | null>(knownAdapter);
const [adapterConfig, setAdapterConfig] = useState<{ const [adapterConfig, setAdapterConfig] = useState<{
models: { value: string; label: string; contextWindow: number }[]; models: { value: string; label: string; contextWindow: number }[];
permissionModes: { value: string; label: string }[]; permissionModes: { value: string; label: string }[];
@@ -146,7 +160,7 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
const wsRef = useRef<WsClient | null>(null); const wsRef = useRef<WsClient | null>(null);
const actualSendRef = useRef<(text: string) => void>(() => {}); const actualSendRef = useRef<(text: string) => void>(() => {});
const clientIdRef = useRef<string | null>(null); const clientIdRef = useRef<string | null>(null);
const selectedAdapterRef = useRef<string>(selectedAdapter); const selectedAdapterRef = useRef<string | null>(selectedAdapter);
selectedAdapterRef.current = selectedAdapter; selectedAdapterRef.current = selectedAdapter;
streamingRef.current = streaming; streamingRef.current = streaming;
@@ -168,7 +182,20 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
switch (msg.type) { switch (msg.type) {
case WS.SESSION_CREATED: case WS.SESSION_CREATED:
setSessionId(msg.sessionId); setSessionId(msg.sessionId);
if (msg.permissionMode) setPermissionMode(msg.permissionMode); if (msg.adapter) {
setSelectedAdapter(msg.adapter);
const prefs = loadAdapterPrefs(msg.adapter);
if (!knownAdapter) {
// First time learning adapter — initialize prefs
setModel(prefs.model || '');
setPermissionMode(msg.permissionMode || prefs.permissionMode || 'default');
setEffort(prefs.effort || 'high');
} else {
if (msg.permissionMode) setPermissionMode(msg.permissionMode);
}
} else {
if (msg.permissionMode) setPermissionMode(msg.permissionMode);
}
break; break;
case WS.CLIENT_ID: case WS.CLIENT_ID:
@@ -221,11 +248,11 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
return next; return next;
}); });
// AskUserQuestion completed — dismiss overlay on all clients // AskUserQuestion completed — dismiss overlay on all clients
// Guard: only dismiss if the current overlay IS an AskUserQuestion // Guard: only dismiss if the current overlay IS a question type
// (a new PermissionRequest may have arrived between answer and TOOL_DONE) // (a new prompt may have arrived between answer and TOOL_DONE)
if (msg.toolName === 'AskUserQuestion') { if (msg.toolName === 'AskUserQuestion') {
setPermissionRequest((prev) => setInteractivePrompt((prev) =>
prev?.toolName === 'AskUserQuestion' ? null : prev prev?.promptType === 'question' ? null : prev
); );
} }
break; break;
@@ -285,7 +312,7 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
setPendingResponse(false); setPendingResponse(false);
setStreamingText(''); setStreamingText('');
setThinkingStatus(null); setThinkingStatus(null);
setPermissionRequest(null); setInteractivePrompt(null);
// Mark remaining running tools: if user interrupted → 'interrupted', otherwise → 'success' // Mark remaining running tools: if user interrupted → 'interrupted', otherwise → 'success'
setToolStatuses(markToolsAs(interruptedRef.current ? 'interrupted' : 'success')); setToolStatuses(markToolsAs(interruptedRef.current ? 'interrupted' : 'success'));
streamingRef.current = false; streamingRef.current = false;
@@ -293,8 +320,11 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
break; break;
case WS.REVIEW_STARTED: case WS.REVIEW_STARTED:
console.log(`[WS.REVIEW_STARTED] reviewId=${msg.reviewId?.slice(0,8)} childSid=${msg.childSessionId?.slice(0,8)} adapter=${msg.childAdapter}`);
setActiveReviews(prev => { setActiveReviews(prev => {
if (prev.some(r => r.reviewId === msg.reviewId)) return prev; const isDup = prev.some(r => r.reviewId === msg.reviewId);
console.log(`[WS.REVIEW_STARTED] dedup check: ${isDup ? 'DUPLICATE, skipping' : 'NEW, adding'}`);
if (isDup) return prev;
return [...prev, { return [...prev, {
reviewId: msg.reviewId, reviewId: msg.reviewId,
childSessionId: msg.childSessionId, childSessionId: msg.childSessionId,
@@ -311,18 +341,59 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
setActiveReviews(prev => prev.filter(r => r.reviewId !== msg.reviewId)); setActiveReviews(prev => prev.filter(r => r.reviewId !== msg.reviewId));
break; break;
// Hook: permission request // Unified interactive prompt (from Gemini/Codex pane monitors or session-manager conversion)
case WS.PERMISSION_REQUEST: case WS.INTERACTIVE_PROMPT:
setPermissionRequest({ setInteractivePrompt({
requestId: msg.requestId, requestId: msg.requestId,
promptType: msg.promptType,
title: msg.title || '',
description: msg.description || '',
toolName: msg.toolName, toolName: msg.toolName,
input: msg.input, toolInput: msg.toolInput,
options: msg.options,
textInput: msg.textInput,
}); });
break; break;
// Another client answered the permission request — dismiss overlay // Another client answered the prompt — dismiss overlay
case WS.PROMPT_DISMISSED:
setInteractivePrompt(prev =>
prev?.requestId === msg.requestId ? null : prev
);
break;
// Legacy hook: permission request — convert to InteractivePrompt
case WS.PERMISSION_REQUEST: {
const isAsk = msg.toolName === 'AskUserQuestion';
if (isAsk) {
const parsed = parseAskQuestionInput(msg.input);
setInteractivePrompt({
requestId: msg.requestId,
promptType: 'question',
title: parsed.header || 'Question',
description: parsed.question,
toolName: msg.toolName,
toolInput: msg.input,
options: parsed.options,
textInput: parsed.options ? undefined : { placeholder: 'Enter your response...' },
});
} else {
setInteractivePrompt({
requestId: msg.requestId,
promptType: 'permission',
title: 'Permission Request',
description: `${msg.toolName} wants to execute`,
toolName: msg.toolName,
toolInput: msg.input,
options: [{ value: 'allow', label: 'Allow' }, { value: 'allow_session', label: 'Allow All' }, { value: 'deny', label: 'Deny' }],
});
}
break;
}
// Legacy: another client answered the permission request — dismiss overlay
case WS.PERMISSION_DISMISSED: case WS.PERMISSION_DISMISSED:
setPermissionRequest((prev) => setInteractivePrompt(prev =>
prev?.requestId === msg.requestId ? null : prev prev?.requestId === msg.requestId ? null : prev
); );
break; break;
@@ -358,9 +429,9 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
case WS.MODE_UPDATED: case WS.MODE_UPDATED:
setPermissionMode(msg.mode); setPermissionMode(msg.mode);
patchAdapterPrefs(selectedAdapterRef.current, { permissionMode: msg.mode }); if (selectedAdapterRef.current) patchAdapterPrefs(selectedAdapterRef.current, { permissionMode: msg.mode });
if (msg.mode === 'bypassPermissions' || msg.mode === 'plan') { if (msg.mode === 'bypassPermissions' || msg.mode === 'plan') {
setPermissionRequest(null); setInteractivePrompt(null);
} }
break; break;
@@ -403,7 +474,7 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
const client = new WsClient(token, handleWsMessage, setWsStatus); const client = new WsClient(token, handleWsMessage, setWsStatus);
wsRef.current = client; wsRef.current = client;
if (initialSessionId) { if (initialSessionId) {
client.setActiveSession(initialSessionId, selectedAdapter); client.setActiveSession(initialSessionId, selectedAdapter ?? undefined);
} }
client.connect(); client.connect();
return () => { return () => {
@@ -425,17 +496,18 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
// Keep WsClient's activeAdapter in sync so reconnect sends correct adapter hint // Keep WsClient's activeAdapter in sync so reconnect sends correct adapter hint
useEffect(() => { useEffect(() => {
if (wsRef.current && sessionId) { if (wsRef.current && sessionId) {
wsRef.current.setActiveSession(sessionId, selectedAdapter); wsRef.current.setActiveSession(sessionId, selectedAdapter ?? undefined);
} }
}, [sessionId, selectedAdapter]); }, [sessionId, selectedAdapter]);
// --- Fetch adapter config (models, permission modes) --- // --- Fetch adapter config (models, permission modes) ---
useEffect(() => { useEffect(() => {
api.adapterConfig(selectedAdapter).then(setAdapterConfig).catch(console.error); if (selectedAdapter) api.adapterConfig(selectedAdapter).then(setAdapterConfig).catch(console.error);
}, [selectedAdapter]); }, [selectedAdapter]);
// --- Send Message --- // --- Send Message ---
const actualSend = useCallback((text: string) => { const actualSend = useCallback((text: string) => {
if (!selectedAdapter) return;
if (!text.trim() || !wsRef.current) return; if (!text.trim() || !wsRef.current) return;
streamingRef.current = true; streamingRef.current = true;
setMessages(prev => [ setMessages(prev => [
@@ -485,7 +557,7 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
behavior, behavior,
message, message,
}); });
setPermissionRequest(null); setInteractivePrompt(null);
}, []); }, []);
const respondAsk = useCallback((requestId: string, response: string) => { const respondAsk = useCallback((requestId: string, response: string) => {
@@ -494,7 +566,17 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
requestId, requestId,
response, response,
}); });
setPermissionRequest(null); setInteractivePrompt(null);
}, []);
const respondPrompt = useCallback((requestId: string, selectedOption?: string, textValue?: string) => {
wsRef.current?.send({
type: WS.PROMPT_RESPONSE,
requestId,
selectedOption,
textValue,
});
setInteractivePrompt(null);
}, []); }, []);
// --- Plan Response --- // --- Plan Response ---
@@ -519,7 +601,7 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
setPendingResponse(false); setPendingResponse(false);
setStreamingText(''); setStreamingText('');
setThinkingStatus(null); setThinkingStatus(null);
setPermissionRequest(null); setInteractivePrompt(null);
setInterrupted(true); // Immediately mark as interrupted for tool card fallback setInterrupted(true); // Immediately mark as interrupted for tool card fallback
setToolStatuses(markToolsAs('interrupted')); setToolStatuses(markToolsAs('interrupted'));
}, [sessionId]); }, [sessionId]);
@@ -527,7 +609,7 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
// --- Settings --- // --- Settings ---
const updateModel = useCallback((m: string) => { const updateModel = useCallback((m: string) => {
setModel(m); setModel(m);
patchAdapterPrefs(selectedAdapter, { model: m }); if (selectedAdapter) patchAdapterPrefs(selectedAdapter, { model: m });
if (sessionId) { if (sessionId) {
wsRef.current?.send({ type: WS.SET_MODEL, sessionId, model: m }); wsRef.current?.send({ type: WS.SET_MODEL, sessionId, model: m });
} }
@@ -544,7 +626,7 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
const updatePermissionMode = useCallback((m: string) => { const updatePermissionMode = useCallback((m: string) => {
setPermissionMode(m); setPermissionMode(m);
patchAdapterPrefs(selectedAdapter, { permissionMode: m }); if (selectedAdapter) patchAdapterPrefs(selectedAdapter, { permissionMode: m });
if (sessionId) { if (sessionId) {
wsRef.current?.send({ type: WS.SET_PERMISSION_MODE, sessionId, mode: m }); wsRef.current?.send({ type: WS.SET_PERMISSION_MODE, sessionId, mode: m });
} }
@@ -559,11 +641,11 @@ export function useChat(initialSessionId?: string, cwd?: string, initialAdapter?
return { return {
messages, toolStatuses, streaming, pendingResponse, wsStatus, sessionId, liveStatus, messages, toolStatuses, streaming, pendingResponse, wsStatus, sessionId, liveStatus,
interrupted, sessionStatus, adapterConfig, selectedAdapter, interrupted, sessionStatus, adapterConfig, selectedAdapter,
permissionRequest, model, permissionMode, effort, interactivePrompt, model, permissionMode, effort,
queuedMessage, clearQueuedMessage, queuedMessage, clearQueuedMessage,
activeReviews, setActiveReviews, activeReviewPanel, setActiveReviewPanel, activeReviews, setActiveReviews, activeReviewPanel, setActiveReviewPanel,
historyReview, setHistoryReview, historyReview, setHistoryReview,
sendMessage, respondPermission, respondAsk, respondPlan, abort, sendMessage, respondPermission, respondAsk, respondPrompt, respondPlan, abort,
updateModel, updatePermissionMode, updateAdapter, updateModel, updatePermissionMode, updateAdapter,
}; };
} }
+2 -2
View File
@@ -124,10 +124,10 @@ export const api = {
pushPending: () => pushPending: () =>
request<Record<string, number>>('/api/push/pending'), request<Record<string, number>>('/api/push/pending'),
registerReview: (parentCliSessionId: string, childSessionId: string, targetAdapter: string, anchorMessageId: string, prompt: string, title: string) => registerReview: (params: { reviewId: string; parentCliSessionId: string; childSessionId: string; targetAdapter: string; anchorMessageId: string; prompt: string; title: string }) =>
request<{ reviewId: string }>('/api/reviews/register', { request<{ reviewId: string }>('/api/reviews/register', {
method: 'POST', method: 'POST',
body: JSON.stringify({ parentCliSessionId, childSessionId, targetAdapter, anchorMessageId, prompt, title }), body: JSON.stringify(params),
}), }),
endReview: (reviewId: string, endAnchorMessageId?: string) => endReview: (reviewId: string, endAnchorMessageId?: string) =>
+18
View File
@@ -0,0 +1,18 @@
/** Parse Claude's AskUserQuestion nested input structure into a flat format */
export function parseAskQuestionInput(input: any): {
question: string;
header?: string;
options?: { label: string; value: string }[];
} {
const q = input?.questions?.[0] || input || {};
const question = q.question || q.text || input?.question || input?.text || '';
const header = q.header;
const rawOpts = q.options || input?.options;
const options = Array.isArray(rawOpts) && rawOpts.length > 0
? rawOpts.map((o: any, i: number) => ({
value: typeof o === 'string' ? String(i) : (o.value ?? String(i)),
label: typeof o === 'string' ? o : (o.label || o.text || `Option ${i + 1}`),
}))
: undefined;
return { question, header, options };
}
+4
View File
@@ -30,6 +30,10 @@ export const WS = {
CLIENT_ID: 'client-id', CLIENT_ID: 'client-id',
PENDING_NOTIFICATIONS: 'pending-notifications', PENDING_NOTIFICATIONS: 'pending-notifications',
ERROR: 'error', ERROR: 'error',
// Interactive Prompts (unified permission/question/plan overlay)
INTERACTIVE_PROMPT: 'interactive-prompt',
PROMPT_RESPONSE: 'prompt-response',
PROMPT_DISMISSED: 'prompt-dismissed',
// Cross-AI Review // Cross-AI Review
REVIEW_STARTED: 'review-started', REVIEW_STARTED: 'review-started',
REVIEW_ENDED: 'review-ended', REVIEW_ENDED: 'review-ended',
+2 -1
View File
@@ -43,6 +43,7 @@ export class WsClient {
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
if (msg.type === WS.SESSION_CREATED) { if (msg.type === WS.SESSION_CREATED) {
this.activeSessionId = msg.sessionId; this.activeSessionId = msg.sessionId;
if (msg.adapter) this.activeAdapter = msg.adapter;
} }
this.onMessage(msg); this.onMessage(msg);
} catch {} } catch {}
@@ -68,7 +69,7 @@ export class WsClient {
} }
} }
setActiveSession(sessionId: string | null, adapter?: string) { setActiveSession(sessionId: string | null, adapter?: string | null) {
this.activeSessionId = sessionId; this.activeSessionId = sessionId;
this.activeAdapter = adapter || null; this.activeAdapter = adapter || null;
} }
+1 -1
View File
@@ -62,7 +62,7 @@ async function main() {
console.log('Starting server...'); console.log('Starting server...');
const serverProcess = spawn('node', ['server/index.js'], { const serverProcess = spawn('node', ['server/index.js'], {
cwd: PROJECT_DIR, cwd: PROJECT_DIR,
env: { ...process.env, PORT: String(PORT), CLAUDE_UI_PASSWORD: PASSWORD }, env: { ...process.env, PORT: String(PORT), CLAWTAP_PASSWORD: PASSWORD },
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
}); });
+1 -1
View File
@@ -28,7 +28,7 @@
- Server: https://localhost:3456 (HTTPS mode) - Server: https://localhost:3456 (HTTPS mode)
- Browser: agent-browser with iPhone 14 viewport - Browser: agent-browser with iPhone 14 viewport
- Password: value of CLAUDE_UI_PASSWORD env var - Password: value of CLAWTAP_PASSWORD env var
--- ---
+2 -2
View File
@@ -4,7 +4,7 @@
# #
# GLOBAL CONFIG: # GLOBAL CONFIG:
# Server URL: http://localhost:${PORT:-3456} # Server URL: http://localhost:${PORT:-3456}
# Password: value of CLAUDE_UI_PASSWORD env var # Password: value of CLAWTAP_PASSWORD env var
# Browser: agent-browser with mobile viewport (e.g. "iPhone 14") # Browser: agent-browser with mobile viewport (e.g. "iPhone 14")
# #
# STEP DEFINITIONS: # STEP DEFINITIONS:
@@ -1205,7 +1205,7 @@ Feature: Desktop ↔ Mobile — Session Discovery
Scenario: Second terminal detects running server (A6) Scenario: Second terminal detects running server (A6)
Given the server is already running (started by first `codetap`) Given the server is already running (started by first `codetap`)
When I run `codetap` in a new terminal WITHOUT CLAUDE_UI_PASSWORD set When I run `codetap` in a new terminal WITHOUT CLAWTAP_PASSWORD set
Then the CLI should detect the running server via health check Then the CLI should detect the running server via health check
And it should create a new tmux window with Claude Code And it should create a new tmux window with Claude Code
And no password prompt should appear And no password prompt should appear