import React, { useEffect, useRef, useState } from "react"; import { Text } from "ink"; import { SearchableList } from "../components/SearchableList"; import { analyzeSession, deriveTurns } from "../../core/ai/ai-session-analysis"; import { ingestAiSessions } from "../../core/ai/ai-session-ingestion"; import { AiSessionStore, aiStudioStorageDir } from "../../core/ai/ai-session-store"; import { openProjectFolder, openProjectInVsCode } from "../../core/ai/ai-session-command-service"; import { getSessionLauncher } from "../../core/ai/launchers/claude-session-launcher"; import type { TAiSession } from "../../core/ai/ai-types"; import type { InkInteractionService } from "../services/ink-interaction-service"; type TScreenProps = { service: InkInteractionService; onDone: (success: boolean) => void; maxVisibleRows?: number }; function formatDuration(ms: number): string { if (!ms) return "0s"; const totalSeconds = Math.round(ms / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return minutes > 0 ? `${minutes}m ${seconds < 10 ? "0" : ""}${seconds}s` : `${seconds}s`; } function formatTokens(count: number): string { if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(2)}M`; if (count >= 1_000) return `${Math.round(count / 1000)}K`; return String(count); } function statusLabel(session: TAiSession): string { return session.errorCount > 0 ? "PARTIAL" : "SUCCESS"; } function labelOutcome(outcome: string): string { return outcome.replace(/-/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); } async function openStoreOrNotify(service: InkInteractionService): Promise { const store = await AiSessionStore.open(); if (!store) { service.notify({ level: "error", message: `AI Studio requires Node.js 22.5+ for its local SQLite store (node:sqlite). You are running ${process.version}. Upgrade Node.js and try again.` }); return undefined; } return store; } export function AiSessionsScreen(props: { service: InkInteractionService; onDone: (success: boolean) => void }) { const startedRef = useRef(false); useEffect(() => { if (startedRef.current) return; startedRef.current = true; void (async () => { const store = await openStoreOrNotify(props.service); if (!store) return props.onDone(false); await ingestAiSessions(store); const { sessions } = store.listSessions({ limit: 20 }); store.close(); if (!sessions.length) { props.service.notify({ level: "muted", message: "No AI sessions found yet. Use Claude Code or Codex, then re-run this command." }); return props.onDone(true); } for (const session of sessions) { const provider = session.provider === "claude" ? "Claude" : session.provider === "codex" ? "Codex" : session.provider; props.service.notify({ level: "muted", message: `${statusLabel(session).padEnd(8)} ${provider.padEnd(8)} ${session.project.slice(0, 28).padEnd(28)} ${formatDuration(session.durationMs).padEnd(9)} ${formatTokens(session.inputTokens + session.outputTokens)}\n ${session.id} ${session.title.slice(0, 90)}`, }); } props.onDone(true); })(); }, []); return Working…; } export function AiDoctorScreen(props: { service: InkInteractionService; onDone: (success: boolean) => void }) { const startedRef = useRef(false); useEffect(() => { if (startedRef.current) return; startedRef.current = true; void (async () => { const store = await openStoreOrNotify(props.service); if (!store) return props.onDone(false); const result = await ingestAiSessions(store); const claudeCount = store.countIngestedFiles("claude"); const codexCount = store.countIngestedFiles("codex"); const totalSessions = store.countSessions(); const diagnostics = store.listDiagnostics(20); store.close(); const notify = (message: string) => props.service.notify({ level: "muted", message }); notify(`Storage: ${aiStudioStorageDir()}`); notify(`Claude files: ${claudeCount} ingested — Codex files: ${codexCount} ingested — Total sessions: ${totalSessions}`); notify( `Last refresh — discovered ${result.filesDiscovered}, ingested ${result.filesIngested}, unchanged ${result.filesSkippedUnchanged}, failed ${result.filesFailed}, took ${formatDuration(result.durationMs)}`, ); if (diagnostics.length) { for (const diagnostic of diagnostics.slice(0, 10)) { props.service.notify({ level: diagnostic.severity === "error" ? "error" : diagnostic.severity === "warning" ? "warn" : "muted", message: `[${diagnostic.provider}] ${diagnostic.message}\n ${diagnostic.sourceFile}`, }); } } else { props.service.notify({ level: "success", message: "No parser diagnostics recorded." }); } props.onDone(true); })(); }, []); return Working…; } export function AiScanScreen(props: { service: InkInteractionService; onDone: (success: boolean) => void }) { const startedRef = useRef(false); useEffect(() => { if (startedRef.current) return; startedRef.current = true; void (async () => { const store = await openStoreOrNotify(props.service); if (!store) return props.onDone(false); const result = await ingestAiSessions(store); store.close(); props.service.notify({ level: "success", message: `Discovered ${result.filesDiscovered}, ingested ${result.filesIngested}, unchanged ${result.filesSkippedUnchanged}, failed ${result.filesFailed}.`, }); props.onDone(true); })(); }, []); return Working…; } /** Shared "pick a recent session" step used by inspect/export/open/copy-command. */ function useSessionPicker(props: { service: InkInteractionService; onDone: (success: boolean) => void }): { choices: { title: string; value: string }[] | undefined; sessionId: string | undefined; pick: (id: string) => void; } { const [choices, setChoices] = useState<{ title: string; value: string }[] | undefined>(undefined); const [sessionId, setSessionId] = useState(undefined); useEffect(() => { void (async () => { const store = await openStoreOrNotify(props.service); if (!store) return props.onDone(false); await ingestAiSessions(store); const { sessions } = store.listSessions({ limit: 25 }); store.close(); if (!sessions.length) { props.service.notify({ level: "muted", message: "No AI sessions found yet." }); return props.onDone(true); } setChoices(sessions.map((session) => ({ title: `${session.project} — ${session.title.slice(0, 70)}`, value: session.id }))); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return { choices, sessionId, pick: setSessionId }; } async function loadResolvedSession(sessionId: string, service: InkInteractionService): Promise { const store = await openStoreOrNotify(service); if (!store) return undefined; const session = store.getSession(sessionId); store.close(); if (!session) { service.notify({ level: "error", message: `Session not found: ${sessionId}` }); } return session; } export function AiInspectScreen(props: TScreenProps) { const { choices, sessionId, pick } = useSessionPicker(props); const startedRef = useRef(false); useEffect(() => { if (!sessionId || startedRef.current) return; startedRef.current = true; void (async () => { const store = await openStoreOrNotify(props.service); if (!store) return props.onDone(false); const session = store.getSession(sessionId); if (!session) { props.service.notify({ level: "error", message: `Session not found: ${sessionId}` }); store.close(); return props.onDone(false); } const observations = store.getObservations(sessionId); const analysis = analyzeSession(sessionId, observations); store.close(); const notify = (message: string) => props.service.notify({ level: "muted", message }); notify(session.title); notify( `Provider: ${session.provider} · Project: ${session.project} · Model: ${session.model || "unknown"} · Duration: ${formatDuration(session.durationMs)}`, ); notify( `Tokens: ${formatTokens(session.inputTokens + session.outputTokens)} (cache-read ${formatTokens(session.cacheReadTokens)}) · Tools: ${session.toolCallCount} · Errors: ${session.errorCount} · Files: ${analysis.fileImpact.length} touched`, ); if (!analysis.verification.length) { notify("Verification: (none observed)"); } else { for (const check of analysis.verification) { notify(` ${check.status === "pass" ? "✓" : check.status === "fail" ? "✗" : "?"} ${check.label}`); } } notify(`Outcome: ${labelOutcome(analysis.outcome)}`); for (const evidence of analysis.outcomeEvidence) notify(` - ${evidence}`); if (analysis.errorGroups.length) { notify(`Top risk: ${analysis.errorGroups[0].category} — ${analysis.errorGroups[0].message.slice(0, 100)} (${analysis.errorGroups[0].count}x)`); } props.onDone(true); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId]); if (!sessionId) { if (!choices) return Loading sessions…; return ( props.onDone(false)} /> ); } return Working…; } export function AiExportScreen(props: TScreenProps) { const { choices, sessionId, pick } = useSessionPicker(props); const [format, setFormat] = useState<"markdown" | "json" | undefined>(undefined); const startedRef = useRef(false); useEffect(() => { if (!sessionId || !format || startedRef.current) return; startedRef.current = true; void (async () => { const store = await openStoreOrNotify(props.service); if (!store) return props.onDone(false); const session = store.getSession(sessionId); if (!session) { props.service.notify({ level: "error", message: `Session not found: ${sessionId}` }); store.close(); return props.onDone(false); } const observations = store.getObservations(sessionId); const turns = deriveTurns(observations); const analysis = analyzeSession(sessionId, observations); store.close(); const { exportSession } = await import("../../core/ai/ai-session-export"); const exported = exportSession({ session, turns, observations, analysis }, format); props.service.notify({ level: "muted", message: exported.content }); props.onDone(true); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId, format]); if (!sessionId) { if (!choices) return Loading sessions…; return ( props.onDone(false)} /> ); } if (!format) { return ( setFormat(value as "markdown" | "json")} onCancel={() => props.onDone(false)} /> ); } return Working…; } export function AiOpenScreen(props: TScreenProps) { const { choices, sessionId, pick } = useSessionPicker(props); const [target, setTarget] = useState<"folder" | "vscode" | undefined>(undefined); const startedRef = useRef(false); useEffect(() => { if (!sessionId || !target || startedRef.current) return; startedRef.current = true; void (async () => { const session = await loadResolvedSession(sessionId, props.service); if (!session) return props.onDone(false); const result = target === "vscode" ? await openProjectInVsCode(session.cwd) : await openProjectFolder(session.cwd); if (!result.ok) { props.service.notify({ level: "error", message: result.error ?? "Failed to open the project." }); return props.onDone(false); } props.service.notify({ level: "success", message: target === "vscode" ? "Opened the project in VS Code." : "Opened the project folder." }); props.onDone(true); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId, target]); if (!sessionId) { if (!choices) return Loading sessions…; return ( props.onDone(false)} /> ); } if (!target) { return ( setTarget(value as "folder" | "vscode")} onCancel={() => props.onDone(false)} /> ); } return Working…; } export function AiCopyCommandScreen(props: TScreenProps) { const { choices, sessionId, pick } = useSessionPicker(props); const startedRef = useRef(false); useEffect(() => { if (!sessionId || startedRef.current) return; startedRef.current = true; void (async () => { const session = await loadResolvedSession(sessionId, props.service); if (!session) return props.onDone(false); const launcher = getSessionLauncher(session.provider); if (!launcher) { props.service.notify({ level: "error", message: `Resuming ${session.provider} sessions is not supported yet.` }); return props.onDone(false); } const launch = launcher.buildResumeCommand(session, { includeChangeDirectory: true }); props.service.notify({ level: "info", message: `Copy and run this to resume the session:\n${launch.command}` }); props.onDone(true); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId]); if (!sessionId) { if (!choices) return Loading sessions…; return ( props.onDone(false)} /> ); } return Working…; }