import { useState, type ReactElement } from 'react' import { Box, Text, render, useApp, useInput } from 'ink' import { InkThemeProvider, StatusHeader } from '@agentskit/ink' import type { DocBridgeConfigV1 } from '../../config/schema.js' import type { DoctorReport } from '../../doctor/run-doctor.js' import type { GateResult } from '../../gates/run-gates.js' import type { AskResult } from './ask.js' import { AskView, Code, DoctorView, EmbeddedContext, FindingsTable, GateStep, Hint, IndexView, PUBLISH_ANYWAY_HINT, Path, tint, type IndexResult, } from './render.js' import { createServices, type DocumentContext, type Freshness, type UiServices } from './services.js' export const TABS = ['Ask', 'Findings', 'Publish', 'Doctor'] as const type Tab = (typeof TABS)[number] const message = (error: unknown): string => (error instanceof Error ? error.message : String(error)) type Loaded = { readonly value?: T; readonly error?: string } const attempt = (run: () => T): Loaded => { try { return { value: run() } } catch (error) { return { error: message(error) } } } const Tabs = ({ active }: { readonly active: Tab }): ReactElement => ( {TABS.map((tab) => ( {` ${tab} `} ))} ) const ErrorLine = ({ error }: { readonly error: string }): ReactElement => {`✗ ${error}`} const ContextView = ({ context, document, line }: { readonly context: DocumentContext | undefined; readonly document: string; readonly line: number | undefined }): ReactElement => ( {context ? ( context.lines.map((text, offset) => { const number = context.start + offset const focus = number === context.focus return ( {`${focus ? '▸' : ' '} ${String(number).padStart(4)} ${text}`} ) }) ) : ( The document could not be read. )} ) const FindingsTab = ({ gate, error, selected, detail, services }: { readonly gate: GateResult | undefined; readonly error: string | undefined; readonly selected: number; readonly detail: boolean; readonly services: UiServices }): ReactElement => { if (error) return if (!gate) return Running the reference-findings gate… const findings = gate.findings ?? [] if (gate.ok) return {`✓ ok · ${gate.message}`} if (findings.length === 0) return const chosen = findings[Math.min(selected, findings.length - 1)] return ( {`${findings.length} pending reference finding(s)`} {findings.slice(0, 50).map((finding, position) => ( {position === selected ? '❯ ' : ' '} {` ${finding.document}${finding.line === undefined ? '' : `:${finding.line}`} → ${finding.target}`} ))} {detail && chosen ? : null} ) } const PublishTab = ({ gate, result, error, confirming }: { readonly gate: GateResult | undefined; readonly result: IndexResult | undefined; readonly error: string | undefined; readonly confirming: boolean }): ReactElement => ( {gate ? : No publication gate is configured; the index is written without one.} {gate && !gate.ok && (gate.findings ?? []).length ? : null} {error ? : null} {confirming ? {`Publish past the gate (--allow-pending-findings)? The index records the override. y to confirm, any other key cancels.`} : null} {result ? ( ) : null} {!result && gate && !gate.ok ? {PUBLISH_ANYWAY_HINT} : null} ) export type UiAppProps = { readonly services: UiServices } /** The interactive app: sections on ←/→, Enter to act, `q` to quit. */ export const UiApp = ({ services }: UiAppProps): ReactElement => { const { exit } = useApp() const [tab, setTab] = useState('Ask') const [mode, setMode] = useState<'nav' | 'input' | 'confirm'>('nav') const [freshness, setFreshness] = useState(() => attempt(services.freshness).value) const [question, setQuestion] = useState('') const [ask, setAsk] = useState>({}) const [findings, setFindings] = useState>({}) const [findingsLoaded, setFindingsLoaded] = useState(false) const [selected, setSelected] = useState(0) const [detail, setDetail] = useState(false) const [publishGate, setPublishGate] = useState<{ readonly gate: GateResult | undefined; readonly error?: string } | undefined>() const [published, setPublished] = useState>({}) const [doctor, setDoctor] = useState>({}) const [doctorLoaded, setDoctorLoaded] = useState(false) const loadFindings = (): void => { setFindings(attempt(services.findings)) setFindingsLoaded(true) setSelected(0) setDetail(false) } const loadPublish = (): void => { const loaded = attempt(services.publicationGate) setPublishGate({ gate: loaded.value, ...(loaded.error ? { error: loaded.error } : {}) }) } const loadDoctor = (): void => { setDoctor(attempt(services.doctor)) setDoctorLoaded(true) } const goto = (next: Tab): void => { setTab(next) if (next === 'Findings' && !findingsLoaded) loadFindings() if (next === 'Publish' && !publishGate) loadPublish() if (next === 'Doctor' && !doctorLoaded) loadDoctor() } const publish = (allowPendingFindings: boolean): void => { setPublished(attempt(() => services.publish({ allowPendingFindings }))) setFreshness(attempt(services.freshness).value) loadPublish() } useInput((input, key) => { if (mode === 'input') { if (key.escape) setMode('nav') else if (key.return) { const asked = question.trim() setMode('nav') if (asked) setAsk(attempt(() => services.ask(asked))) } else if (key.backspace || key.delete) setQuestion((text) => text.slice(0, -1)) else if (input && !key.ctrl && !key.meta) setQuestion((text) => `${text}${input}`) return } if (mode === 'confirm') { setMode('nav') if (input === 'y') publish(true) return } if (input === 'q') { exit() return } const position = TABS.indexOf(tab) if (key.leftArrow) goto(TABS[(position + TABS.length - 1) % TABS.length] as Tab) else if (key.rightArrow) goto(TABS[(position + 1) % TABS.length] as Tab) else if (tab === 'Ask' && key.return) setMode('input') else if (tab === 'Findings') { const count = findings.value?.findings?.length ?? 0 if (key.upArrow) setSelected((value) => Math.max(0, value - 1)) else if (key.downArrow) setSelected((value) => Math.min(Math.max(0, count - 1), value + 1)) else if (key.return && count > 0) setDetail((value) => !value) else if (input === 'r') loadFindings() } else if (tab === 'Publish') { if (input === 'p') publish(false) else if (input === 'P') setMode('confirm') } else if (tab === 'Doctor' && input === 'r') loadDoctor() }) const hints = mode === 'input' ? 'type a question · Enter ask · Esc cancel' : mode === 'confirm' ? 'y publish anyway · any key cancels' : tab === 'Ask' ? '←/→ section · Enter ask · q quit' : tab === 'Findings' ? '←/→ section · ↑/↓ select · Enter context · r rerun · q quit' : tab === 'Publish' ? '←/→ section · p publish · P publish anyway · q quit' : '←/→ section · r rerun · q quit' return ( {tab === 'Ask' ? ( {'❯ '} {question} {mode === 'input' ? : question ? null : Press Enter, then type a question about this repository} {ask.error ? : null} {ask.value ? : null} ) : null} {tab === 'Findings' ? : null} {tab === 'Publish' ? : null} {tab === 'Doctor' ? (doctor.error ? : doctor.value ? : Loading…) : null} {`${services.name} · index ${freshness ?? 'unknown'} · ${hints}`} ) } /** `ak-docs ui`: the full-screen app over the library functions. Resolves when the user quits. */ export const runUi = async ({ root, config }: { readonly root: string; readonly config: DocBridgeConfigV1 }): Promise => { const instance = render(, { alternateScreen: true, exitOnCtrlC: true }) await instance.waitUntilExit() }