diff --git a/.development/index.js b/.development/index.js index 410fd7d..afdec6a 100644 --- a/.development/index.js +++ b/.development/index.js @@ -1,12 +1,10 @@ import chalk from "chalk"; -import { createInterface } from "readline"; +import * as prompts from "@clack/prompts"; import { executeCommands } from "../src/executor.js"; -import { printCommand, printError, printFinished, sep } from "../src/ui.js"; - -// ── action definitions ──────────────────────────────────────────────── -// Each entry mirrors the shape of an AI response so the real executor -// path is exercised end-to-end with no mocking. +import { printCommand, printError, printFinished } from "../src/ui.js"; +// Each entry mirrors the shape of an AI response so the real executor path is +// exercised end-to-end with no mocking. const ACTIONS = [ { id: 1, @@ -81,119 +79,58 @@ const ACTIONS = [ }, ]; -// ── helpers ─────────────────────────────────────────────────────────── - -const preview = (cmds) => { - const joined = cmds.join(" → "); - return joined.length > 52 ? joined.slice(0, 49) + "…" : joined; +const preview = (commands) => { + const joined = commands.join(" -> "); + return joined.length > 52 ? `${joined.slice(0, 49)}...` : joined; }; -const pad = (str, n) => str + " ".repeat(Math.max(0, n - str.length)); - -// ── menu renderer ───────────────────────────────────────────────────── - -function printMenu() { - const labelWidth = Math.max(...ACTIONS.map((a) => a.label.length)) + 2; - - console.log(); - sep(); - console.log( - ` ${chalk.white(">")} ${chalk.bold.white("NOVA")} ${chalk.gray("— development mode")}`, - ); - sep(); - console.log(); - - for (const a of ACTIONS) { - const num = chalk.cyan(`[${a.id}]`); - const label = chalk.white(pad(a.label, labelWidth)); - const hint = chalk.gray(preview(a.commands)); - console.log(` ${num} ${label}${hint}`); - } - - console.log(); -} - -// ── run an action ───────────────────────────────────────────────────── - async function runAction(action) { - console.log(); - sep(); - console.log(` ${chalk.cyan("→")} ${chalk.gray(action.description)}`); - console.log(` ${chalk.gray("category:")} ${chalk.white(action.category)}`); + prompts.log.info(action.description); + prompts.log.message( + `${chalk.dim("Category:")} ${chalk.white(action.category)}`, + ); const start = Date.now(); try { - await executeCommands(action.commands, (cmd) => printCommand(cmd)); - - const secs = ((Date.now() - start) / 1000).toFixed(1); - printFinished(secs); - } catch (err) { - console.log(); - printError(err.message); - sep(); + await executeCommands(action.commands, (command) => printCommand(command)); + const seconds = ((Date.now() - start) / 1000).toFixed(1); + printFinished(seconds); + } catch (error) { + printError(error.message); } } -// ── prompt loop ─────────────────────────────────────────────────────── - export async function devMode() { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - - rl.on("SIGINT", () => { - console.log( - `\n\n ${chalk.gray(">")} ${chalk.gray("Exiting dev mode.")}\n`, - ); - process.exit(0); - }); - - const ask = () => { - printMenu(); - - rl.question( - ` ${chalk.white(">")} ${chalk.gray("pick action [1-9] or exit: ")}`, - async (raw) => { - const input = raw.trim().toLowerCase(); - - if (!input) { - ask(); - return; - } - - if (input === "exit" || input === "quit") { - console.log( - `\n ${chalk.gray(">")} ${chalk.gray("Exiting dev mode.")}\n`, - ); - rl.close(); - process.exit(0); - } - - const num = parseInt(input, 10); - const action = ACTIONS.find((a) => a.id === num); - - if (!action) { - console.log( - `\n ${chalk.red("✗")} ${chalk.red(`"${input}" is not valid — enter 1–9 or exit.`)}`, - ); - ask(); - return; - } - - // ── hand stdin back to the child process ────────────────────── - // readline holds process.stdin and keeps it in line-buffered mode. - // Interactive tools like create-next-app need raw TTY access for - // arrow keys and keyboard navigation. pause() releases that hold - // so the spawned process gets full control of the terminal. - rl.pause(); - - await runAction(action); - - // ── reclaim stdin for the next menu prompt ──────────────────── - rl.resume(); - ask(); - }, - ); - }; - - ask(); + prompts.intro(`${chalk.bold.white("NOVA")} ${chalk.dim("Development mode")}`); + + while (true) { + const selected = await prompts.select({ + message: "Choose a safe development action", + options: [ + ...ACTIONS.map((action) => ({ + value: action.id, + label: action.label, + hint: preview(action.commands), + })), + { value: "exit", label: "Exit development mode" }, + ], + maxItems: 10, + }); + + if (prompts.isCancel(selected) || selected === "exit") { + prompts.outro("Exiting development mode."); + return; + } + + const action = ACTIONS.find(({ id }) => id === selected); + if (!action) { + printError("The selected development action is unavailable."); + continue; + } + + // The select prompt has completed, so the child receives the terminal + // directly through the executor's inherited stdio. + await runAction(action); + } } diff --git a/README.md b/README.md index a57351e..d121f72 100644 --- a/README.md +++ b/README.md @@ -38,30 +38,24 @@ On first run, NOVA will ask for your **Anthropic/GenAI API key** and save it to ``` $ nova - - - > Hello, vikash. I'm 'NOVA' - - - - > describe your task! create a new react app called my-portfolio using vite - - - - ◕ thinking... ( 1.8 seconds ) - → Scaffolding a Vite React project called my-portfolio and installing dependencies - - $ npx create-vite@latest my-portfolio --template react - [npx output...] - - $ cd /Users/vikash/my-portfolio - - $ npm install - [npm output...] - - - - ◕ generating... ( 18.4 seconds ) - ◓ finished. - - - - > describe your task! +┌ NOVA Local terminal assistant for vikash +│ +◆ What would you like Nova to do? +│ create a new react app called my-portfolio using vite +│ +◇ Thinking complete (1.8s). +│ +● Scaffolding a Vite React project called my-portfolio +│ +◇ $ npx create-vite@latest my-portfolio --template react + [npx output...] +│ +◇ $ npm install + [npm output...] +│ +◆ Finished in 18.4 seconds. +│ +◇ What would you like Nova to do? ``` --- @@ -115,6 +109,7 @@ nova --dev ## Tech - Node.js 18+ (ESM) +- [Clack](https://github.com/bombshell-dev/clack) — prompts, spinners, and terminal UI - [Anthropic SDK](https://github.com/anthropics/anthropic-sdk-node) — task evaluation - [Google GenAI](https://ai.google.dev/gemini-api/docs/get-started) — task evaluation - [chalk](https://github.com/chalk/chalk) — terminal styling diff --git a/package-lock.json b/package-lock.json index 29d1853..3d4e14f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", + "@clack/prompts": "1.2.0", "@google/genai": "^2.9.0", "chalk": "^5.3.0" }, @@ -34,6 +35,28 @@ "node-fetch": "^2.6.7" } }, + "node_modules/@clack/core": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.2.0.tgz", + "integrity": "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.1.3", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.2.0.tgz", + "integrity": "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.2.0", + "fast-string-width": "^1.1.0", + "fast-wrap-ansi": "^0.1.3", + "sisteransi": "^1.0.5" + } + }, "node_modules/@google/genai": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.9.0.tgz", @@ -369,6 +392,30 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-string-truncated-width": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-1.2.1.tgz", + "integrity": "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-1.1.0.tgz", + "integrity": "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^1.2.0" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.1.6.tgz", + "integrity": "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^1.1.0" + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -816,6 +863,12 @@ ], "license": "MIT" }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", diff --git a/package.json b/package.json index 96e494a..ade921b 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,12 @@ "bin": { "nova": "./bin/nova.js" }, + "scripts": { + "test": "node --test" + }, "dependencies": { "@anthropic-ai/sdk": "^0.39.0", + "@clack/prompts": "1.2.0", "@google/genai": "^2.9.0", "chalk": "^5.3.0" }, diff --git a/src/config.js b/src/config.js index 188538d..2083b25 100644 --- a/src/config.js +++ b/src/config.js @@ -1,14 +1,12 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; import { join } from "path"; import { homedir } from "os"; -import { createInterface } from "readline"; +import * as prompts from "@clack/prompts"; import chalk from "chalk"; const CONFIG_DIR = join(homedir(), ".nova"); const CONFIG_FILE = join(CONFIG_DIR, "config.json"); -// ── read ───────────────────────────────────────────────────────────── - export function loadConfig() { try { if (!existsSync(CONFIG_FILE)) return null; @@ -18,40 +16,29 @@ export function loadConfig() { } } -// ── write ──────────────────────────────────────────────────────────── - export function saveConfig(data) { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(CONFIG_FILE, JSON.stringify(data, null, 2), "utf-8"); } -// ── first-time setup ───────────────────────────────────────────────── - export async function promptForApiKey() { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - - console.log(); - console.log(chalk.gray(" ──────────────────────────────────────")); - console.log( - ` ${chalk.bold.white("NOVA")} ${chalk.gray("— first time setup")}`, - ); - console.log( - chalk.gray(" Your key will be stored at ") + - chalk.cyan("~/.nova/config.json"), + prompts.intro(`${chalk.bold.white("NOVA")} ${chalk.dim("First-time setup")}`); + prompts.note( + `Your key will be stored at ${chalk.cyan("~/.nova/config.json")}.`, + "Configuration", ); - console.log(chalk.gray(" ──────────────────────────────────────")); - console.log(); - return new Promise((resolve) => { - rl.question(` ${chalk.gray("GenAI API key ›")} `, (input) => { - rl.close(); - const key = input.trim(); - if (!key) { - console.log(chalk.red("\n ✗ No key entered. Exiting.\n")); - process.exit(1); - } - console.log(chalk.green("\n ✓ Key saved.\n")); - resolve(key); - }); + const key = await prompts.password({ + message: "Enter your Google GenAI API key", + validate(value) { + if (!value?.trim()) return "An API key is required."; + }, }); + + if (prompts.isCancel(key)) { + prompts.cancel("Setup cancelled."); + return null; + } + + return key.trim(); } diff --git a/src/index.js b/src/index.js index bc19f53..add809f 100644 --- a/src/index.js +++ b/src/index.js @@ -8,9 +8,9 @@ import { printInfo, printError, printFinished, + printSetupComplete, withSpinner, startPromptLoop, - sep, } from "./ui.js"; export async function activate() { @@ -22,7 +22,9 @@ export async function activate() { apiKey = config.apiKey; } else { apiKey = await promptForApiKey(); + if (!apiKey) return; saveConfig({ apiKey }); + printSetupComplete(); } // ── 2. Banner ───────────────────────────────────────────────────── @@ -30,7 +32,7 @@ export async function activate() { printBanner(username); // ── 3. Main loop ────────────────────────────────────────────────── - startPromptLoop(async (task) => { + await startPromptLoop(async (task) => { // Step A: evaluate the task with AI let plan; try { @@ -55,7 +57,6 @@ export async function activate() { } catch (err) { console.log(); printError(err.message); - sep(); } }); } diff --git a/src/ui.js b/src/ui.js index 98ddf6b..9f62cd7 100644 --- a/src/ui.js +++ b/src/ui.js @@ -1,113 +1,80 @@ +import * as prompts from "@clack/prompts"; import chalk from "chalk"; -import { createInterface } from "readline"; -// ── primitives ──────────────────────────────────────────────────────── - -export const sep = () => console.log(chalk.gray(" -")); +const EXIT_COMMANDS = new Set(["exit", "quit", "bye"]); export const printBanner = (username) => { - console.log(); - sep(); - console.log( - ` ${chalk.white(">")} Hello, ${chalk.cyan(username)}. I'm ${chalk.bold.white("'NOVA'")}`, + prompts.intro( + `${chalk.dim(`I'm`)} ${chalk.bold.white("NOVA")} ${chalk.dim(`- Hello, ${username}`)}`, ); - sep(); }; -export const printCommand = (cmd) => - console.log(` ${chalk.green("$")} ${chalk.white(cmd)}`); +export const printCommand = (command) => + prompts.log.step(`${chalk.green("$")} ${chalk.white(command)}`); -export const printInfo = (text) => - console.log(` ${chalk.cyan("→")} ${chalk.gray(text)}`); +export const printInfo = (message) => prompts.log.info(message); -export const printError = (text) => - console.log(` ${chalk.red("✗")} ${chalk.red(text)}`); +export const printError = (message) => prompts.log.error(message); export const printOutput = (line, stream) => { - const color = stream === "err" ? chalk.yellow : chalk.gray; - console.log(` ${color(line)}`); + const color = stream === "err" ? chalk.yellow : chalk.dim; + prompts.log.message(color(line)); }; export const printFinished = (elapsedSeconds) => { - sep(); - if (elapsedSeconds !== undefined) { - console.log( - ` ${chalk.gray("◕")} ${chalk.gray(`generating... ( ${elapsedSeconds} seconds )`)}`, - ); - } - console.log(` ${chalk.green("◓")} ${chalk.gray("finished.")}`); - sep(); + const duration = + elapsedSeconds === undefined ? "" : ` in ${elapsedSeconds} seconds`; + prompts.log.success(`Finished${duration}.`); }; -// ── spinner (for async ops with no stdout — e.g. AI call) ──────────── +export const printSetupComplete = () => prompts.outro("API key saved."); -const FRAMES = ["◕", "◔", "◑", "◒"]; - -export async function withSpinner(label, fn) { +export async function withSpinner( + label, + operation, + createSpinner = prompts.spinner, +) { + const indicator = createSpinner(); const start = Date.now(); - let i = 0; - - const tick = () => - process.stdout.write( - `\r ${chalk.gray(FRAMES[i++ % FRAMES.length])} ${chalk.gray(label + "...")} `, - ); - tick(); - const timer = setInterval(tick, 120); + indicator.start(`${label}...`); try { - const result = await fn(); - clearInterval(timer); - const secs = ((Date.now() - start) / 1000).toFixed(1); - process.stdout.write( - `\r ${chalk.gray("◕")} ${chalk.gray(`${label}... ( ${secs} seconds )`)}\n`, - ); + const result = await operation(); + const seconds = ((Date.now() - start) / 1000).toFixed(1); + indicator.stop(`${label} complete (${seconds}s).`); return result; - } catch (err) { - clearInterval(timer); - process.stdout.write("\n"); - throw err; + } catch (error) { + indicator.error(`${label} failed.`); + throw error; } } -// ── prompt loop ─────────────────────────────────────────────────────── - -export function startPromptLoop(onTask) { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - - // Ctrl-C graceful exit - rl.on("SIGINT", () => { - console.log(`\n\n ${chalk.gray(">")} ${chalk.gray("Goodbye.")}\n`); - process.exit(0); - }); - - const ask = () => { - console.log(); - rl.question( - ` ${chalk.white(">")} ${chalk.gray("describe your task! ")}`, - async (raw) => { - const task = raw.trim(); - - if (!task) { - ask(); - return; - } - - if (["exit", "quit", "bye"].includes(task.toLowerCase())) { - console.log(`\n ${chalk.gray(">")} ${chalk.gray("Goodbye.")}\n`); - rl.close(); - process.exit(0); - } - - console.log(); - sep(); - rl.pause(); // release stdin before any command runs - await onTask(task); - rl.resume(); // reclaim stdin for the next prompt - ask(); +export async function startPromptLoop(onTask, promptApi = prompts) { + while (true) { + const answer = await promptApi.text({ + message: "What would you like Nova to do?", + placeholder: "Create a React app with authentication", + validate(value) { + if (!value?.trim()) return "Please describe a task."; }, - ); - }; + }); - ask(); + if (promptApi.isCancel(answer)) { + promptApi.cancel("Goodbye."); + return; + } + + const task = answer.trim(); + + if (EXIT_COMMANDS.has(task.toLowerCase())) { + promptApi.outro("Goodbye."); + return; + } + + // A Clack prompt releases stdin after it resolves. Interactive child + // processes can therefore inherit the terminal without competing with + // a persistent readline interface. + await onTask(task); + } } diff --git a/test/ui.test.js b/test/ui.test.js new file mode 100644 index 0000000..8b0c71d --- /dev/null +++ b/test/ui.test.js @@ -0,0 +1,90 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { startPromptLoop, withSpinner } from "../src/ui.js"; + +function createPromptApi(answers, cancelValue = Symbol("cancel")) { + const events = []; + const configurations = []; + + return { + events, + configurations, + async text(configuration) { + configurations.push(configuration); + return answers.shift(); + }, + isCancel(value) { + return value === cancelValue; + }, + cancel(message) { + events.push(["cancel", message]); + }, + outro(message) { + events.push(["outro", message]); + }, + }; +} + +test("prompt loop runs trimmed tasks until an exit command", async () => { + const promptApi = createPromptApi([" create an app ", "QUIT"]); + const tasks = []; + + await startPromptLoop(async (task) => tasks.push(task), promptApi); + + assert.deepEqual(tasks, ["create an app"]); + assert.deepEqual(promptApi.events, [["outro", "Goodbye."]]); + assert.equal( + promptApi.configurations[0].validate(" "), + "Please describe a task.", + ); + assert.equal(promptApi.configurations[0].validate("valid task"), undefined); +}); + +test("prompt loop handles Ctrl+C cancellation without running a task", async () => { + const cancelValue = Symbol("cancel"); + const promptApi = createPromptApi([cancelValue], cancelValue); + let taskCount = 0; + + await startPromptLoop(async () => taskCount++, promptApi); + + assert.equal(taskCount, 0); + assert.deepEqual(promptApi.events, [["cancel", "Goodbye."]]); +}); + +test("spinner reports success and returns the operation result", async () => { + const events = []; + const createSpinner = () => ({ + start: (message) => events.push(["start", message]), + stop: (message) => events.push(["stop", message]), + error: (message) => events.push(["error", message]), + }); + + const result = await withSpinner("Thinking", async () => 42, createSpinner); + + assert.equal(result, 42); + assert.deepEqual(events[0], ["start", "Thinking..."]); + assert.match(events[1][1], /^Thinking complete \(\d+\.\d+s\)\.$/); +}); + +test("spinner stops with an error and preserves the original failure", async () => { + const events = []; + const failure = new Error("provider unavailable"); + const createSpinner = () => ({ + start: (message) => events.push(["start", message]), + stop: (message) => events.push(["stop", message]), + error: (message) => events.push(["error", message]), + }); + + await assert.rejects( + withSpinner( + "Thinking", + async () => { + throw failure; + }, + createSpinner, + ), + (error) => error === failure, + ); + + assert.deepEqual(events.at(-1), ["error", "Thinking failed."]); +});