Files
kuannnn 42861ea7fa 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
2026-03-26 10:40:26 +08:00

131 lines
4.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}