From a38b2bc64f7827cc761e071f5d7efea5db570fba Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Tue, 4 Feb 2025 23:30:28 -0600 Subject: [PATCH] Adds `stream` API (#231) --- .changeset/gentle-jokes-fail.md | 13 ++++++++++ examples/basic/package.json | 1 + examples/basic/stream.ts | 34 ++++++++++++++++++++++++++ packages/prompts/README.md | 16 +++++++++++++ packages/prompts/src/index.ts | 42 +++++++++++++++++++++++++++++++++ 5 files changed, 106 insertions(+) create mode 100644 .changeset/gentle-jokes-fail.md create mode 100644 examples/basic/stream.ts diff --git a/.changeset/gentle-jokes-fail.md b/.changeset/gentle-jokes-fail.md new file mode 100644 index 00000000..9ac14013 --- /dev/null +++ b/.changeset/gentle-jokes-fail.md @@ -0,0 +1,13 @@ +--- +"@clack/prompts": minor +--- + +Adds `stream` API which provides the same methods as `log`, but for iterable (even async) message streams. This is particularly useful for AI responses which are dynamically generated by LLMs. + +```ts +import * as p from '@clack/prompts'; + +await p.stream.step((async function* () { + yield* generateLLMResponse(question); +})()) +``` diff --git a/examples/basic/package.json b/examples/basic/package.json index d23f29c2..8db663c3 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -10,6 +10,7 @@ }, "scripts": { "start": "jiti ./index.ts", + "stream": "jiti ./stream.ts", "spinner": "jiti ./spinner.ts", "spinner-ci": "npx cross-env CI=\"true\" jiti ./spinner-ci.ts" }, diff --git a/examples/basic/stream.ts b/examples/basic/stream.ts new file mode 100644 index 00000000..0e03a4e1 --- /dev/null +++ b/examples/basic/stream.ts @@ -0,0 +1,34 @@ +import { setTimeout } from 'node:timers/promises'; +import * as p from '@clack/prompts'; +import color from 'picocolors'; + +async function main() { + console.clear(); + + await setTimeout(1000); + + p.intro(`${color.bgCyan(color.black(' create-app '))}`); + + await p.stream.step((async function* () { + for (const line of lorem) { + for (const word of line.split(' ')) { + yield word; + yield ' '; + await setTimeout(200); + } + yield '\n'; + if (line !== lorem.at(-1)) { + await setTimeout(1000); + } + } + })()) + + p.outro(`Problems? ${color.underline(color.cyan('https://example.com/issues'))}`); +} + +const lorem = [ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + 'Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.', +] + +main().catch(console.error); diff --git a/packages/prompts/README.md b/packages/prompts/README.md index ed7b5b4f..2bc0d937 100644 --- a/packages/prompts/README.md +++ b/packages/prompts/README.md @@ -188,4 +188,20 @@ log.error('Error!'); log.message('Hello, World', { symbol: color.cyan('~') }); ``` + +### Stream + +When interacting with dynamic LLMs or other streaming message providers, use the `stream` APIs to log messages from an iterable, even an async one. + +```js +import { stream } from '@clack/prompts'; + +stream.info((function *() { yield 'Info!'; })()); +stream.success((function *() { yield 'Success!'; })()); +stream.step((function *() { yield 'Step!'; })()); +stream.warn((function *() { yield 'Warn!'; })()); +stream.error((function *() { yield 'Error!'; })()); +stream.message((function *() { yield 'Hello'; yield ", World" })(), { symbol: color.cyan('~') }); +``` + [clack-log-prompts](https://github.com/natemoo-re/clack/blob/main/.github/assets/clack-logs.png) diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index 2c152845..094fab2c 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -675,6 +675,48 @@ export const log = { }, }; +const prefix = `${color.gray(S_BAR)} `; +export const stream = { + message: async (iterable: Iterable|AsyncIterable, { symbol = color.gray(S_BAR) }: LogMessageOptions = {}) => { + process.stdout.write(`${color.gray(S_BAR)}\n${symbol} `); + let lineWidth = 3; + for await (let chunk of iterable) { + chunk = chunk.replace(/\n/g, `\n${prefix}`); + if (chunk.includes('\n')) { + lineWidth = 3 + strip(chunk.slice(chunk.lastIndexOf('\n'))).length; + } + const chunkLen = strip(chunk).length; + if ((lineWidth + chunkLen) < process.stdout.columns) { + lineWidth += chunkLen; + process.stdout.write(chunk); + } else { + process.stdout.write(`\n${prefix}${chunk.trimStart()}`); + lineWidth = 3 + strip(chunk.trimStart()).length; + } + } + process.stdout.write('\n'); + }, + info: (iterable: Iterable|AsyncIterable) => { + return stream.message(iterable, { symbol: color.blue(S_INFO) }); + }, + success: (iterable: Iterable|AsyncIterable) => { + return stream.message(iterable, { symbol: color.green(S_SUCCESS) }); + }, + step: (iterable: Iterable|AsyncIterable) => { + return stream.message(iterable, { symbol: color.green(S_STEP_SUBMIT) }); + }, + warn: (iterable: Iterable|AsyncIterable) => { + return stream.message(iterable, { symbol: color.yellow(S_WARN) }); + }, + /** alias for `log.warn()`. */ + warning: (iterable: Iterable|AsyncIterable) => { + return stream.warn(iterable); + }, + error: (iterable: Iterable|AsyncIterable) => { + return stream.message(iterable, { symbol: color.red(S_ERROR) }); + }, +} + export const spinner = () => { const frames = unicode ? ['◒', '◐', '◓', '◑'] : ['•', 'o', 'O', '0']; const delay = unicode ? 80 : 120;