import React, { useEffect, useRef, useState } from "react"; import { Text } from "ink"; import { SearchableList } from "../components/SearchableList"; import { TextInputPrompt } from "../components/TextInputPrompt"; import { isInsideGitRepository, getGitRepoRoot } from "../../core/git/git-repository"; import { analyzeSymbolChangeImpact } from "../../core/nexus/nexus-change-impact-service"; import { getGitNexusVersion } from "../../core/nexus/gitnexus-runtime"; import { getProjectOverview } from "../../core/nexus/nexus-overview-service"; import { getSymbolContext, searchFeature } from "../../core/nexus/nexus-query-service"; import { getRepoFreshness, listAnalyzedRepos, normalizeRepoPath } from "../../core/nexus/nexus-repo-service"; import { mapInstallStatus } from "../../core/nexus/nexus-status"; import type { TNexusRepoSummary } from "../../core/nexus/nexus-types"; import type { InkInteractionService } from "../services/ink-interaction-service"; type TScreenProps = { service: InkInteractionService; onDone: (success: boolean) => void; maxVisibleRows?: number }; function statusIcon(status: string): string { if (status === "ready") return "✓"; if (status === "analyzing") return "…"; if (status === "update-required") return "⚠"; if (status === "index-required" || status === "setup-required") return "○"; return "✗"; } export function AiNexusStatusScreen(props: { service: InkInteractionService; onDone: (success: boolean) => void }) { const startedRef = useRef(false); useEffect(() => { if (startedRef.current) return; startedRef.current = true; void (async () => { const version = await getGitNexusVersion(); const install = mapInstallStatus(version); props.service.notify({ level: "muted", message: `${statusIcon(install.status)} ${install.message}` }); if (install.status !== "ready") return props.onDone(true); const listed = await listAnalyzedRepos(); if (!listed.ok) { props.service.notify({ level: "error", message: listed.message }); return props.onDone(false); } if (listed.repos.length === 0) { props.service.notify({ level: "muted", message: "No repositories analyzed yet." }); return props.onDone(true); } for (const repo of listed.repos) { props.service.notify({ level: "muted", message: `${statusIcon(repo.status)} ${repo.name.padEnd(28)} ${repo.path}\n ${repo.message}` }); } props.onDone(true); })(); }, []); return Working…; } export function AiNexusDoctorScreen(props: { service: InkInteractionService; onDone: (success: boolean) => void }) { const startedRef = useRef(false); useEffect(() => { if (startedRef.current) return; startedRef.current = true; void (async () => { const version = await getGitNexusVersion(); const install = mapInstallStatus(version); props.service.notify({ level: "step", message: "Code Intelligence doctor" }); props.service.notify({ level: "muted", message: `${statusIcon(install.status)} ${install.message}` }); if (install.status !== "ready") return props.onDone(true); if (await isInsideGitRepository(process.cwd())) { const root = await getGitRepoRoot(process.cwd()); const freshness = await getRepoFreshness(root); if (freshness.ok) { props.service.notify({ level: "muted", message: `Current repository — branch: ${freshness.info.branch ?? "unknown"}, indexed commit: ${freshness.info.indexedCommit ?? "n/a"}, up to date: ${freshness.info.upToDate === undefined ? "unknown" : freshness.info.upToDate ? "yes" : "no"}`, }); } else { props.service.notify({ level: "muted", message: freshness.message }); } } props.onDone(true); })(); }, []); return Working…; } /** Shared "resolve an already-analyzed repo" step for overview/search/trace/impact — own native picker (the traditional `resolveAnalyzedRepo` calls `searchableSelectChoice`, raw-prompts-based, directly). */ function useAnalyzedRepoPicker(props: { service: InkInteractionService; onDone: (success: boolean) => void }): { repo: TNexusRepoSummary | undefined; choices: { title: string; value: string }[] | undefined; pick: (name: string) => void; } { const [repos, setRepos] = useState(undefined); const [repo, setRepo] = useState(undefined); useEffect(() => { void (async () => { const listed = await listAnalyzedRepos(); if (!listed.ok) { props.service.notify({ level: "error", message: listed.message }); return props.onDone(false); } if (listed.repos.length === 0) { props.service.notify({ level: "warn", message: 'No repositories have been analyzed yet. Run "ai nexus analyze " first.' }); return props.onDone(false); } if (await isInsideGitRepository(process.cwd())) { const root = await getGitRepoRoot(process.cwd()); const normalizedRoot = normalizeRepoPath(root); const match = listed.repos.find((entry) => normalizeRepoPath(entry.path) === normalizedRoot); if (match) { setRepo(match); return; } props.service.notify({ level: "warn", message: 'This repository hasn\'t been analyzed yet. Run "ai nexus analyze" first.' }); return props.onDone(false); } if (listed.repos.length === 1) { setRepo(listed.repos[0]); return; } setRepos(listed.repos); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return { repo, choices: repos?.map((entry) => ({ title: `${entry.name} (${entry.path})`, value: entry.name })), pick: (name: string) => setRepo(repos?.find((entry) => entry.name === name)), }; } export function AiNexusOverviewScreen(props: TScreenProps) { const { repo, choices, pick } = useAnalyzedRepoPicker(props); const startedRef = useRef(false); useEffect(() => { if (!repo || startedRef.current) return; startedRef.current = true; const overview = getProjectOverview(repo); const notify = (message: string) => props.service.notify({ level: "muted", message }); notify(`${repo.name}\n${repo.path}`); notify(`Branch: ${overview.branch ?? "unknown"} — Analyzed: ${overview.indexedAt ?? "unknown"}${overview.upToDate === false ? " (out of date)" : ""}`); if (overview.stats) { notify( `Files: ${overview.stats.files} — Symbols: ${overview.stats.symbols} — Relationships: ${overview.stats.edges} — Clusters: ${overview.stats.clusters} — Execution flows: ${overview.stats.processes}`, ); } props.onDone(true); }, [repo]); if (!repo) { if (!choices) return Loading analyzed repositories…; return ( props.onDone(false)} /> ); } return Working…; } // Search/Trace/Impact all share the same two-step shape (pick an analyzed // repo, then type a query) via RepoAndQueryShellWithRepoCapture below; each // screen re-resolves the full repo summary once a query is set (cheap — // local cache read, no network) so its own query/report logic stays in one // place instead of threading results back through the shared shell. async function resolveAnalyzedRepoQuietly(repoName: string): Promise { const listed = await listAnalyzedRepos(); return listed.ok ? listed.repos.find((entry) => entry.name === repoName) : undefined; } export function AiNexusSearchScreen(props: TScreenProps) { const [query, setQuery] = useState(undefined); const [repoName, setRepoName] = useState(undefined); const startedRef = useRef(false); useEffect(() => { if (!query || !repoName || startedRef.current) return; startedRef.current = true; void (async () => { const repo = await resolveAnalyzedRepoQuietly(repoName); if (!repo) return props.onDone(false); const outcome = await searchFeature(query, { repo: repo.name }); if (!outcome.ok) { props.service.notify({ level: "error", message: outcome.message }); return props.onDone(false); } if (outcome.result.warning) { props.service.notify({ level: "warn", message: outcome.result.warning }); } if (!outcome.result.matches.length) { props.service.notify({ level: "muted", message: "No matching files found." }); } else { for (const match of outcome.result.matches) { props.service.notify({ level: "muted", message: `${match.rank}. ${match.filePath} score ${match.score.toFixed(1)}` }); } } props.onDone(true); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [query, repoName]); return ( ); } export function AiNexusTraceScreen(props: TScreenProps) { const [query, setQuery] = useState(undefined); const [repoName, setRepoName] = useState(undefined); const startedRef = useRef(false); useEffect(() => { if (!query || !repoName || startedRef.current) return; startedRef.current = true; void (async () => { const repo = await resolveAnalyzedRepoQuietly(repoName); if (!repo) return props.onDone(false); const outcome = await getSymbolContext(query, { repo: repo.name, cwd: repo.path }); if (!outcome.ok) { props.service.notify({ level: "error", message: outcome.message }); return props.onDone(false); } if (!outcome.result.found || !outcome.result.symbol) { props.service.notify({ level: "warn", message: `"${query}" wasn't found in the analyzed code.` }); return props.onDone(true); } const { symbol, callers, callees } = outcome.result; const notify = (message: string) => props.service.notify({ level: "muted", message }); notify(`${symbol.name} ${symbol.filePath}`); notify(`Used by ${callers.length} caller(s):\n${callers.map((c) => ` - ${c.name} ${c.filePath}`).join("\n") || " (none)"}`); notify(`Calls ${callees.length} function(s):\n${callees.map((c) => ` - ${c.name} ${c.filePath}`).join("\n") || " (none)"}`); props.onDone(true); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [query, repoName]); return ( ); } export function AiNexusImpactScreen(props: TScreenProps) { const [query, setQuery] = useState(undefined); const [repoName, setRepoName] = useState(undefined); const startedRef = useRef(false); useEffect(() => { if (!query || !repoName || startedRef.current) return; startedRef.current = true; void (async () => { const repo = await resolveAnalyzedRepoQuietly(repoName); if (!repo) return props.onDone(false); if (query.includes("/") || query.endsWith(".ts")) { props.service.notify({ level: "error", message: 'Impact analysis needs a function/class name, not a file path — use "ai nexus changes" for whole-file change impact.', }); return props.onDone(false); } const outcome = await analyzeSymbolChangeImpact(repo.name, query, repo.path); if (!outcome.ok) { props.service.notify({ level: "error", message: outcome.message }); return props.onDone(false); } props.service.notify({ level: "info", message: `Risk: ${outcome.result.risk.toUpperCase()} — ${outcome.result.riskReason}` }); props.onDone(true); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [query, repoName]); return ( ); } /** Same as RepoAndQueryShell, but also reports which repo name was picked (or auto-resolved) so the caller can re-resolve the full TNexusRepoSummary once the query is known. */ function RepoAndQueryShellWithRepoCapture( props: TScreenProps & { promptMessage: string; query: string | undefined; onQuery: (value: string) => void; onRepo: (name: string) => void }, ) { const { repo, choices, pick } = useAnalyzedRepoPicker(props); useEffect(() => { if (repo) props.onRepo(repo.name); // eslint-disable-next-line react-hooks/exhaustive-deps }, [repo]); if (!repo) { if (!choices) return Loading analyzed repositories…; return ( props.onDone(false)} /> ); } if (!props.query) { return props.onDone(false)} />; } return Working…; }