feat: ClawTap v0.1.0 — initial release

Multi-adapter mobile UI for AI coding assistants.
Supports Claude Code, Codex CLI, and Gemini CLI through one interface.

Features:
- Real-time bidirectional sync via tmux + WebSocket
- Cross-AI review (send one AI's output to another for review)
- Multi-review tabs with minimize/expand
- Push notifications (PWA) with smart session-aware filtering
- Three-channel event system (hooks, file watcher, pane monitor)
- Voice input, image paste, draft persistence
- Terminal-native design (JetBrains Mono, dark theme, pixel art claw)
- CLI with --adapter flag on every command
- Zero-overhead fire-and-forget hooks
This commit is contained in:
kuannnn
2026-03-18 10:24:45 +08:00
commit 42861ea7fa
151 changed files with 33897 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
import { tmuxManager } from '../shared/tmux-manager.js';
/** Thinking indicator detected from pane content */
export interface ThinkingInfo {
text: string;
detail: string | null;
}
/**
* Simplified PaneMonitor — only detects:
* 1. Thinking indicator (spinner + verb)
* 2. Streaming response text (text after ⏺ marker)
*
* Permission mode detection removed — handled by syncPermissionMode() in
* tmux-adapter via hook body's permission_mode field (including statusline).
* Permission, question, and idle detection handled by HTTP hooks
* (PreToolUse, PostToolUse, PermissionRequest, Stop).
*/
export class PaneMonitor {
windowId: string;
lastContent: string;
interval: ReturnType<typeof setInterval> | null;
private _lastResponseText: string;
private _onThinking: ((thinking: ThinkingInfo) => void) | null;
private _onStreamingText: ((text: string) => void) | null;
constructor(windowId: string) {
this.windowId = windowId;
this.lastContent = '';
this.interval = null;
this._lastResponseText = '';
this._onThinking = null;
this._onStreamingText = null;
}
start(): void {
this.interval = setInterval(async () => {
try {
const content = await tmuxManager.capturePane(this.windowId);
if (content === this.lastContent) return;
this.lastContent = content;
// 1. Check thinking (spinner in status area)
const thinking = detectThinking(content);
if (thinking && this._onThinking) {
this._onThinking(thinking);
}
// 2. Extract streaming response text
if (this._onStreamingText && !thinking) {
const text = extractResponseText(content);
if (text && text !== this._lastResponseText) {
this._lastResponseText = text;
this._onStreamingText(text);
}
}
} catch (err) {
// Silently ignore — window may have been killed
}
}, 500);
}
stop(): void {
if (this.interval) { clearInterval(this.interval); this.interval = null; }
}
onThinking(cb: (thinking: ThinkingInfo) => void): void { this._onThinking = cb; }
onStreamingText(cb: (text: string) => void): void { this._onStreamingText = cb; }
}
// --- Detection functions ---
export function detectThinking(content: string): ThinkingInfo | null {
const lines = content.split('\n');
const tail = lines.slice(-15);
for (const line of tail) {
// Match: spinner char + word ending in "…", with optional (detail)
// But NOT "Worked for" (completion summary)
if (/Worked for|completed|Done/i.test(line)) continue;
const match = line.match(/^\s*([✶✻·✽✳✢])\s+(\S+…)\s*(?:\((.+?)\))?\s*$/);
if (match) {
return { text: match[2]!, detail: match[3] || null };
}
}
return null;
}
export function extractResponseText(content: string): string {
const lines = content.split('\n');
// Find the LAST user prompt ( with text) — only look for responses AFTER it
let lastUserPrompt = -1;
for (let i = lines.length - 1; i >= 0; i--) {
if (/^\s*\s+\S/.test(lines[i]!)) {
lastUserPrompt = i;
break;
}
}
// Find the response ⏺ AFTER the last user prompt
let lastResponseStart = -1;
const searchStart = lastUserPrompt >= 0 ? lastUserPrompt : 0;
for (let i = lines.length - 1; i >= searchStart; i--) {
const line = lines[i]!;
// Skip tool calls: ⏺ CapitalWord( or ⏺ Read/Write N file
if (/^\s*⏺\s+[A-Z]\w*[\(]/.test(line)) continue;
if (/^\s*⏺\s+[A-Z]\w+\s+\d+\s+file/.test(line)) continue;
if (/^\s*⏺\s+/.test(line)) {
lastResponseStart = i;
break;
}
}
if (lastResponseStart === -1) return '';
const responseLines = [lines[lastResponseStart]!.replace(/^\s*⏺\s?/, '')];
for (let i = lastResponseStart + 1; i < lines.length; i++) {
const line = lines[i]!;
if (/^[─━═]{5,}/.test(line.trim()) ||
/^\s*/.test(line) ||
/^\s*⎿/.test(line) ||
/^\s*⏺/.test(line) ||
/^\s*[✶✻·✽✳✢]\s+/.test(line)) {
break;
}
responseLines.push(line);
}
return responseLines.join('\n').trim();
}