1import { assert, describe, it } from "@effect/vitest"
2import { Data, DateTime, Deferred, Effect, Fiber, FileSystem, Layer, Match, Path, Queue, Redacted } from "effect"
3import { Prompt } from "effect/cli"
4import * as MockTerminal from "./services/MockTerminal.ts"
5
6const FileSystemLayer = FileSystem.layerNoop({})
7const PathLayer = Path.layer
8const TerminalLayer = MockTerminal.layer
9
10const TestLayer = Layer.mergeAll(
11 FileSystemLayer,
12 PathLayer,
13 TerminalLayer
14)
15const Action = Data.taggedEnum<Prompt.ActionDefinition>()
16
17const escape = String.fromCharCode(27)
18const bell = String.fromCharCode(7)
19
20const stripAnsi = (text: string) => {
21 let result = ""
22 let skipping = false
23 for (let index = 0; index < text.length; index++) {
24 const char = text[index]
25 if (skipping) {
26 if ((char >= "A" && char <= "Z") || (char >= "a" && char <= "z")) {
27 skipping = false
28 }
29 continue
30 }
31 if (char === escape) {
32 skipping = true
33 continue
34 }
35 result += char
36 }
37 return result
38}
39
40const toFrames = (lines: ReadonlyArray<unknown>) =>
41 lines
42 .map((line) => stripAnsi(String(line)))
43 .filter((line) => line.split(bell).join("").trim().length > 0)
44
45const toRawFrames = (lines: ReadonlyArray<unknown>) =>
46 lines
47 .map((line) => String(line))
48 .filter((line) => stripAnsi(line).split(bell).join("").trim().length > 0)
49
50const findFrame = (frames: ReadonlyArray<string>, text: string) => frames.find((frame) => frame.includes(text))
51
52describe("Prompt.Date", () => {
53 it.effect("renders two-digit years, teen ordinals, and noon meridiem correctly", () =>
54 Effect.gen(function*() {
55 const initial = DateTime.toDateUtc(DateTime.makeZonedUnsafe(
56 { year: 2024, month: 1, day: 11, hour: 12 },
57 { timeZone: DateTime.zoneMakeLocal(), adjustForTimeZone: true }
58 ))
59 yield* MockTerminal.inputKey("enter")
60
61 yield* Prompt.run(Prompt.Date({ message: "When", initial, dateMask: "YY Do A" }))
62 const output = (yield* MockTerminal.displayLines).map(String).join("\n")
63
64 assert.include(output, "24 11th PM")
65 }).pipe(Effect.provide(TestLayer)))
66
67 it.effect("starts a fresh typed buffer when Tab advances to the next field", () =>
68 Effect.gen(function*() {
69 const initial = new Date(2024, 0, 1, 12)
70 yield* MockTerminal.inputText("1")
71 yield* MockTerminal.inputKey("tab")
72 yield* MockTerminal.inputText("5")
73 yield* MockTerminal.inputKey("enter")
74
75 const result = yield* Prompt.run(Prompt.Date({ message: "When", initial, dateMask: "MM-DD" }))
76
77 assert.deepStrictEqual(result, new Date(2024, 0, 5, 12))
78 }).pipe(Effect.provide(TestLayer)))
79
80 it.effect("starts a fresh typed buffer when Tab wraps to the first field", () =>
81 Effect.gen(function*() {
82 const initial = new Date(2024, 0, 1, 12)
83 yield* MockTerminal.inputKey("tab")
84 yield* MockTerminal.inputText("1")
85 yield* MockTerminal.inputKey("tab")
86 yield* MockTerminal.inputText("5")
87 yield* MockTerminal.inputKey("enter")
88
89 const result = yield* Prompt.run(Prompt.Date({ message: "When", initial, dateMask: "DD-MM" }))
90
91 assert.deepStrictEqual(result, new Date(2024, 0, 5, 12))
92 }).pipe(Effect.provide(TestLayer)))
93
94 it.effect("accumulates multiple digits while staying in the same field", () =>
95 Effect.gen(function*() {
96 const initial = new Date(2024, 0, 1, 12)
97 yield* MockTerminal.inputText("15")
98 yield* MockTerminal.inputKey("enter")
99
100 const result = yield* Prompt.run(Prompt.Date({ message: "When", initial, dateMask: "DD" }))
101
102 assert.deepStrictEqual(result, new Date(2024, 0, 15, 12))
103 }).pipe(Effect.provide(TestLayer)))
104
105 it.effect("starts a fresh typed buffer when Tab wraps a single editable field", () =>
106 Effect.gen(function*() {
107 const initial = new Date(2024, 0, 1, 12)
108 yield* MockTerminal.inputText("1")
109 yield* MockTerminal.inputKey("tab")
110 yield* MockTerminal.inputText("5")
111 yield* MockTerminal.inputKey("enter")
112
113 const result = yield* Prompt.run(Prompt.Date({ message: "When", initial, dateMask: "DD" }))
114
115 assert.deepStrictEqual(result, new Date(2024, 0, 5, 12))
116 }).pipe(Effect.provide(TestLayer)))
117})
118
119describe("Prompt.all", () => {
120 it.effect("supports an empty record", () =>
121 Effect.gen(function*() {
122 const result = yield* Prompt.all({})
123 assert.deepStrictEqual(result, {})
124 }).pipe(Effect.provide(TestLayer)))
125
126 it.effect("supports a non-array iterable", () =>
127 Effect.gen(function*() {
128 const result = yield* Prompt.all(new Set([Prompt.succeed(1)]))
129 assert.deepStrictEqual(result, [1])
130 }).pipe(Effect.provide(TestLayer)))
131})
132
133describe("Prompt.Int", () => {
134 it.effect("submits the default value", () =>
135 Effect.gen(function*() {
136 const prompt = Prompt.Int({ message: "Count", default: 42 })
137
138 yield* MockTerminal.inputKey("enter")
139
140 const result = yield* Prompt.run(prompt)
141 assert.strictEqual(result, 42)
142 }).pipe(Effect.provide(TestLayer)))
143
144 it.effect("starts from the default value so it can be edited", () =>
145 Effect.gen(function*() {
146 const prompt = Prompt.Int({ message: "Count", default: 4 })
147
148 yield* MockTerminal.inputText("2")
149 yield* MockTerminal.inputKey("enter")
150
151 const result = yield* Prompt.run(prompt)
152 assert.strictEqual(result, 42)
153 }).pipe(Effect.provide(TestLayer)))
154
155 it.effect("clears the default value on ctrl-u", () =>
156 Effect.gen(function*() {
157 const prompt = Prompt.Int({ message: "Count", default: 42 })
158
159 yield* MockTerminal.inputKey("u", { ctrl: true })
160 yield* MockTerminal.inputText("7")
161 yield* MockTerminal.inputKey("enter")
162
163 const result = yield* Prompt.run(prompt)
164 assert.strictEqual(result, 7)
165 }).pipe(Effect.provide(TestLayer)))
166})
167
168describe("Prompt.Number", () => {
169 it.effect("preserves a leading zero in the fractional part", () =>
170 Effect.gen(function*() {
171 yield* MockTerminal.inputText("0.05")
172 yield* MockTerminal.inputKey("enter")
173
174 const value = yield* Prompt.run(Prompt.Number({ message: "Rate" }))
175
176 assert.strictEqual(value, 0.05)
177 }).pipe(Effect.provide(TestLayer)))
178
179 it.effect("renders appended input without literal parsed", () =>
180 Effect.gen(function*() {
181 const prompt = Prompt.Number({ message: "Rate" })
182
183 yield* MockTerminal.inputText("12.5")
184 yield* MockTerminal.inputKey("enter")
185
186 const result = yield* Prompt.run(prompt)
187 assert.strictEqual(result, 12.5)
188
189 const output = yield* MockTerminal.displayLines
190 const frames = toFrames(output)
191 const rendered = frames.join("\n")
192
193 assert.isTrue(rendered.includes("12.5"))
194 assert.isFalse(rendered.includes("parsed"))
195 }).pipe(Effect.provide(TestLayer)))
196
197 it.effect("clears the current input on ctrl-u", () =>
198 Effect.gen(function*() {
199 const prompt = Prompt.Number({ message: "Rate" })
200
201 yield* MockTerminal.inputText("12.5")
202 yield* MockTerminal.inputKey("u", { ctrl: true })
203 yield* MockTerminal.inputText("7.25")
204 yield* MockTerminal.inputKey("enter")
205
206 const result = yield* Prompt.run(prompt)
207 assert.strictEqual(result, 7.25)
208 }).pipe(Effect.provide(TestLayer)))
209})
210
211describe("Prompt.String", () => {
212 it.effect("renders the default prompt theme", () =>
213 Effect.gen(function*() {
214 yield* MockTerminal.inputKey("enter")
215
216 yield* Prompt.run(Prompt.String({ message: "Name" }))
217
218 const frames = toFrames(yield* MockTerminal.displayLines)
219 assert.include(frames[0] ?? "", "? Name")
220 }).pipe(Effect.provide(TestLayer)))
221
222 it.effect("renders the prompt theme from context", () =>
223 Effect.gen(function*() {
224 yield* MockTerminal.inputKey("enter")
225
226 yield* Prompt.run(Prompt.String({ message: "Name" })).pipe(
227 Effect.provideService(
228 Prompt.Theme,
229 Prompt.makeTheme({
230 prefix: "!",
231 pointerSmall: ">",
232 tick: "+",
233 ellipsis: "~"
234 })
235 )
236 )
237
238 const frames = toFrames(yield* MockTerminal.displayLines)
239 assert.include(frames[0] ?? "", "! Name >")
240 assert.include(frames.at(-1) ?? "", "+ Name ~")
241 }).pipe(Effect.provide(TestLayer)))
242
243 it.effect("renders theme colors from context with per-prompt overrides", () =>
244 Effect.gen(function*() {
245 yield* MockTerminal.inputText("A")
246 yield* MockTerminal.inputKey("enter")
247
248 yield* Prompt.run(Prompt.String({
249 message: "Name",
250 theme: { primaryColor: `${escape}[35m` }
251 })).pipe(
252 Effect.provideService(
253 Prompt.Theme,
254 Prompt.makeTheme({
255 primaryColor: `${escape}[31m`,
256 mutedColor: `${escape}[34m`,
257 successColor: `${escape}[33m`,
258 submittedColor: `${escape}[36m`
259 })
260 )
261 )
262
263 const output = toRawFrames(yield* MockTerminal.displayLines).join("\n")
264 assert.include(output, `${escape}[35m?`)
265 assert.include(output, `${escape}[34m›`)
266 assert.include(output, `${escape}[33m✔`)
267 assert.include(output, `${escape}[36mA`)
268 assert.notInclude(output, `${escape}[31m?`)
269 }).pipe(Effect.provide(TestLayer)))
270
271 it.effect("renders validation errors with the theme error color", () =>
272 Effect.gen(function*() {
273 yield* MockTerminal.inputText("bad")
274 yield* MockTerminal.inputKey("enter")
275 yield* MockTerminal.inputKey("u", { ctrl: true })
276 yield* MockTerminal.inputText("ok")
277 yield* MockTerminal.inputKey("enter")
278
279 yield* Prompt.run(Prompt.String({
280 message: "Name",
281 validate: (value) => value === "bad" ? Effect.fail("Try again") : Effect.succeed(value),
282 theme: { errorColor: `${escape}[35m` }
283 }))
284
285 const output = toRawFrames(yield* MockTerminal.displayLines).join("\n")
286 assert.include(output, `${escape}[35m›`)
287 assert.include(output, `${escape}[35mbad`)
288 assert.notInclude(output, `${escape}[31m›`)
289 }).pipe(Effect.provide(TestLayer)))
290
291 it.effect("omits spacing for empty theme symbols", () =>
292 Effect.gen(function*() {
293 yield* MockTerminal.inputKey("enter")
294
295 yield* Prompt.run(Prompt.String({
296 message: "Name",
297 theme: { prefix: "", pointerSmall: "" }
298 }))
299
300 const frames = toFrames(yield* MockTerminal.displayLines)
301 assert.strictEqual(frames[0], "Name")
302 }).pipe(Effect.provide(TestLayer)))
303
304 it.effect("starts from the default value so it can be edited", () =>
305 Effect.gen(function*() {
306 const prompt = Prompt.String({
307 message: "Name",
308 default: "Jane"
309 })
310
311 yield* MockTerminal.inputText(" Doe")
312 yield* MockTerminal.inputKey("enter")
313
314 const result = yield* Prompt.run(prompt)
315 assert.strictEqual(result, "Jane Doe")
316 }).pipe(Effect.provide(TestLayer)))
317
318 it.effect("clears the current input on ctrl-u", () =>
319 Effect.gen(function*() {
320 const prompt = Prompt.String({
321 message: "Name",
322 default: "Jane"
323 })
324
325 yield* MockTerminal.inputText(" Doe")
326 yield* MockTerminal.inputKey("u", { ctrl: true })
327 yield* MockTerminal.inputText("John")
328 yield* MockTerminal.inputKey("enter")
329
330 const result = yield* Prompt.run(prompt)
331 assert.strictEqual(result, "John")
332 }).pipe(Effect.provide(TestLayer)))
333
334 it.effect("moves the cursor to the beginning on ctrl-a", () =>
335 Effect.gen(function*() {
336 const prompt = Prompt.String({
337 message: "Name",
338 default: "Jane"
339 })
340
341 yield* MockTerminal.inputText(" Doe")
342 yield* MockTerminal.inputKey("a", { ctrl: true })
343 yield* MockTerminal.inputText("Dr. ")
344 yield* MockTerminal.inputKey("enter")
345
346 const result = yield* Prompt.run(prompt)
347 assert.strictEqual(result, "Dr. Jane Doe")
348 }).pipe(Effect.provide(TestLayer)))
349
350 it.effect("moves the cursor to the end on ctrl-e", () =>
351 Effect.gen(function*() {
352 const prompt = Prompt.String({
353 message: "Name"
354 })
355
356 yield* MockTerminal.inputText("Jane")
357 yield* MockTerminal.inputKey("left")
358 yield* MockTerminal.inputKey("left")
359 yield* MockTerminal.inputKey("e", { ctrl: true })
360 yield* MockTerminal.inputText(" Doe")
361 yield* MockTerminal.inputKey("enter")
362
363 const result = yield* Prompt.run(prompt)
364 assert.strictEqual(result, "Jane Doe")
365 }).pipe(Effect.provide(TestLayer)))
366
367 it.effect("does not insert characters for unsupported ctrl key combinations", () =>
368 Effect.gen(function*() {
369 const prompt = Prompt.String({
370 message: "Name"
371 })
372
373 yield* MockTerminal.inputText("Ja")
374 yield* MockTerminal.inputKey("l", { ctrl: true })
375 yield* MockTerminal.inputText("ne")
376 yield* MockTerminal.inputKey("enter")
377
378 const result = yield* Prompt.run(prompt)
379 assert.strictEqual(result, "Jane")
380 }).pipe(Effect.provide(TestLayer)))
381
382 it.effect("does not render or submit the cleared default value", () =>
383 Effect.gen(function*() {
384 const prompt = Prompt.String({
385 message: "Name",
386 default: "Jane"
387 })
388
389 yield* MockTerminal.inputKey("u", { ctrl: true })
390 yield* MockTerminal.inputKey("enter")
391
392 const result = yield* Prompt.run(prompt)
393 assert.strictEqual(result, "")
394
395 const output = yield* MockTerminal.displayLines
396 const frames = toFrames(output)
397 const lastFrame = frames.at(-1)
398
399 assert.isTrue(lastFrame !== undefined)
400 assert.isFalse(lastFrame?.includes("Jane"))
401 }).pipe(Effect.provide(TestLayer)))
402})
403
404describe("Prompt.Select", () => {
405 it.effect("renders only the choice list when message is omitted", () =>
406 Effect.gen(function*() {
407 yield* MockTerminal.inputKey("down")
408 yield* MockTerminal.inputKey("enter")
409
410 const result = yield* Prompt.run(Prompt.Select({
411 choices: [
412 { title: "First", value: "first" },
413 { title: "Second", value: "second" }
414 ]
415 }))
416
417 assert.strictEqual(result, "second")
418 const output = yield* MockTerminal.displayLines
419 const frames = toFrames(output)
420 assert.deepStrictEqual(frames, [
421 "❯ First \n Second ",
422 " First \n❯ Second ",
423 "✔ Second\n"
424 ])
425 assert.isTrue(toRawFrames(output)[1]?.startsWith(`${escape}[2K${escape}[1A${escape}[2K${escape}[G`))
426 }).pipe(Effect.provide(TestLayer)))
427
428 it.effect("keeps the header when message is empty", () =>
429 Effect.gen(function*() {
430 yield* MockTerminal.inputKey("enter")
431
432 yield* Prompt.run(Prompt.Select({ message: "", choices: [{ title: "First", value: "first" }] }))
433
434 const frames = toFrames(yield* MockTerminal.displayLines)
435 assert.strictEqual(frames[0]?.split("\n")[0], "? › ")
436 }).pipe(Effect.provide(TestLayer)))
437
438 it.effect("renders a per-prompt theme across redraws", () =>
439 Effect.gen(function*() {
440 yield* MockTerminal.inputKey("down")
441 yield* MockTerminal.inputKey("enter")
442
443 const result = yield* Prompt.run(Prompt.Select({
444 message: "Pick item",
445 theme: {
446 prefix: "!",
447 pointer: ">",
448 descriptionSeparator: ": "
449 },
450 choices: [
451 { title: "First", value: "first", description: "one" },
452 { title: "Second", value: "second", description: "two" }
453 ]
454 }))
455
456 assert.strictEqual(result, "second")
457 const frames = toFrames(yield* MockTerminal.displayLines)
458 assert.strictEqual(frames.filter((frame) => frame.includes("! Pick item")).length, 2)
459 assert.isTrue(frames.some((frame) => frame.includes("> Second : two")))
460 }).pipe(Effect.provide(TestLayer)))
461
462 it.effect("renders selection colors from the prompt theme", () =>
463 Effect.gen(function*() {
464 yield* MockTerminal.inputKey("enter")
465
466 yield* Prompt.run(Prompt.Select({
467 message: "Pick item",
468 theme: {
469 primaryColor: `${escape}[35m`,
470 mutedColor: `${escape}[34m`,
471 successColor: `${escape}[33m`,
472 submittedColor: `${escape}[36m`
473 },
474 choices: [{ title: "First", value: "first", description: "one" }]
475 }))
476
477 const output = toRawFrames(yield* MockTerminal.displayLines).join("\n")
478 assert.include(output, `${escape}[35m❯`)
479 assert.include(output, `${escape}[34m- one`)
480 assert.include(output, `${escape}[33m✔`)
481 assert.include(output, `${escape}[36mFirst`)
482 }).pipe(Effect.provide(TestLayer)))
483
484 it.effect("keeps title and description separated when the theme separator is empty", () =>
485 Effect.gen(function*() {
486 yield* MockTerminal.inputKey("enter")
487
488 yield* Prompt.run(Prompt.Select({
489 message: "Pick item",
490 theme: {
491 descriptionSeparator: "",
492 mutedColor: `${escape}[34m`
493 },
494 choices: [{ title: "First", value: "first", description: "one" }]
495 }))
496
497 const output = yield* MockTerminal.displayLines
498 const frames = toFrames(output)
499 const rawFrames = toRawFrames(output)
500 assert.isTrue(frames.some((frame) => frame.includes("First one")))
501 assert.isFalse(frames.some((frame) => frame.includes("Firstone")))
502 assert.isTrue(rawFrames.some((frame) => frame.includes(`${escape}[34mone${escape}[0m`)))
503 }).pipe(Effect.provide(TestLayer)))
504
505 it.effect("aligns choices when paging arrows have different widths", () =>
506 Effect.gen(function*() {
507 yield* MockTerminal.inputKey("down")
508 yield* MockTerminal.inputKey("down")
509 yield* MockTerminal.inputKey("enter")
510
511 yield* Prompt.run(Prompt.Select({
512 message: "Pick item",
513 maxPerPage: 2,
514 theme: { arrowUp: "", arrowDown: "vvv" },
515 choices: [
516 { title: "Alpha", value: "alpha" },
517 { title: "Beta", value: "beta" },
518 { title: "Gamma", value: "gamma" }
519 ]
520 }))
521
522 const frames = toFrames(yield* MockTerminal.displayLines)
523 assert.isTrue(frames.some((frame) => frame.includes("❯ Alpha") && frame.includes("vvv Beta")))
524 assert.isTrue(frames.some((frame) => frame.includes(" Beta") && frame.includes("❯ Gamma")))
525 }).pipe(Effect.provide(TestLayer)))
526})
527
528describe("Prompt.Password", () => {
529 it.effect("renders the password mask from the prompt theme", () =>
530 Effect.gen(function*() {
531 yield* MockTerminal.inputText("abc")
532 yield* MockTerminal.inputKey("enter")
533
534 yield* Prompt.run(Prompt.Password({ message: "Password" })).pipe(
535 Effect.provideService(Prompt.Theme, Prompt.makeTheme({ passwordMask: "•" }))
536 )
537
538 const frames = toFrames(yield* MockTerminal.displayLines)
539 assert.isTrue(frames.some((frame) => frame.includes("•••")))
540 assert.isFalse(frames.some((frame) => frame.includes("***")))
541 }).pipe(Effect.provide(TestLayer)))
542
543 it.effect("keeps cursor movement aligned with a multi-character password mask", () =>
544 Effect.gen(function*() {
545 yield* MockTerminal.inputText("abc")
546 yield* MockTerminal.inputKey("left")
547 yield* MockTerminal.inputKey("enter")
548
549 yield* Prompt.run(Prompt.Password({
550 message: "Password",
551 theme: { passwordMask: "**" }
552 }))
553
554 const frames = toRawFrames(yield* MockTerminal.displayLines)
555 assert.isTrue(frames.some((frame) => frame.includes("******") && frame.endsWith(`${escape}[2D`)))
556 }).pipe(Effect.provide(TestLayer)))
557
558 it.effect("starts from the default value so it can be edited", () =>
559 Effect.gen(function*() {
560 const prompt = Prompt.Password({
561 message: "Password",
562 default: "secret"
563 })
564
565 yield* MockTerminal.inputText("123")
566 yield* MockTerminal.inputKey("enter")
567
568 const result = yield* Prompt.run(prompt)
569 assert.strictEqual(Redacted.value(result), "secret123")
570 }).pipe(Effect.provide(TestLayer)))
571
572 it.effect("does not submit the cleared default value", () =>
573 Effect.gen(function*() {
574 const prompt = Prompt.Password({
575 message: "Password",
576 default: "secret"
577 })
578
579 yield* MockTerminal.inputKey("u", { ctrl: true })
580 yield* MockTerminal.inputKey("enter")
581
582 const result = yield* Prompt.run(prompt)
583 assert.strictEqual(Redacted.value(result), "")
584 }).pipe(Effect.provide(TestLayer)))
585})
586
587describe("Prompt.Toggle", () => {
588 it.effect("renders the separator from the prompt theme", () =>
589 Effect.gen(function*() {
590 yield* MockTerminal.inputKey("enter")
591
592 yield* Prompt.run(Prompt.Toggle({
593 message: "Enabled",
594 theme: { toggleSeparator: "|" }
595 }))
596
597 const frames = toFrames(yield* MockTerminal.displayLines)
598 assert.isTrue(frames.some((frame) => frame.includes("on | off")))
599 assert.isFalse(frames.some((frame) => frame.includes("on / off")))
600 }).pipe(Effect.provide(TestLayer)))
601
602 it.effect("keeps labels separated when the theme separator is empty", () =>
603 Effect.gen(function*() {
604 yield* MockTerminal.inputKey("enter")
605
606 yield* Prompt.run(Prompt.Toggle({
607 message: "Enabled",
608 theme: { toggleSeparator: "" }
609 }))
610
611 const frames = toFrames(yield* MockTerminal.displayLines)
612 assert.isTrue(frames.some((frame) => frame.includes("on off")))
613 assert.isFalse(frames.some((frame) => frame.includes("onoff")))
614 }).pipe(Effect.provide(TestLayer)))
615})
616
617describe("Prompt.AutoComplete", () => {
618 it.effect("filters choices as you type", () =>
619 Effect.gen(function*() {
620 const prompt = Prompt.AutoComplete({
621 message: "Pick fruit",
622 choices: [
623 { title: "Apple", value: "apple" },
624 { title: "Banana", value: "banana" },
625 { title: "Cherry", value: "cherry" }
626 ]
627 })
628
629 yield* MockTerminal.inputText("ban")
630 yield* MockTerminal.inputKey("enter")
631
632 const result = yield* Prompt.run(prompt)
633 assert.strictEqual(result, "banana")
634
635 const output = yield* MockTerminal.displayLines
636 const frames = toFrames(output)
637 const filteredFrame = findFrame(frames, "[filter: ban]")
638
639 assert.isTrue(filteredFrame !== undefined)
640 assert.isTrue(filteredFrame?.includes("Banana"))
641 assert.isFalse(filteredFrame?.includes("Apple"))
642 }).pipe(Effect.provide(TestLayer)))
643
644 it.effect("removes the last character on backspace", () =>
645 Effect.gen(function*() {
646 const prompt = Prompt.AutoComplete({
647 message: "Pick item",
648 choices: [
649 { title: "Alpha", value: "alpha" },
650 { title: "Beta", value: "beta" },
651 { title: "Delta", value: "delta" }
652 ]
653 })
654
655 yield* MockTerminal.inputText("al")
656 yield* MockTerminal.inputKey("backspace")
657 yield* MockTerminal.inputKey("enter")
658
659 const result = yield* Prompt.run(prompt)
660 assert.strictEqual(result, "alpha")
661
662 const output = yield* MockTerminal.displayLines
663 const frames = toFrames(output)
664 const narrowedFrame = findFrame(frames, "[filter: al]")
665 const expandedFrame = findFrame(frames, "[filter: a]")
666
667 assert.isTrue(narrowedFrame !== undefined)
668 assert.isTrue(expandedFrame !== undefined)
669 assert.isFalse(narrowedFrame?.includes("Beta"))
670 assert.isTrue(expandedFrame?.includes("Beta"))
671 }).pipe(Effect.provide(TestLayer)))
672
673 it.effect("clears the filter input on ctrl-u", () =>
674 Effect.gen(function*() {
675 const prompt = Prompt.AutoComplete({
676 message: "Pick item",
677 choices: [
678 { title: "Alpha", value: "alpha" },
679 { title: "Beta", value: "beta" },
680 { title: "Delta", value: "delta" }
681 ]
682 })
683
684 yield* MockTerminal.inputText("al")
685 yield* MockTerminal.inputKey("u", { ctrl: true })
686 yield* MockTerminal.inputKey("enter")
687
688 const result = yield* Prompt.run(prompt)
689 assert.strictEqual(result, "alpha")
690
691 const output = yield* MockTerminal.displayLines
692 const frames = toFrames(output)
693 const narrowedFrame = findFrame(frames, "[filter: al]")
694 const clearedFrame = findFrame(frames, "[filter: type to filter]")
695
696 assert.isTrue(narrowedFrame !== undefined)
697 assert.isTrue(clearedFrame !== undefined)
698 assert.isFalse(narrowedFrame?.includes("Beta"))
699 assert.isTrue(clearedFrame?.includes("Beta"))
700 }).pipe(Effect.provide(TestLayer)))
701
702 it.effect("renders empty message and beeps on submit with no matches", () =>
703 Effect.gen(function*() {
704 const prompt = Prompt.AutoComplete({
705 message: "Pick pet",
706 choices: [
707 { title: "Cat", value: "cat" },
708 { title: "Dog", value: "dog" }
709 ]
710 })
711
712 yield* MockTerminal.inputText("zzz")
713 yield* MockTerminal.inputKey("enter")
714 for (let i = 0; i < 3; i++) {
715 yield* MockTerminal.inputKey("backspace")
716 }
717 yield* MockTerminal.inputKey("enter")
718
719 const result = yield* Prompt.run(prompt)
720 assert.strictEqual(result, "cat")
721
722 const output = yield* MockTerminal.displayLines
723 const frames = toFrames(output)
724
725 assert.isTrue(output.some((line) => String(line).includes("\x07")))
726 assert.isTrue(findFrame(frames, "No matches") !== undefined)
727 }).pipe(Effect.provide(TestLayer)))
728
729 it.effect("beeps when submitting a disabled choice", () =>
730 Effect.gen(function*() {
731 const prompt = Prompt.AutoComplete({
732 message: "Pick mode",
733 choices: [
734 { title: "Slow", value: "slow", disabled: true },
735 { title: "Fast", value: "fast" }
736 ]
737 })
738
739 yield* MockTerminal.inputKey("enter")
740 yield* MockTerminal.inputKey("down")
741 yield* MockTerminal.inputKey("enter")
742
743 const result = yield* Prompt.run(prompt)
744 assert.strictEqual(result, "fast")
745
746 const output = yield* MockTerminal.displayLines
747 assert.isTrue(output.some((line) => String(line).includes("\x07")))
748 }).pipe(Effect.provide(TestLayer)))
749
750 it.effect("renders empty message with no choices", () =>
751 Effect.gen(function*() {
752 const prompt = Prompt.AutoComplete({
753 message: "Pick option",
754 choices: []
755 })
756
757 yield* MockTerminal.inputKey("c", { ctrl: true })
758 const exit = yield* Prompt.run(prompt).pipe(Effect.exit)
759 assert.isTrue(exit._tag === "Failure")
760
761 const output = yield* MockTerminal.displayLines
762 const frames = toFrames(output)
763
764 assert.isTrue(findFrame(frames, "No matches") !== undefined)
765 }).pipe(Effect.provide(TestLayer)))
766
767 it.effect("keeps `j` and `k` in the filter query instead of moving the cursor", () =>
768 Effect.gen(function*() {
769 const prompt = Prompt.AutoComplete({
770 message: "Pick a branch",
771 choices: [
772 { title: "feat/jira-fetch-tool", value: "feat/jira-fetch-tool" },
773 { title: "main", value: "main" },
774 { title: "feat/worktree-tool", value: "feat/worktree-tool" }
775 ]
776 })
777
778 yield* MockTerminal.inputText("jira")
779 yield* MockTerminal.inputKey("enter")
780
781 const result = yield* Prompt.run(prompt)
782 assert.strictEqual(result, "feat/jira-fetch-tool")
783
784 const output = yield* MockTerminal.displayLines
785 const frames = toFrames(output)
786 const filteredFrame = findFrame(frames, "[filter: jira]")
787
788 assert.isTrue(filteredFrame !== undefined)
789 assert.isTrue(filteredFrame?.includes("feat/jira-fetch-tool"))
790 assert.isFalse(filteredFrame?.includes("main"))
791 }).pipe(Effect.provide(TestLayer)))
792
793 it.effect("filters a query that merely contains a `k`", () =>
794 Effect.gen(function*() {
795 const prompt = Prompt.AutoComplete({
796 message: "Pick a branch",
797 choices: [
798 { title: "main", value: "main" },
799 { title: "feat/jira-fetch-tool", value: "feat/jira-fetch-tool" },
800 { title: "feat/worktree-tool", value: "feat/worktree-tool" }
801 ]
802 })
803
804 yield* MockTerminal.inputText("worktree")
805 yield* MockTerminal.inputKey("enter")
806
807 const result = yield* Prompt.run(prompt)
808 assert.strictEqual(result, "feat/worktree-tool")
809
810 const output = yield* MockTerminal.displayLines
811 const frames = toFrames(output)
812 const filteredFrame = findFrame(frames, "[filter: worktree]")
813
814 assert.isTrue(filteredFrame !== undefined)
815 assert.isTrue(filteredFrame?.includes("feat/worktree-tool"))
816 assert.isFalse(filteredFrame?.includes("feat/jira-fetch-tool"))
817 }).pipe(Effect.provide(TestLayer)))
818
819 it.effect("moves the cursor with ctrl-n and ctrl-p", () =>
820 Effect.gen(function*() {
821 const prompt = Prompt.AutoComplete({
822 message: "Pick item",
823 choices: [
824 { title: "Alpha", value: "alpha" },
825 { title: "Beta", value: "beta" },
826 { title: "Delta", value: "delta" }
827 ]
828 })
829
830 yield* MockTerminal.inputKey("n", { ctrl: true })
831 yield* MockTerminal.inputKey("n", { ctrl: true })
832 yield* MockTerminal.inputKey("p", { ctrl: true })
833 yield* MockTerminal.inputKey("enter")
834
835 const result = yield* Prompt.run(prompt)
836 assert.strictEqual(result, "beta")
837 }).pipe(Effect.provide(TestLayer)))
838
839 it.effect("moves the cursor up with ctrl-k", () =>
840 Effect.gen(function*() {
841 const prompt = Prompt.AutoComplete({
842 message: "Pick item",
843 choices: [
844 { title: "Alpha", value: "alpha" },
845 { title: "Beta", value: "beta" },
846 { title: "Delta", value: "delta" }
847 ]
848 })
849
850 yield* MockTerminal.inputKey("down")
851 yield* MockTerminal.inputKey("down")
852 yield* MockTerminal.inputKey("k", { ctrl: true })
853 yield* MockTerminal.inputKey("enter")
854
855 const result = yield* Prompt.run(prompt)
856 assert.strictEqual(result, "beta")
857 }).pipe(Effect.provide(TestLayer)))
858})
859
860describe("Prompt.File", () => {
861 const FilePromptLayer = Layer.mergeAll(
862 FileSystem.layerNoop({
863 exists: () => Effect.succeed(true),
864 readDirectory: (directory) =>
865 Effect.succeed(
866 directory === "/workspace"
867 ? ["alpha.txt", "banana.txt", "basket.txt"]
868 : []
869 ),
870 stat: (path) =>
871 Effect.succeed(
872 path.endsWith(".txt")
873 ? ({ type: "File" } as any)
874 : ({ type: "Directory" } as any)
875 )
876 }),
877 PathLayer,
878 TerminalLayer
879 )
880
881 it.effect("starts from the default value so it can be submitted", () =>
882 Effect.gen(function*() {
883 const prompt = Prompt.File({
884 message: "Pick file",
885 default: "/workspace/banana.txt"
886 })
887
888 yield* MockTerminal.inputKey("enter")
889
890 const result = yield* Prompt.run(prompt)
891 assert.strictEqual(result, "/workspace/banana.txt")
892 }).pipe(Effect.provide(FilePromptLayer)))
893
894 it.effect("filters files as you type", () =>
895 Effect.gen(function*() {
896 const prompt = Prompt.File({
897 message: "Pick file",
898 startingPath: "/workspace"
899 })
900
901 yield* MockTerminal.inputText("ban")
902 yield* MockTerminal.inputKey("enter")
903
904 const result = yield* Prompt.run(prompt)
905 assert.strictEqual(result, "/workspace/banana.txt")
906
907 const output = yield* MockTerminal.displayLines
908 const frames = toFrames(output)
909 const filteredFrame = findFrame(frames, "[filter: ban]")
910
911 assert.isTrue(filteredFrame !== undefined)
912 assert.isTrue(filteredFrame?.includes("banana.txt"))
913 assert.isFalse(filteredFrame?.includes("alpha.txt"))
914 assert.isFalse(filteredFrame?.includes("basket.txt"))
915 }).pipe(Effect.provide(FilePromptLayer)))
916
917 it.effect("removes the last character on backspace", () =>
918 Effect.gen(function*() {
919 const prompt = Prompt.File({
920 message: "Pick file",
921 startingPath: "/workspace"
922 })
923
924 yield* MockTerminal.inputText("ban")
925 yield* MockTerminal.inputKey("backspace")
926 yield* MockTerminal.inputKey("enter")
927
928 const result = yield* Prompt.run(prompt)
929 assert.strictEqual(result, "/workspace/banana.txt")
930
931 const output = yield* MockTerminal.displayLines
932 const frames = toFrames(output)
933 const narrowedFrame = findFrame(frames, "[filter: ban]")
934 const expandedFrame = findFrame(frames, "[filter: ba]")
935
936 assert.isTrue(narrowedFrame !== undefined)
937 assert.isTrue(expandedFrame !== undefined)
938 assert.isFalse(narrowedFrame?.includes("basket.txt"))
939 assert.isTrue(expandedFrame?.includes("basket.txt"))
940 }).pipe(Effect.provide(FilePromptLayer)))
941
942 const JkFilePromptLayer = Layer.mergeAll(
943 FileSystem.layerNoop({
944 exists: () => Effect.succeed(true),
945 readDirectory: (directory) =>
946 Effect.succeed(
947 directory === "/workspace"
948 ? ["jest.config.ts", "package.json", "readme.md"]
949 : []
950 ),
951 stat: () => Effect.succeed({ type: "File" } as any)
952 }),
953 PathLayer,
954 TerminalLayer
955 )
956
957 it.effect("keeps `j` in the filter query instead of moving the cursor", () =>
958 Effect.gen(function*() {
959 const prompt = Prompt.File({
960 message: "Pick file",
961 startingPath: "/workspace"
962 })
963
964 yield* MockTerminal.inputText("jest")
965 yield* MockTerminal.inputKey("enter")
966
967 const result = yield* Prompt.run(prompt)
968 assert.strictEqual(result, "/workspace/jest.config.ts")
969
970 const output = yield* MockTerminal.displayLines
971 const frames = toFrames(output)
972 const filteredFrame = findFrame(frames, "[filter: jest]")
973
974 assert.isTrue(filteredFrame !== undefined)
975 assert.isTrue(filteredFrame?.includes("jest.config.ts"))
976 assert.isFalse(filteredFrame?.includes("readme.md"))
977 }).pipe(Effect.provide(JkFilePromptLayer)))
978
979 it.effect("keeps `k` in the filter query instead of moving the cursor", () =>
980 Effect.gen(function*() {
981 const prompt = Prompt.File({
982 message: "Pick file",
983 startingPath: "/workspace"
984 })
985
986 yield* MockTerminal.inputText("package")
987 yield* MockTerminal.inputKey("enter")
988
989 const result = yield* Prompt.run(prompt)
990 assert.strictEqual(result, "/workspace/package.json")
991
992 const output = yield* MockTerminal.displayLines
993 const frames = toFrames(output)
994 const filteredFrame = findFrame(frames, "[filter: package]")
995
996 assert.isTrue(filteredFrame !== undefined)
997 assert.isTrue(filteredFrame?.includes("package.json"))
998 assert.isFalse(filteredFrame?.includes("readme.md"))
999 }).pipe(Effect.provide(JkFilePromptLayer)))
1000
1001 it.effect("moves the cursor with ctrl-n and ctrl-p", () =>
1002 Effect.gen(function*() {
1003 const prompt = Prompt.File({
1004 message: "Pick file",
1005 startingPath: "/workspace"
1006 })
1007
1008 yield* MockTerminal.inputKey("n", { ctrl: true })
1009 yield* MockTerminal.inputKey("n", { ctrl: true })
1010 yield* MockTerminal.inputKey("p", { ctrl: true })
1011 yield* MockTerminal.inputKey("enter")
1012
1013 const result = yield* Prompt.run(prompt)
1014 assert.strictEqual(result, "/workspace/alpha.txt")
1015 }).pipe(Effect.provide(FilePromptLayer)))
1016
1017 it.effect("moves the cursor up with ctrl-k", () =>
1018 Effect.gen(function*() {
1019 const prompt = Prompt.File({
1020 message: "Pick file",
1021 startingPath: "/workspace"
1022 })
1023
1024 yield* MockTerminal.inputKey("down")
1025 yield* MockTerminal.inputKey("down")
1026 yield* MockTerminal.inputKey("k", { ctrl: true })
1027 yield* MockTerminal.inputKey("enter")
1028
1029 const result = yield* Prompt.run(prompt)
1030 assert.strictEqual(result, "/workspace/alpha.txt")
1031 }).pipe(Effect.provide(FilePromptLayer)))
1032})
1033
1034describe("Prompt.MultiSelect", () => {
1035 it.effect("renders only the choice list when message is omitted", () =>
1036 Effect.gen(function*() {
1037 yield* MockTerminal.inputKey("space")
1038 yield* MockTerminal.inputKey("enter")
1039
1040 const result = yield* Prompt.run(Prompt.MultiSelect({
1041 choices: [
1042 { title: "First", value: "first" },
1043 { title: "Second", value: "second" }
1044 ]
1045 }))
1046
1047 assert.deepStrictEqual(result, ["first", "second"])
1048 const output = yield* MockTerminal.displayLines
1049 const frames = toFrames(output)
1050 assert.deepStrictEqual(frames, [
1051 " Select All\n Inverse Selection\n ☐ First \n ☐ Second ",
1052 " Select None\n Inverse Selection\n ☒ First \n ☒ Second ",
1053 "✔ First, Second\n"
1054 ])
1055 const clearChoices = `${escape}[2K${escape}[1A`.repeat(3) + `${escape}[2K${escape}[G`
1056 assert.isTrue(toRawFrames(output)[1]?.startsWith(clearChoices))
1057 }).pipe(Effect.provide(TestLayer)))
1058
1059 it.effect("clears validation errors without a message", () =>
1060 Effect.gen(function*() {
1061 yield* MockTerminal.inputKey("enter")
1062 yield* MockTerminal.inputKey("space")
1063 yield* MockTerminal.inputKey("enter")
1064
1065 const result = yield* Prompt.run(Prompt.MultiSelect({
1066 min: 1,
1067 choices: [
1068 { title: "First", value: "first" },
1069 { title: "Second", value: "second" }
1070 ]
1071 }))
1072
1073 assert.deepStrictEqual(result, ["first", "second"])
1074 const output = yield* MockTerminal.displayLines
1075 const frames = toFrames(output)
1076 assert.isTrue(frames[1]?.endsWith("\n❯ At least 1 are required"))
1077 assert.strictEqual(frames.at(-1), "✔ First, Second\n")
1078
1079 const clearError = `${escape}[1B${escape}[2K${escape}[1A${escape}[2K${escape}[G`
1080 const clearChoices = `${escape}[2K${escape}[1A`.repeat(3) + `${escape}[2K${escape}[G`
1081 const rawFrames = toRawFrames(output)
1082 assert.isTrue(rawFrames[2]?.startsWith(clearError + clearChoices))
1083 assert.isTrue(rawFrames[3]?.startsWith(clearError + clearChoices))
1084 }).pipe(Effect.provide(TestLayer)))
1085
1086 it.effect("renders paging and checkbox symbols from the prompt theme", () =>
1087 Effect.gen(function*() {
1088 const prompt = Prompt.MultiSelect({
1089 message: "Pick items",
1090 maxPerPage: 3,
1091 theme: {
1092 arrowDown: "v",
1093 checkboxOn: "[x]",
1094 checkboxOff: "[ ]"
1095 },
1096 choices: [
1097 { title: "Alpha", value: "alpha" },
1098 { title: "Beta", value: "beta" }
1099 ]
1100 })
1101 yield* MockTerminal.inputKey("down")
1102 yield* MockTerminal.inputKey("down")
1103 yield* MockTerminal.inputKey("space")
1104 yield* MockTerminal.inputKey("enter")
1105
1106 const value = yield* Prompt.run(prompt)
1107
1108 assert.deepStrictEqual(value, ["alpha"])
1109 const frames = toFrames(yield* MockTerminal.displayLines)
1110 assert.isTrue(frames.some((frame) => frame.includes("v [ ] Alpha")))
1111 assert.isTrue(frames.some((frame) => frame.includes("[x] Alpha")))
1112 }).pipe(Effect.provide(TestLayer)))
1113
1114 it.effect("aligns choices when checkbox symbols have different widths", () =>
1115 Effect.gen(function*() {
1116 const prompt = Prompt.MultiSelect({
1117 message: "Pick items",
1118 theme: { checkboxOn: "[x]", checkboxOff: "" },
1119 choices: [
1120 { title: "Alpha", value: "alpha", selected: true },
1121 { title: "Beta", value: "beta" }
1122 ]
1123 })
1124 yield* MockTerminal.inputKey("enter")
1125
1126 yield* Prompt.run(prompt)
1127
1128 const frame = toFrames(yield* MockTerminal.displayLines).find((frame) =>
1129 frame.includes("[x] Alpha") && frame.includes("Beta")
1130 )
1131 assert.isTrue(frame !== undefined)
1132 const alpha = frame?.split("\n").find((line) => line.includes("Alpha")) ?? ""
1133 const beta = frame?.split("\n").find((line) => line.includes("Beta")) ?? ""
1134 assert.strictEqual(alpha.indexOf("Alpha"), beta.indexOf("Beta"))
1135 }).pipe(Effect.provide(TestLayer)))
1136
1137 it.effect("does not allow a disabled multi-select choice to be selected", () =>
1138 Effect.gen(function*() {
1139 const prompt = Prompt.MultiSelect({
1140 message: "Pick items",
1141 choices: [{ title: "Unavailable", value: "unavailable", disabled: true }]
1142 })
1143 yield* MockTerminal.inputKey("down")
1144 yield* MockTerminal.inputKey("down")
1145 yield* MockTerminal.inputKey("space")
1146 yield* MockTerminal.inputKey("enter")
1147
1148 const value = yield* Prompt.run(prompt)
1149
1150 assert.deepStrictEqual(value, [])
1151 const output = yield* MockTerminal.displayLines
1152 assert.isTrue(output.some((line) => String(line).includes("\x07")))
1153 }).pipe(Effect.provide(TestLayer)))
1154
1155 it.effect("does not select disabled choices when selecting all", () =>
1156 Effect.gen(function*() {
1157 const prompt = Prompt.MultiSelect({
1158 message: "Pick items",
1159 choices: [
1160 { title: "Available", value: "available" },
1161 { title: "Unavailable", value: "unavailable", disabled: true }
1162 ]
1163 })
1164 yield* MockTerminal.inputKey("space")
1165 yield* MockTerminal.inputKey("enter")
1166
1167 const value = yield* Prompt.run(prompt)
1168
1169 assert.deepStrictEqual(value, ["available"])
1170 const output = yield* MockTerminal.displayLines
1171 assert.isTrue(findFrame(toFrames(output), "Select None") !== undefined)
1172 }).pipe(Effect.provide(TestLayer)))
1173
1174 it.effect("does not select disabled choices when inverting the selection", () =>
1175 Effect.gen(function*() {
1176 const prompt = Prompt.MultiSelect({
1177 message: "Pick items",
1178 choices: [
1179 { title: "Available", value: "available" },
1180 { title: "Unavailable", value: "unavailable", disabled: true }
1181 ]
1182 })
1183 yield* MockTerminal.inputKey("down")
1184 yield* MockTerminal.inputKey("space")
1185 yield* MockTerminal.inputKey("enter")
1186
1187 const value = yield* Prompt.run(prompt)
1188
1189 assert.deepStrictEqual(value, ["available"])
1190 }).pipe(Effect.provide(TestLayer)))
1191
1192 it.effect("ignores disabled preselected choices when validating and submitting", () =>
1193 Effect.gen(function*() {
1194 const prompt = Prompt.MultiSelect({
1195 message: "Pick items",
1196 choices: [
1197 { title: "Available", value: "available", selected: true },
1198 { title: "Unavailable", value: "unavailable", disabled: true, selected: true }
1199 ],
1200 max: 1
1201 })
1202 yield* MockTerminal.inputKey("enter")
1203
1204 const value = yield* Prompt.run(prompt)
1205
1206 assert.deepStrictEqual(value, ["available"])
1207 const output = yield* MockTerminal.displayLines
1208 const initialFrame = findFrame(toFrames(output), "Unavailable")
1209 assert.isTrue(initialFrame?.includes("☐ Unavailable"))
1210 const rawInitialFrame = findFrame(toRawFrames(output), "Unavailable")
1211 assert.isTrue(rawInitialFrame?.includes(`${escape}[9m${escape}[90mUnavailable`))
1212 }).pipe(Effect.provide(TestLayer)))
1213
1214 it.effect("underlines the active label", () =>
1215 Effect.gen(function*() {
1216 const prompt = Prompt.MultiSelect({
1217 message: "Pick items",
1218 choices: [
1219 { title: "Alpha", value: "alpha" },
1220 { title: "Beta", value: "beta" },
1221 { title: "Gamma", value: "gamma" }
1222 ]
1223 })
1224
1225 const fiber = yield* Prompt.run(prompt).pipe(Effect.forkChild)
1226
1227 yield* Effect.yieldNow
1228 yield* MockTerminal.inputKey("down")
1229 yield* MockTerminal.inputKey("down")
1230 yield* MockTerminal.inputKey("down")
1231 yield* Effect.yieldNow
1232
1233 const output = yield* MockTerminal.displayLines
1234 const frames = toRawFrames(output)
1235 const highlightedFrame = [...frames].reverse().find((frame) => frame.includes("Beta"))
1236
1237 assert.isTrue(highlightedFrame !== undefined)
1238 assert.isTrue(highlightedFrame?.includes(`${escape}[4m${escape}[96mBeta${escape}[0m`))
1239
1240 yield* MockTerminal.inputKey("enter")
1241
1242 const result = yield* Fiber.join(fiber)
1243 assert.deepStrictEqual(result, [])
1244 }).pipe(Effect.provide(TestLayer)))
1245})
1246
1247describe("Prompt.Custom", () => {
1248 for (const transition of ["NextFrame", "Submit"] as const) {
1249 it.effect(`keeps the previous frame visible while rendering ${transition}`, () =>
1250 Effect.gen(function*() {
1251 const rendering = yield* Deferred.make<void>()
1252 const resume = yield* Deferred.make<void>()
1253 const calls: Array<string> = []
1254 const clear = `${escape}[2K\r`
1255 const prompt = Prompt.Custom<number, number>(0, {
1256 render: (state, action) =>
1257 Effect.gen(function*() {
1258 calls.push(`render ${state} ${action._tag}`)
1259 if (action._tag === transition && (state > 0 || action._tag === "Submit")) {
1260 yield* Deferred.succeed(rendering, undefined)
1261 yield* Deferred.await(resume)
1262 }
1263 return action._tag === "Submit" ? `Done ${state}` : action._tag === "Beep" ? bell : `Frame ${state}`
1264 }),
1265 clear: (state, action) =>
1266 Effect.sync(() => {
1267 calls.push(`clear ${state} ${action._tag}`)
1268 return clear
1269 }),
1270 process: (input, state) =>
1271 Effect.succeed(
1272 input.key.name === "q"
1273 ? Action.Submit({ value: 42 })
1274 : input.key.name === "b"
1275 ? Action.Beep()
1276 : Action.NextFrame({ state: state + 1 })
1277 )
1278 })
1279
1280 if (transition === "NextFrame") {
1281 yield* MockTerminal.inputKey("enter")
1282 yield* MockTerminal.inputKey("b")
1283 }
1284 yield* MockTerminal.inputKey("q")
1285 const fiber = yield* Prompt.run(prompt).pipe(Effect.forkChild)
1286 yield* Deferred.await(rendering)
1287
1288 assert.deepStrictEqual(yield* MockTerminal.displayLines, ["Frame 0"])
1289 assert.deepStrictEqual(calls, [
1290 "render 0 NextFrame",
1291 `clear 0 ${transition}`,
1292 `render ${transition === "NextFrame" ? 1 : 0} ${transition}`
1293 ])
1294
1295 yield* Deferred.succeed(resume, undefined)
1296 assert.strictEqual(yield* Fiber.join(fiber), 42)
1297 assert.deepStrictEqual(
1298 yield* MockTerminal.displayLines,
1299 transition === "NextFrame"
1300 ? ["Frame 0", `${clear}Frame 1`, bell, `${clear}Done 1`, `${escape}[?25h`]
1301 : ["Frame 0", `${clear}Done 0`, `${escape}[?25h`]
1302 )
1303 }).pipe(Effect.provide(TestLayer)))
1304 }
1305
1306 it.effect("receive handles events from external dequeue", () =>
1307 Effect.gen(function*() {
1308 const eventQueue = yield* Queue.make<string>()
1309
1310 const prompt = Prompt.Custom(
1311 { count: 0 },
1312 Queue.asDequeue(eventQueue),
1313 {
1314 render: (state) => Effect.succeed(`Count: ${state.count}`),
1315 process: (input, state) =>
1316 Match.value(input).pipe(
1317 Match.tag("Input", () => Effect.succeed(Action.Submit({ value: state.count }))),
1318 Match.tag("Event", ({ value }) =>
1319 Effect.succeed(
1320 Action.NextFrame({ state: { count: state.count + (value === "tick" ? 1 : 0) } })
1321 )),
1322 Match.exhaustive
1323 ),
1324 clear: () => Effect.succeed("")
1325 }
1326 )
1327
1328 const fiber = yield* Prompt.run(prompt).pipe(Effect.forkChild)
1329
1330 // Give the prompt loop time to start and block on the race
1331 yield* Effect.yieldNow
1332
1333 // Push two events
1334 yield* Queue.offer(eventQueue, "tick")
1335 yield* Effect.yieldNow
1336 yield* Queue.offer(eventQueue, "tock")
1337 yield* Effect.yieldNow
1338 yield* Queue.offer(eventQueue, "tick")
1339 yield* Effect.yieldNow
1340
1341 // Submit via keypress
1342 yield* MockTerminal.inputKey("enter")
1343
1344 const result = yield* Fiber.join(fiber)
1345 assert.strictEqual(result, 2)
1346 }).pipe(Effect.provide(TestLayer)))
1347
1348 it.effect("falls back to process when no events are pending", () =>
1349 Effect.gen(function*() {
1350 const eventQueue = yield* Queue.make<string>()
1351
1352 const prompt = Prompt.Custom(
1353 { keys: 0 },
1354 Queue.asDequeue(eventQueue),
1355 {
1356 render: (state) => Effect.succeed(`Keys: ${state.keys}`),
1357 process: (input, state) =>
1358 Match.value(input).pipe(
1359 Match.tag("Input", () => {
1360 const next = state.keys + 1
1361 return next >= 3
1362 ? Effect.succeed(Action.Submit({ value: next }))
1363 : Effect.succeed(Action.NextFrame({ state: { keys: next } }))
1364 }),
1365 Match.tag("Event", () => Effect.succeed(Action.NextFrame({ state }))),
1366 Match.exhaustive
1367 ),
1368 clear: () => Effect.succeed("")
1369 }
1370 )
1371
1372 yield* MockTerminal.inputKey("a")
1373 yield* MockTerminal.inputKey("b")
1374 yield* MockTerminal.inputKey("c")
1375
1376 const result = yield* Prompt.run(prompt)
1377 assert.strictEqual(result, 3)
1378 }).pipe(Effect.provide(TestLayer)))
1379
1380 it.effect("Input variant exposes the input field with UserInput", () =>
1381 Effect.gen(function*() {
1382 const eventQueue = yield* Queue.make<string>()
1383
1384 const prompt = Prompt.Custom(
1385 { captured: "" },
1386 Queue.asDequeue(eventQueue),
1387 {
1388 render: (state) => Effect.succeed(`Captured: ${state.captured}`),
1389 process: (input, state) =>
1390 Match.value(input).pipe(
1391 Match.tag("Input", ({ input: userInput }) => {
1392 const key = userInput.key.name
1393 return key === "enter"
1394 ? Effect.succeed(Action.Submit({ value: state.captured }))
1395 : Effect.succeed(Action.NextFrame({ state: { captured: state.captured + key } }))
1396 }),
1397 Match.tag("Event", () => Effect.succeed(Action.NextFrame({ state }))),
1398 Match.exhaustive
1399 ),
1400 clear: () => Effect.succeed("")
1401 }
1402 )
1403
1404 yield* MockTerminal.inputKey("x")
1405 yield* MockTerminal.inputKey("y")
1406 yield* MockTerminal.inputKey("enter")
1407
1408 const result = yield* Prompt.run(prompt)
1409 assert.strictEqual(result, "xy")
1410 }).pipe(Effect.provide(TestLayer)))
1411})