42861ea7fa
Multi-adapter mobile UI for AI coding assistants. Supports Claude Code, Codex CLI, and Gemini CLI through one interface. Features: - Real-time bidirectional sync via tmux + WebSocket - Cross-AI review (send one AI's output to another for review) - Multi-review tabs with minimize/expand - Push notifications (PWA) with smart session-aware filtering - Three-channel event system (hooks, file watcher, pane monitor) - Voice input, image paste, draft persistence - Terminal-native design (JetBrains Mono, dark theme, pixel art claw) - CLI with --adapter flag on every command - Zero-overhead fire-and-forget hooks
108 lines
3.5 KiB
TypeScript
108 lines
3.5 KiB
TypeScript
import { execFile } from 'child_process';
|
|
import { promisify } from 'util';
|
|
import { unlinkSync } from 'fs';
|
|
import { writeFile } from 'fs/promises';
|
|
import { randomUUID } from 'crypto';
|
|
|
|
const exec = promisify(execFile);
|
|
const TMUX = 'tmux';
|
|
const SESSION_NAME = 'clawtap';
|
|
|
|
/** A tmux window entry from listWindows */
|
|
export interface TmuxWindow {
|
|
id: string;
|
|
name: string;
|
|
command: string;
|
|
cwd: string;
|
|
}
|
|
|
|
export class TmuxManager {
|
|
async ensureSession(): Promise<void> {
|
|
try {
|
|
await exec(TMUX, ['has-session', '-t', SESSION_NAME]);
|
|
} catch {
|
|
await exec(TMUX, ['new-session', '-d', '-s', SESSION_NAME, '-n', 'main']);
|
|
}
|
|
}
|
|
|
|
async createWindow(name: string, cwd: string, command: string): Promise<string> {
|
|
await this.ensureSession();
|
|
await exec(TMUX, [
|
|
'new-window', '-t', SESSION_NAME, '-n', name, '-c', cwd, command
|
|
]);
|
|
const { stdout } = await exec(TMUX, [
|
|
'list-windows', '-t', SESSION_NAME, '-F', '#{window_name}\t#{window_id}'
|
|
]);
|
|
const line = stdout.trim().split('\n').find(l => l.startsWith(name + '\t'));
|
|
return line ? line.split('\t')[1]! : name;
|
|
}
|
|
|
|
async sendKeys(windowId: string, text: string, enter: boolean = true): Promise<void> {
|
|
const target = `${SESSION_NAME}:${windowId}`;
|
|
await exec(TMUX, ['send-keys', '-t', target, '-l', text]);
|
|
if (enter) {
|
|
await exec(TMUX, ['send-keys', '-t', target, 'Enter']);
|
|
}
|
|
}
|
|
|
|
async sendControl(windowId: string, key: string): Promise<void> {
|
|
const target = `${SESSION_NAME}:${windowId}`;
|
|
await exec(TMUX, ['send-keys', '-t', target, key]);
|
|
}
|
|
|
|
async pasteBuffer(windowId: string, content: string, sendEnter: boolean = true): Promise<void> {
|
|
const id = randomUUID();
|
|
const tmpFile = `/tmp/clawtap-buf-${id}.txt`;
|
|
const bufName = `ct-${id.slice(0, 8)}`;
|
|
await writeFile(tmpFile, content);
|
|
const target = `${SESSION_NAME}:${windowId}`;
|
|
try {
|
|
await exec(TMUX, ['load-buffer', '-b', bufName, tmpFile]);
|
|
await exec(TMUX, ['paste-buffer', '-b', bufName, '-t', target]);
|
|
if (sendEnter) {
|
|
await exec(TMUX, ['send-keys', '-t', target, 'Enter']);
|
|
}
|
|
} finally {
|
|
exec(TMUX, ['delete-buffer', '-b', bufName]).catch(() => {});
|
|
try { unlinkSync(tmpFile); } catch {}
|
|
}
|
|
}
|
|
|
|
async capturePane(windowId: string, lines: number = 200): Promise<string> {
|
|
const target = `${SESSION_NAME}:${windowId}`;
|
|
const { stdout } = await exec(TMUX, [
|
|
'capture-pane', '-t', target, '-p', '-S', `-${lines}`
|
|
]);
|
|
return stdout;
|
|
}
|
|
|
|
async killWindow(windowId: string): Promise<void> {
|
|
const target = `${SESSION_NAME}:${windowId}`;
|
|
try { await exec(TMUX, ['kill-window', '-t', target]); } catch {}
|
|
}
|
|
|
|
async renameWindow(windowId: string, newName: string): Promise<void> {
|
|
const target = `${SESSION_NAME}:${windowId}`;
|
|
await exec(TMUX, ['rename-window', '-t', target, newName]);
|
|
}
|
|
|
|
async killSession(): Promise<void> {
|
|
try { await exec(TMUX, ['kill-session', '-t', SESSION_NAME]); } catch {}
|
|
}
|
|
|
|
async listWindows(): Promise<TmuxWindow[]> {
|
|
try {
|
|
const { stdout } = await exec(TMUX, [
|
|
'list-windows', '-t', SESSION_NAME, '-F',
|
|
'#{window_id}\t#{window_name}\t#{pane_current_command}\t#{pane_current_path}'
|
|
]);
|
|
return stdout.trim().split('\n').filter(Boolean).map(line => {
|
|
const [id, name, command, cwd] = line.split('\t');
|
|
return { id: id!, name: name!, command: command!, cwd: cwd! };
|
|
});
|
|
} catch { return []; }
|
|
}
|
|
}
|
|
|
|
export const tmuxManager = new TmuxManager();
|