import { createContext, useContext, type ReactElement, type ReactNode } from 'react'
import { isAbsolute, relative } from 'node:path'
import { Box, Text, renderToString } from 'ink'
import { InkThemeProvider, StatusHeader } from '@agentskit/ink'
import { splitLines, toPosix } from '@agentskit/cross-platform'
import type { DoctorReport } from '../../doctor/run-doctor.js'
import type { GateResult, GateRunResult } from '../../gates/run-gates.js'
import type { PendingReferenceFinding } from '../../gates/reference-findings.js'
import type { KnowledgeDiagnostic } from '../../schemas/knowledge.js'
import type { AskResult } from './ask.js'
/** Where relative paths are anchored; defaults to the current directory. */
export type ViewOptions = { readonly root?: string }
const AMBER = 'yellow'
export const relPath = (path: string, root: string = process.cwd()): string => {
if (!isAbsolute(path)) return toPosix(path)
const rel = relative(root, path)
return rel.startsWith('..') || isAbsolute(rel) ? toPosix(path) : toPosix(rel)
}
/** Set inside the interactive app, which draws its own header: views then render only their body. */
export const EmbeddedContext = createContext(false)
/** The status header and spacing every view shares. */
export const Frame = ({ title, children }: { readonly title: string; readonly children?: ReactNode }): ReactElement => {
const embedded = useContext(EmbeddedContext)
return (
{embedded ? null : }
{children}
)
}
export const Path = ({ path, line }: { readonly path: string; readonly line?: number | undefined }): ReactElement => (
{path}
{line === undefined ? null : {`:${line}`}}
)
/** `color` only when there is one: Ink rejects an explicit `undefined` under exactOptionalPropertyTypes. */
export const tint = (color: string | undefined): { readonly color?: string } => (color === undefined ? {} : { color })
const codeColor = (code: string): string => (code.startsWith('BROKEN') ? 'red' : AMBER)
export const Code = ({ code, width = 0 }: { readonly code: string; readonly width?: number }): ReactElement => (
{code.padEnd(width)}
)
/** A tool-call style block: ✓/✗, a title, an optional duration, and the detail underneath. */
export const Step = ({ ok, title, durationMs, children }: { readonly ok: boolean; readonly title: string; readonly durationMs?: number | undefined; readonly children?: ReactNode }): ReactElement => (
{`${ok ? '✓' : '✗'} ${title}`}
{` · ${ok ? 'ok' : 'failed'}${durationMs === undefined ? '' : ` · ${durationMs}ms`}`}
{children}
)
export const Hint = ({ children }: { readonly children: ReactNode }): ReactElement => {children}
export const Footer = ({ title, lines }: { readonly title: string; readonly lines: readonly string[] }): ReactElement | null =>
lines.length === 0 ? null : (
{title}
{lines.map((line) => (
{` $ ${line}`}
))}
)
/** `CODE file:line → target`, one row per pending reference finding. */
export const FindingsTable = ({ findings, limit = 50 }: { readonly findings: readonly PendingReferenceFinding[]; readonly limit?: number }): ReactElement => {
const shown = findings.slice(0, limit)
const width = Math.max(0, ...shown.map((finding) => finding.code.length))
return (
{shown.map((finding, position) => (
{' '}
{' → '}
{finding.target}
))}
{findings.length > shown.length ? {`… and ${findings.length - shown.length} more (see the JSON output)`} : null}
)
}
export const PUBLISH_ANYWAY_HINT = 'Fix the cited documentation, or publish anyway with ak-docs index --allow-pending-findings.'
const firstLine = (text: string): string => splitLines(text)[0] ?? ''
/** One gate as a step: the pending findings table and the publish-anyway hint for `reference-findings`. */
export const GateStep = ({ gate }: { readonly gate: GateResult & { readonly durationMs?: number } }): ReactElement => {
const findings = gate.findings ?? []
return (
{gate.ok ? (
{gate.message}
) : gate.id === 'reference-findings' ? (
{firstLine(gate.message)}
{findings.length ? : null}
{findings.length ? {PUBLISH_ANYWAY_HINT} : null}
) : (
{gate.message}
{gate.expected ? {`expected ${gate.expected}`} : null}
{gate.actual ? {`actual ${gate.actual}`} : null}
)}
)
}
// ---- ask --------------------------------------------------------------------------------------
export const AskView = ({ result, root }: { readonly result: AskResult } & ViewOptions): ReactElement => {
const { best, matches, handoff } = result
return (
Question
{result.question}
{best ? (
Best match
{`${best.type} ${best.id}`}
{' ('}
{')'}
) : (
Best match: none
)}
{handoff ? (
Handoff
{` start: ${handoff.startHere ?? '(unknown)'}`}
{` edit: ${(handoff.editRoots ?? []).join(', ') || '(none)'}`}
{` checks: ${(handoff.checks ?? []).join(' · ') || '(none)'}`}
) : null}
Matches
{matches.length ? (
matches.slice(0, 5).map((match, position) => (
{` ${match.score.toFixed(2).padStart(6)} `}
{`[${match.type}] ${match.id}`}
{' '}
{match.summary ? {` ${match.summary.replace(/\s+/g, ' ').slice(0, 100)}`} : null}
))
) : (
{' No local matches. Try: ak-docs search '}
)}
)
}
// ---- gate -------------------------------------------------------------------------------------
export const GateView = ({ result }: { readonly result: GateRunResult }): ReactElement => {
const failing = result.results.filter((gate) => !gate.ok)
const blocked = failing.some((gate) => gate.id === 'reference-findings')
return (
{result.results.map((gate) => (
))}
{result.ok ? (
{`✓ ok · ${result.results.length} gate(s) passing`}
) : (
{`${blocked ? 'Publication blocked' : 'Gates failing'} · ${failing.length}/${result.results.length} gate(s) failing`}
)}
)
}
// ---- index ------------------------------------------------------------------------------------
export type IndexPublished = {
readonly ok: true
readonly publication?: { readonly pendingReferenceFindings?: { readonly override: string; readonly reason?: string | undefined; readonly pendingCount?: number | undefined } }
readonly indexPath: string
readonly llmsTxtPath?: string
readonly capabilitiesPath?: string
readonly contentHash: string
readonly knowledgeCount: number
readonly packageCount: number
readonly handoffCount: number
readonly diagnostics?: readonly string[]
readonly nextCommands: readonly string[]
}
export type IndexBlocked = { readonly ok: false; readonly blockedBy: string; readonly gate: GateResult }
export type IndexResult = IndexPublished | IndexBlocked
export const IndexView = ({ result, root }: { readonly result: IndexResult } & ViewOptions): ReactElement => {
if (!result.ok) {
return (
Publication blocked
Nothing was written: the index and llms.txt stay as they were.
)
}
const written = [result.indexPath, result.llmsTxtPath, result.capabilitiesPath].filter((path): path is string => Boolean(path))
const override = result.publication?.pendingReferenceFindings
return (
✓ published
{override ? {`Published past the reference-findings gate (${override.override}${override.pendingCount === undefined ? '' : `, ${override.pendingCount} pending`}); the index records the override.`} : null}
{` ${result.knowledgeCount} documents · ${result.packageCount} packages · ${result.handoffCount} handoffs`}
{` hash ${result.contentHash}`}
{written.map((path) => (
{' wrote '}
))}
{result.diagnostics?.length ? (
Notes
{result.diagnostics.map((note) => (
{` ! ${note}`}
))}
) : null}
)
}
// ---- diff -------------------------------------------------------------------------------------
export type DiffViewResult = { readonly findings: readonly KnowledgeDiagnostic[] }
const documentOf = (finding: KnowledgeDiagnostic): { readonly path: string; readonly line?: number } => {
const citation = finding.evidence.find((item) => item.source === 'documentation') ?? finding.evidence[0]
return citation?.path ? { path: citation.path, ...(citation.lineStart === undefined ? {} : { line: citation.lineStart }) } : { path: '(no document)' }
}
export const DiffView = ({ result }: { readonly result: DiffViewResult }): ReactElement => {
const groups = new Map()
for (const finding of result.findings) {
const { path, line } = documentOf(finding)
const rows = groups.get(path) ?? []
rows.push({ finding, ...(line === undefined ? {} : { line }) })
groups.set(path, rows)
}
const width = Math.max(0, ...result.findings.map((finding) => finding.code.length))
return (
{groups.size === 0 ? (
✓ ok · no findings
) : (
[...groups].map(([path, rows]) => (
{rows.map(({ finding, line }, position) => (
{' '}
{line === undefined ? ' ' : ` :${line} `}
{firstLine(finding.message)}
))}
))
)}
{`${result.findings.length} finding(s) in ${groups.size} document(s)`}
)
}
// ---- doctor -----------------------------------------------------------------------------------
export const Bar = ({ pct, width = 20 }: { readonly pct: number; readonly width?: number }): ReactElement => {
const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
const color = pct >= 90 ? 'green' : pct >= 60 ? AMBER : 'red'
return (
{'█'.repeat(filled)}
{'░'.repeat(width - filled)}
{` ${String(Math.round(pct)).padStart(3)}%`}
)
}
const gradeColor = (grade: DoctorReport['grade']): string => (grade === 'A' || grade === 'B' ? 'green' : grade === 'C' ? AMBER : 'red')
export const DoctorView = ({ report }: { readonly report: DoctorReport }): ReactElement => {
const { coverage } = report
const pct = (part: number, total: number): number => (total > 0 ? (part / total) * 100 : 0)
const rows: readonly (readonly [string, number])[] = [
['Reachability', coverage.reachability.pct],
['Connectivity', coverage.connectivity.pct],
['Handoff-ready', pct(coverage.packages.withAgentDoc, coverage.packages.total)],
['Human guides', pct(coverage.packages.withHumanDoc, coverage.packages.total)],
['Corpus indexed', pct(coverage.agentDocs.indexed, coverage.agentDocs.total)],
]
const passing = coverage.gates.results.filter((gate) => gate.ok).length
return (
Grade
{report.grade}
{` score ${report.score}/100`}
{rows.map(([label, value]) => (
{` ${label.padEnd(15)}`}
))}
{` ${'Index'.padEnd(15)}${coverage.freshness.ok ? 'fresh' : 'stale or missing'}`}
{` ${'Gates'.padEnd(15)}${passing}/${coverage.gates.results.length} passing`}
{report.grading.unmet.length ? (
{`Grade ${report.grade}: an A requires`}
{report.grading.unmet.map((reason) => (
{` • ${reason}`}
))}
) : null}
{report.issues.length ? (
Issues
{report.issues.slice(0, 8).map((issue) => (
{` ${issue.severity === 'error' ? '✗' : issue.severity === 'warn' ? '!' : '·'} [${issue.code}] ${issue.message}`}
))}
) : null}
)
}
// ---- vault export -----------------------------------------------------------------------------
export type VaultExportResult = { readonly ok?: boolean; readonly output: string; readonly notes: number; readonly removed: number; readonly findings?: number; readonly warnings?: readonly string[] }
export const VaultExportView = ({ result, root }: { readonly result: VaultExportResult } & ViewOptions): ReactElement => (
✓ ok · vault exported
{' vault '}
{` notes ${result.notes}`}
{` removed ${result.removed}`}
{result.findings === undefined ? null : 0 ? AMBER : undefined)}>{` findings ${result.findings}`}}
{result.warnings?.length ? (
{result.warnings.map((warning) => (
{` ! ${warning}`}
))}
) : null}
)
// ---- registry and printing --------------------------------------------------------------------
/** One view per command id, taking the result the command already produces. */
export const RICH_VIEWS = {
ask: (result: AskResult, options: ViewOptions = {}) => ,
gate: (result: GateRunResult) => ,
index: (result: IndexResult, options: ViewOptions = {}) => ,
diff: (result: DiffViewResult) => ,
doctor: (report: DoctorReport) => ,
vault: (result: VaultExportResult, options: ViewOptions = {}) => ,
} as const
export type RichCommand = keyof typeof RICH_VIEWS
/**
* Renders once into the scrollback at the terminal's width: a static frame, no input, no live
* region, so nothing is redrawn or duplicated when the process exits.
*/
export const printRich = async (element: ReactElement, options: { readonly stdout?: NodeJS.WriteStream } = {}): Promise => {
const stdout = options.stdout ?? process.stdout
stdout.write(`${renderToString(element, { columns: stdout.columns || 100 })}\n`)
}