|
import { |
|
createCliRenderer, |
|
BoxRenderable, |
|
TextRenderable, |
|
InputRenderable, |
|
SelectRenderable, |
|
ScrollBoxRenderable, |
|
} from "@opentui/core"; |
|
import type { CliRenderer } from "@opentui/core"; |
|
import { Effect, Layer } from "effect"; |
|
import { GeminiLive, GitHubLive, Gemini, GitHub, fetchRepo } from "./services"; |
|
import type { RepoData } from "./services"; |
|
import type { Command, DocumentationStyle } from "./shared"; |
|
import { loadEnv } from "./shared"; |
|
import * as validation from "../validation"; |
|
|
|
// ── Palette (GitHub dark) ── |
|
const C = { |
|
bg: "#0d1117", |
|
surface: "#161b22", |
|
border: "#30363d", |
|
text: "#e6edf3", |
|
muted: "#7d8590", |
|
accent: "#58a6ff", |
|
success: "#3fb950", |
|
hint: "#484f58", |
|
} as const; |
|
|
|
// ── Types ── |
|
const COMMANDS: { name: string; description: string; value: Command }[] = [ |
|
{ name: "readme", description: "Generate README documentation", value: "readme" }, |
|
{ name: "topics", description: "Generate and apply repository topics", value: "topics" }, |
|
{ name: "describe", description: "Generate description and homepage URL", value: "describe" }, |
|
{ name: "summary", description: "Summarize repository", value: "summary" }, |
|
{ name: "tech", description: "List technologies used", value: "tech" }, |
|
{ name: "improve", description: "Suggest improvements", value: "improve" }, |
|
]; |
|
|
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; |
|
const ACTION_LABELS: Record<Command, string> = { |
|
readme: "[s] save [p] save & push [q] quit", |
|
topics: "[a] apply [m] merge & apply [q] quit", |
|
describe: "[a] apply [q] quit", |
|
summary: "[q] quit", |
|
tech: "[q] quit", |
|
improve: "[q] quit", |
|
}; |
|
const compareNumbersAscending = (left: number, right: number) => left - right; |
|
const compareStringsAscending = (left: string, right: string) => left.localeCompare(right); |
|
|
|
// ============================================================================ |
|
// Multi-Select Component |
|
// ============================================================================ |
|
|
|
interface MultiSelectControl { |
|
container: BoxRenderable; |
|
getSelected: () => Command[]; |
|
destroy: () => void; |
|
} |
|
|
|
interface DescribeResult { |
|
description: string; |
|
homepage: string | null; |
|
} |
|
|
|
type ActionRunner = (() => Promise<void>) | "quit"; |
|
|
|
function getRequiredItem<T>(items: readonly T[], index: number, label: string): T { |
|
const item = items[index]; |
|
if (item === undefined) { |
|
throw new Error(`Missing ${label} at index ${index}`); |
|
} |
|
return item; |
|
} |
|
|
|
function getSelectedCommands(selected: Set<number>, options: typeof COMMANDS): Command[] { |
|
return [...selected] |
|
.sort(compareNumbersAscending) |
|
.map((index) => getRequiredItem(options, index, "command option").value); |
|
} |
|
|
|
function createMultiSelect( |
|
renderer: CliRenderer, |
|
options: typeof COMMANDS, |
|
onConfirm: (selected: Command[]) => void, |
|
): MultiSelectControl { |
|
const selected = new Set<number>(); |
|
let focusIndex = 0; |
|
|
|
const container = new BoxRenderable(renderer, { |
|
flexDirection: "column", |
|
width: 56, |
|
gap: 0, |
|
}); |
|
|
|
const rows: { box: BoxRenderable; checkbox: TextRenderable; label: TextRenderable; desc: TextRenderable }[] = []; |
|
|
|
for (let i = 0; i < options.length; i++) { |
|
const opt = getRequiredItem(options, i, "command option"); |
|
const row = new BoxRenderable(renderer, { |
|
flexDirection: "row", |
|
width: "100%", |
|
height: 2, |
|
paddingLeft: 1, |
|
paddingRight: 1, |
|
backgroundColor: i === 0 ? C.surface : C.bg, |
|
}); |
|
|
|
const checkbox = new TextRenderable(renderer, { |
|
content: "[ ]", |
|
width: 4, |
|
height: 1, |
|
fg: C.muted, |
|
}); |
|
|
|
const label = new TextRenderable(renderer, { |
|
content: opt.name, |
|
height: 1, |
|
fg: i === 0 ? C.text : C.muted, |
|
}); |
|
|
|
const desc = new TextRenderable(renderer, { |
|
content: ` ${opt.description}`, |
|
height: 1, |
|
fg: C.hint, |
|
}); |
|
|
|
const textCol = new BoxRenderable(renderer, { |
|
flexDirection: "column", |
|
height: 2, |
|
}); |
|
textCol.add(label); |
|
textCol.add(desc); |
|
|
|
row.add(checkbox); |
|
row.add(textCol); |
|
container.add(row); |
|
rows.push({ box: row, checkbox, label, desc }); |
|
} |
|
|
|
function updateVisuals() { |
|
for (let i = 0; i < rows.length; i++) { |
|
const r = getRequiredItem(rows, i, "command row"); |
|
const opt = getRequiredItem(options, i, "command option"); |
|
const isFocused = i === focusIndex; |
|
const isSelected = selected.has(i); |
|
r.box.backgroundColor = isFocused ? C.surface : C.bg; |
|
r.label.content = opt.name; |
|
// @ts-ignore - fg is settable on TextRenderable |
|
r.label.fg = isFocused ? C.text : C.muted; |
|
r.checkbox.content = isSelected ? "[✓]" : "[ ]"; |
|
// @ts-ignore |
|
r.checkbox.fg = isSelected ? C.success : C.muted; |
|
} |
|
} |
|
|
|
function handleKey(key: any): boolean { |
|
if (key.name === "up" || key.name === "k") { |
|
focusIndex = (focusIndex - 1 + options.length) % options.length; |
|
updateVisuals(); |
|
return true; |
|
} |
|
if (key.name === "down" || key.name === "j") { |
|
focusIndex = (focusIndex + 1) % options.length; |
|
updateVisuals(); |
|
return true; |
|
} |
|
if (key.name === "space" || key.sequence === " ") { |
|
if (selected.has(focusIndex)) selected.delete(focusIndex); |
|
else selected.add(focusIndex); |
|
updateVisuals(); |
|
return true; |
|
} |
|
if (key.name === "return" || key.name === "enter") { |
|
if (selected.size === 0) return true; // require at least one |
|
const cmds = getSelectedCommands(selected, options); |
|
onConfirm(cmds); |
|
return true; |
|
} |
|
return false; |
|
} |
|
|
|
const keyHandler = (key: any) => handleKey(key); |
|
renderer.keyInput.on("keypress", keyHandler); |
|
|
|
return { |
|
container, |
|
getSelected: () => getSelectedCommands(selected, options), |
|
destroy: () => renderer.keyInput.off("keypress", keyHandler), |
|
}; |
|
} |
|
|
|
// ============================================================================ |
|
// Spinner Helper |
|
// ============================================================================ |
|
|
|
interface SpinnerControl { |
|
stop: (success: boolean) => void; |
|
} |
|
|
|
function createSpinner(text: TextRenderable, label: string): SpinnerControl { |
|
let frame = 0; |
|
let running = true; |
|
const baseText = label; |
|
|
|
// Render first frame immediately (no 80ms blank gap) |
|
// @ts-ignore - fg is settable on TextRenderable |
|
text.fg = C.accent; |
|
text.content = `${SPINNER_FRAMES[0]} ${baseText}`; |
|
|
|
const interval = setInterval(() => { |
|
if (!running) return; |
|
frame++; |
|
text.content = `${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ${baseText}`; |
|
}, 80); |
|
|
|
return { |
|
stop: (success: boolean) => { |
|
running = false; |
|
clearInterval(interval); |
|
const label = baseText.replace("...", ""); |
|
if (success) { |
|
// @ts-ignore |
|
text.fg = C.success; |
|
text.content = `✓ ${label}`; |
|
} else { |
|
// @ts-ignore |
|
text.fg = "#f85149"; |
|
text.content = `✗ ${label}`; |
|
} |
|
}, |
|
}; |
|
} |
|
|
|
// ============================================================================ |
|
// Utilities |
|
// ============================================================================ |
|
|
|
function waitForKey(renderer: CliRenderer, keyName: string): Promise<void> { |
|
return new Promise((resolve) => { |
|
const handler = (key: any) => { |
|
if (key.name === keyName) { |
|
renderer.keyInput.off("keypress", handler); |
|
resolve(); |
|
} |
|
}; |
|
renderer.keyInput.on("keypress", handler); |
|
}); |
|
} |
|
|
|
function sleep(ms: number): Promise<void> { |
|
return new Promise((r) => setTimeout(r, ms)); |
|
} |
|
|
|
function parseTopics(result: string): string[] { |
|
const jsonMatch = /\[[\s\S]*?\]/.exec(result); |
|
if (!jsonMatch) { |
|
throw new Error("Could not parse topics"); |
|
} |
|
|
|
return (JSON.parse(jsonMatch[0]) as string[]).map((topic) => topic.toLowerCase().trim().replaceAll(/\s+/g, "-")); |
|
} |
|
|
|
function parseDescribeResult(result: string): DescribeResult { |
|
const jsonMatch = /\{[\s\S]*?\}/.exec(result); |
|
if (!jsonMatch) { |
|
throw new Error("Could not parse description"); |
|
} |
|
|
|
return JSON.parse(jsonMatch[0]) as DescribeResult; |
|
} |
|
|
|
async function writeReadme(result: string): Promise<void> { |
|
const { writeFile } = await import("node:fs/promises"); |
|
await writeFile("README.md", result); |
|
} |
|
|
|
async function pushReadme(ctx: ExecuteContext, result: string): Promise<void> { |
|
await Effect.runPromise( |
|
Effect.gen(function* () { |
|
const github = yield* GitHub; |
|
yield* github.pushFile(ctx.owner, ctx.repo, "README.md", result, "docs: update README with AI-generated content", "main"); |
|
}).pipe(Effect.provide(ctx.layers)), |
|
); |
|
} |
|
|
|
async function getExistingTopics(ctx: ExecuteContext): Promise<readonly string[]> { |
|
return Effect.runPromise( |
|
Effect.gen(function* () { |
|
const github = yield* GitHub; |
|
return yield* github.getTopics(ctx.owner, ctx.repo); |
|
}).pipe(Effect.provide(ctx.layers)), |
|
); |
|
} |
|
|
|
async function updateTopics(ctx: ExecuteContext, topics: string[]): Promise<void> { |
|
await Effect.runPromise( |
|
Effect.gen(function* () { |
|
const github = yield* GitHub; |
|
yield* github.setTopics(ctx.owner, ctx.repo, topics); |
|
}).pipe(Effect.provide(ctx.layers)), |
|
); |
|
} |
|
|
|
async function applyRepoDescription( |
|
ctx: ExecuteContext, |
|
data: { description?: string; homepage?: string }, |
|
): Promise<void> { |
|
await Effect.runPromise( |
|
Effect.gen(function* () { |
|
const github = yield* GitHub; |
|
yield* github.updateRepo(ctx.owner, ctx.repo, data); |
|
}).pipe(Effect.provide(ctx.layers)), |
|
); |
|
} |
|
|
|
async function saveReadmeMutation( |
|
renderer: CliRenderer, |
|
main: BoxRenderable, |
|
result: string, |
|
): Promise<void> { |
|
await performMutation(renderer, main, "Save README to README.md?", "README saved to README.md", async () => { |
|
await writeReadme(result); |
|
}); |
|
} |
|
|
|
async function saveAndPushReadmeMutation( |
|
renderer: CliRenderer, |
|
main: BoxRenderable, |
|
result: string, |
|
ctx: ExecuteContext, |
|
): Promise<void> { |
|
await performMutation( |
|
renderer, |
|
main, |
|
`Save & push README to ${ctx.owner}/${ctx.repo}?`, |
|
`README saved and pushed to ${ctx.owner}/${ctx.repo}`, |
|
async () => { |
|
await writeReadme(result); |
|
await pushReadme(ctx, result); |
|
}, |
|
); |
|
} |
|
|
|
async function applyTopicsMutation( |
|
renderer: CliRenderer, |
|
main: BoxRenderable, |
|
result: string, |
|
ctx: ExecuteContext, |
|
merge: boolean, |
|
): Promise<void> { |
|
const confirmMessage = `${merge ? "Merge & apply" : "Apply"} topics to ${ctx.owner}/${ctx.repo}?`; |
|
const successMessage = `Topics applied to ${ctx.owner}/${ctx.repo}`; |
|
await performMutation(renderer, main, confirmMessage, successMessage, async () => { |
|
let topics = parseTopics(result); |
|
|
|
if (merge && ctx.githubToken) { |
|
const current = await getExistingTopics(ctx); |
|
topics = [...new Set([...current, ...topics])].sort(compareStringsAscending); |
|
} |
|
|
|
await updateTopics(ctx, topics); |
|
}); |
|
} |
|
|
|
async function applyDescriptionMutation( |
|
renderer: CliRenderer, |
|
main: BoxRenderable, |
|
result: string, |
|
ctx: ExecuteContext, |
|
): Promise<void> { |
|
await performMutation( |
|
renderer, |
|
main, |
|
`Apply description to ${ctx.owner}/${ctx.repo}?`, |
|
`Description applied to ${ctx.owner}/${ctx.repo}`, |
|
async () => { |
|
const parsed = parseDescribeResult(result); |
|
const data: { description?: string; homepage?: string } = { description: parsed.description }; |
|
if (parsed.homepage) { |
|
data.homepage = parsed.homepage; |
|
} |
|
|
|
await applyRepoDescription(ctx, data); |
|
}, |
|
); |
|
} |
|
|
|
function getActionRunner( |
|
renderer: CliRenderer, |
|
main: BoxRenderable, |
|
command: Command, |
|
keyName: string | undefined, |
|
result: string, |
|
ctx: ExecuteContext, |
|
): ActionRunner | undefined { |
|
if (keyName === "q") { |
|
return "quit"; |
|
} |
|
|
|
if (command === "readme" && keyName === "s") { |
|
return () => saveReadmeMutation(renderer, main, result); |
|
} |
|
|
|
if (command === "readme" && keyName === "p") { |
|
return () => saveAndPushReadmeMutation(renderer, main, result, ctx); |
|
} |
|
|
|
if (command === "topics" && (keyName === "a" || keyName === "m")) { |
|
return () => applyTopicsMutation(renderer, main, result, ctx, keyName === "m"); |
|
} |
|
|
|
if (command === "describe" && keyName === "a") { |
|
return () => applyDescriptionMutation(renderer, main, result, ctx); |
|
} |
|
|
|
return undefined; |
|
} |
|
|
|
// ============================================================================ |
|
// Prompts (TUI needs these directly for fine-grained loading control) |
|
// ============================================================================ |
|
|
|
interface ExecuteContext { |
|
commands: Command[]; |
|
repoUrl: string; |
|
owner: string; |
|
repo: string; |
|
readmeStyle: DocumentationStyle; |
|
layers: Layer.Layer<Gemini | GitHub>; |
|
githubToken?: string; |
|
} |
|
|
|
function extractExistingReadme(content: string): string | null { |
|
// gitingest output includes file paths — look for README content |
|
const readmeMatch = /(?:^|\n)(?:={3,}|─{3,})?\s*README\.md\s*(?:={3,}|─{3,})?\n([\s\S]*?)(?=\n(?:={3,}|─{3,})\s*\S+\.\S+|$)/.exec(content); |
|
if (readmeMatch?.[1] && readmeMatch[1].trim().length > 50) { |
|
return readmeMatch[1].trim(); |
|
} |
|
return null; |
|
} |
|
|
|
function getReadmePrompt(repoData: RepoData, style: DocumentationStyle): string { |
|
const styleGuidance: Record<DocumentationStyle, string> = { |
|
minimal: "Keep total README under 300 lines; prioritize scannability. Include ONLY: title, one-liner description, install command, and single quick-start example.", |
|
standard: "Target 400-800 lines; balance depth with readability. Include: badges, description, installation, usage examples (2-3), API overview, and contributing basics.", |
|
comprehensive: "No line limit; prioritize thoroughness. Include all sections: badges, description, features list, installation, API reference, architecture, etc.", |
|
}; |
|
|
|
const existingReadme = extractExistingReadme(repoData.content); |
|
const existingSection = existingReadme |
|
? `\n<existing_readme> |
|
The repository already has a README. Build upon and improve it — preserve any accurate content, links, badges, and structure that are still relevant. Enhance with better organization, missing sections, and more detail based on the actual codebase. Do NOT discard good existing content. |
|
${existingReadme.slice(0, 4000)} |
|
</existing_readme>` |
|
: ""; |
|
|
|
return `You are an expert technical writer. Generate production-ready MD documentation for this repository. |
|
|
|
<output_rules> |
|
- Return ONLY raw MD content—no preamble, no markdown fences |
|
- First line must be the H1 title |
|
</output_rules> |
|
|
|
<style_profile> |
|
Style: ${style.toUpperCase()} |
|
${styleGuidance[style]} |
|
</style_profile> |
|
|
|
<structure> |
|
Provide: Header, TOC, Overview, Features, Architecture (with Mermaid), Quick Start, Usage, Configuration, API, Development, Contributing, Roadmap, License. |
|
</structure> |
|
${existingSection} |
|
<repository_context> |
|
URL: ${repoData.repo_url} |
|
Summary: ${repoData.summary} |
|
Structure: ${repoData.tree} |
|
Content Sample: ${repoData.content.slice(0, 8000)} |
|
</repository_context>`; |
|
} |
|
|
|
const TOPICS_PROMPT = `Analyze this repository and suggest 5-8 relevant GitHub topics. |
|
Return ONLY a JSON array of lowercase, hyphenated strings. Example: ["typescript", "cli-tool", "ai-powered"]`; |
|
|
|
const DESCRIBE_PROMPT = `Analyze this repository and generate: |
|
1. A concise repository description (max 350 characters) suitable for GitHub's "About" section |
|
2. A homepage URL if you can detect one from the repository content |
|
|
|
Look for homepage URLs in: |
|
- package.json "homepage" field |
|
- docs site configurations (docusaurus, vitepress, mkdocs, etc.) |
|
- deployment configs referencing domains (vercel.json, netlify.toml, CNAME files) |
|
- GitHub Pages configuration |
|
- README badges or links pointing to live demos, docs, or package registries |
|
|
|
Return ONLY a JSON object: { "description": "...", "homepage": "https://... or null" }`; |
|
|
|
const ANALYSIS_PROMPTS: Record<string, string> = { |
|
summary: "Provide a comprehensive 2-3 paragraph summary of this repository.", |
|
tech: "List all technologies, frameworks, and tools used in this repository as a categorized markdown list.", |
|
improve: "Suggest 5 specific, actionable improvements for this repository.", |
|
}; |
|
|
|
function getPromptForCommand(command: Command, repoData: RepoData, style: DocumentationStyle): string { |
|
if (command === "readme") return getReadmePrompt(repoData, style); |
|
if (command === "topics") return `${TOPICS_PROMPT}\n\nTree:\n${repoData.tree}\n\nContent:\n${repoData.content.slice(0, 4000)}`; |
|
if (command === "describe") return `${DESCRIBE_PROMPT}\n\nURL: ${repoData.repo_url}\nSummary: ${repoData.summary}\nTree:\n${repoData.tree}\n\nContent:\n${repoData.content.slice(0, 6000)}`; |
|
return `${ANALYSIS_PROMPTS[command]}\n\nTree:\n${repoData.tree}\n\nContent:\n${repoData.content.slice(0, 4000)}`; |
|
} |
|
|
|
// ============================================================================ |
|
// Result Formatting |
|
// ============================================================================ |
|
|
|
function formatResult(command: Command, raw: string): string { |
|
if (command === "topics") { |
|
const jsonMatch = /\[[\s\S]*?\]/.exec(raw); |
|
if (jsonMatch) { |
|
try { |
|
const topics = JSON.parse(jsonMatch[0]) as string[]; |
|
const topicList = topics.map((topic) => ` • ${topic}`).join("\n"); |
|
return `Suggested Topics:\n\n${topicList}`; |
|
} catch { /* fall through */ } |
|
} |
|
return raw; |
|
} |
|
|
|
if (command === "describe") { |
|
const jsonMatch = /\{[\s\S]*?\}/.exec(raw); |
|
if (jsonMatch) { |
|
try { |
|
const parsed = JSON.parse(jsonMatch[0]) as DescribeResult; |
|
let out = `Description:\n ${parsed.description}`; |
|
if (parsed.homepage) out += `\n\nHomepage:\n ${parsed.homepage}`; |
|
return out; |
|
} catch { /* fall through */ } |
|
} |
|
return raw; |
|
} |
|
|
|
// readme, summary, tech, improve — show raw |
|
return raw; |
|
} |
|
|
|
// ============================================================================ |
|
// Mutation Handling |
|
// ============================================================================ |
|
|
|
async function performMutation( |
|
renderer: CliRenderer, |
|
main: BoxRenderable, |
|
confirmMessage: string, |
|
successMessage: string, |
|
action: () => Promise<void>, |
|
): Promise<void> { |
|
// Show confirmation per spec: "Apply topics to owner/repo? [enter] confirm [esc] cancel" |
|
const confirm = new TextRenderable(renderer, { |
|
content: `${confirmMessage} [enter] confirm [esc] cancel`, |
|
height: 1, |
|
fg: C.text, |
|
marginTop: 1, |
|
}); |
|
main.add(confirm); |
|
renderer.requestRender(); |
|
|
|
const confirmed = await new Promise<boolean>((resolve) => { |
|
const handler = (key: any) => { |
|
if (key.name === "return" || key.name === "enter") { |
|
renderer.keyInput.off("keypress", handler); |
|
resolve(true); |
|
} else if (key.name === "escape") { |
|
renderer.keyInput.off("keypress", handler); |
|
resolve(false); |
|
} |
|
}; |
|
renderer.keyInput.on("keypress", handler); |
|
}); |
|
|
|
main.remove(confirm.id); |
|
|
|
if (!confirmed) { |
|
const cancelled = new TextRenderable(renderer, { |
|
content: "Cancelled", |
|
height: 1, |
|
fg: C.muted, |
|
}); |
|
main.add(cancelled); |
|
renderer.requestRender(); |
|
await sleep(800); |
|
main.remove(cancelled.id); |
|
return; |
|
} |
|
|
|
// Execute |
|
const status = new TextRenderable(renderer, { |
|
content: "Applying...", |
|
height: 1, |
|
fg: C.accent, |
|
}); |
|
main.add(status); |
|
renderer.requestRender(); |
|
|
|
try { |
|
await action(); |
|
// Per spec: "✓ Topics applied to owner/repo" with just [q] quit |
|
status.content = `✓ ${successMessage}`; |
|
// @ts-ignore |
|
status.fg = C.success; |
|
renderer.requestRender(); |
|
|
|
// Show [q] quit and wait for user |
|
const doneBar = new TextRenderable(renderer, { |
|
content: "[q] quit", |
|
height: 1, |
|
fg: C.muted, |
|
marginTop: 1, |
|
}); |
|
main.add(doneBar); |
|
renderer.requestRender(); |
|
await waitForKey(renderer, "q"); |
|
main.remove(doneBar.id); |
|
} catch (err) { |
|
status.content = `✗ Failed: ${err instanceof Error ? err.message : String(err)}`; |
|
// @ts-ignore |
|
status.fg = "#f85149"; |
|
renderer.requestRender(); |
|
await waitForKey(renderer, "q"); |
|
} |
|
|
|
main.remove(status.id); |
|
} |
|
|
|
// ============================================================================ |
|
// Action Bars |
|
// ============================================================================ |
|
|
|
async function showActionBar( |
|
renderer: CliRenderer, |
|
main: BoxRenderable, |
|
command: Command, |
|
result: string, |
|
ctx: ExecuteContext, |
|
): Promise<void> { |
|
const actionBar = new TextRenderable(renderer, { |
|
content: ACTION_LABELS[command], |
|
height: 1, |
|
fg: C.muted, |
|
marginTop: 1, |
|
}); |
|
main.add(actionBar); |
|
renderer.requestRender(); |
|
|
|
await new Promise<void>((resolve) => { |
|
const handler = async (key: any) => { |
|
const action = getActionRunner(renderer, main, command, key.name, result, ctx); |
|
if (!action) { |
|
return; |
|
} |
|
|
|
renderer.keyInput.off("keypress", handler); |
|
if (action === "quit") { |
|
main.remove(actionBar.id); |
|
resolve(); |
|
return; |
|
} |
|
|
|
await action(); |
|
main.remove(actionBar.id); |
|
resolve(); |
|
}; |
|
|
|
renderer.keyInput.on("keypress", handler); |
|
}); |
|
} |
|
|
|
// ============================================================================ |
|
// Execution Phase |
|
// ============================================================================ |
|
|
|
async function executeCommands( |
|
renderer: CliRenderer, |
|
main: BoxRenderable, |
|
ctx: ExecuteContext, |
|
): Promise<void> { |
|
// Fetch repo data once |
|
const fetchStatus = new TextRenderable(renderer, { |
|
content: "Fetching repository content", |
|
height: 1, |
|
fg: C.muted, |
|
}); |
|
main.add(fetchStatus); |
|
renderer.requestRender(); |
|
|
|
const fetchSpinner = createSpinner(fetchStatus, "Fetching repository content"); |
|
let repoData: RepoData; |
|
|
|
try { |
|
repoData = await Effect.runPromise(fetchRepo(ctx.repoUrl)); |
|
fetchSpinner.stop(true); |
|
} catch (err) { |
|
fetchSpinner.stop(false); |
|
fetchStatus.content = `✗ Failed to fetch: ${err instanceof Error ? err.message : String(err)}`; |
|
renderer.requestRender(); |
|
await waitForKey(renderer, "q"); |
|
main.remove(fetchStatus.id); |
|
return; |
|
} |
|
renderer.requestRender(); |
|
|
|
// Execute each command sequentially |
|
for (let i = 0; i < ctx.commands.length; i++) { |
|
const command = getRequiredItem(ctx.commands, i, "command"); |
|
|
|
const cmdHeader = new TextRenderable(renderer, { |
|
content: `${command} ${i + 1}/${ctx.commands.length} › ${ctx.owner}/${ctx.repo}`, |
|
height: 1, |
|
fg: C.accent, |
|
marginTop: 1, |
|
marginBottom: 1, |
|
}); |
|
main.add(cmdHeader); |
|
|
|
// Generate |
|
const genStatus = new TextRenderable(renderer, { |
|
content: "Generating via Gemini...", |
|
height: 1, |
|
fg: C.muted, |
|
}); |
|
main.add(genStatus); |
|
renderer.requestRender(); |
|
|
|
const genSpinner = createSpinner(genStatus, "Generating via Gemini..."); |
|
let result: string; |
|
|
|
try { |
|
const prompt = getPromptForCommand(command, repoData, ctx.readmeStyle); |
|
result = await Effect.runPromise( |
|
Effect.gen(function* () { |
|
const gemini = yield* Gemini; |
|
return yield* gemini.generateContent(prompt); |
|
}).pipe(Effect.provide(ctx.layers)), |
|
); |
|
genSpinner.stop(true); |
|
} catch (err) { |
|
genSpinner.stop(false); |
|
genStatus.content = `✗ Generation failed: ${err instanceof Error ? err.message : String(err)}`; |
|
renderer.requestRender(); |
|
await waitForKey(renderer, "q"); |
|
main.remove(cmdHeader.id); |
|
main.remove(genStatus.id); |
|
continue; |
|
} |
|
|
|
renderer.requestRender(); |
|
|
|
// Display results in ScrollBox |
|
const resultBox = new ScrollBoxRenderable(renderer, { |
|
width: 58, |
|
height: 12, |
|
scrollY: true, |
|
border: true, |
|
borderColor: C.border, |
|
borderStyle: "single", |
|
backgroundColor: C.surface, |
|
}); |
|
|
|
const resultText = new TextRenderable(renderer, { |
|
content: formatResult(command, result), |
|
fg: C.text, |
|
width: 54, |
|
}); |
|
resultBox.add(resultText); |
|
main.add(resultBox); |
|
renderer.requestRender(); |
|
|
|
// Show action bar and wait for user action |
|
await showActionBar(renderer, main, command, result, ctx); |
|
|
|
// Clean up results for this command before next |
|
main.remove(resultBox.id); |
|
main.remove(cmdHeader.id); |
|
main.remove(genStatus.id); |
|
renderer.requestRender(); |
|
} |
|
|
|
// Remove fetch status |
|
main.remove(fetchStatus.id); |
|
renderer.requestRender(); |
|
} |
|
|
|
// ============================================================================ |
|
// Phase Helpers |
|
// ============================================================================ |
|
|
|
function promptForUrl(renderer: CliRenderer, main: BoxRenderable): Promise<string> { |
|
return new Promise((resolve) => { |
|
const inputLabel = new TextRenderable(renderer, { |
|
content: "Repository URL", |
|
height: 1, |
|
fg: C.muted, |
|
marginBottom: 1, |
|
}); |
|
|
|
const urlInput = new InputRenderable(renderer, { |
|
placeholder: "https://github.com/owner/repo", |
|
maxLength: 200, |
|
width: 56, |
|
backgroundColor: C.surface, |
|
textColor: C.text, |
|
focusedBackgroundColor: C.surface, |
|
focusedTextColor: C.text, |
|
placeholderColor: C.hint, |
|
}); |
|
|
|
const inputHint = new TextRenderable(renderer, { |
|
content: "enter submit esc quit", |
|
height: 1, |
|
fg: C.hint, |
|
marginTop: 1, |
|
}); |
|
|
|
main.add(inputLabel); |
|
main.add(urlInput); |
|
main.add(inputHint); |
|
urlInput.focus(); |
|
renderer.requestRender(); |
|
|
|
urlInput.on("enter", () => { |
|
const url = urlInput.value.trim(); |
|
if (!url) return; |
|
|
|
main.remove(inputLabel.id); |
|
main.remove(urlInput.id); |
|
main.remove(inputHint.id); |
|
|
|
const urlBreadcrumb = new TextRenderable(renderer, { |
|
content: `Repo: ${url}`, |
|
height: 1, |
|
fg: C.success, |
|
marginBottom: 1, |
|
}); |
|
main.add(urlBreadcrumb); |
|
renderer.requestRender(); |
|
|
|
resolve(url); |
|
}); |
|
}); |
|
} |
|
|
|
function promptForReadmeStyle(renderer: CliRenderer, main: BoxRenderable): Promise<DocumentationStyle> { |
|
return new Promise((resolve) => { |
|
const styleLabel = new TextRenderable(renderer, { |
|
content: "README style", |
|
height: 1, |
|
fg: C.muted, |
|
marginBottom: 1, |
|
}); |
|
|
|
const styleSelect = new SelectRenderable(renderer, { |
|
options: [ |
|
{ name: "minimal", description: "Short, scannable (~300 lines)" }, |
|
{ name: "standard", description: "Balanced depth and readability (~400-800 lines)" }, |
|
{ name: "comprehensive", description: "Thorough, no line limit" }, |
|
], |
|
selectedIndex: 1, |
|
width: 56, |
|
height: 9, |
|
showDescription: true, |
|
itemSpacing: 1, |
|
backgroundColor: C.bg, |
|
textColor: C.muted, |
|
focusedBackgroundColor: C.bg, |
|
focusedTextColor: C.muted, |
|
selectedBackgroundColor: C.surface, |
|
selectedTextColor: C.accent, |
|
descriptionColor: C.hint, |
|
selectedDescriptionColor: C.muted, |
|
wrapSelection: true, |
|
}); |
|
|
|
const styleHint = new TextRenderable(renderer, { |
|
content: "↑↓ navigate enter select", |
|
height: 1, |
|
fg: C.hint, |
|
marginTop: 1, |
|
}); |
|
|
|
main.add(styleLabel); |
|
main.add(styleSelect); |
|
main.add(styleHint); |
|
styleSelect.focus(); |
|
renderer.requestRender(); |
|
|
|
styleSelect.on("itemSelected", () => { |
|
const opt = styleSelect.getSelectedOption(); |
|
if (!opt) return; |
|
const style = opt.name as DocumentationStyle; |
|
|
|
main.remove(styleLabel.id); |
|
main.remove(styleSelect.id); |
|
main.remove(styleHint.id); |
|
|
|
const styleBreadcrumb = new TextRenderable(renderer, { |
|
content: `Style: ${style}`, |
|
height: 1, |
|
fg: C.success, |
|
marginBottom: 1, |
|
}); |
|
main.add(styleBreadcrumb); |
|
renderer.requestRender(); |
|
|
|
resolve(style); |
|
}); |
|
}); |
|
} |
|
|
|
function promptNextRepo(renderer: CliRenderer, main: BoxRenderable): Promise<boolean> { |
|
return new Promise((resolve) => { |
|
const prompt = new TextRenderable(renderer, { |
|
content: "[n] next repo [q] quit", |
|
height: 1, |
|
fg: C.muted, |
|
marginTop: 2, |
|
}); |
|
main.add(prompt); |
|
renderer.requestRender(); |
|
|
|
const handler = (key: any) => { |
|
if (key.name === "n") { |
|
renderer.keyInput.off("keypress", handler); |
|
main.remove(prompt.id); |
|
renderer.requestRender(); |
|
resolve(true); |
|
} else if (key.name === "q") { |
|
renderer.keyInput.off("keypress", handler); |
|
main.remove(prompt.id); |
|
resolve(false); |
|
} |
|
}; |
|
renderer.keyInput.on("keypress", handler); |
|
}); |
|
} |
|
|
|
// ============================================================================ |
|
// Main Entry Point |
|
// ============================================================================ |
|
|
|
export async function launchTui(): Promise<void> { |
|
await loadEnv(); |
|
|
|
const geminiApiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || ""; |
|
const githubToken = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; |
|
const layers = Layer.merge(GeminiLive(geminiApiKey), GitHubLive(githubToken)); |
|
|
|
const renderer = await createCliRenderer({ |
|
exitOnCtrlC: true, |
|
useAlternateScreen: true, |
|
backgroundColor: C.bg, |
|
}); |
|
|
|
// ── Main container ── |
|
const main = new BoxRenderable(renderer, { |
|
flexDirection: "column", |
|
padding: 2, |
|
gap: 0, |
|
width: "100%", |
|
height: "100%", |
|
backgroundColor: C.bg, |
|
}); |
|
|
|
// ── Header ── |
|
const title = new TextRenderable(renderer, { content: "grepo", height: 1, fg: C.text }); |
|
const subtitle = new TextRenderable(renderer, { |
|
content: "AI-powered GitHub repository tools", |
|
height: 1, |
|
fg: C.muted, |
|
marginBottom: 1, |
|
}); |
|
const divider = new TextRenderable(renderer, { |
|
content: "─".repeat(56), |
|
height: 1, |
|
fg: C.border, |
|
marginBottom: 1, |
|
}); |
|
|
|
main.add(title); |
|
main.add(subtitle); |
|
main.add(divider); |
|
renderer.root.add(main); |
|
|
|
// ── Global escape handler ── |
|
renderer.keyInput.on("keypress", (key: any) => { |
|
if (key.name === "escape") { |
|
renderer.destroy(); |
|
process.exit(0); |
|
} |
|
}); |
|
|
|
// ── Phase 1: Command Select ── |
|
const selectedCommands = await new Promise<Command[]>((resolve) => { |
|
const selectLabel = new TextRenderable(renderer, { |
|
content: "Select commands (space to toggle, enter to confirm)", |
|
height: 1, |
|
fg: C.muted, |
|
marginBottom: 1, |
|
}); |
|
|
|
const multiSelect = createMultiSelect(renderer, COMMANDS, (cmds) => { |
|
multiSelect.destroy(); |
|
main.remove(selectLabel.id); |
|
main.remove(multiSelect.container.id); |
|
main.remove(selectHint.id); |
|
resolve(cmds); |
|
}); |
|
|
|
const selectHint = new TextRenderable(renderer, { |
|
content: "↑↓ navigate space toggle enter confirm esc quit", |
|
height: 1, |
|
fg: C.hint, |
|
marginTop: 1, |
|
}); |
|
|
|
main.add(selectLabel); |
|
main.add(multiSelect.container); |
|
main.add(selectHint); |
|
renderer.start(); |
|
}); |
|
|
|
// Show breadcrumb of selected commands |
|
const breadcrumb = new TextRenderable(renderer, { |
|
content: `Commands: ${selectedCommands.join(", ")}`, |
|
height: 1, |
|
fg: C.success, |
|
marginBottom: 1, |
|
}); |
|
main.add(breadcrumb); |
|
|
|
// ── Phase 2+ : URL input loop ── |
|
let continueLoop = true; |
|
|
|
while (continueLoop) { |
|
const repoUrl = await promptForUrl(renderer, main); |
|
const { owner, repo } = validation.parseGitHubUrl(repoUrl); |
|
|
|
// Phase 3: Options (readme style) |
|
let readmeStyle: DocumentationStyle = "standard"; |
|
if (selectedCommands.includes("readme")) { |
|
readmeStyle = await promptForReadmeStyle(renderer, main); |
|
} |
|
|
|
// Phase 4: Execute all commands for this repo |
|
await executeCommands(renderer, main, { |
|
commands: selectedCommands, |
|
repoUrl, |
|
owner, |
|
repo, |
|
readmeStyle, |
|
layers, |
|
githubToken, |
|
}); |
|
|
|
// Phase 5: Next repo prompt |
|
continueLoop = await promptNextRepo(renderer, main); |
|
} |
|
|
|
renderer.destroy(); |
|
process.exit(0); |
|
} |