diff --git a/README.md b/README.md index ee02dc8..e597239 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ -# Reliverse Prompts +# @reliverse/relinka -[**Docs**](https://docs.reliverse.org/prompts) | [**npmjs.com**](https://npmjs.com/package/@reliverse/relinka) | [**GitHub**](https://github.com/reliverse/prompts) +[**Docs**](https://docs.reliverse.org/relinka) | [**npmjs.com**](https://npmjs.com/package/@reliverse/relinka) | [**GitHub**](https://github.com/reliverse/relinka) -[![Separator](./public/split.png)](https://docs.reliverse.org/prompts) +[![Separator](./public/split.png)](https://docs.reliverse.org/relinka) @reliverse/relinka is a powerful library that enables seamless, type-safe, and resilient prompts for command-line applications. Crafted with simplicity and elegance, it provides developers with an intuitive and robust way to build interactive CLIs. -[![CLI Example](./public/example.png)](https://docs.reliverse.org/prompts) +[![CLI Example](./public/example.png)](https://docs.reliverse.org/relinka) ## Installation @@ -23,7 +23,7 @@ bun add @reliverse/relinka # Replace 'bun' with npm, pnpm, or yarn if desired (d - **Flexible Prompt Types**: Supports a range of prompt types, including text, password, number, select, and multiselect. - **Crash Resilience**: Designed to handle cancellations and errors gracefully, ensuring stability. -[![Confirm Prompt](./public/confirm.gif)](https://docs.reliverse.org/prompts) +[![Confirm Prompt](./public/confirm.gif)](https://docs.reliverse.org/relinka) ## Prompt Types @@ -36,13 +36,13 @@ Each type has its own validation and display logic. More types are planned for f - **Select**: Dropdown selection for multiple choices. - **Multiselect**: Allows users to select multiple items from a list. -[![Multiselect Prompt](./public/list.gif)](https://docs.reliverse.org/prompts) +[![Multiselect Prompt](./public/list.gif)](https://docs.reliverse.org/relinka) ## Input Validation All prompts support custom validation logic, providing immediate feedback to users. -[![Number Prompt with Validation](./public/validate.gif)](https://docs.reliverse.org/prompts) +[![Number Prompt with Validation](./public/validate.gif)](https://docs.reliverse.org/relinka) ## Contributing @@ -51,8 +51,8 @@ All prompts support custom validation logic, providing immediate feedback to use Here is how to install the library for development: ```sh -git clone https://github.com/reliverse/prompts.git -cd prompts +git clone https://github.com/reliverse/relinka.git +cd relinka bun i ``` @@ -114,7 +114,7 @@ This project wouldn’t exist without the amazing work of the following projects ## Wrap-Up -@reliverse/relinka is a versatile library designed to accelerate CLI development by providing customizable prompt components. Integrated into the [Reliverse CLI](https://github.com/blefnk/reliverse#readme), @reliverse/relinka enables you to create a unique design aligned with your CLI app’s aesthetics, similar to how @shadcn/ui supports customizable web UI components. Quickly get started by copying configurations from the [Reliverse Docs](https://docs.reliverse.org/prompts) and using components that fit your project, making it faster to bring your CLI app to life. You’re free to customize each component as desired, with default designs provided to ensure an attractive interface from the start. +@reliverse/relinka is a versatile library designed to accelerate CLI development by providing customizable prompt components. Integrated into the [Reliverse CLI](https://github.com/blefnk/reliverse#readme), @reliverse/relinka enables you to create a unique design aligned with your CLI app’s aesthetics, similar to how @shadcn/ui supports customizable web UI components. Quickly get started by copying configurations from the [Reliverse Docs](https://docs.reliverse.org/relinka) and using components that fit your project, making it faster to bring your CLI app to life. You’re free to customize each component as desired, with default designs provided to ensure an attractive interface from the start. **Example Configuration:** diff --git a/cspell.json b/cspell.json index 339304e..860b7e8 100644 --- a/cspell.json +++ b/cspell.json @@ -54,6 +54,7 @@ "outro", "pagedown", "pageup", + "picocolors", "printj", "redrun", "relinka", diff --git a/examples/1-main-example.ts b/examples/1-main-example.ts index 20b14d3..e58a27f 100644 --- a/examples/1-main-example.ts +++ b/examples/1-main-example.ts @@ -6,10 +6,10 @@ import { doSomeFunStuff, showAnimatedText, showAnyKeyPrompt, - showConfirmPrompt, + // showConfirmPrompt, showDatePrompt, showEndPrompt, - // showMultiSelectPrompt, + showNumMultiSelectPrompt, showNextStepsPrompt, showNumberPrompt, showNumSelectPrompt, @@ -17,48 +17,40 @@ import { showResults, showStartPrompt, showTextPrompt, + showSelectPrompt, + showMultiSelectPrompt, } from "@/reliverse/main-prompts"; import { type UserInput } from "@/reliverse/main-schema"; -import { multiSelectPrompt } from "~/components/multi-select"; -import { selectPrompt } from "~/components/select"; import { errorHandler } from "~/utils/errors"; export async function detailedExample() { await showStartPrompt(); await showAnyKeyPrompt("privacy"); - - const selectOptions = ["Option 1", "Option 2", "Option 3", "Option 4"]; - const multiSelectOptions = ["Option 1", "Option 2", "Option 3", "Option 4"]; - - const selectedOption = await selectPrompt(selectOptions); - console.log(`You selected: ${selectedOption}`); - - const selectedOptions = await multiSelectPrompt(multiSelectOptions); - console.log("You selected:"); - selectedOptions.forEach((option) => console.log(option)); - const username = await showTextPrompt(); const dir = await askDir(username); const age = await showNumberPrompt(); + const lang = await showSelectPrompt(); const color = await showNumSelectPrompt(); const password = await showPasswordPrompt(); const birthday = await showDatePrompt(); - // const features = await showMultiSelectPrompt(); - const features = ["Feature 1", "Feature 2", "Feature 3"]; - const deps = await showConfirmPrompt(username); + const langs = await showMultiSelectPrompt(); + const features = await showNumMultiSelectPrompt(); + // const deps = await showConfirmPrompt(username); const userInput = { username, dir, age, + lang, color, password, birthday, + langs, features, - deps, + // deps, } satisfies UserInput; - await showResults(userInput); - await doSomeFunStuff(userInput); + // await showResults(userInput); + // await doSomeFunStuff(userInput); await showNextStepsPrompt(); await showAnimatedText(); await showEndPrompt(); diff --git a/examples/4-experimental.ts b/examples/4-experimental.ts index 9e2a46b..662a7b3 100644 --- a/examples/4-experimental.ts +++ b/examples/4-experimental.ts @@ -1,8 +1,8 @@ -import type { TreeItem } from "~/utils/helpers/utils/tree"; +import type { TreeItem } from "~/components/modules/helpers/tree"; +import { relinka, createRelinka } from "~/components/modules"; +import { formatTree } from "~/components/modules/helpers/tree"; import { errorHandler } from "~/utils/errors"; -import { relinka, createRelinka } from "~/utils/helpers"; -import { formatTree } from "~/utils/helpers/utils/tree"; import { reporterDemo } from "./reliverse/experiments/utils"; diff --git a/examples/reliverse/experiments/state/main-with-state.ts b/examples/reliverse/experiments/state/main-with-state.ts index fc70a93..b1dc084 100644 --- a/examples/reliverse/experiments/state/main-with-state.ts +++ b/examples/reliverse/experiments/state/main-with-state.ts @@ -11,7 +11,7 @@ import { nextStepsPrompt } from "~/components/next-steps"; // import { multiSelectPrompt } from "~/components/num-multi-select"; import { numberPrompt } from "~/components/number"; import { passwordPrompt } from "~/components/password"; -import { selectPrompt } from "~/components/select"; +import { selectPrompt } from "~/components/select-2"; import type { PromptOptionsWithState } from "./types-wth-state"; diff --git a/examples/reliverse/experiments/tests/relinka.test.ts b/examples/reliverse/experiments/tests/relinka.test.ts index 43ece05..6aca28e 100644 --- a/examples/reliverse/experiments/tests/relinka.test.ts +++ b/examples/reliverse/experiments/tests/relinka.test.ts @@ -1,8 +1,8 @@ import { describe, test, expect } from "vitest"; -import type { RelinkaReporter, LogObject } from "~/utils/helpers"; +import type { RelinkaReporter, LogObject } from "~/components/modules"; -import { LogLevels, createRelinka } from "~/utils/helpers"; +import { LogLevels, createRelinka } from "~/components/modules"; describe("relinka", () => { test("can set level", () => { diff --git a/examples/reliverse/experiments/tmp/main-2.txt b/examples/reliverse/experiments/tmp/main-2.txt index a384ce2..b5ffd16 100644 --- a/examples/reliverse/experiments/tmp/main-2.txt +++ b/examples/reliverse/experiments/tmp/main-2.txt @@ -51,7 +51,7 @@ export async function prompts( value = await selectPrompt(options); break; case "multiselect": - value = await multisSlectPrompt(options); + value = await multiSelectPrompt(options); break; case "password": value = await passwordPrompt(options); @@ -259,7 +259,7 @@ async function selectPrompt( } } -async function multisSlectPrompt( +async function multiSelectPrompt( options: PromptOptions, ): Promise> { const { title, choices, schema } = options; diff --git a/examples/reliverse/experiments/utils/index.ts b/examples/reliverse/experiments/utils/index.ts index 302380a..55c65f6 100644 --- a/examples/reliverse/experiments/utils/index.ts +++ b/examples/reliverse/experiments/utils/index.ts @@ -1,6 +1,6 @@ -import type { RelinkaOptions } from "~/utils/helpers"; +import type { RelinkaOptions } from "~/components/modules"; -import { createRelinka } from "~/utils/helpers"; +import { createRelinka } from "~/components/modules"; import { randomSentence } from "./sentence"; @@ -22,7 +22,7 @@ export function reporterDemo( relinka.error(new Error(randomSentence())); - const tagged = relinka.withTag("unjs").withTag("router"); + const tagged = relinka.withTag("reliverse").withTag("relinka"); for (const type of Object.keys(relinka.options.types).sort()) { tagged[type](randomSentence()); diff --git a/examples/reliverse/main-prompts.ts b/examples/reliverse/main-prompts.ts index f586a95..d85b322 100644 --- a/examples/reliverse/main-prompts.ts +++ b/examples/reliverse/main-prompts.ts @@ -4,6 +4,7 @@ import { emojify } from "node-emoji"; import { bold } from "picocolors"; import { pressAnyKeyPrompt } from "~/components/any-key"; +import { relinka } from "~/components/modules"; import { numSelectPrompt } from "~/components/num-select"; import { promptsDisplayResults } from "~/components/results"; import { @@ -12,11 +13,11 @@ import { datePrompt, endPrompt, msg, - multiSelectPrompt, + numMultiSelectPrompt, nextStepsPrompt, numberPrompt, passwordPrompt, - selectPrompt, + // selectPrompt, spinnerPrompts, startPrompt, textPrompt, @@ -39,7 +40,7 @@ const IDs = { deps: "deps", password: "password", age: "age", - language: "language", + lang: "lang", color: "color", birthday: "birthday", features: "features", @@ -109,22 +110,23 @@ export async function showNumberPrompt(): Promise { return age ?? 34; } -// TODO: fix, currently works only if it's the first prompt -// export async function showSelectPrompt(): Promise { -// const language = await selectPrompt({ -// id: IDs.language, -// title: "Choose your language", -// choices: [ -// { -// title: "English", -// id: "en", -// }, -// { title: "Other", id: "other" }, -// ], -// }); +export async function showSelectPrompt(): Promise { + const lang = await relinka.prompt("Choose your language", { + type: "select", + options: [ + { label: "English", value: "English" }, + { label: "Ukrainian", value: "Ukrainian" }, + { label: "Other", value: "Other" }, + ], + initial: "English", + }); + + if (typeof lang !== "string") { + process.exit(0); + } -// return language ?? ""; -// } + return lang.toString(); +} export async function showNumSelectPrompt(): Promise { const choices = createColorChoices(); @@ -188,8 +190,53 @@ export async function showDatePrompt(): Promise { return birthdayDate ?? "16.11.1988"; } -/* export async function showMultiSelectPrompt(): Promise { - const features = await multiSelectPrompt({ +export async function showMultiSelectPrompt(): Promise { + const features = await relinka.prompt( + "Select your programming language(s) | Use to select/deselect", + { + type: "multiselect", + options: [ + { + label: "TypeScript", + value: "typescript", + hint: emojify(":blue_heart:"), + }, + { + label: "JavaScript", + value: "javascript", + hint: emojify(":yellow_heart:"), + }, + { + label: "CoffeeScript", + value: "coffeescript", + hint: emojify(":coffee:"), + }, + { + label: "Python", + value: "python", + hint: emojify(":snake:"), + }, + { label: "Java", value: "java", hint: emojify(":coffee:") }, + { label: "C#", value: "csharp", hint: emojify(":hash:") }, + { label: "Go", value: "go", hint: emojify(":dolphin:") }, + { label: "Rust", value: "rust", hint: emojify(":crab:") }, + { label: "Swift", value: "swift", hint: emojify(":apple:") }, + ], + initial: ["javascript", "typescript"], + }, + ); + + if (!Array.isArray(features)) { + process.exit(0); + } + + return features.toString().split(","); +} + +export async function showNumMultiSelectPrompt(): Promise< + UserInput["features"] +> { + const features = await numMultiSelectPrompt({ id: IDs.features, title: "What features do you want to use?", defaultValue: ["react", "typescript"], @@ -215,9 +262,10 @@ export async function showDatePrompt(): Promise { schema: schema.properties.features, }); return features ?? ["react", "typescript"]; -} */ +} -export async function showConfirmPrompt( +// TODO: fix bun crash +/* export async function showConfirmPrompt( username: string, ): Promise { await showAnyKeyPrompt("pm", username); @@ -240,7 +288,7 @@ export async function showConfirmPrompt( }); // A return value is unnecessary for prompts when the result is not needed later. return deps ?? false; -} +} */ // Prompt ID is not required for the following // components, as they don't return any values. @@ -301,7 +349,7 @@ export async function showEndPrompt() { await endPrompt({ id: "end", title: emojify( - "ℹ :books: Learn the docs here: https://docs.reliverse.org/prompts", + "ℹ :books: Learn the docs here: https://docs.reliverse.org/relinka", ), titleAnimation: "glitch", ...basicConfig, diff --git a/examples/reliverse/main-schema.ts b/examples/reliverse/main-schema.ts index b3ae0b0..6472332 100644 --- a/examples/reliverse/main-schema.ts +++ b/examples/reliverse/main-schema.ts @@ -7,7 +7,7 @@ import { colorMap } from "~/utils/mapping"; const colorKeys = Object.keys(colorMap) as [keyof typeof colorMap]; // You can find the schema templates like this -// one at https://docs.reliverse.org/prompts +// one at https://docs.reliverse.org/relinka const colorSchema = Type.Enum( Object.keys(colorMap).reduce( (acc, key) => { @@ -25,11 +25,13 @@ export const schema = Type.Object({ pattern: "^[a-zA-Z0-9\u0400-\u04FF]+$", }), dir: Type.String({ minLength: 1 }), - deps: Type.Boolean(), + // deps: Type.Boolean(), password: Type.String({ minLength: 4 }), age: Type.Number({ minimum: 18, maximum: 99 }), + lang: Type.String(), color: colorSchema, birthday: Type.String({ minLength: 10, maxLength: 10 }), + langs: Type.Array(Type.String()), features: Type.Array(Type.String()), }); diff --git a/examples/run-example.ts b/examples/run-example.ts index a2d329b..bb64815 100644 --- a/examples/run-example.ts +++ b/examples/run-example.ts @@ -1,64 +1,47 @@ -import type { ChoiceOptionalOptions } from "~/types/prod"; - -import { selectPrompt } from "~/components/select"; -import { colorize } from "~/utils/colorize"; +import { relinka } from "~/components/modules"; import { errorHandler } from "~/utils/errors"; -const selectPromptConfig = { - description: colorize("(not finished)", "red"), - titleTypography: "bold", -} satisfies ChoiceOptionalOptions; - async function examplesRunner() { - // console.clear(); - - await import("./1-main-example"); - - /* const exampleToRun = await selectPrompt({ - id: "start", - title: "Choose an example to run", - content: colorize( - "Use arrow keys to navigate or just press the corresponding number", - "dim", - ), - titleColor: "green", - titleTypography: "bold", - choices: [ - { - id: "1-main-example", - title: "1. Main Example", - description: colorize("(recommended)", "viceGradient"), - action: async () => { - await import("./1-main-example"); - }, - }, + const exampleToRun = await relinka.prompt("Choose an example to run", { + type: "select", + options: [ + { label: "1-main-example", value: "1-main-example", hint: "recommended" }, { - id: "2-mono-example", - title: "2. Mono Example", - ...selectPromptConfig, - action: async () => { - await import("./2-mono-example"); - }, + label: "2-mono-example", + value: "2-mono-example", + hint: "not finished", }, { - id: "3-basic-example", - title: "3. Basic Example", - ...selectPromptConfig, - action: async () => { - await import("./3-basic-example"); - }, + label: "3-basic-example", + value: "3-basic-example", + hint: "not finished", }, { - id: "exit", - title: "4. Exit", - description: colorize("(Ctrl+C)", "passionGradient"), + label: "4-experimental", + value: "4-experimental", + hint: "experimental", }, - ], + { label: "exit", value: "exit" }, + ] as const, + initial: "1-main-example", }); - if (exampleToRun === "exit") { - process.exit(0); - } */ + switch (exampleToRun) { + case "1-main-example": + await import("./1-main-example"); + break; + case "2-mono-example": + await import("./2-mono-example"); + break; + case "3-basic-example": + await import("./3-basic-example"); + break; + case "4-experimental": + await import("./4-experimental"); + break; + default: + break; + } } await examplesRunner().catch((error) => errorHandler(error)); diff --git a/package.json b/package.json index 85d31fb..c0bd158 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "@reliverse/relinka", - "version": "0.1.0", + "version": "1.0.1", "description": "@reliverse/relinka is a powerful library that enables seamless, typesafe, and resilient prompts for command-line applications. Crafted with simplicity and elegance, it provides developers with an intuitive and robust way to build interactive CLIs.", "scripts": { "appts": "bun typecheck && bun lint && bun format", - "build": "unbuild && bun build.optim.ts && bun optimize", + "build": "unbuild && bun build.optim.ts", "bunp": "bun publish", "pub": "redrun build bunp", "lint": "eslint --cache --fix .", @@ -23,7 +23,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/reliverse/prompts.git" + "url": "git+https://github.com/reliverse/relinka.git" }, "main": "./output/main.js", "types": "./output/main.d.ts", @@ -33,11 +33,11 @@ "types": "./output/main.d.ts" }, "bugs": { - "url": "https://github.com/reliverse/prompts/issues", + "url": "https://github.com/reliverse/relinka/issues", "email": "blefnk@gmail.com" }, "files": ["package.json", "README.md", "LICENSE.md", "output"], - "homepage": "https://github.com/reliverse/prompts", + "homepage": "https://github.com/reliverse/relinka", "keywords": ["cli", "reliverse"], "license": "MIT", "dependencies": { diff --git a/src/utils/helpers/basic.ts b/src/components/modules/basic.ts similarity index 100% rename from src/utils/helpers/basic.ts rename to src/components/modules/basic.ts diff --git a/src/utils/helpers/browser.ts b/src/components/modules/browser.ts similarity index 100% rename from src/utils/helpers/browser.ts rename to src/components/modules/browser.ts diff --git a/src/utils/helpers/constants.ts b/src/components/modules/constants.ts similarity index 100% rename from src/utils/helpers/constants.ts rename to src/components/modules/constants.ts diff --git a/src/utils/helpers/core.ts b/src/components/modules/core.ts similarity index 100% rename from src/utils/helpers/core.ts rename to src/components/modules/core.ts diff --git a/src/utils/helpers/utils/box.ts b/src/components/modules/helpers/box.ts similarity index 100% rename from src/utils/helpers/utils/box.ts rename to src/components/modules/helpers/box.ts diff --git a/src/utils/helpers/utils/color.ts b/src/components/modules/helpers/color.ts similarity index 100% rename from src/utils/helpers/utils/color.ts rename to src/components/modules/helpers/color.ts diff --git a/src/utils/helpers/utils/error.ts b/src/components/modules/helpers/error.ts similarity index 100% rename from src/utils/helpers/utils/error.ts rename to src/components/modules/helpers/error.ts diff --git a/src/utils/helpers/utils/format.ts b/src/components/modules/helpers/format.ts similarity index 100% rename from src/utils/helpers/utils/format.ts rename to src/components/modules/helpers/format.ts diff --git a/src/utils/helpers/utils/log.ts b/src/components/modules/helpers/log.ts similarity index 100% rename from src/utils/helpers/utils/log.ts rename to src/components/modules/helpers/log.ts diff --git a/src/utils/helpers/utils/prompt.ts b/src/components/modules/helpers/prompt.ts similarity index 100% rename from src/utils/helpers/utils/prompt.ts rename to src/components/modules/helpers/prompt.ts diff --git a/src/utils/helpers/utils/stream.ts b/src/components/modules/helpers/stream.ts similarity index 100% rename from src/utils/helpers/utils/stream.ts rename to src/components/modules/helpers/stream.ts diff --git a/src/utils/helpers/utils/string.ts b/src/components/modules/helpers/string.ts similarity index 100% rename from src/utils/helpers/utils/string.ts rename to src/components/modules/helpers/string.ts diff --git a/src/utils/helpers/utils/tree.ts b/src/components/modules/helpers/tree.ts similarity index 100% rename from src/utils/helpers/utils/tree.ts rename to src/components/modules/helpers/tree.ts diff --git a/src/utils/helpers/index.ts b/src/components/modules/index.ts similarity index 100% rename from src/utils/helpers/index.ts rename to src/components/modules/index.ts diff --git a/src/utils/helpers/prompt.ts b/src/components/modules/prompt.ts similarity index 98% rename from src/utils/helpers/prompt.ts rename to src/components/modules/prompt.ts index d514082..781c80b 100644 --- a/src/utils/helpers/prompt.ts +++ b/src/components/modules/prompt.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-unnecessary-type-parameters */ -import { text, confirm, select, multiselect } from "./utils/prompt"; +import { text, confirm, select, multiselect } from "./helpers/prompt"; type SelectOption = { label: string; diff --git a/src/utils/helpers/relinka.ts b/src/components/modules/relinka.ts similarity index 99% rename from src/utils/helpers/relinka.ts rename to src/components/modules/relinka.ts index 04990ae..7ac3071 100644 --- a/src/utils/helpers/relinka.ts +++ b/src/components/modules/relinka.ts @@ -10,7 +10,7 @@ import type { } from "./types"; import { LogTypes } from "./constants"; -import { isLogObj } from "./utils/log"; +import { isLogObj } from "./helpers/log"; let paused = false; const queue: any[] = []; diff --git a/src/utils/helpers/reporters/basic.ts b/src/components/modules/reporters/basic.ts similarity index 95% rename from src/utils/helpers/reporters/basic.ts rename to src/components/modules/reporters/basic.ts index 92144f2..88cdd9b 100644 --- a/src/utils/helpers/reporters/basic.ts +++ b/src/components/modules/reporters/basic.ts @@ -5,8 +5,8 @@ import type { FormatOptions, RelinkaOptions, } from "../types"; -import { parseStack } from "../utils/error"; -import { writeStream } from "../utils/stream"; +import { parseStack } from "../helpers/error"; +import { writeStream } from "../helpers/stream"; const bracket = (x: string) => (x ? `[${x}]` : ""); diff --git a/src/utils/helpers/reporters/browser.ts b/src/components/modules/reporters/browser.ts similarity index 100% rename from src/utils/helpers/reporters/browser.ts rename to src/components/modules/reporters/browser.ts diff --git a/src/utils/helpers/reporters/fancy.ts b/src/components/modules/reporters/fancy.ts similarity index 95% rename from src/utils/helpers/reporters/fancy.ts rename to src/components/modules/reporters/fancy.ts index 31a955e..97cbc4f 100644 --- a/src/utils/helpers/reporters/fancy.ts +++ b/src/components/modules/reporters/fancy.ts @@ -3,12 +3,12 @@ import _stringWidth from "string-width"; import type { LogLevel, LogType } from "../constants"; import type { FormatOptions, LogObject } from "../types"; -import type { BoxOpts } from "../utils/box"; +import type { BoxOpts } from "../helpers/box"; import { stripAnsi } from "../utils"; -import { box } from "../utils/box"; -import { colors } from "../utils/color"; -import { parseStack } from "../utils/error"; +import { box } from "../helpers/box"; +import { colors } from "../helpers/color"; +import { parseStack } from "../helpers/error"; import { BasicReporter } from "./basic"; export const TYPE_COLOR_MAP: Partial> = { @@ -41,7 +41,6 @@ const TYPE_ICONS: Partial> = { }; function stringWidth(str: string) { - // https://github.com/unjs/relinka/issues/204 if (!Intl.Segmenter) { return stripAnsi(str).length; } diff --git a/src/utils/helpers/shared.ts b/src/components/modules/shared.ts similarity index 100% rename from src/utils/helpers/shared.ts rename to src/components/modules/shared.ts diff --git a/src/utils/helpers/types.ts b/src/components/modules/types.ts similarity index 100% rename from src/utils/helpers/types.ts rename to src/components/modules/types.ts diff --git a/src/components/modules/utils.ts b/src/components/modules/utils.ts new file mode 100644 index 0000000..e97a244 --- /dev/null +++ b/src/components/modules/utils.ts @@ -0,0 +1,9 @@ +export * from "./helpers/box"; +export * from "./helpers/color"; +export { + stripAnsi, + centerAlign, + rightAlign, + leftAlign, + align, +} from "./helpers/string"; diff --git a/src/components/mono.ts b/src/components/mono.ts index 4968503..0093ffb 100644 --- a/src/components/mono.ts +++ b/src/components/mono.ts @@ -5,12 +5,12 @@ import type { PromptOptions, PromptType } from "~/types/prod"; import { confirmPrompt } from "~/components/confirm"; import { datePrompt } from "~/components/date"; import { endPrompt } from "~/components/end"; -import { multiSelectPrompt } from "~/components/multi-select"; +import { multiSelectPrompt } from "~/components/multi-select-2"; import { nextStepsPrompt } from "~/components/next-steps"; import { numMultiSelectPrompt } from "~/components/num-multi-select"; import { numberPrompt } from "~/components/number"; import { passwordPrompt } from "~/components/password"; -import { selectPrompt } from "~/components/select"; +import { selectPrompt } from "~/components/select-2"; import { startPrompt } from "~/components/start"; import { textPrompt } from "~/components/text"; diff --git a/src/components/multi-select-1.ts b/src/components/multi-select-1.ts new file mode 100644 index 0000000..533ff21 --- /dev/null +++ b/src/components/multi-select-1.ts @@ -0,0 +1,143 @@ +import type { Static, TSchema } from "@sinclair/typebox"; + +import { Value } from "@sinclair/typebox/value"; +import { stdin as input, stdout as output } from "node:process"; +import readline from "node:readline/promises"; + +import type { PromptOptions } from "~/types/prod"; + +import { colorize } from "~/utils/colorize"; +import { bar, fmt, msg } from "~/utils/messages"; +import { countLines, deleteLastLine, deleteLastLines } from "~/utils/terminal"; + +export async function numMultiSelectPrompt( + options: PromptOptions, +): Promise> { + const { + title, + choices, + schema, + defaultValue, + titleColor = "cyanBright", + answerColor = "none", + titleTypography = "bold", + titleVariant, + hint, + content, + contentColor = "dim", + contentTypography, + contentVariant, + borderColor = "viceGradient", + variantOptions, + } = options; + + if (!choices || choices.length === 0) { + throw new Error("Choices are required for multiselect prompt."); + } + + const rl = readline.createInterface({ input, output }); + + const formattedBar = bar({ borderColor }); + + let linesToDelete = 0; + let errorMessage = ""; + + try { + while (true) { + if (linesToDelete > 0) { + deleteLastLines(linesToDelete); + } + + const question = fmt({ + type: errorMessage !== "" ? "M_ERROR" : "M_GENERAL", + title: `${title}${defaultValue ? ` [Default: ${Array.isArray(defaultValue) ? defaultValue.join(", ") : defaultValue}]` : ""}`, + titleColor, + titleTypography, + titleVariant, + content, + contentColor, + contentTypography, + contentVariant, + borderColor, + hint, + variantOptions, + errorMessage, + addNewLineBefore: false, + addNewLineAfter: false, + }); + + // Generate choices text with formatted bar + const choicesText = choices + .map( + (choice, index) => + `${formattedBar} ${index + 1}) ${choice.title}${ + choice.description ? ` - ${choice.description}` : "" + }`, + ) + .join("\n"); + + const fullPrompt = `${question}\n${choicesText}\n${formattedBar} ${colorize(`Enter your choices (comma-separated numbers between 1-${choices.length})`, contentColor)}:\n${formattedBar} `; + + const formattedPrompt = fmt({ + type: "M_NULL", + title: fullPrompt, + }); + + const questionLines = countLines(formattedPrompt); + linesToDelete = questionLines + 1; // +1 for the user's input line + + const answer = (await rl.question(formattedPrompt)).trim(); + + // Use defaultValue if no input is provided + if (!answer && defaultValue !== undefined) { + deleteLastLine(); + msg({ + type: "M_MIDDLE", + title: ` ${Array.isArray(defaultValue) ? defaultValue.join(", ") : defaultValue}`, + titleColor: answerColor, + }); + msg({ type: "M_NEWLINE" }); + return defaultValue as Static; + } + + // Parse and validate selections + const selections = answer.split(",").map((s) => s.trim()); + const invalidSelections = selections.filter((s) => { + const num = Number(s); + return isNaN(num) || num < 1 || num > choices.length; + }); + + if (invalidSelections.length > 0) { + errorMessage = `Invalid selections: ${invalidSelections.join( + ", ", + )}. Please enter numbers between 1 and ${choices.length}.`; + continue; + } + + const selectedValues = selections.map((s) => choices[Number(s) - 1]?.id); + + // Schema validation if provided + let isValid = true; + errorMessage = ""; // Reset errorMessage + + if (schema) { + isValid = Value.Check(schema, selectedValues); + if (!isValid) { + const errors = [...Value.Errors(schema, selectedValues)]; + errorMessage = + errors.length > 0 + ? (errors[0]?.message ?? "Invalid input.") + : "Invalid input."; + } + } + + if (isValid) { + msg({ type: "M_NEWLINE" }); + rl.close(); + return selectedValues as Static; + } + } + } finally { + rl.close(); + } +} diff --git a/src/components/multi-select copy 2.ts b/src/components/multi-select-2.ts similarity index 100% rename from src/components/multi-select copy 2.ts rename to src/components/multi-select-2.ts diff --git a/src/components/multi-select copy.ts b/src/components/multi-select-3.ts similarity index 100% rename from src/components/multi-select copy.ts rename to src/components/multi-select-3.ts diff --git a/src/components/multi-select-4.ts b/src/components/multi-select-4.ts new file mode 100644 index 0000000..f63d57b --- /dev/null +++ b/src/components/multi-select-4.ts @@ -0,0 +1,65 @@ +import { stdin as input, stdout as output } from "process"; +import * as readline from "readline"; + +export async function multiSelectPrompt(options: string[]): Promise { + return new Promise((resolve) => { + let selectedIndex = 0; + const selectedOptions = new Set(); + + const rl = readline.createInterface({ input, output }); + readline.emitKeypressEvents(input, rl); + + if (input.isTTY) input.setRawMode(true); + + const render = () => { + // Hide cursor and move to the top-left corner + output.write("\x1B[?25l"); // Hide cursor + output.write("\x1B[0f"); // Move cursor to top-left + options.forEach((option, index) => { + const isSelected = selectedIndex === index; + const isChecked = selectedOptions.has(index); + const prefix = isSelected ? "> " : " "; + const checkbox = isChecked ? "[x]" : "[ ]"; + output.write(`${prefix}${checkbox} ${option}\n`); + }); + output.write("\x1B[J"); // Clear to the end of the screen + }; + + const onKeyPress = (str: string, key: readline.Key) => { + if (key.name === "up") { + selectedIndex = (selectedIndex - 1 + options.length) % options.length; + render(); + } else if (key.name === "down") { + selectedIndex = (selectedIndex + 1) % options.length; + render(); + } else if (key.name === "space") { + if (selectedOptions.has(selectedIndex)) { + selectedOptions.delete(selectedIndex); + } else { + selectedOptions.add(selectedIndex); + } + render(); + } else if (key.name === "return") { + cleanup(); + const results = Array.from(selectedOptions).map( + (index) => options[index], + ); + resolve(results); + } else if (key.name === "c" && key.ctrl) { + cleanup(); + process.exit(); + } + }; + + const cleanup = () => { + input.removeListener("keypress", onKeyPress); + if (input.isTTY) input.setRawMode(false); + rl.close(); + output.write("\x1B[?25h"); // Show cursor + output.write("\n"); + }; + + input.on("keypress", onKeyPress); + render(); + }); +} diff --git a/src/components/multi-select.ts b/src/components/multi-select.ts index f63d57b..533ff21 100644 --- a/src/components/multi-select.ts +++ b/src/components/multi-select.ts @@ -1,65 +1,143 @@ -import { stdin as input, stdout as output } from "process"; -import * as readline from "readline"; - -export async function multiSelectPrompt(options: string[]): Promise { - return new Promise((resolve) => { - let selectedIndex = 0; - const selectedOptions = new Set(); - - const rl = readline.createInterface({ input, output }); - readline.emitKeypressEvents(input, rl); - - if (input.isTTY) input.setRawMode(true); - - const render = () => { - // Hide cursor and move to the top-left corner - output.write("\x1B[?25l"); // Hide cursor - output.write("\x1B[0f"); // Move cursor to top-left - options.forEach((option, index) => { - const isSelected = selectedIndex === index; - const isChecked = selectedOptions.has(index); - const prefix = isSelected ? "> " : " "; - const checkbox = isChecked ? "[x]" : "[ ]"; - output.write(`${prefix}${checkbox} ${option}\n`); +import type { Static, TSchema } from "@sinclair/typebox"; + +import { Value } from "@sinclair/typebox/value"; +import { stdin as input, stdout as output } from "node:process"; +import readline from "node:readline/promises"; + +import type { PromptOptions } from "~/types/prod"; + +import { colorize } from "~/utils/colorize"; +import { bar, fmt, msg } from "~/utils/messages"; +import { countLines, deleteLastLine, deleteLastLines } from "~/utils/terminal"; + +export async function numMultiSelectPrompt( + options: PromptOptions, +): Promise> { + const { + title, + choices, + schema, + defaultValue, + titleColor = "cyanBright", + answerColor = "none", + titleTypography = "bold", + titleVariant, + hint, + content, + contentColor = "dim", + contentTypography, + contentVariant, + borderColor = "viceGradient", + variantOptions, + } = options; + + if (!choices || choices.length === 0) { + throw new Error("Choices are required for multiselect prompt."); + } + + const rl = readline.createInterface({ input, output }); + + const formattedBar = bar({ borderColor }); + + let linesToDelete = 0; + let errorMessage = ""; + + try { + while (true) { + if (linesToDelete > 0) { + deleteLastLines(linesToDelete); + } + + const question = fmt({ + type: errorMessage !== "" ? "M_ERROR" : "M_GENERAL", + title: `${title}${defaultValue ? ` [Default: ${Array.isArray(defaultValue) ? defaultValue.join(", ") : defaultValue}]` : ""}`, + titleColor, + titleTypography, + titleVariant, + content, + contentColor, + contentTypography, + contentVariant, + borderColor, + hint, + variantOptions, + errorMessage, + addNewLineBefore: false, + addNewLineAfter: false, + }); + + // Generate choices text with formatted bar + const choicesText = choices + .map( + (choice, index) => + `${formattedBar} ${index + 1}) ${choice.title}${ + choice.description ? ` - ${choice.description}` : "" + }`, + ) + .join("\n"); + + const fullPrompt = `${question}\n${choicesText}\n${formattedBar} ${colorize(`Enter your choices (comma-separated numbers between 1-${choices.length})`, contentColor)}:\n${formattedBar} `; + + const formattedPrompt = fmt({ + type: "M_NULL", + title: fullPrompt, + }); + + const questionLines = countLines(formattedPrompt); + linesToDelete = questionLines + 1; // +1 for the user's input line + + const answer = (await rl.question(formattedPrompt)).trim(); + + // Use defaultValue if no input is provided + if (!answer && defaultValue !== undefined) { + deleteLastLine(); + msg({ + type: "M_MIDDLE", + title: ` ${Array.isArray(defaultValue) ? defaultValue.join(", ") : defaultValue}`, + titleColor: answerColor, + }); + msg({ type: "M_NEWLINE" }); + return defaultValue as Static; + } + + // Parse and validate selections + const selections = answer.split(",").map((s) => s.trim()); + const invalidSelections = selections.filter((s) => { + const num = Number(s); + return isNaN(num) || num < 1 || num > choices.length; }); - output.write("\x1B[J"); // Clear to the end of the screen - }; - - const onKeyPress = (str: string, key: readline.Key) => { - if (key.name === "up") { - selectedIndex = (selectedIndex - 1 + options.length) % options.length; - render(); - } else if (key.name === "down") { - selectedIndex = (selectedIndex + 1) % options.length; - render(); - } else if (key.name === "space") { - if (selectedOptions.has(selectedIndex)) { - selectedOptions.delete(selectedIndex); - } else { - selectedOptions.add(selectedIndex); + + if (invalidSelections.length > 0) { + errorMessage = `Invalid selections: ${invalidSelections.join( + ", ", + )}. Please enter numbers between 1 and ${choices.length}.`; + continue; + } + + const selectedValues = selections.map((s) => choices[Number(s) - 1]?.id); + + // Schema validation if provided + let isValid = true; + errorMessage = ""; // Reset errorMessage + + if (schema) { + isValid = Value.Check(schema, selectedValues); + if (!isValid) { + const errors = [...Value.Errors(schema, selectedValues)]; + errorMessage = + errors.length > 0 + ? (errors[0]?.message ?? "Invalid input.") + : "Invalid input."; } - render(); - } else if (key.name === "return") { - cleanup(); - const results = Array.from(selectedOptions).map( - (index) => options[index], - ); - resolve(results); - } else if (key.name === "c" && key.ctrl) { - cleanup(); - process.exit(); } - }; - - const cleanup = () => { - input.removeListener("keypress", onKeyPress); - if (input.isTTY) input.setRawMode(false); - rl.close(); - output.write("\x1B[?25h"); // Show cursor - output.write("\n"); - }; - - input.on("keypress", onKeyPress); - render(); - }); + + if (isValid) { + msg({ type: "M_NEWLINE" }); + rl.close(); + return selectedValues as Static; + } + } + } finally { + rl.close(); + } } diff --git a/src/components/select-tmp.ts b/src/components/select-1.ts similarity index 100% rename from src/components/select-tmp.ts rename to src/components/select-1.ts diff --git a/src/components/select-2.ts b/src/components/select-2.ts new file mode 100644 index 0000000..fb9446c --- /dev/null +++ b/src/components/select-2.ts @@ -0,0 +1,51 @@ +import { stdin as input, stdout as output } from "process"; +import * as readline from "readline"; + +export async function selectPrompt(options: string[]): Promise { + return new Promise((resolve) => { + let selected = 0; + + const rl = readline.createInterface({ input, output }); + readline.emitKeypressEvents(input, rl); + + if (input.isTTY) input.setRawMode(true); + + const render = () => { + // Clear the console + // output.write("\x1B[2J\x1B[0f"); + options.forEach((option, index) => { + if (index === selected) { + output.write(`> ${option}\n`); + } else { + output.write(` ${option}\n`); + } + }); + }; + + const onKeyPress = (str: string, key: readline.Key) => { + if (key.name === "up") { + selected = (selected - 1 + options.length) % options.length; + render(); + } else if (key.name === "down") { + selected = (selected + 1) % options.length; + render(); + } else if (key.name === "return") { + cleanup(); + resolve(options[selected]); + } else if (key.name === "c" && key.ctrl) { + cleanup(); + process.exit(); + } + }; + + const cleanup = () => { + input.removeListener("keypress", onKeyPress); + if (input.isTTY) input.setRawMode(false); + rl.close(); + output.write("\n"); + }; + + input.on("keypress", onKeyPress); + render(); + }); +} diff --git a/src/components/select copy.ts b/src/components/select-3.ts similarity index 100% rename from src/components/select copy.ts rename to src/components/select-3.ts diff --git a/src/components/select copy 2.ts b/src/components/select-4.ts similarity index 100% rename from src/components/select copy 2.ts rename to src/components/select-4.ts diff --git a/src/components/select.ts b/src/components/select.ts index fb9446c..bbfa4d5 100644 --- a/src/components/select.ts +++ b/src/components/select.ts @@ -1,51 +1,129 @@ -import { stdin as input, stdout as output } from "process"; -import * as readline from "readline"; +import type { Key } from "node:readline"; -export async function selectPrompt(options: string[]): Promise { - return new Promise((resolve) => { - let selected = 0; +import { Value } from "@sinclair/typebox/value"; +import { stdout } from "node:process"; +import color from "picocolors"; - const rl = readline.createInterface({ input, output }); - readline.emitKeypressEvents(input, rl); +import type { PromptOptions } from "~/types/prod"; - if (input.isTTY) input.setRawMode(true); +import { useKeyPress } from "~/hooks/useKeyPress"; +import { colorize } from "~/utils/colorize"; +import { resetCursorAndClear, moveCursorAndClear } from "~/utils/readline"; - const render = () => { - // Clear the console - // output.write("\x1B[2J\x1B[0f"); - options.forEach((option, index) => { - if (index === selected) { - output.write(`> ${option}\n`); +export async function selectPrompt(options: PromptOptions): Promise { + const { + title, + choices, + defaultValue, + schema, + titleColor = "cyanBright", + answerColor = "none", + titleTypography = "bold", + } = options; + + if (schema) { + throw new Error( + "Schema providing is currently not supported for selectPrompt().\n│ But don't worry, we're already handling some validations for you.", + ); + } + + if (!choices || choices.length === 0) { + throw new Error("Choices are required for select prompt."); + } + + const coloredTitle = colorize(title, titleColor, titleTypography); + + let selectedIndex = + defaultValue !== undefined + ? choices.findIndex( + (choice, index) => + choice.id === defaultValue || index + 1 === Number(defaultValue), + ) + : 0; + if (selectedIndex === -1) selectedIndex = 0; + + function renderChoices() { + if (!choices) { + throw new Error("Choices are required for select prompt."); + } + resetCursorAndClear(stdout, 0, 0); + console.log(color.cyanBright(color.bold(coloredTitle))); + choices.forEach((choice, index) => { + const isSelected = index === selectedIndex; + const prefix = isSelected ? color.greenBright(">") : " "; + const choiceText = isSelected + ? color.bgGreen(color.black(choice.title)) + : choice.title; + console.log(`${prefix} ${choiceText}`); + }); + } + + renderChoices(); + + return new Promise((resolve, reject) => { + const finalizeSelection = () => { + cleanupKeyPress(); + moveCursorAndClear(stdout, 0, choices.length + 2); + + const selectedChoice = choices[selectedIndex]; + const selectedValue = selectedChoice?.id; + + let isValid = true; + let errorMessage = "Invalid input."; + + if (schema) { + try { + isValid = Value.Check(schema, selectedValue); + } catch (error) { + isValid = false; + errorMessage = "Validation error."; + console.error(error); + } + if (!isValid) { + const errors = [...Value.Errors(schema, selectedValue)]; + if (errors.length > 0) { + errorMessage = errors[0]?.message ?? "Invalid input."; + } + } + } + + if (isValid) { + if (selectedChoice?.action) { + selectedChoice + .action() + .then(() => resolve(selectedValue ?? "")) + .catch(reject); } else { - output.write(` ${option}\n`); + resolve(selectedValue ?? ""); } - }); + } else { + console.log(errorMessage); + renderChoices(); + } }; - const onKeyPress = (str: string, key: readline.Key) => { + const handleKeyPress = (str: string, key: Key) => { if (key.name === "up") { - selected = (selected - 1 + options.length) % options.length; - render(); + selectedIndex = (selectedIndex - 1 + choices.length) % choices.length; + renderChoices(); } else if (key.name === "down") { - selected = (selected + 1) % options.length; - render(); + selectedIndex = (selectedIndex + 1) % choices.length; + renderChoices(); } else if (key.name === "return") { - cleanup(); - resolve(options[selected]); - } else if (key.name === "c" && key.ctrl) { - cleanup(); + finalizeSelection(); + } else if (key.ctrl && key.name === "c") { + cleanupKeyPress(); process.exit(); + } else if ( + !isNaN(Number(str)) && + Number(str) >= 1 && + Number(str) <= choices.length + ) { + selectedIndex = Number(str) - 1; + finalizeSelection(); } }; - const cleanup = () => { - input.removeListener("keypress", onKeyPress); - if (input.isTTY) input.setRawMode(false); - rl.close(); - output.write("\n"); - }; - - input.on("keypress", onKeyPress); - render(); + const cleanupKeyPress = useKeyPress(handleKeyPress); }); } diff --git a/src/main.ts b/src/main.ts index d65ab6a..052a9d8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,10 +5,10 @@ export { createAsciiArt } from "~/components/ascii-art"; export { spinnerPrompts } from "~/components/spinner"; export { startPrompt } from "~/components/start"; export { textPrompt } from "~/components/text"; -export { selectPrompt } from "~/components/select"; +// export { selectPrompt } from "~/components/select"; export { confirmPrompt } from "~/components/confirm"; export { datePrompt } from "~/components/date"; -export { multiSelectPrompt } from "~/components/multi-select"; +// export { multiSelectPrompt } from "~/components/multi-select"; export { numMultiSelectPrompt } from "~/components/num-multi-select"; export { nextStepsPrompt } from "~/components/next-steps"; export { numberPrompt } from "~/components/number"; diff --git a/src/utils/helpers/utils.ts b/src/utils/helpers/utils.ts deleted file mode 100644 index 582caca..0000000 --- a/src/utils/helpers/utils.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from "./utils/box"; -export * from "./utils/color"; -export { - stripAnsi, - centerAlign, - rightAlign, - leftAlign, - align, -} from "./utils/string";