Skip to content

Commit 71f7d4e

Browse files
authored
Prompt multi select (#3929)
1 parent 7b93dd6 commit 71f7d4e

File tree

6 files changed

+491
-37
lines changed

6 files changed

+491
-37
lines changed

.changeset/smooth-garlics-smoke.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@effect/cli": patch
3+
---
4+
5+
added prompt multi select

packages/cli/src/Prompt.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import * as InternalConfirmPrompt from "./internal/prompt/confirm.js"
1313
import * as InternalDatePrompt from "./internal/prompt/date.js"
1414
import * as InternalFilePrompt from "./internal/prompt/file.js"
1515
import * as InternalListPrompt from "./internal/prompt/list.js"
16+
import * as InternalMultiSelectPrompt from "./internal/prompt/multi-select.js"
1617
import * as InternalNumberPrompt from "./internal/prompt/number.js"
1718
import * as InternalSelectPrompt from "./internal/prompt/select.js"
1819
import * as InternalTextPrompt from "./internal/prompt/text.js"
@@ -366,6 +367,33 @@ export declare namespace Prompt {
366367
readonly maxPerPage?: number
367368
}
368369

370+
/**
371+
* @since 1.0.0
372+
* @category models
373+
*/
374+
export interface MultiSelectOptions {
375+
/**
376+
* Text for the "Select All" option (defaults to "Select All").
377+
*/
378+
readonly selectAll?: string
379+
/**
380+
* Text for the "Select None" option (defaults to "Select None").
381+
*/
382+
readonly selectNone?: string
383+
/**
384+
* Text for the "Inverse Selection" option (defaults to "Inverse Selection").
385+
*/
386+
readonly inverseSelection?: string
387+
/**
388+
* The minimum number of choices that must be selected.
389+
*/
390+
readonly min?: number
391+
/**
392+
* The maximum number of choices that can be selected.
393+
*/
394+
readonly max?: number
395+
}
396+
369397
/**
370398
* @since 1.0.0
371399
* @category models
@@ -631,6 +659,13 @@ export const run: <Output>(self: Prompt<Output>) => Effect<Output, QuitException
631659
*/
632660
export const select: <A>(options: Prompt.SelectOptions<A>) => Prompt<A> = InternalSelectPrompt.select
633661

662+
/**
663+
* @since 1.0.0
664+
* @category constructors
665+
*/
666+
export const multiSelect: <A>(options: Prompt.SelectOptions<A> & Prompt.MultiSelectOptions) => Prompt<Array<A>> =
667+
InternalMultiSelectPrompt.multiSelect
668+
634669
/**
635670
* Creates a `Prompt` which immediately succeeds with the specified value.
636671
*

packages/cli/src/internal/prompt/ansi-utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ const defaultFigures = {
1010
arrowRight: Doc.text("→"),
1111
radioOn: Doc.text("◉"),
1212
radioOff: Doc.text("◯"),
13+
checkboxOn: Doc.text("☒"),
14+
checkboxOff: Doc.text("☐"),
1315
tick: Doc.text("✔"),
1416
cross: Doc.text("✖"),
1517
ellipsis: Doc.text("…"),
@@ -25,6 +27,8 @@ const windowsFigures = {
2527
arrowRight: defaultFigures.arrowRight,
2628
radioOn: Doc.text("(*)"),
2729
radioOff: Doc.text("( )"),
30+
checkboxOn: Doc.text("[*]"),
31+
checkboxOff: Doc.text("[ ]"),
2832
tick: Doc.text("√"),
2933
cross: Doc.text("×"),
3034
ellipsis: Doc.text("..."),
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
import * as Terminal from "@effect/platform/Terminal"
2+
import { Optimize } from "@effect/printer"
3+
import * as Ansi from "@effect/printer-ansi/Ansi"
4+
import * as Doc from "@effect/printer-ansi/AnsiDoc"
5+
import * as Arr from "effect/Array"
6+
import * as Effect from "effect/Effect"
7+
import * as Number from "effect/Number"
8+
import * as Option from "effect/Option"
9+
import type * as Prompt from "../../Prompt.js"
10+
import * as InternalPrompt from "../prompt.js"
11+
import { Action } from "./action.js"
12+
import * as InternalAnsiUtils from "./ansi-utils.js"
13+
import { entriesToDisplay } from "./utils.js"
14+
15+
interface SelectOptions<A> extends Required<Prompt.Prompt.SelectOptions<A>> {}
16+
interface MultiSelectOptions extends Prompt.Prompt.MultiSelectOptions {}
17+
18+
type State = {
19+
index: number
20+
selectedIndices: Set<number>
21+
error: Option.Option<string>
22+
}
23+
24+
const renderBeep = Doc.render(Doc.beep, { style: "pretty" })
25+
26+
const NEWLINE_REGEX = /\r?\n/
27+
28+
function renderOutput<A>(
29+
leadingSymbol: Doc.AnsiDoc,
30+
trailingSymbol: Doc.AnsiDoc,
31+
options: SelectOptions<A>
32+
) {
33+
const annotateLine = (line: string): Doc.AnsiDoc => Doc.annotate(Doc.text(line), Ansi.bold)
34+
const prefix = Doc.cat(leadingSymbol, Doc.space)
35+
return Arr.match(options.message.split(NEWLINE_REGEX), {
36+
onEmpty: () => Doc.hsep([prefix, trailingSymbol]),
37+
onNonEmpty: (promptLines) => {
38+
const lines = Arr.map(promptLines, (line) => annotateLine(line))
39+
return prefix.pipe(
40+
Doc.cat(Doc.nest(Doc.vsep(lines), 2)),
41+
Doc.cat(Doc.space),
42+
Doc.cat(trailingSymbol),
43+
Doc.cat(Doc.space)
44+
)
45+
}
46+
})
47+
}
48+
49+
function renderError(state: State, pointer: Doc.AnsiDoc) {
50+
return Option.match(state.error, {
51+
onNone: () => Doc.empty,
52+
onSome: (error) =>
53+
Arr.match(error.split(NEWLINE_REGEX), {
54+
onEmpty: () => Doc.empty,
55+
onNonEmpty: (errorLines) => {
56+
const annotateLine = (line: string): Doc.AnsiDoc =>
57+
Doc.annotate(Doc.text(line), Ansi.combine(Ansi.italicized, Ansi.red))
58+
const prefix = Doc.cat(Doc.annotate(pointer, Ansi.red), Doc.space)
59+
const lines = Arr.map(errorLines, (str) => annotateLine(str))
60+
return Doc.cursorSavePosition.pipe(
61+
Doc.cat(Doc.hardLine),
62+
Doc.cat(prefix),
63+
Doc.cat(Doc.align(Doc.vsep(lines))),
64+
Doc.cat(Doc.cursorRestorePosition)
65+
)
66+
}
67+
})
68+
})
69+
}
70+
71+
const metaOptionsCount = 2
72+
73+
function renderChoices<A>(
74+
state: State,
75+
options: SelectOptions<A> & MultiSelectOptions,
76+
figures: Effect.Effect.Success<typeof InternalAnsiUtils.figures>
77+
) {
78+
const choices = options.choices
79+
const totalChoices = choices.length
80+
const selectedCount = state.selectedIndices.size
81+
const allSelected = selectedCount === totalChoices
82+
83+
const selectAllText = allSelected
84+
? options?.selectNone ?? "Select None"
85+
: options?.selectAll ?? "Select All"
86+
87+
const inverseSelectionText = options?.inverseSelection ?? "Inverse Selection"
88+
89+
const metaOptions = [
90+
{ title: selectAllText },
91+
{ title: inverseSelectionText }
92+
]
93+
const allChoices = [...metaOptions, ...choices]
94+
const toDisplay = entriesToDisplay(state.index, allChoices.length, options.maxPerPage)
95+
const documents: Array<Doc.AnsiDoc> = []
96+
for (let index = toDisplay.startIndex; index < toDisplay.endIndex; index++) {
97+
const choice = allChoices[index]
98+
const isHighlighted = state.index === index
99+
let prefix: Doc.AnsiDoc = Doc.space
100+
if (index === toDisplay.startIndex && toDisplay.startIndex > 0) {
101+
prefix = figures.arrowUp
102+
} else if (index === toDisplay.endIndex - 1 && toDisplay.endIndex < allChoices.length) {
103+
prefix = figures.arrowDown
104+
}
105+
if (index < metaOptions.length) {
106+
// Meta options
107+
const title = isHighlighted
108+
? Doc.annotate(Doc.text(choice.title), Ansi.cyanBright)
109+
: Doc.text(choice.title)
110+
documents.push(
111+
prefix.pipe(
112+
Doc.cat(Doc.space),
113+
Doc.cat(title)
114+
)
115+
)
116+
} else {
117+
// Regular choices
118+
const choiceIndex = index - metaOptions.length
119+
const isSelected = state.selectedIndices.has(choiceIndex)
120+
const checkbox = isSelected ? figures.checkboxOn : figures.checkboxOff
121+
const annotatedCheckbox = isHighlighted
122+
? Doc.annotate(checkbox, Ansi.cyanBright)
123+
: checkbox
124+
const title = Doc.text(choice.title)
125+
documents.push(
126+
prefix.pipe(
127+
Doc.cat(Doc.space),
128+
Doc.cat(annotatedCheckbox),
129+
Doc.cat(Doc.space),
130+
Doc.cat(title)
131+
)
132+
)
133+
}
134+
}
135+
return Doc.vsep(documents)
136+
}
137+
138+
function renderNextFrame<A>(state: State, options: SelectOptions<A>) {
139+
return Effect.gen(function*() {
140+
const terminal = yield* Terminal.Terminal
141+
const columns = yield* terminal.columns
142+
const figures = yield* InternalAnsiUtils.figures
143+
const choices = renderChoices(state, options, figures)
144+
const leadingSymbol = Doc.annotate(Doc.text("?"), Ansi.cyanBright)
145+
const trailingSymbol = Doc.annotate(figures.pointerSmall, Ansi.blackBright)
146+
const promptMsg = renderOutput(leadingSymbol, trailingSymbol, options)
147+
const error = renderError(state, figures.pointer)
148+
return Doc.cursorHide.pipe(
149+
Doc.cat(promptMsg),
150+
Doc.cat(Doc.hardLine),
151+
Doc.cat(choices),
152+
Doc.cat(error),
153+
Doc.render({ style: "pretty", options: { lineWidth: columns } })
154+
)
155+
})
156+
}
157+
158+
function renderSubmission<A>(state: State, options: SelectOptions<A>) {
159+
return Effect.gen(function*() {
160+
const terminal = yield* Terminal.Terminal
161+
const columns = yield* terminal.columns
162+
const figures = yield* InternalAnsiUtils.figures
163+
const selectedChoices = Array.from(state.selectedIndices).sort(Number.Order).map((index) =>
164+
options.choices[index].title
165+
)
166+
const selectedText = selectedChoices.join(", ")
167+
const selected = Doc.text(selectedText)
168+
const leadingSymbol = Doc.annotate(figures.tick, Ansi.green)
169+
const trailingSymbol = Doc.annotate(figures.ellipsis, Ansi.blackBright)
170+
const promptMsg = renderOutput(leadingSymbol, trailingSymbol, options)
171+
return promptMsg.pipe(
172+
Doc.cat(Doc.space),
173+
Doc.cat(Doc.annotate(selected, Ansi.white)),
174+
Doc.cat(Doc.hardLine),
175+
Doc.render({ style: "pretty", options: { lineWidth: columns } })
176+
)
177+
})
178+
}
179+
180+
function processCursorUp(state: State, totalChoices: number) {
181+
const newIndex = state.index === 0 ? totalChoices - 1 : state.index - 1
182+
return Effect.succeed(Action.NextFrame({ state: { ...state, index: newIndex } }))
183+
}
184+
185+
function processCursorDown(state: State, totalChoices: number) {
186+
const newIndex = (state.index + 1) % totalChoices
187+
return Effect.succeed(Action.NextFrame({ state: { ...state, index: newIndex } }))
188+
}
189+
190+
function processSpace<A>(
191+
state: State,
192+
options: SelectOptions<A>
193+
) {
194+
const selectedIndices = new Set(state.selectedIndices)
195+
if (state.index === 0) {
196+
if (state.selectedIndices.size === options.choices.length) {
197+
selectedIndices.clear()
198+
} else {
199+
for (let i = 0; i < options.choices.length; i++) {
200+
selectedIndices.add(i)
201+
}
202+
}
203+
} else if (state.index === 1) {
204+
for (let i = 0; i < options.choices.length; i++) {
205+
if (state.selectedIndices.has(i)) {
206+
selectedIndices.delete(i)
207+
} else {
208+
selectedIndices.add(i)
209+
}
210+
}
211+
} else {
212+
const choiceIndex = state.index - metaOptionsCount
213+
if (selectedIndices.has(choiceIndex)) {
214+
selectedIndices.delete(choiceIndex)
215+
} else {
216+
selectedIndices.add(choiceIndex)
217+
}
218+
}
219+
return Effect.succeed(Action.NextFrame({ state: { ...state, selectedIndices } }))
220+
}
221+
222+
export function handleClear<A>(options: SelectOptions<A>) {
223+
return Effect.gen(function*() {
224+
const terminal = yield* Terminal.Terminal
225+
const columns = yield* terminal.columns
226+
const clearPrompt = Doc.cat(Doc.eraseLine, Doc.cursorLeft)
227+
const text = "\n".repeat(Math.min(options.choices.length + 2, options.maxPerPage)) + options.message + 1
228+
const clearOutput = InternalAnsiUtils.eraseText(text, columns)
229+
return clearOutput.pipe(
230+
Doc.cat(clearPrompt),
231+
Optimize.optimize(Optimize.Deep),
232+
Doc.render({ style: "pretty", options: { lineWidth: columns } })
233+
)
234+
})
235+
}
236+
237+
function handleProcess<A>(options: SelectOptions<A> & MultiSelectOptions) {
238+
return (input: Terminal.UserInput, state: State) => {
239+
const totalChoices = options.choices.length + metaOptionsCount
240+
switch (input.key.name) {
241+
case "k":
242+
case "up": {
243+
return processCursorUp({ ...state, error: Option.none() }, totalChoices)
244+
}
245+
case "j":
246+
case "down":
247+
case "tab": {
248+
return processCursorDown({ ...state, error: Option.none() }, totalChoices)
249+
}
250+
case "space": {
251+
return processSpace(state, options)
252+
}
253+
case "enter":
254+
case "return": {
255+
const selectedCount = state.selectedIndices.size
256+
if (options.min !== undefined && selectedCount < options.min) {
257+
return Effect.succeed(
258+
Action.NextFrame({ state: { ...state, error: Option.some(`At least ${options.min} are required`) } })
259+
)
260+
}
261+
if (options.max !== undefined && selectedCount > options.max) {
262+
return Effect.succeed(
263+
Action.NextFrame({ state: { ...state, error: Option.some(`At most ${options.max} choices are allowed`) } })
264+
)
265+
}
266+
const selectedValues = Array.from(state.selectedIndices).sort(Number.Order).map((index) =>
267+
options.choices[index].value
268+
)
269+
return Effect.succeed(Action.Submit({ value: selectedValues }))
270+
}
271+
default: {
272+
return Effect.succeed(Action.Beep())
273+
}
274+
}
275+
}
276+
}
277+
278+
function handleRender<A>(options: SelectOptions<A>) {
279+
return (state: State, action: Prompt.Prompt.Action<State, Array<A>>) => {
280+
return Action.$match(action, {
281+
Beep: () => Effect.succeed(renderBeep),
282+
NextFrame: ({ state }) => renderNextFrame(state, options),
283+
Submit: () => renderSubmission(state, options)
284+
})
285+
}
286+
}
287+
288+
/** @internal */
289+
export const multiSelect = <A>(
290+
options: Prompt.Prompt.SelectOptions<A> & Prompt.Prompt.MultiSelectOptions
291+
): Prompt.Prompt<Array<A>> => {
292+
const opts: SelectOptions<A> & MultiSelectOptions = {
293+
maxPerPage: 10,
294+
...options
295+
}
296+
return InternalPrompt.custom({ index: 0, selectedIndices: new Set<number>(), error: Option.none() }, {
297+
render: handleRender(opts),
298+
process: handleProcess(opts),
299+
clear: () => handleClear(opts)
300+
})
301+
}

0 commit comments

Comments
 (0)