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:
@@ -0,0 +1,175 @@
|
||||
import type { ContentBlock } from '../claude/message-utils.js';
|
||||
import type { GeminiSessionMessage } from '../../stores/json-watcher.js';
|
||||
import {
|
||||
extractUserText,
|
||||
extractGeminiText,
|
||||
toolCallsToContentBlocks,
|
||||
} from './message-utils.js';
|
||||
import type { GeminiToolCall } from './message-utils.js';
|
||||
|
||||
/** Parsed message ready for the frontend */
|
||||
export interface ParsedMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'plan';
|
||||
/** Always ContentBlock[] — never a plain string, for consistency with Claude/Codex */
|
||||
content: ContentBlock[];
|
||||
adapter?: string;
|
||||
senderClientId?: string | null;
|
||||
}
|
||||
|
||||
/** Result returned by parse() */
|
||||
export interface ParseResult {
|
||||
messages: ParsedMessage[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/** Model/token status extracted from an info message */
|
||||
export interface StatusInfo {
|
||||
model: string | null;
|
||||
tokens: Record<string, number> | null;
|
||||
}
|
||||
|
||||
/** A single thought entry from a gemini message */
|
||||
export interface ThoughtEntry {
|
||||
subject: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export class GeminiTranscriptParser {
|
||||
/** Monotonically increasing message index — NOT reset between parse() calls */
|
||||
private _msgIndex: number = 0;
|
||||
|
||||
/**
|
||||
* Parse an incremental batch of GeminiSessionMessages into frontend-ready ParsedMessages.
|
||||
*
|
||||
* NOTE: _msgIndex is intentionally NOT reset here. parse() is called incrementally via
|
||||
* JsonWatcher.onNewMessages(). Resetting would restart IDs at msg-0, causing React key
|
||||
* collisions across batches.
|
||||
*/
|
||||
parse(messages: GeminiSessionMessage[]): ParseResult {
|
||||
const result: ParsedMessage[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
switch (msg.type) {
|
||||
case 'user': {
|
||||
const parsed = this._parseUserMessage(msg);
|
||||
if (parsed) result.push(parsed);
|
||||
break;
|
||||
}
|
||||
case 'gemini': {
|
||||
const parsed = this._parseGeminiMessage(msg);
|
||||
if (parsed) result.push(parsed);
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
// Collect errors to emit as session-error events; don't add as chat messages
|
||||
const errText = extractUserText(msg.content) || String(msg.content ?? 'Unknown error');
|
||||
errors.push(errText);
|
||||
break;
|
||||
}
|
||||
case 'info':
|
||||
// Info messages carry metadata (model, tokens) — skip as chat messages
|
||||
break;
|
||||
default:
|
||||
// Unknown type — skip silently
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { messages: result, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract thought entries from a gemini message's thoughts array.
|
||||
* Returns an empty array if none are present.
|
||||
*/
|
||||
static extractThoughts(msg: GeminiSessionMessage): ThoughtEntry[] {
|
||||
if (!Array.isArray(msg.thoughts) || msg.thoughts.length === 0) return [];
|
||||
return msg.thoughts
|
||||
.filter((t): t is Record<string, unknown> => t != null && typeof t === 'object')
|
||||
.map((t) => ({
|
||||
subject: typeof t['subject'] === 'string' ? t['subject'] : '',
|
||||
description: typeof t['description'] === 'string' ? t['description'] : '',
|
||||
timestamp: typeof t['timestamp'] === 'string' ? t['timestamp'] : (msg.timestamp ?? ''),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract model/token status from an info-type message.
|
||||
* Returns null if the message carries no relevant status data.
|
||||
*/
|
||||
static extractStatus(msg: GeminiSessionMessage): StatusInfo | null {
|
||||
const model = msg.model ?? null;
|
||||
let tokens: Record<string, number> | null = null;
|
||||
|
||||
if (msg.tokens && typeof msg.tokens === 'object') {
|
||||
const raw = msg.tokens as Record<string, unknown>;
|
||||
const parsed: Record<string, number> = {};
|
||||
for (const [key, val] of Object.entries(raw)) {
|
||||
if (typeof val === 'number') parsed[key] = val;
|
||||
}
|
||||
if (Object.keys(parsed).length > 0) tokens = parsed;
|
||||
}
|
||||
|
||||
if (model === null && tokens === null) return null;
|
||||
return { model, tokens };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private _parseUserMessage(msg: GeminiSessionMessage): ParsedMessage | null {
|
||||
const text = extractUserText(msg.content);
|
||||
if (!text.trim()) return null;
|
||||
|
||||
const content: ContentBlock[] = [{ type: 'text', text }];
|
||||
return {
|
||||
id: `msg-${this._msgIndex++}`,
|
||||
role: 'user',
|
||||
content,
|
||||
adapter: 'gemini',
|
||||
};
|
||||
}
|
||||
|
||||
private _parseGeminiMessage(msg: GeminiSessionMessage): ParsedMessage | null {
|
||||
const text = extractGeminiText(msg.content);
|
||||
const toolBlocks = this._extractToolBlocks(msg);
|
||||
|
||||
// Skip completely empty messages
|
||||
if (!text.trim() && toolBlocks.length === 0) return null;
|
||||
|
||||
const content: ContentBlock[] = [];
|
||||
|
||||
// Text block first (if present)
|
||||
if (text.trim()) {
|
||||
content.push({ type: 'text', text });
|
||||
}
|
||||
|
||||
// Tool call blocks after the text
|
||||
content.push(...toolBlocks);
|
||||
|
||||
return {
|
||||
id: `msg-${this._msgIndex++}`,
|
||||
role: 'assistant',
|
||||
content,
|
||||
adapter: 'gemini',
|
||||
};
|
||||
}
|
||||
|
||||
private _extractToolBlocks(msg: GeminiSessionMessage): ContentBlock[] {
|
||||
if (!Array.isArray(msg.toolCalls) || msg.toolCalls.length === 0) return [];
|
||||
|
||||
const toolCalls = (msg.toolCalls as unknown[]).filter(
|
||||
(tc): tc is GeminiToolCall =>
|
||||
tc != null &&
|
||||
typeof tc === 'object' &&
|
||||
typeof (tc as Record<string, unknown>)['id'] === 'string' &&
|
||||
typeof (tc as Record<string, unknown>)['name'] === 'string',
|
||||
);
|
||||
|
||||
return toolCallsToContentBlocks(toolCalls);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user