Repository: bombshell-dev/clack
Pull request #314: feat(core+prompts): Add suggestion + path prompt
Author: login: MacFJA
Created: 2025-05-10T15:08:55Z | Merged: 2025-05-14T21:41:16Z

Add two new prompts:
- Suggestion prompt
- Path prompt (closes #96)

`path` prompt is a specialized version of `suggestion` prompt (<=> The function that generate the suggestion list is predefined)

Linked issue #96: [Request] Path input 
**Is your feature request related to a problem? Please describe.**
I would like to enter a folder directory input and I want it clack auto complete these folders like zsh 

**Describe the solution you'd like**
Create a input type name with **path input** and handle it like zsh shell

![image](https://user-images.githubusercontent.com/32804505/222965916-88e5ef59-fa60-40c8-acaa-8ff4d0d692a6.png)

I found a source related this 
- https://stackoverflow.com/questions/42197385/node-js-file-folder-auto-completion-on-user-input


Linked issue comments: user: login: ulken
created_at: 2023-03-05T16:44:06Z
body: Sounds neat 👍

Wonder if this should be part of, separate or replace the suggested file select in https://github.com/natemoo-re/clack/issues/32?
author_association: COLLABORATOR
user: login: hasantezcan
created_at: 2023-03-05T20:25:05Z
body: I guess this two issue were pointed the same issue
author_association: NONE
user: login: bjesuiter
created_at: 2023-06-08T09:30:34Z
body: I would also like to have a path select component with autocomplete. 
Especially, since most clis do need to work with lots of paths! 
author_association: NONE
user: login: orochaa
created_at: 2023-08-25T00:34:23Z
body: @bjesuiter @hasantezcan does select component from PR #148 satisfies this issue, or do you really need a input with autocomplete sugestions?
author_association: CONTRIBUTOR
user: login: bjesuiter
created_at: 2023-08-28T06:26:50Z
body: @Mist3rBru This path input select component would be sufficient. 
However, I think that this could be annoying for power users, since they have to navigate with the arrow keys. 
I'd also like the option of having a path input which allows tabbing a path, like in a shell. 
Since this is relatively widespread behavior, it would be nice to have such a widget. 

Alternatively: Is there a possibility to extend the framework myself to do that? 
For example: Does the input widget has some kind of "onInput" hook, where i could add such behavior?
author_association: NONE
user: login: orochaa
created_at: 2023-08-29T13:04:24Z
body: @cpreston321 What do you think about I create a param type on `path` prompt that receives text or select, so user can choose between them, and we cover both cases?
author_association: CONTRIBUTOR
user: login: cpreston321
created_at: 2023-08-29T13:12:08Z
body: @Mist3rBru I think you are on the right path. I am thinking of something that can try to fill most gaps with current p.text() input. Have you used https://fig.io/ ? They handle this with creating a neat handler `postProcess`: https://fig.io/docs/getting-started/generating-argument-suggestions.  

Furthermore, I think we could create a plug and play for neat custom generators that are built in and what people can come up. So I think this would be a neat DX vs creating multiple different primitives.
author_association: COLLABORATOR
user: login: orochaa
created_at: 2023-08-29T13:37:21Z
body: @cpreston321 That's a great idea!

I will stick with these two primitives, and let `path` prompt as a "specialized" one, then for `text` prompt we open this possibility.
author_association: CONTRIBUTOR
user: login: hasantezcan
created_at: 2023-09-04T12:56:33Z
body: Thanks for your [great work](https://github.com/natemoo-re/clack/pull/148) [Mist3rBru](https://github.com/Mist3rBru). But I agree to [bjesuiter](https://github.com/bjesuiter) this cool feature makes happy to the general user but it could annoy the power user, I expect behavior like how `cd` command work in **zsh** more likely
author_association: NONE

Diff:
diff --git a/.changeset/curvy-seals-sit.md b/.changeset/curvy-seals-sit.md
new file mode 100644
index 00000000..69ce6dae
--- /dev/null
+++ b/.changeset/curvy-seals-sit.md
@@ -0,0 +1,6 @@
+---
+"@clack/prompts": minor
+"@clack/core": minor
+---
+
+Adds suggestion and path prompts
diff --git a/examples/basic/package.json b/examples/basic/package.json
index 410a7fab..f8e617e7 100644
--- a/examples/basic/package.json
+++ b/examples/basic/package.json
@@ -13,6 +13,7 @@
     "stream": "jiti ./stream.ts",
     "progress": "jiti ./progress.ts",
     "spinner": "jiti ./spinner.ts",
+    "path": "jiti ./path.ts",
     "spinner-ci": "npx cross-env CI=\"true\" jiti ./spinner-ci.ts",
     "spinner-timer": "jiti ./spinner-timer.ts",
     "task-log": "jiti ./task-log.ts"
diff --git a/examples/basic/path.ts b/examples/basic/path.ts
new file mode 100644
index 00000000..644afd04
--- /dev/null
+++ b/examples/basic/path.ts
@@ -0,0 +1,13 @@
+import * as p from '@clack/prompts';
+
+async function demo() {
+	p.intro('path start...');
+
+	const path = await p.path({
+		message: 'Read file',
+	});
+
+	p.outro('path stop...');
+}
+
+void demo();
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index ca4103f4..569f506d 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,4 +1,4 @@
-export type { ClackState as State } from './types.js';
+export type { ClackState as State, ValueWithCursorPart } from './types.js';
 export type { ClackSettings } from './utils/settings.js';
 
 export { default as ConfirmPrompt } from './prompts/confirm.js';
@@ -10,5 +10,6 @@ export { default as SelectPrompt } from './prompts/select.js';
 export { default as SelectKeyPrompt } from './prompts/select-key.js';
 export { default as TextPrompt } from './prompts/text.js';
 export { default as AutocompletePrompt } from './prompts/autocomplete.js';
+export { default as SuggestionPrompt } from './prompts/suggestion.js';
 export { block, isCancel, getColumns } from './utils/index.js';
 export { updateSettings, settings } from './utils/settings.js';
diff --git a/packages/core/src/prompts/prompt.ts b/packages/core/src/prompts/prompt.ts
index 7e58b866..d213a60d 100644
--- a/packages/core/src/prompts/prompt.ts
+++ b/packages/core/src/prompts/prompt.ts
@@ -26,7 +26,7 @@ export default class Prompt {
 	protected output: Writable;
 	private _abortSignal?: AbortSignal;
 
-	private rl: ReadLine | undefined;
+	protected rl: ReadLine | undefined;
 	private opts: Omit<PromptOptions<Prompt>, 'render' | 'input' | 'output'>;
 	private _render: (context: Omit<Prompt, 'prompt'>) => string | undefined;
 	private _track = false;
diff --git a/packages/core/src/prompts/suggestion.ts b/packages/core/src/prompts/suggestion.ts
new file mode 100644
index 00000000..f06f80f1
--- /dev/null
+++ b/packages/core/src/prompts/suggestion.ts
@@ -0,0 +1,114 @@
+import color from 'picocolors';
+import type { ValueWithCursorPart } from '../types.js';
+import Prompt, { type PromptOptions } from './prompt.js';
+
+interface SuggestionOptions extends PromptOptions<SuggestionPrompt> {
+	suggest: (value: string) => Array<string>;
+	initialValue: string;
+}
+
+export default class SuggestionPrompt extends Prompt {
+	value: string;
+	protected suggest: (value: string) => Array<string>;
+	private selectionIndex = 0;
+	private nextItems: Array<string> = [];
+
+	constructor(opts: SuggestionOptions) {
+		super(opts);
+
+		this.value = opts.initialValue;
+		this.suggest = opts.suggest;
+		this.getNextItems();
+		this.selectionIndex = 0;
+		this._cursor = this.value.length;
+
+		this.on('cursor', (key) => {
+			switch (key) {
+				case 'up':
+					this.selectionIndex = Math.max(
+						0,
+						this.selectionIndex === 0 ? this.nextItems.length - 1 : this.selectionIndex - 1
+					);
+					break;
+				case 'down':
+					this.selectionIndex =
+						this.nextItems.length === 0 ? 0 : (this.selectionIndex + 1) % this.nextItems.length;
+					break;
+			}
+		});
+		this.on('key', (key, info) => {
+			if (info.name === 'tab' && this.nextItems.length > 0) {
+				const delta = this.nextItems[this.selectionIndex].substring(this.value.length);
+				this.value = this.nextItems[this.selectionIndex];
+				this.rl?.write(delta);
+				this._cursor = this.value.length;
+				this.selectionIndex = 0;
+				this.getNextItems();
+			}
+		});
+		this.on('value', () => {
+			this.getNextItems();
+		});
+	}
+
+	get displayValue(): Array<ValueWithCursorPart> {
+		const result: Array<ValueWithCursorPart> = [];
+		if (this._cursor > 0) {
+			result.push({
+				text: this.value.substring(0, this._cursor),
+				type: 'value',
+			});
+		}
+		if (this._cursor < this.value.length) {
+			result.push({
+				text: this.value.substring(this._cursor, this._cursor + 1),
+				type: 'cursor_on_value',
+			});
+			const left = this.value.substring(this._cursor + 1);
+			if (left.length > 0) {
+				result.push({
+					text: left,
+					type: 'value',
+				});
+			}
+			if (this.suggestion.length > 0) {
+				result.push({
+					text: this.suggestion,
+					type: 'suggestion',
+				});
+			}
+			return result;
+		}
+		if (this.suggestion.length === 0) {
+			result.push({
+				text: '\u00A0',
+				type: 'cursor_on_value',
+			});
+			return result;
+		}
+		result.push(
+			{
+				text: this.suggestion[0],
+				type: 'cursor_on_suggestion',
+			},
+			{
+				text: this.suggestion.substring(1),
+				type: 'suggestion',
+			}
+		);
+		return result;
+	}
+
+	get suggestion(): string {
+		return this.nextItems[this.selectionIndex]?.substring(this.value.length) ?? '';
+	}
+
+	private getNextItems(): void {
+		this.nextItems = this.suggest(this.value).filter((item) => {
+			return item.startsWith(this.value) && item !== this.value;
+		});
+		if (this.selectionIndex > this.nextItems.length) {
+			this.selectionIndex = 0;
+		}
+	}
+}
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 3250177c..27397f17 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -21,3 +21,11 @@ export interface ClackEvents {
 	confirm: (value?: boolean) => void;
 	finalize: () => void;
 }
+
+/**
+ * Display a value
+ */
+export interface ValueWithCursorPart {
+	text: string;
+	type: 'value' | 'cursor_on_value' | 'suggestion' | 'cursor_on_suggestion';
+}
diff --git a/packages/core/test/prompts/suggestion.test.ts b/packages/core/test/prompts/suggestion.test.ts
new file mode 100644
index 00000000..1551add3
--- /dev/null
+++ b/packages/core/test/prompts/suggestion.test.ts
@@ -0,0 +1,226 @@
+import color from 'picocolors';
+import { cursor } from 'sisteransi';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+import { default as SelectPrompt } from '../../src/prompts/select.js';
+import { default as SuggestionPrompt } from '../../src/prompts/suggestion.js';
+import { MockReadable } from '../mock-readable.js';
+import { MockWritable } from '../mock-writable.js';
+
+describe(SuggestionPrompt.name, () => {
+	let input: MockReadable;
+	let output: MockWritable;
+
+	beforeEach(() => {
+		input = new MockReadable();
+		output = new MockWritable();
+	});
+
+	afterEach(() => {
+		vi.restoreAllMocks();
+	});
+
+	describe('displayValue getter return all parts/cases', () => {
+		test('no suggestion, cursor at the end', () => {
+			const instance = new SuggestionPrompt({
+				input,
+				output,
+				suggest: () => [],
+				initialValue: 'Lorem ipsum',
+				render: () => 'Lorem ipsum',
+			});
+			// leave the promise hanging since we don't want to submit in this test
+			instance.prompt();
+			expect(instance.displayValue).to.deep.equal([
+				{ text: 'Lorem ipsum', type: 'value' },
+				{ text: '\u00a0', type: 'cursor_on_value' },
+			]);
+		});
+
+		test('no suggestion, cursor at the start', () => {
+			const instance = new SuggestionPrompt({
+				input,
+				output,
+				suggest: () => [],
+				initialValue: 'Lorem',
+				render: () => 'Lorem',
+			});
+			// leave the promise hanging since we don't want to submit in this test
+			instance.prompt();
+			for (let index = 0; index < 5; index++) input.emit('keypress', '', { name: 'left' });
+			expect(instance.displayValue).to.deep.equal([
+				{ text: 'L', type: 'cursor_on_value' },
+				{ text: 'orem', type: 'value' },
+			]);
+		});
+		test('no suggestion, cursor in the middle', () => {
+			const instance = new SuggestionPrompt({
+				input,
+				output,
+				suggest: () => [],
+				initialValue: 'Lorem',
+				render: () => 'Lorem',
+			});
+			// leave the promise hanging since we don't want to submit in this test
+			instance.prompt();
+			for (let index = 0; index < 3; index++) input.emit('keypress', '', { name: 'left' });
+			expect(instance.displayValue).to.deep.equal([
+				{ text: 'Lo', type: 'value' },
+				{ text: 'r', type: 'cursor_on_value' },
+				{ text: 'em', type: 'value' },
+			]);
+		});
+		test('no suggestion, cursor on the last letter', () => {
+			const instance = new SuggestionPrompt({
+				input,
+				output,
+				suggest: () => [],
+				initialValue: 'Lorem',
+				render: () => 'Lorem',
+			});
+			// leave the promise hanging since we don't want to submit in this test
+			instance.prompt();
+			input.emit('keypress', '', { name: 'left' });
+			expect(instance.displayValue).to.deep.equal([
+				{ text: 'Lore', type: 'value' },
+				{ text: 'm', type: 'cursor_on_value' },
+			]);
+		});
+		test('with suggestion, cursor at the end', () => {
+			const instance = new SuggestionPrompt({
+				input,
+				output,
+				suggest: () => ['Lorem ipsum dolor sit amet, consectetur adipiscing elit'],
+				initialValue: 'Lorem ipsum dolor sit amet',
+				render: () => 'Lorem ipsum dolor sit amet',
+			});
+			// leave the promise hanging since we don't want to submit in this test
+			instance.prompt();
+			expect(instance.displayValue).to.deep.equal([
+				{ text: 'Lorem ipsum dolor sit amet', type: 'value' },
+				{ text: ',', type: 'cursor_on_suggestion' },
+				{ text: ' consectetur adipiscing elit', type: 'suggestion' },
+			]);
+		});
+		test('with suggestion, cursor not at the end', () => {
+			const instance = new SuggestionPrompt({
+				input,
+				output,
+				suggest: () => ['Lorem ipsum dolor sit amet, consectetur adipiscing elit'],
+				initialValue: 'Lorem ipsum dolor sit amet',
+				render: () => 'Lorem ipsum dolor sit amet',
+			});
+			// leave the promise hanging since we don't want to submit in this test
+			instance.prompt();
+			for (let index = 0; index < 3; index++) input.emit('keypress', '', { name: 'left' });
+			expect(instance.displayValue).to.deep.equal([
+				{ text: 'Lorem ipsum dolor sit a', type: 'value' },
+				{ text: 'm', type: 'cursor_on_value' },
+				{ text: 'et', type: 'value' },
+				{ text: ', consectetur adipiscing elit', type: 'suggestion' },
+			]);
+		});
+	});
+	describe('navigate suggestion', () => {
+		test('the default is the first suggestion', () => {
+			const instance = new SuggestionPrompt({
+				input,
+				output,
+				suggest: () => ['foobar', 'foobaz'],
+				initialValue: 'foo',
+				render: () => 'foo',
+			});
+			// leave the promise hanging since we don't want to submit in this test
+			instance.prompt();
+			expect(instance.suggestion).to.be.equal('bar');
+		});
+		test('down display next suggestion', () => {
+			const instance = new SuggestionPrompt({
+				input,
+				output,
+				suggest: () => ['foobar', 'foobaz'],
+				initialValue: 'foo',
+				render: () => 'foo',
+			});
+			// leave the promise hanging since we don't want to submit in this test
+			instance.prompt();
+			input.emit('keypress', '', { name: 'down' });
+
+			expect(instance.suggestion).to.be.equal('baz');
+		});
+		test('suggestions loops (down)', () => {
+			const instance = new SuggestionPrompt({
+				input,
+				output,
+				suggest: () => ['foobar', 'foobaz'],
+				initialValue: 'foo',
+				render: () => 'foo',
+			});
+			// leave the promise hanging since we don't want to submit in this test
+			instance.prompt();
+			expect(instance.suggestion).to.be.equal('bar');
+			input.emit('keypress', '', { name: 'down' });
+			expect(instance.suggestion).to.be.equal('baz');
+			input.emit('keypress', '', { name: 'down' });
+			expect(instance.suggestion).to.be.equal('bar');
+		});
+
+		test('suggestions loops (up)', () => {
+			const instance = new SuggestionPrompt({
+				input,
+				output,
+				suggest: () => ['foobar', 'foobaz'],
+				initialValue: 'foo',
+				render: () => 'foo',
+			});
+			// leave the promise hanging since we don't want to submit in this test
+			instance.prompt();
+			expect(instance.suggestion).to.be.equal('bar');
+			input.emit('keypress', '', { name: 'up' });
+			expect(instance.suggestion).to.be.equal('baz');
+			input.emit('keypress', '', { name: 'up' });
+			expect(instance.suggestion).to.be.equal('bar');
+		});
+	});
+	test('tab validate suggestion', () => {
+		const instance = new SuggestionPrompt({
+			input,
+			output,
+			suggest: () => ['foobar', 'foobaz'],
+			initialValue: 'foo',
+			render: () => 'foo',
+		});
+		// leave the promise hanging since we don't want to submit in this test
+		instance.prompt();
+		expect(instance.suggestion).to.be.equal('bar');
+		expect(instance.value).to.be.equal('foo');
+		input.emit('keypress', '', { name: 'tab' });
+		expect(instance.suggestion).to.be.equal('');
+		expect(instance.value).to.be.equal('foobar');
+	});
+	describe('suggestion are filtered', () => {
+		test("suggestion that don't match (begin) at not displayed", () => {
+			const instance = new SuggestionPrompt({
+				input,
+				output,
+				suggest: () => ['foobar', 'foobaz', 'hello world'],
+				initialValue: 'foo',
+				render: () => 'foo',
+			});
+			// leave the promise hanging since we don't want to submit in this test
+			instance.prompt();
+			expect((instance as unknown as { nextItems: Array<string> }).nextItems.length).to.be.equal(2);
+		});
+		test('empty suggestions are removed', () => {
+			const instance = new SuggestionPrompt({
+				input,
+				output,
+				suggest: () => ['foo'],
+				initialValue: 'foo',
+				render: () => 'foo',
+			});
+			// leave the promise hanging since we don't want to submit in this test
+			instance.prompt();
+			expect((instance as unknown as { nextItems: Array<string> }).nextItems.length).to.be.equal(0);
+		});
+	});
+});
diff --git a/packages/prompts/__mocks__/fs.cjs b/packages/prompts/__mocks__/fs.cjs
new file mode 100644
index 00000000..3c1dd612
--- /dev/null
+++ b/packages/prompts/__mocks__/fs.cjs
@@ -0,0 +1,2 @@
+const { fs } = require('memfs');
+module.exports = fs;
diff --git a/packages/prompts/package.json b/packages/prompts/package.json
index 45edd3cd..676a7e46 100644
--- a/packages/prompts/package.json
+++ b/packages/prompts/package.json
@@ -56,6 +56,7 @@
   },
   "devDependencies": {
     "is-unicode-supported": "^1.3.0",
+    "memfs": "^4.17.1",
     "vitest": "^3.1.1",
     "vitest-ansi-serializer": "^0.1.2"
   }
diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts
index c77046c7..e316ef65 100644
--- a/packages/prompts/src/index.ts
+++ b/packages/prompts/src/index.ts
@@ -11,11 +11,13 @@ export * from './messages.js';
 export * from './multi-select.js';
 export * from './note.js';
 export * from './password.js';
+export * from './path.js';
 export * from './progress-bar.js';
 export * from './select-key.js';
 export * from './select.js';
 export * from './spinner.js';
 export * from './stream.js';
+export * from './suggestion.js';
 export * from './task.js';
 export * from './task-log.js';
 export * from './text.js';
diff --git a/packages/prompts/src/path.ts b/packages/prompts/src/path.ts
new file mode 100644
index 00000000..b1e04b14
--- /dev/null
+++ b/packages/prompts/src/path.ts
@@ -0,0 +1,44 @@
+import { existsSync, lstatSync, readdirSync } from 'node:fs';
+import { join } from 'node:path';
+import { dirname } from 'knip/dist/util/path.js';
+import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js';
+import { suggestion } from './suggestion.js';
+
+export interface PathOptions extends CommonOptions {
+	root?: string;
+	directory?: boolean;
+	initialValue?: string;
+	message: string;
+	validate?: (value: string) => string | Error | undefined;
+}
+
+export const path = (opts: PathOptions) => {
+	return suggestion({
+		...opts,
+		initialValue: opts.initialValue ?? opts.root ?? process.cwd(),
+		suggest: (value: string) => {
+			try {
+				const searchPath = !existsSync(value) ? dirname(value) : value;
+				if (!lstatSync(searchPath).isDirectory()) {
+					return [];
+				}
+				const items = readdirSync(searchPath)
+					.map((item) => {
+						const path = join(searchPath, item);
+						const stats = lstatSync(path);
+						return {
+							name: item,
+							path,
+							isDirectory: stats.isDirectory(),
+						};
+					})
+					.filter(({ path }) => path.startsWith(value));
+				return ((opts.directory ?? false) ? items.filter((item) => item.isDirectory) : items).map(
+					({ path }) => path
+				);
+			} catch (e) {
+				return [];
+			}
+		},
+	});
+};
diff --git a/packages/prompts/src/suggestion.ts b/packages/prompts/src/suggestion.ts
new file mode 100644
index 00000000..885524fa
--- /dev/null
+++ b/packages/prompts/src/suggestion.ts
@@ -0,0 +1,50 @@
+import { SuggestionPrompt, type ValueWithCursorPart } from '@clack/core';
+import color from 'picocolors';
+import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js';
+
+export interface SuggestionOptions extends CommonOptions {
+	initialValue?: string;
+	message: string;
+	validate?: (value: string) => string | Error | undefined;
+	suggest: (value: string) => Array<string>;
+}
+
+export const suggestion = (opts: SuggestionOptions) => {
+	return new SuggestionPrompt({
+		initialValue: opts.initialValue ?? '',
+		output: opts.output,
+		input: opts.input,
+		validate: opts.validate,
+		suggest: opts.suggest,
+		render() {
+			const title = `${color.gray(S_BAR)}\n${symbol(this.state)}  ${opts.message}\n`;
+			const value = this.displayValue.reduce((text: string, line: ValueWithCursorPart) => {
+				switch (line.type) {
+					case 'value':
+						return text + line.text;
+					case 'cursor_on_value':
+						return text + color.inverse(line.text);
+					case 'suggestion':
+						return text + color.gray(line.text);
+					case 'cursor_on_suggestion':
+						return text + color.inverse(color.gray(line.text));
+				}
+			}, '');
+
+			switch (this.state) {
+				case 'error':
+					return `${title.trim()}\n${color.yellow(S_BAR)}  ${value}\n${color.yellow(
+						S_BAR_END
+					)}  ${color.yellow(this.error)}\n`;
+				case 'submit':
+					return `${title}${color.gray(S_BAR)}  ${color.dim(this.value)}`;
+				case 'cancel':
+					return `${title}${color.gray(S_BAR)}  ${color.strikethrough(
+						color.dim(this.value ?? '')
+					)}${this.value?.trim() ? `\n${color.gray(S_BAR)}` : ''}`;
+				default:
+					return `${title}${color.cyan(S_BAR)}  ${value}\n${color.cyan(S_BAR_END)}\n`;
+			}
+		},
+	}).prompt() as Promise<string | symbol>;
+};
diff --git a/packages/prompts/test/__snapshots__/path.test.ts.snap b/packages/prompts/test/__snapshots__/path.test.ts.snap
new file mode 100644
index 00000000..ce1138df
--- /dev/null
+++ b/packages/prompts/test/__snapshots__/path.test.ts.snap
@@ -0,0 +1,455 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`text (isCI = false) > can cancel 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  /tmp/[7m[90mf[39m[27m[90moo[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[31m■[39m  foo
+[90m│[39m  [9m[2m/tmp/[22m[29m
+[90m│[39m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = false) > initialValue sets the value 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  /tmp/bar[7m [27m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m  [2m/tmp/bar[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = false) > renders and apply (<tab>) suggestion 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  /tmp[7m[90m/[39m[27m[90mfoo[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  /tmp/foo[7m[90m/[39m[27m[90mbar.txt[39m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m  [2m/tmp/foo[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = false) > renders cancelled value if one set 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  /tmp/[7m[90mf[39m[27m[90moo[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  /tmp/x[7m [27m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  /tmp/xy[7m [27m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[31m■[39m  foo
+[90m│[39m  [9m[2m/tmp/xy[22m[29m
+[90m│[39m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = false) > renders message 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  /tmp/[7m[90mf[39m[27m[90moo[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m  [2m/tmp/[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = false) > renders submitted value 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  /tmp/[7m[90mf[39m[27m[90moo[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  /tmp/x[7m [27m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  /tmp/xy[7m [27m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m  [2m/tmp/xy[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = false) > validation errors render and clear (using Error) 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  /tmp/[7m[90mf[39m[27m[90moo[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  /tmp/b[7m [27m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[33m▲[39m  foo
+[33m│[39m  /tmp/b[7m [27m
+[33m└[39m  [33mshould be /tmp/bar[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[36m◆[39m  foo
+[36m│[39m  /tmp/ba[7m [27m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  /tmp/bar[7m [27m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m  [2m/tmp/bar[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = false) > validation errors render and clear 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  /tmp/[7m[90mf[39m[27m[90moo[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  /tmp/b[7m [27m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[33m▲[39m  foo
+[33m│[39m  /tmp/b[7m [27m
+[33m└[39m  [33mshould be /tmp/bar[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[36m◆[39m  foo
+[36m│[39m  /tmp/ba[7m [27m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  /tmp/bar[7m [27m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m  [2m/tmp/bar[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = true) > can cancel 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  /tmp/[7m[90mf[39m[27m[90moo[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[31m■[39m  foo
+[90m│[39m  [9m[2m/tmp/[22m[29m
+[90m│[39m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = true) > initialValue sets the value 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  /tmp/bar[7m [27m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m  [2m/tmp/bar[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = true) > renders and apply (<tab>) suggestion 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  /tmp[7m[90m/[39m[27m[90mfoo[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  /tmp/foo[7m[90m/[39m[27m[90mbar.txt[39m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m  [2m/tmp/foo[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = true) > renders cancelled value if one set 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  /tmp/[7m[90mf[39m[27m[90moo[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  /tmp/x[7m [27m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  /tmp/xy[7m [27m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[31m■[39m  foo
+[90m│[39m  [9m[2m/tmp/xy[22m[29m
+[90m│[39m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = true) > renders message 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  /tmp/[7m[90mf[39m[27m[90moo[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m  [2m/tmp/[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = true) > renders submitted value 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  /tmp/[7m[90mf[39m[27m[90moo[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  /tmp/x[7m [27m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  /tmp/xy[7m [27m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m  [2m/tmp/xy[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = true) > validation errors render and clear (using Error) 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  /tmp/[7m[90mf[39m[27m[90moo[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  /tmp/b[7m [27m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[33m▲[39m  foo
+[33m│[39m  /tmp/b[7m [27m
+[33m└[39m  [33mshould be /tmp/bar[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[36m◆[39m  foo
+[36m│[39m  /tmp/ba[7m [27m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  /tmp/bar[7m [27m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m  [2m/tmp/bar[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = true) > validation errors render and clear 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  /tmp/[7m[90mf[39m[27m[90moo[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  /tmp/b[7m [27m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[33m▲[39m  foo
+[33m│[39m  /tmp/b[7m [27m
+[33m└[39m  [33mshould be /tmp/bar[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[36m◆[39m  foo
+[36m│[39m  /tmp/ba[7m [27m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  /tmp/bar[7m [27m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m  [2m/tmp/bar[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
diff --git a/packages/prompts/test/__snapshots__/suggestion.test.ts.snap b/packages/prompts/test/__snapshots__/suggestion.test.ts.snap
new file mode 100644
index 00000000..e1f2aeeb
--- /dev/null
+++ b/packages/prompts/test/__snapshots__/suggestion.test.ts.snap
@@ -0,0 +1,433 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`text (isCI = false) > can cancel 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  [7m [27m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[31m■[39m  foo
+[90m│[39m[9m[2m[22m[29m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = false) > initialValue sets the value 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  bar[7m [27m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m  [2mbar[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = false) > renders and apply (<tab>) suggestion 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  [7m[90mb[39m[27m[90mar[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  bar[7m [27m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m  [2mbar[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = false) > renders cancelled value if one set 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  [7m[90mx[39m[27m[90myz[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  x[7m[90my[39m[27m[90mz[39m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  xy[7m[90mz[39m[27m[90m[39m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[31m■[39m  foo
+[90m│[39m  [9m[2mxy[22m[29m
+[90m│[39m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = false) > renders message 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  [7m [27m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m[2m[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = false) > renders submitted value 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  [7m[90mx[39m[27m[90myz[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  x[7m[90my[39m[27m[90mz[39m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  xy[7m[90mz[39m[27m[90m[39m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m  [2mxy[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = false) > validation errors render and clear (using Error) 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  [7m[90mx[39m[27m[90myz[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  x[7m[90my[39m[27m[90mz[39m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[33m▲[39m  foo
+[33m│[39m  x[7m[90my[39m[27m[90mz[39m
+[33m└[39m  [33mshould be xy[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[36m◆[39m  foo
+[36m│[39m  xy[7m[90mz[39m[27m[90m[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m  [2mxy[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = false) > validation errors render and clear 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  [7m[90mx[39m[27m[90myz[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  x[7m[90my[39m[27m[90mz[39m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[33m▲[39m  foo
+[33m│[39m  x[7m[90my[39m[27m[90mz[39m
+[33m└[39m  [33mshould be xy[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[36m◆[39m  foo
+[36m│[39m  xy[7m[90mz[39m[27m[90m[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m  [2mxy[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = true) > can cancel 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  [7m [27m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[31m■[39m  foo
+[90m│[39m[9m[2m[22m[29m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = true) > initialValue sets the value 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  bar[7m [27m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m  [2mbar[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = true) > renders and apply (<tab>) suggestion 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  [7m[90mb[39m[27m[90mar[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  bar[7m [27m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m  [2mbar[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = true) > renders cancelled value if one set 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  [7m[90mx[39m[27m[90myz[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  x[7m[90my[39m[27m[90mz[39m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  xy[7m[90mz[39m[27m[90m[39m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[31m■[39m  foo
+[90m│[39m  [9m[2mxy[22m[29m
+[90m│[39m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = true) > renders message 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  [7m [27m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m[2m[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = true) > renders submitted value 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  [7m[90mx[39m[27m[90myz[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  x[7m[90my[39m[27m[90mz[39m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  xy[7m[90mz[39m[27m[90m[39m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m  [2mxy[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = true) > validation errors render and clear (using Error) 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  [7m[90mx[39m[27m[90myz[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  x[7m[90my[39m[27m[90mz[39m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[33m▲[39m  foo
+[33m│[39m  x[7m[90my[39m[27m[90mz[39m
+[33m└[39m  [33mshould be xy[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[36m◆[39m  foo
+[36m│[39m  xy[7m[90mz[39m[27m[90m[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m  [2mxy[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
+
+exports[`text (isCI = true) > validation errors render and clear 1`] = `
+[
+  "<cursor.hide>",
+  "[90m│[39m
+[36m◆[39m  foo
+[36m│[39m  [7m[90mx[39m[27m[90myz[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=2>",
+  "<erase.line><cursor.left count=1>",
+  "[36m│[39m  x[7m[90my[39m[27m[90mz[39m",
+  "<cursor.down count=2>",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[33m▲[39m  foo
+[33m│[39m  x[7m[90my[39m[27m[90mz[39m
+[33m└[39m  [33mshould be xy[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[36m◆[39m  foo
+[36m│[39m  xy[7m[90mz[39m[27m[90m[39m
+[36m└[39m
+",
+  "<cursor.backward count=999><cursor.up count=4>",
+  "<cursor.down count=1>",
+  "<erase.down>",
+  "[32m◇[39m  foo
+[90m│[39m  [2mxy[22m",
+  "
+",
+  "<cursor.show>",
+]
+`;
diff --git a/packages/prompts/test/path.test.ts b/packages/prompts/test/path.test.ts
new file mode 100644
index 00000000..52df1d8b
--- /dev/null
+++ b/packages/prompts/test/path.test.ts
@@ -0,0 +1,186 @@
+import { fs, vol } from 'memfs';
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
+import * as prompts from '../src/index.js';
+import { MockReadable, MockWritable } from './test-utils.js';
+
+vi.mock('node:fs');
+
+describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => {
+	let originalCI: string | undefined;
+	let output: MockWritable;
+	let input: MockReadable;
+
+	beforeAll(() => {
+		originalCI = process.env.CI;
+		process.env.CI = isCI;
+	});
+
+	afterAll(() => {
+		process.env.CI = originalCI;
+	});
+
+	beforeEach(() => {
+		output = new MockWritable();
+		input = new MockReadable();
+		vol.reset();
+		vol.fromJSON(
+			{
+				'./foo/bar.txt': '1',
+				'./foo/baz.text': '2',
+				'./hello/world.jpg': '3',
+				'./hello/john.jpg': '4',
+				'./hello/jeanne.png': '5',
+				'./root.zip': '6',
+			},
+			'/tmp'
+		);
+	});
+
+	afterEach(() => {
+		vi.restoreAllMocks();
+	});
+
+	test('renders message', async () => {
+		const result = prompts.path({
+			message: 'foo',
+			input,
+			output,
+			root: '/tmp/',
+		});
+
+		input.emit('keypress', '', { name: 'return' });
+
+		await result;
+
+		expect(output.buffer).toMatchSnapshot();
+	});
+
+	test('renders and apply (<tab>) suggestion', async () => {
+		const result = prompts.path({
+			message: 'foo',
+			root: '/tmp',
+			input,
+			output,
+		});
+
+		input.emit('keypress', '\t', { name: 'tab' });
+		input.emit('keypress', '', { name: 'return' });
+
+		const value = await result;
+
+		expect(output.buffer).toMatchSnapshot();
+
+		expect(value).toBe('/tmp/foo');
+	});
+
+	test('can cancel', async () => {
+		const result = prompts.path({
+			message: 'foo',
+			root: '/tmp/',
+			input,
+			output,
+		});
+
+		input.emit('keypress', 'escape', { name: 'escape' });
+
+		const value = await result;
+
+		expect(prompts.isCancel(value)).toBe(true);
+		expect(output.buffer).toMatchSnapshot();
+	});
+
+	test('renders cancelled value if one set', async () => {
+		const result = prompts.path({
+			message: 'foo',
+			input,
+			output,
+			root: '/tmp/',
+		});
+
+		input.emit('keypress', 'x', { name: 'x' });
+		input.emit('keypress', 'y', { name: 'y' });
+		input.emit('keypress', '', { name: 'escape' });
+
+		const value = await result;
+
+		expect(prompts.isCancel(value)).toBe(true);
+		expect(output.buffer).toMatchSnapshot();
+	});
+
+	test('renders submitted value', async () => {
+		const result = prompts.path({
+			message: 'foo',
+			root: '/tmp/',
+			input,
+			output,
+		});
+
+		input.emit('keypress', 'x', { name: 'x' });
+		input.emit('keypress', 'y', { name: 'y' });
+		input.emit('keypress', '', { name: 'return' });
+
+		const value = await result;
+
+		expect(value).toBe('/tmp/xy');
+		expect(output.buffer).toMatchSnapshot();
+	});
+
+	test('initialValue sets the value', async () => {
+		const result = prompts.path({
+			message: 'foo',
+			initialValue: '/tmp/bar',
+			root: '/tmp/',
+			input,
+			output,
+		});
+
+		input.emit('keypress', '', { name: 'return' });
+
+		const value = await result;
+
+		expect(value).toBe('/tmp/bar');
+		expect(output.buffer).toMatchSnapshot();
+	});
+
+	test('validation errors render and clear', async () => {
+		const result = prompts.path({
+			message: 'foo',
+			root: '/tmp/',
+			validate: (val) => (val !== '/tmp/bar' ? 'should be /tmp/bar' : undefined),
+			input,
+			output,
+		});
+
+		input.emit('keypress', 'b', { name: 'b' });
+		input.emit('keypress', '', { name: 'return' });
+		input.emit('keypress', 'a', { name: 'a' });
+		input.emit('keypress', 'r', { name: 'r' });
+		input.emit('keypress', '', { name: 'return' });
+
+		const value = await result;
+
+		expect(value).toBe('/tmp/bar');
+		expect(output.buffer).toMatchSnapshot();
+	});
+
+	test('validation errors render and clear (using Error)', async () => {
+		const result = prompts.path({
+			message: 'foo',
+			root: '/tmp/',
+			validate: (val) => (val !== '/tmp/bar' ? new Error('should be /tmp/bar') : undefined),
+			input,
+			output,
+		});
+
+		input.emit('keypress', 'b', { name: 'b' });
+		input.emit('keypress', '', { name: 'return' });
+		input.emit('keypress', 'a', { name: 'a' });
+		input.emit('keypress', 'r', { name: 'r' });
+		input.emit('keypress', '', { name: 'return' });
+
+		const value = await result;
+
+		expect(value).toBe('/tmp/bar');
+		expect(output.buffer).toMatchSnapshot();
+	});
+});
diff --git a/packages/prompts/test/suggestion.test.ts b/packages/prompts/test/suggestion.test.ts
new file mode 100644
index 00000000..37caf3ef
--- /dev/null
+++ b/packages/prompts/test/suggestion.test.ts
@@ -0,0 +1,169 @@
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
+import * as prompts from '../src/index.js';
+import { MockReadable, MockWritable } from './test-utils.js';
+
+describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => {
+	let originalCI: string | undefined;
+	let output: MockWritable;
+	let input: MockReadable;
+
+	beforeAll(() => {
+		originalCI = process.env.CI;
+		process.env.CI = isCI;
+	});
+
+	afterAll(() => {
+		process.env.CI = originalCI;
+	});
+
+	beforeEach(() => {
+		output = new MockWritable();
+		input = new MockReadable();
+	});
+
+	afterEach(() => {
+		vi.restoreAllMocks();
+	});
+
+	test('renders message', async () => {
+		const result = prompts.suggestion({
+			message: 'foo',
+			input,
+			output,
+			suggest: () => [],
+		});
+
+		input.emit('keypress', '', { name: 'return' });
+
+		await result;
+
+		expect(output.buffer).toMatchSnapshot();
+	});
+
+	test('renders and apply (<tab>) suggestion', async () => {
+		const result = prompts.suggestion({
+			message: 'foo',
+			suggest: () => ['bar'],
+			input,
+			output,
+		});
+
+		input.emit('keypress', '\t', { name: 'tab' });
+		input.emit('keypress', '', { name: 'return' });
+
+		const value = await result;
+
+		expect(output.buffer).toMatchSnapshot();
+
+		expect(value).toBe('bar');
+	});
+
+	test('can cancel', async () => {
+		const result = prompts.suggestion({
+			message: 'foo',
+			suggest: () => [],
+			input,
+			output,
+		});
+
+		input.emit('keypress', 'escape', { name: 'escape' });
+
+		const value = await result;
+
+		expect(prompts.isCancel(value)).toBe(true);
+		expect(output.buffer).toMatchSnapshot();
+	});
+
+	test('renders cancelled value if one set', async () => {
+		const result = prompts.suggestion({
+			message: 'foo',
+			input,
+			output,
+			suggest: () => ['xyz'],
+		});
+
+		input.emit('keypress', 'x', { name: 'x' });
+		input.emit('keypress', 'y', { name: 'y' });
+		input.emit('keypress', '', { name: 'escape' });
+
+		const value = await result;
+
+		expect(prompts.isCancel(value)).toBe(true);
+		expect(output.buffer).toMatchSnapshot();
+	});
+
+	test('renders submitted value', async () => {
+		const result = prompts.suggestion({
+			message: 'foo',
+			suggest: () => ['xyz'],
+			input,
+			output,
+		});
+
+		input.emit('keypress', 'x', { name: 'x' });
+		input.emit('keypress', 'y', { name: 'y' });
+		input.emit('keypress', '', { name: 'return' });
+
+		const value = await result;
+
+		expect(value).toBe('xy');
+		expect(output.buffer).toMatchSnapshot();
+	});
+
+	test('initialValue sets the value', async () => {
+		const result = prompts.suggestion({
+			message: 'foo',
+			initialValue: 'bar',
+			suggest: () => [],
+			input,
+			output,
+		});
+
+		input.emit('keypress', '', { name: 'return' });
+
+		const value = await result;
+
+		expect(value).toBe('bar');
+		expect(output.buffer).toMatchSnapshot();
+	});
+
+	test('validation errors render and clear', async () => {
+		const result = prompts.suggestion({
+			message: 'foo',
+			suggest: () => ['xyz'],
+			validate: (val) => (val !== 'xy' ? 'should be xy' : undefined),
+			input,
+			output,
+		});
+
+		input.emit('keypress', 'x', { name: 'x' });
+		input.emit('keypress', '', { name: 'return' });
+		input.emit('keypress', 'y', { name: 'y' });
+		input.emit('keypress', '', { name: 'return' });
+
+		const value = await result;
+
+		expect(value).toBe('xy');
+		expect(output.buffer).toMatchSnapshot();
+	});
+
+	test('validation errors render and clear (using Error)', async () => {
+		const result = prompts.suggestion({
+			message: 'foo',
+			suggest: () => ['xyz'],
+			validate: (val) => (val !== 'xy' ? new Error('should be xy') : undefined),
+			input,
+			output,
+		});
+
+		input.emit('keypress', 'x', { name: 'x' });
+		input.emit('keypress', '', { name: 'return' });
+		input.emit('keypress', 'y', { name: 'y' });
+		input.emit('keypress', '', { name: 'return' });
+
+		const value = await result;
+
+		expect(value).toBe('xy');
+		expect(output.buffer).toMatchSnapshot();
+	});
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2eb8527e..d2ae3cb1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -89,6 +89,9 @@ importers:
       is-unicode-supported:
         specifier: ^1.3.0
         version: 1.3.0
+      memfs:
+        specifier: ^4.17.1
+        version: 4.17.1
       vitest:
         specifier: ^3.1.1
         version: 3.1.1(@types/node@18.16.0)
@@ -716,6 +719,24 @@ packages:
   '@jridgewell/trace-mapping@0.3.25':
     resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
 
+  '@jsonjoy.com/base64@1.1.2':
+    resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==}
+    engines: {node: '>=10.0'}
+    peerDependencies:
+      tslib: '2'
+
+  '@jsonjoy.com/json-pack@1.2.0':
+    resolution: {integrity: sha512-io1zEbbYcElht3tdlqEOFxZ0dMTYrHz9iMf0gqn1pPjZFTCgM5R4R5IMA20Chb2UPYYsxjzs8CgZ7Nb5n2K2rA==}
+    engines: {node: '>=10.0'}
+    peerDependencies:
+      tslib: '2'
+
+  '@jsonjoy.com/util@1.6.0':
+    resolution: {integrity: sha512-sw/RMbehRhN68WRtcKCpQOPfnH6lLP4GJfqzi3iYej8tnzpZUDr6UkZYJjcjjC0FWEJOJbyM3PTIwxucUmDG2A==}
+    engines: {node: '>=10.0'}
+    peerDependencies:
+      tslib: '2'
+
   '@manypkg/find-root@1.1.0':
     resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==}
 
@@ -1544,6 +1565,10 @@ packages:
   human-id@1.0.2:
     resolution: {integrity: sha512-UNopramDEhHJD+VR+ehk8rOslwSfByxPIZyJRfV739NDhN5LF1fa1MqnzKm2lGTQRjNrjK19Q5fhkgIfjlVUKw==}
 
+  hyperdyperid@1.2.0:
+    resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==}
+    engines: {node: '>=10.18'}
+
   iconv-lite@0.4.24:
     resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
     engines: {node: '>=0.10.0'}
@@ -1826,6 +1851,10 @@ packages:
   mdn-data@2.0.30:
     resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
 
+  memfs@4.17.1:
+    resolution: {integrity: sha512-thuTRd7F4m4dReCIy7vv4eNYnU6XI/tHMLSMMHLiortw/Y0QxqKtinG523U2aerzwYWGi606oBP4oMPy4+edag==}
+    engines: {node: '>= 4.0.0'}
+
   meow@6.1.1:
     resolution: {integrity: sha512-3YffViIt2QWgTy6Pale5QpopX/IvU3LPL03jOTqp6pGj3VjesdO/U8CuHMKpnQr4shCNCM5fd5XFFvIIl6JBHg==}
     engines: {node: '>=8'}
@@ -2477,6 +2506,12 @@ packages:
     resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==}
     engines: {node: '>=8'}
 
+  thingies@1.21.0:
+    resolution: {integrity: sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==}
+    engines: {node: '>=10.18'}
+    peerDependencies:
+      tslib: ^2
+
   tinybench@2.9.0:
     resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
 
@@ -2507,10 +2542,19 @@ packages:
     resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
     engines: {node: '>=8.0'}
 
+  tree-dump@1.0.2:
+    resolution: {integrity: sha512-dpev9ABuLWdEubk+cIaI9cHwRNNDjkBBLXTwI4UCUFdQ5xXKqNXoK4FEciw/vxf+NQ7Cb7sGUyeUtORvHIdRXQ==}
+    engines: {node: '>=10.0'}
+    peerDependencies:
+      tslib: '2'
+
   trim-newlines@3.0.1:
     resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==}
     engines: {node: '>=8'}
 
+  tslib@2.8.1:
+    resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
   tty-table@4.2.3:
     resolution: {integrity: sha512-Fs15mu0vGzCrj8fmJNP7Ynxt5J7praPXqFN0leZeZBXJwkMxv9cb2D454k1ltrtUSJbZ4yH4e0CynsHLxmUfFA==}
     engines: {node: '>=8.0.0'}
@@ -3271,6 +3315,22 @@ snapshots:
       '@jridgewell/resolve-uri': 3.1.2
       '@jridgewell/sourcemap-codec': 1.5.0
 
+  '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)':
+    dependencies:
+      tslib: 2.8.1
+
+  '@jsonjoy.com/json-pack@1.2.0(tslib@2.8.1)':
+    dependencies:
+      '@jsonjoy.com/base64': 1.1.2(tslib@2.8.1)
+      '@jsonjoy.com/util': 1.6.0(tslib@2.8.1)
+      hyperdyperid: 1.2.0
+      thingies: 1.21.0(tslib@2.8.1)
+      tslib: 2.8.1
+
+  '@jsonjoy.com/util@1.6.0(tslib@2.8.1)':
+    dependencies:
+      tslib: 2.8.1
+
   '@manypkg/find-root@1.1.0':
     dependencies:
       '@babel/runtime': 7.26.0
@@ -4208,6 +4268,8 @@ snapshots:
 
   human-id@1.0.2: {}
 
+  hyperdyperid@1.2.0: {}
+
   iconv-lite@0.4.24:
     dependencies:
       safer-buffer: 2.1.2
@@ -4459,6 +4521,13 @@ snapshots:
 
   mdn-data@2.0.30: {}
 
+  memfs@4.17.1:
+    dependencies:
+      '@jsonjoy.com/json-pack': 1.2.0(tslib@2.8.1)
+      '@jsonjoy.com/util': 1.6.0(tslib@2.8.1)
+      tree-dump: 1.0.2(tslib@2.8.1)
+      tslib: 2.8.1
+
   meow@6.1.1:
     dependencies:
       '@types/minimist': 1.2.5
@@ -5109,6 +5178,10 @@ snapshots:
 
   term-size@2.2.1: {}
 
+  thingies@1.21.0(tslib@2.8.1):
+    dependencies:
+      tslib: 2.8.1
+
   tinybench@2.9.0: {}
 
   tinyexec@0.3.2: {}
@@ -5132,8 +5205,14 @@ snapshots:
     dependencies:
       is-number: 7.0.0
 
+  tree-dump@1.0.2(tslib@2.8.1):
+    dependencies:
+      tslib: 2.8.1
+
   trim-newlines@3.0.1: {}
 
+  tslib@2.8.1: {}
+
   tty-table@4.2.3:
     dependencies:
       chalk: 4.1.2


Reviews:
user: login: dreyfus92
body: Hey @MacFJA, this looks pretty cool, but I would like to see some tests for this 😁
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-10T15:21:47Z
user: login: MacFJA
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-10T20:32:42Z
user: login: dreyfus92
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-10T22:45:57Z
user: login: dreyfus92
body: Thank you @MacFJA, LGTM 😁
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-14T21:40:58Z

Review comments:
diff_hunk: @@ -0,0 +1,6 @@
+---
+"@clack/prompts": minor
+"@clack/core": minor
+---
+
+Add suggestion prompt and path prompt
path: .changeset/curvy-seals-sit.md
user: login: dreyfus92
body: ```suggestion
Adds suggestion and path prompts
```
created_at: 2025-05-10T15:20:12Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,113 @@
+import color from 'picocolors';
+import type { ValueWithCursorPart } from '../types.js';
+import Prompt, { type PromptOptions } from './prompt.js';
+
+interface SuggestionOptions extends PromptOptions<SuggestionPrompt> {
+	suggest: (value: string) => Array<string>;
+}
+
+export default class SuggestionPrompt extends Prompt {
+	value: string;
+	protected suggest: (value: string) => Array<string>;
+	private selectionIndex = 0;
+	private nextItems: Array<string> = [];
+
+	constructor(opts: SuggestionOptions) {
+		super(opts);
+
+		this.value = opts.initialValue;
+		this.suggest = opts.suggest;
+		this.getNextItems();
+		this.selectionIndex = 0;
+		this._cursor = this.value.length;
+
+		this.on('cursor', (key) => {
+			switch (key) {
+				case 'up':
+					this.selectionIndex = Math.max(
+						0,
+						this.selectionIndex === 0 ? this.nextItems.length - 1 : this.selectionIndex - 1
+					);
+					break;
+				case 'down':
+					this.selectionIndex =
+						this.nextItems.length === 0 ? 0 : (this.selectionIndex + 1) % this.nextItems.length;
+					break;
+			}
+		});
+		this.on('key', (key, info) => {
+			if (info.name === 'tab' && this.nextItems.length > 0) {
+				const delta = this.nextItems[this.selectionIndex].substring(this.value.length);
+				this.value = this.nextItems[this.selectionIndex];
+				this.rl?.write(delta);
+				this._cursor = this.value.length;
+				this.selectionIndex = 0;
+				this.getNextItems();
+			}
+		});
+		this.on('value', () => {
+			this.getNextItems();
+		});
+	}
+
+	get displayValue(): Array<ValueWithCursorPart> {
+		const result: Array<ValueWithCursorPart> = [];
+		if (this._cursor > 0) {
+			result.push({
+				text: this.value.substring(0, this._cursor),
+				type: 'value',
+			});
+		}
+		if (this._cursor < this.value.length) {
+			result.push({
+				text: this.value.substring(this._cursor, this._cursor + 1),
+				type: 'cursor_on_value',
+			});
+			const left = this.value.substring(this._cursor + 1);
+			if (left.length > 0) {
+				result.push({
+					text: left,
+					type: 'value',
+				});
+			}
+			if (this.suggestion.length > 0) {
+				result.push({
+					text: this.suggestion,
+					type: 'suggestion',
+				});
+			}
+			return result;
+		}
+		if (this.suggestion.length === 0) {
+			result.push({
+				text: color.hidden('_'),
path: packages/core/src/prompts/suggestion.ts
user: login: MacFJA
body: I didn't really like this solution, but `wrap-ansi` remove trailing spaces, so I need to put a char :confused: 

Any idea to improve this part?
created_at: 2025-05-10T20:32:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,113 @@
+import color from 'picocolors';
+import type { ValueWithCursorPart } from '../types.js';
+import Prompt, { type PromptOptions } from './prompt.js';
+
+interface SuggestionOptions extends PromptOptions<SuggestionPrompt> {
+	suggest: (value: string) => Array<string>;
+}
+
+export default class SuggestionPrompt extends Prompt {
+	value: string;
+	protected suggest: (value: string) => Array<string>;
+	private selectionIndex = 0;
+	private nextItems: Array<string> = [];
+
+	constructor(opts: SuggestionOptions) {
+		super(opts);
+
+		this.value = opts.initialValue;
+		this.suggest = opts.suggest;
+		this.getNextItems();
+		this.selectionIndex = 0;
+		this._cursor = this.value.length;
+
+		this.on('cursor', (key) => {
+			switch (key) {
+				case 'up':
+					this.selectionIndex = Math.max(
+						0,
+						this.selectionIndex === 0 ? this.nextItems.length - 1 : this.selectionIndex - 1
+					);
+					break;
+				case 'down':
+					this.selectionIndex =
+						this.nextItems.length === 0 ? 0 : (this.selectionIndex + 1) % this.nextItems.length;
+					break;
+			}
+		});
+		this.on('key', (key, info) => {
+			if (info.name === 'tab' && this.nextItems.length > 0) {
+				const delta = this.nextItems[this.selectionIndex].substring(this.value.length);
+				this.value = this.nextItems[this.selectionIndex];
+				this.rl?.write(delta);
+				this._cursor = this.value.length;
+				this.selectionIndex = 0;
+				this.getNextItems();
+			}
+		});
+		this.on('value', () => {
+			this.getNextItems();
+		});
+	}
+
+	get displayValue(): Array<ValueWithCursorPart> {
+		const result: Array<ValueWithCursorPart> = [];
+		if (this._cursor > 0) {
+			result.push({
+				text: this.value.substring(0, this._cursor),
+				type: 'value',
+			});
+		}
+		if (this._cursor < this.value.length) {
+			result.push({
+				text: this.value.substring(this._cursor, this._cursor + 1),
+				type: 'cursor_on_value',
+			});
+			const left = this.value.substring(this._cursor + 1);
+			if (left.length > 0) {
+				result.push({
+					text: left,
+					type: 'value',
+				});
+			}
+			if (this.suggestion.length > 0) {
+				result.push({
+					text: this.suggestion,
+					type: 'suggestion',
+				});
+			}
+			return result;
+		}
+		if (this.suggestion.length === 0) {
+			result.push({
+				text: color.hidden('_'),
path: packages/core/src/prompts/suggestion.ts
user: login: dreyfus92
body: i have 2 alternatives which are using a non-breaking space with (`\u00A0`) or using a zero-width space (`u200B`) but they're also hacky and some tools/editors may not handle zero-width spaces and for the case of non-breaking spaces may still be visible in some terminals, and not truly "empty" 😭. so i'd say we stick with this for now, what do you think @43081j 😁?
created_at: 2025-05-10T22:45:57Z
author_association: MEMBER

PR comments:
user: login: changeset-bot[bot]
created_at: 2025-05-10T15:08:59Z
body: ###  🦋  Changeset detected

Latest commit: 668e21fe084e39e0ecbca3a726228f1edc506989

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 2 packages</summary>

  | Name           | Type  |
| -------------- | ----- |
| @clack/prompts | Minor |
| @clack/core    | Minor |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/MacFJA/clack/new/feat/path?filename=.changeset/clean-turkeys-refuse.md&value=---%0A%22%40example%2Fbasic%22%3A%20patch%0A%22%40clack%2Fcore%22%3A%20patch%0A%22%40clack%2Fprompts%22%3A%20patch%0A---%0A%0Afeat(core%2Bprompts)%3A%20Add%20suggestion%20%2B%20path%20prompt%0A)


author_association: NONE
user: login: pkg-pr-new[bot]
created_at: 2025-05-10T15:09:29Z
body: 
[@example/basic](https://pkg.pr.new/template/4e94fee3-fc42-40bc-bed7-cd52536bb74d) • [@example/changesets](https://pkg.pr.new/template/d8f2ec78-69df-4ac8-b7a4-88aca1d6e469)


```
npm i https://pkg.pr.new/bombshell-dev/clack/@clack/core@314
```


```
npm i https://pkg.pr.new/bombshell-dev/clack/@clack/prompts@314
```


_commit: <a href="https://github.com/bombshell-dev/clack/runs/42019119323"><code>668e21f</code></a>_

author_association: NONE
user: login: MacFJA
created_at: 2025-05-10T20:25:18Z
body: > Hey @MacFJA, this looks pretty cool, but I would like to see some tests for this 😁

Added unit tests for `core`.

`prompt` will follow soon
author_association: CONTRIBUTOR
user: login: MacFJA
created_at: 2025-05-11T20:09:22Z
body: > > Hey @MacFJA, this looks pretty cool, but I would like to see some tests for this 😁
> 
> Added unit tests for `core`.
> 
> `prompt` will follow soon

@dreyfus92 I added unit test in both `core` and `prompts`
author_association: CONTRIBUTOR