import type { NativeApi, ProviderEvent, ProviderKind, ProviderSession } from "@t3tools/contracts"; import type { ChatMessage, SessionPhase, TurnDiffFileChange, TurnDiffSummary } from "./types"; import { createWsNativeApi } from "./wsNativeApi"; export const PROVIDER_OPTIONS: Array<{ value: ProviderKind; label: string; available: boolean; }> = [ { value: "codex", label: "Codex", available: true }, { value: "claudeCode", label: "Claude Code (soon)", available: false }, ]; let cachedApi: NativeApi | undefined; export function readNativeApi(): NativeApi | undefined { if (typeof window === "undefined") return undefined; if (cachedApi) return cachedApi; // Prefer Electron preload bridge if available if (window.nativeApi) { cachedApi = window.nativeApi; return cachedApi; } // Fall back to WebSocket transport cachedApi = createWsNativeApi(); return cachedApi; } export function asObject(value: unknown): Record | undefined { if (!value || typeof value !== "object") return undefined; return value as Record; } function asArray(value: unknown): unknown[] | undefined { return Array.isArray(value) ? value : undefined; } export function asString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } function asNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } export function formatTimestamp(isoDate: string): string { return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit", second: "2-digit", }).format(new Date(isoDate)); } export function formatDuration(durationMs: number): string { if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms"; if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`; if (durationMs < 10_000) return `${(durationMs / 1_000).toFixed(1)}s`; if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`; const minutes = Math.floor(durationMs / 60_000); const seconds = Math.round((durationMs % 60_000) / 1_000); if (seconds === 0) return `${minutes}m`; if (seconds === 60) return `${minutes + 1}m`; return `${minutes}m ${seconds}s`; } export function formatElapsed(startIso: string, endIso: string | undefined): string | null { if (!endIso) return null; const startedAt = Date.parse(startIso); const endedAt = Date.parse(endIso); if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) { return null; } return formatDuration(endedAt - startedAt); } export interface WorkLogEntry { id: string; createdAt: string; label: string; detail?: string; tone: "thinking" | "tool" | "info" | "error"; } export interface PendingApproval { requestId: string; requestKind: "command" | "file-change"; createdAt: string; detail?: string; } export type TimelineEntry = | { id: string; kind: "message"; createdAt: string; message: ChatMessage; } | { id: string; kind: "work"; createdAt: string; entry: WorkLogEntry; }; export type { TurnDiffFileChange, TurnDiffSummary } from "./types"; function normalizeDetail(value: string | undefined): string | undefined { if (!value) return undefined; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } function approvalDetail(event: ProviderEvent): string | undefined { const payload = asObject(event.payload); const command = asString(payload?.command); if (command) return command; return asString(payload?.reason); } export function derivePendingApprovals(events: ProviderEvent[]): PendingApproval[] { const pending = new Map(); const ordered = [...events].toReversed(); for (const event of ordered) { if ( event.method === "session/closed" || event.method === "session/exited" || event.method === "turn/completed" ) { pending.clear(); continue; } const requestId = event.requestId ?? asString(asObject(event.payload)?.requestId); if (!requestId) continue; if ( event.kind === "request" && (event.requestKind === "command" || event.requestKind === "file-change") ) { const detail = approvalDetail(event); pending.set(requestId, { requestId, requestKind: event.requestKind, createdAt: event.createdAt, ...(detail ? { detail } : {}), }); continue; } if (event.method === "item/requestApproval/decision") { pending.delete(requestId); } } return Array.from(pending.values()); } function normalizeItemType(raw: string | undefined): string { if (!raw) return "item"; return raw .replace(/([a-z0-9])([A-Z])/g, "$1 $2") .replace(/[._/-]/g, " ") .replace(/\s+/g, " ") .trim() .toLowerCase(); } function shouldDropItemType(type: string): boolean { if (type.includes("preamble") || type.includes("reasoning") || type.includes("thought")) { return true; } return type === "work" || type.startsWith("work "); } function shouldShowItemLifecycle(type: string): boolean { return type.includes("tool") || type.includes("command") || type.includes("file change"); } function shouldDropMethod(method: string): boolean { return /(^|\/)(preamble|work|reasoning|thought)(\/|$)/i.test(method); } function itemTypeMeta(type: string): { label: string; tone: WorkLogEntry["tone"]; } { if (type.includes("command")) { return { label: "Command run", tone: "tool" }; } if (type.includes("file change")) { return { label: "File change", tone: "tool" }; } if (type.includes("tool")) { return { label: "Tool call", tone: "tool" }; } return { label: "Work item", tone: "info" }; } interface ItemLifecycleCandidate { id: string; itemId?: string; createdAt: string; label: string; detail?: string; tone: WorkLogEntry["tone"]; phase: "started" | "completed"; } function extractDetail( payload: Record | undefined, item: Record | undefined, ): string | undefined { const candidates = [ asString(item?.command), asString(item?.tool), asString(item?.name), asString(item?.title), asString(item?.summary), asString(item?.text), asString(item?.prompt), asString(payload?.message), asString(payload?.prompt), asString(payload?.command), ]; for (const candidate of candidates) { const detail = normalizeDetail(candidate); if (detail) return detail; } return undefined; } function lifecycleCandidateFromItemEvent(event: ProviderEvent): ItemLifecycleCandidate | null { const payload = asObject(event.payload); const item = asObject(payload?.item); const normalizedType = normalizeItemType(asString(item?.type)); if (shouldDropItemType(normalizedType) || shouldDropMethod(event.method)) { return null; } if (!shouldShowItemLifecycle(normalizedType)) { return null; } const meta = itemTypeMeta(normalizedType); const isStarted = event.method === "item/started"; const isCompleted = event.method === "item/completed"; if (!isStarted && !isCompleted) { return null; } const detail = extractDetail(payload, item); const itemId = event.itemId ?? asString(item?.id); return { id: event.id, ...(itemId ? { itemId } : {}), createdAt: event.createdAt, label: meta.label, ...(detail ? { detail } : {}), tone: meta.tone, phase: isCompleted ? "completed" : "started", }; } function entryFromRequest(event: ProviderEvent): WorkLogEntry | null { if (event.kind !== "request") return null; if (shouldDropMethod(event.method)) return null; if (event.method.includes("commandExecution")) { return { id: event.id, createdAt: event.createdAt, label: "Command approval requested", tone: "tool", }; } if (event.method.includes("fileChange")) { return { id: event.id, createdAt: event.createdAt, label: "File-change approval requested", tone: "tool", }; } if (event.method.includes("requestUserInput")) { return { id: event.id, createdAt: event.createdAt, label: "Tool requested user input", tone: "tool", }; } return { id: event.id, createdAt: event.createdAt, label: `Request: ${event.method}`, tone: "info", }; } function entryFromNotification(event: ProviderEvent): WorkLogEntry | null { if (event.kind !== "notification") return null; if (shouldDropMethod(event.method)) return null; if (event.method === "item/agentMessage/delta") return null; if (event.method === "turn/started") { return null; } if (event.method === "thread/started") return null; if (event.method === "turn/completed") { const payload = asObject(event.payload); const turn = asObject(payload?.turn); const status = asString(turn?.status); if (status !== "failed") { return null; } const turnError = asObject(turn?.error); const turnErrorMessage = asString(turnError?.message); const turnErrorDetail = normalizeDetail(turnErrorMessage); return { id: event.id, createdAt: event.createdAt, label: "Turn failed", ...(turnErrorDetail ? { detail: turnErrorDetail } : {}), tone: "error", }; } if (event.method.startsWith("item/")) return null; return null; } function entryFromError(event: ProviderEvent): WorkLogEntry | null { if (event.kind !== "error") return null; if (shouldDropMethod(event.method)) return null; const detail = normalizeDetail(event.message); return { id: event.id, createdAt: event.createdAt, label: "Runtime error", ...(detail ? { detail } : {}), tone: "error", }; } function eventTurnId(event: ProviderEvent): string | undefined { const payload = asObject(event.payload); const turn = asObject(payload?.turn); return event.turnId ?? asString(turn?.id); } export function deriveWorkLogEntries( events: ProviderEvent[], turnId: string | undefined, ): WorkLogEntry[] { const ordered = [...events].toReversed(); const entries: WorkLogEntry[] = []; const turnStartedAtIso = turnId ? ordered.find((event) => { if (event.method !== "turn/started") return false; return eventTurnId(event) === turnId; })?.createdAt : undefined; const turnStartedAt = turnStartedAtIso ? Date.parse(turnStartedAtIso) : Number.NaN; const shouldIncludeEvent = (event: ProviderEvent): boolean => { if (!turnId) return true; const scopedTurnId = eventTurnId(event); if (scopedTurnId && scopedTurnId !== turnId) { return false; } if (!scopedTurnId && !Number.isNaN(turnStartedAt)) { const eventAt = Date.parse(event.createdAt); if (!Number.isNaN(eventAt) && eventAt < turnStartedAt) { return false; } } return true; }; const completedLifecycleItemIds = new Set(); for (const event of ordered) { if (!shouldIncludeEvent(event)) continue; const candidate = lifecycleCandidateFromItemEvent(event); if (candidate?.phase === "completed" && candidate.itemId) { completedLifecycleItemIds.add(candidate.itemId); } } for (const event of ordered) { if (!shouldIncludeEvent(event)) continue; const lifecycleCandidate = lifecycleCandidateFromItemEvent(event); if (lifecycleCandidate) { if ( lifecycleCandidate.phase === "started" && lifecycleCandidate.itemId && completedLifecycleItemIds.has(lifecycleCandidate.itemId) ) { continue; } if (lifecycleCandidate.label === "Tool call" && !lifecycleCandidate.detail) { continue; } entries.push({ id: lifecycleCandidate.id, createdAt: lifecycleCandidate.createdAt, label: lifecycleCandidate.label, ...(lifecycleCandidate.detail ? { detail: lifecycleCandidate.detail } : {}), tone: lifecycleCandidate.tone, }); continue; } const fromRequest = entryFromRequest(event); if (fromRequest) { entries.push(fromRequest); continue; } const fromNotification = entryFromNotification(event); if (fromNotification) { entries.push(fromNotification); continue; } const fromError = entryFromError(event); if (fromError) { entries.push(fromError); } } return entries; } interface MutableTurnDiffSummary { turnId: string; completedAt: string | undefined; status: string | undefined; assistantMessageId: string | undefined; unifiedDiff: string | undefined; filesByPath: Map; } function parseFileChangeEntriesFromEvent(event: ProviderEvent): TurnDiffFileChange[] { if (event.method !== "item/completed") { return []; } const item = asObject(asObject(event.payload)?.item); const itemType = normalizeItemType(asString(item?.type)); if (itemType !== "file change") { return []; } const changes = asArray(item?.changes) ?? []; const parsed: TurnDiffFileChange[] = []; for (const rawChange of changes) { const change = asObject(rawChange); if (!change) continue; const diff = normalizeDetail(asString(change.diff)); const path = normalizeDetail(asString(change.path)) ?? (diff ? parsePathFromDiff(diff) : undefined); if (!path) continue; const additions = asNumber(change.additions); const deletions = asNumber(change.deletions); const stat = additions !== undefined && deletions !== undefined ? null : diff ? countDiffStat(diff) : null; const kind = normalizeDetail(asString(change.kind)); parsed.push({ path, ...(kind ? { kind } : {}), ...(diff ? { diff } : {}), ...(additions !== undefined ? { additions } : stat ? { additions: stat.additions } : {}), ...(deletions !== undefined ? { deletions } : stat ? { deletions: stat.deletions } : {}), }); } return parsed; } function mergeTurnDiffFileChange( byPath: Map, incoming: TurnDiffFileChange, ): void { const existing = byPath.get(incoming.path); if (!existing) { byPath.set(incoming.path, incoming); return; } if (existing.kind === undefined && incoming.kind !== undefined) { existing.kind = incoming.kind; } if (existing.diff === undefined && incoming.diff !== undefined) { existing.diff = incoming.diff; } if (existing.additions === undefined && incoming.additions !== undefined) { existing.additions = incoming.additions; } if (existing.deletions === undefined && incoming.deletions !== undefined) { existing.deletions = incoming.deletions; } } function parsePathFromDiff(diff: string): string | undefined { const normalized = diff.replace(/\r\n/g, "\n"); const bPath = normalized.match(/^\+\+\+ b\/(.+)$/m); if (bPath && bPath[1]) return bPath[1]; const gitHeader = normalized.match(/^diff --git a\/(.+) b\/\1$/m); if (gitHeader && gitHeader[1]) return gitHeader[1]; const direct = normalized.match(/^\+\+\+ (.+)$/m); if (!direct || !direct[1] || direct[1] === "/dev/null") { return undefined; } return direct[1]; } export function splitUnifiedDiffByFile(diff: string): Map { const normalized = diff.replace(/\r\n/g, "\n"); const byPath = new Map(); const headerMatches = [...normalized.matchAll(/^diff --git .+$/gm)]; if (headerMatches.length === 0) { const path = parsePathFromDiff(normalized); if (path) { byPath.set(path, normalized.trim()); } return byPath; } for (let index = 0; index < headerMatches.length; index += 1) { const match = headerMatches[index]; if (!match) continue; const start = match.index ?? 0; const nextStart = headerMatches[index + 1]?.index ?? normalized.length; const segment = normalized.slice(start, nextStart).trim(); const path = parsePathFromDiff(segment); if (!path || segment.length === 0) continue; byPath.set(path, segment); } return byPath; } export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileChange[] { const fileDiffsByPath = splitUnifiedDiffByFile(diff); return Array.from(fileDiffsByPath.entries()) .map(([path, fileDiff]) => { const stat = countDiffStat(fileDiff); return { path, diff: fileDiff, additions: stat.additions, deletions: stat.deletions, }; }) .toSorted((a, b) => a.path.localeCompare(b.path)); } export function inferCheckpointTurnCountByTurnId( summaries: TurnDiffSummary[], ): Record { if (summaries.length === 0) { return {}; } const sorted = [...summaries].toSorted((a, b) => { const aTime = Date.parse(a.completedAt); const bTime = Date.parse(b.completedAt); if (Number.isNaN(aTime) || Number.isNaN(bTime)) { return a.completedAt.localeCompare(b.completedAt); } return aTime - bTime; }); const next: Record = {}; let fallbackTurnCount = 1; for (const summary of sorted) { if (typeof summary.checkpointTurnCount === "number") { next[summary.turnId] = summary.checkpointTurnCount; fallbackTurnCount = Math.max(fallbackTurnCount, summary.checkpointTurnCount + 1); continue; } next[summary.turnId] = fallbackTurnCount; fallbackTurnCount += 1; } return next; } export function countDiffStat(patch: string | undefined): { additions: number; deletions: number } { if (!patch) { return { additions: 0, deletions: 0 }; } let additions = 0; let deletions = 0; for (const line of patch.replace(/\r\n/g, "\n").split("\n")) { if (line.startsWith("+++ ") || line.startsWith("--- ")) continue; if (line.startsWith("+")) { additions += 1; continue; } if (line.startsWith("-")) { deletions += 1; } } return { additions, deletions }; } export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummary[] { const firstEventAt = Date.parse(events[0]?.createdAt ?? ""); const lastEventAt = Date.parse(events.at(-1)?.createdAt ?? ""); const ordered = events.length < 2 || Number.isNaN(firstEventAt) || Number.isNaN(lastEventAt) || firstEventAt >= lastEventAt ? events : [...events].toReversed(); const byTurnId = new Map(); const ensureSummary = (turnId: string): MutableTurnDiffSummary => { const existing = byTurnId.get(turnId); if (existing) return existing; const next: MutableTurnDiffSummary = { turnId, completedAt: undefined, status: undefined, assistantMessageId: undefined, unifiedDiff: undefined, filesByPath: new Map(), }; byTurnId.set(turnId, next); return next; }; for (const event of ordered) { const turnId = eventTurnId(event); if (!turnId) continue; const summary = ensureSummary(turnId); if (event.method === "turn/completed") { if (summary.completedAt === undefined) { summary.completedAt = event.createdAt; } const turn = asObject(asObject(event.payload)?.turn); if (summary.status === undefined) { summary.status = normalizeDetail(asString(turn?.status)); } } if (event.method === "item/completed") { const item = asObject(asObject(event.payload)?.item); if (asString(item?.type) === "agentMessage") { const itemId = normalizeDetail(asString(item?.id)); if (itemId && summary.assistantMessageId === undefined) { summary.assistantMessageId = itemId; } } } if (event.method === "turn/diff/updated" && summary.unifiedDiff === undefined) { const diff = normalizeDetail(asString(asObject(event.payload)?.diff)); if (diff) { summary.unifiedDiff = diff; for (const file of deriveTurnDiffFilesFromUnifiedDiff(diff)) { mergeTurnDiffFileChange(summary.filesByPath, file); } } } for (const file of parseFileChangeEntriesFromEvent(event)) { mergeTurnDiffFileChange(summary.filesByPath, file); } } const summaries: TurnDiffSummary[] = []; for (const summary of byTurnId.values()) { if (!summary.completedAt) continue; summaries.push({ turnId: summary.turnId, completedAt: summary.completedAt, ...(summary.status ? { status: summary.status } : {}), files: Array.from(summary.filesByPath.values()).toSorted((a, b) => a.path.localeCompare(b.path), ), ...(summary.unifiedDiff ? { unifiedDiff: summary.unifiedDiff } : {}), ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}), }); } summaries.sort((a, b) => { const aTime = Date.parse(a.completedAt); const bTime = Date.parse(b.completedAt); if (Number.isNaN(aTime) || Number.isNaN(bTime)) { return b.completedAt.localeCompare(a.completedAt); } return bTime - aTime; }); return summaries; } function toTimestamp(isoDate: string): number { const parsed = Date.parse(isoDate); return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed; } export function deriveTimelineEntries( messages: ChatMessage[], workEntries: WorkLogEntry[], ): TimelineEntry[] { const timeline: TimelineEntry[] = []; let messageIndex = 0; let workIndex = 0; while (messageIndex < messages.length || workIndex < workEntries.length) { const message = messages[messageIndex]; const workEntry = workEntries[workIndex]; if (!message && workEntry) { timeline.push({ id: `work:${workEntry.id}`, kind: "work", createdAt: workEntry.createdAt, entry: workEntry, }); workIndex += 1; continue; } if (!workEntry && message) { timeline.push({ id: `message:${message.id}`, kind: "message", createdAt: message.createdAt, message, }); messageIndex += 1; continue; } if (!message || !workEntry) { break; } const messageAt = toTimestamp(message.createdAt); const workAt = toTimestamp(workEntry.createdAt); if (workAt <= messageAt) { timeline.push({ id: `work:${workEntry.id}`, kind: "work", createdAt: workEntry.createdAt, entry: workEntry, }); workIndex += 1; continue; } timeline.push({ id: `message:${message.id}`, kind: "message", createdAt: message.createdAt, message, }); messageIndex += 1; } return timeline; } export function derivePhase(session: ProviderSession | null): SessionPhase { if (!session || session.status === "closed") return "disconnected"; if (session.status === "connecting") return "connecting"; if (session.status === "running") return "running"; return "ready"; } export function evolveSession(previous: ProviderSession, event: ProviderEvent): ProviderSession { const payload = asObject(event.payload); if (event.method === "thread/started") { const thread = asObject(payload?.thread); return { ...previous, threadId: asString(thread?.id) ?? event.threadId ?? previous.threadId, updatedAt: event.createdAt, }; } if (event.method === "turn/started") { const turn = asObject(payload?.turn); return { ...previous, status: "running", activeTurnId: asString(turn?.id) ?? event.turnId ?? previous.activeTurnId, updatedAt: event.createdAt, }; } if (event.method === "turn/completed") { const turn = asObject(payload?.turn); const status = asString(turn?.status); const turnError = asObject(turn?.error); return { ...previous, status: status === "failed" ? "error" : "ready", activeTurnId: undefined, lastError: asString(turnError?.message) ?? previous.lastError, updatedAt: event.createdAt, }; } if (event.kind === "error") { return { ...previous, status: "error", lastError: event.message ?? previous.lastError, updatedAt: event.createdAt, }; } if (event.method === "session/closed" || event.method === "session/exited") { return { ...previous, status: "closed", activeTurnId: undefined, lastError: event.message ?? previous.lastError, updatedAt: event.createdAt, }; } return { ...previous, updatedAt: event.createdAt }; } export function applyEventToMessages( previous: ChatMessage[], event: ProviderEvent, activeAssistantItemRef: { current: string | null }, ): ChatMessage[] { const payload = asObject(event.payload); if (event.method === "item/started") { const item = asObject(payload?.item); if (asString(item?.type) !== "agentMessage") return previous; const itemId = asString(item?.id); if (!itemId) return previous; activeAssistantItemRef.current = itemId; const seedText = asString(item?.text) ?? ""; const filtered = previous.filter((entry) => entry.id !== itemId); return [ ...filtered, { id: itemId, role: "assistant", text: seedText, createdAt: event.createdAt, streaming: true, }, ]; } if (event.method === "item/agentMessage/delta") { const itemId = event.itemId ?? asString(payload?.itemId); const delta = event.textDelta ?? asString(payload?.delta) ?? ""; if (!itemId || !delta) return previous; const existingIndex = previous.findIndex((entry) => entry.id === itemId); if (existingIndex === -1) { activeAssistantItemRef.current = itemId; return [ ...previous, { id: itemId, role: "assistant", text: delta, createdAt: event.createdAt, streaming: true, }, ]; } const updated = [...previous]; const existing = updated[existingIndex]; if (!existing) return previous; updated[existingIndex] = { ...existing, text: `${existing.text}${delta}`, streaming: true, }; return updated; } if (event.method === "item/completed") { const item = asObject(payload?.item); if (asString(item?.type) !== "agentMessage") return previous; const itemId = asString(item?.id); if (!itemId) return previous; const fullText = asString(item?.text); const existingIndex = previous.findIndex((entry) => entry.id === itemId); if (existingIndex === -1) { return [ ...previous, { id: itemId, role: "assistant", text: fullText ?? "", createdAt: event.createdAt, streaming: false, }, ]; } const updated = [...previous]; const existing = updated[existingIndex]; if (!existing) return previous; updated[existingIndex] = { ...existing, text: fullText ?? existing.text, streaming: false, }; if (activeAssistantItemRef.current === itemId) { activeAssistantItemRef.current = null; } return updated; } if (event.method === "turn/completed") { return previous.map((entry) => ({ ...entry, streaming: false })); } return previous; }