Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Renders the default for all Prompt types that accepts TextOptions. #3508

Merged
merged 11 commits into from
Aug 29, 2024
9 changes: 9 additions & 0 deletions .changeset/late-apricots-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@effect/cli": patch
---

Renders the default for all `Prompt` types that accepts `TextOptions`.

- The default value will be rendered as ghost text for `Prompt.text` and `Prompt.list`.
- The default value will be rendered as redacted ghost text for `Prompt.password`.
- The default value will remain hidden for `Prompt.hidden`.
39 changes: 35 additions & 4 deletions packages/cli/src/internal/prompt/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ interface State {
readonly error: Option.Option<string>
}

function getValue(state: State, options: Options): string {
return state.value.length > 0 ? state.value : options.default
}

const renderBeep = Doc.render(Doc.beep, { style: "pretty" })

function renderClearScreen(state: State, options: Options) {
Expand Down Expand Up @@ -58,19 +62,32 @@ function renderClearScreen(state: State, options: Options) {
}

function renderInput(nextState: State, options: Options, submitted: boolean) {
const text = getValue(nextState, options)

const annotation = Option.match(nextState.error, {
onNone: () => submitted ? Ansi.white : Ansi.combine(Ansi.underlined, Ansi.cyanBright),
onNone: () => {
if (submitted) {
return Ansi.white
}

if (nextState.value.length === 0) {
return Ansi.blackBright
}

return Ansi.combine(Ansi.underlined, Ansi.cyanBright)
},
onSome: () => Ansi.red
})

switch (options.type) {
case "hidden": {
return Doc.empty
}
case "password": {
return Doc.annotate(Doc.text("*".repeat(nextState.value.length)), annotation)
return Doc.annotate(Doc.text("*".repeat(text.length)), annotation)
}
case "text": {
return Doc.annotate(Doc.text(nextState.value), annotation)
return Doc.annotate(Doc.text(text), annotation)
}
}
}
Expand Down Expand Up @@ -191,6 +208,17 @@ function processCursorRight(state: State) {
}))
}

function processTab(state: State, options: Options) {
if (state.value === options.default) {
return Effect.succeed(Action.Beep())
}
const value = getValue(state, options)
const cursor = value.length
return Effect.succeed(Action.NextFrame({
state: { ...state, value, cursor, error: Option.none() }
}))
}

function defaultProcessor(input: string, state: State) {
const beforeCursor = state.value.slice(0, state.cursor)
const afterCursor = state.value.slice(state.cursor)
Expand Down Expand Up @@ -232,7 +260,7 @@ function handleProcess(options: Options) {
}
case "enter":
case "return": {
const value = state.value.length > 0 ? state.value : options.default
const value = getValue(state, options)
return Effect.match(options.validate(value), {
onFailure: (error) =>
Action.NextFrame({
Expand All @@ -241,6 +269,9 @@ function handleProcess(options: Options) {
onSuccess: (value) => Action.Submit({ value })
})
}
case "tab": {
return processTab(state, options)
}
default: {
const value = Option.getOrElse(input.input, () => "")
return defaultProcessor(value, state)
Expand Down
288 changes: 285 additions & 3 deletions packages/cli/test/Prompt.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
import type * as CliApp from "@effect/cli/CliApp"
import * as Prompt from "@effect/cli/Prompt"
import * as MockConsole from "@effect/cli/test/services/MockConsole"
import * as MockTerminal from "@effect/cli/test/services/MockTerminal"
import type { Terminal } from "@effect/platform/Terminal"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import * as Ansi from "@effect/printer-ansi/Ansi"
import * as Doc from "@effect/printer-ansi/AnsiDoc"
import * as Console from "effect/Console"
import * as Effect from "effect/Effect"
import * as Fiber from "effect/Fiber"
import * as Layer from "effect/Layer"
import * as Redacted from "effect/Redacted"
import { describe, expect, it } from "vitest"

const MainLive = Effect.gen(function*() {
const console = yield* MockConsole.make
return Layer.mergeAll(
Console.setConsole(console),
NodeFileSystem.layer,
MockTerminal.layer,
NodePath.layer
)
}).pipe(Layer.unwrapEffect)

const runEffect = <E, A>(
self: Effect.Effect<A, E, Terminal>
): Promise<A> => Effect.provide(self, MockTerminal.layer).pipe(Effect.runPromise)
self: Effect.Effect<A, E, CliApp.CliApp.Environment>
): Promise<A> => Effect.provide(self, MainLive).pipe(Effect.runPromise)

describe("Prompt", () => {
describe("text", () => {
Expand Down Expand Up @@ -37,5 +54,270 @@ describe("Prompt", () => {

expect(result).toBe("default-value")
}).pipe(runEffect))

it("should render the default value when the default is provided", () =>
Effect.gen(function*() {
const prompt = Prompt.text({
message: "Test Prompt",
default: "default-value"
})

const fiber = yield* Effect.fork(prompt)

yield* MockTerminal.inputKey("enter")
yield* Fiber.join(fiber)

const lines = yield* MockConsole.getLines()

const unsubmittedValue = Doc.annotate(Doc.text("default-value"), Ansi.blackBright).pipe(Doc.render({
style: "pretty"
}))

const submittedValue = Doc.annotate(Doc.text("default-value"), Ansi.white).pipe(Doc.render({
style: "pretty"
}))

expect(lines).toEqual(
expect.arrayContaining([
expect.stringContaining(
unsubmittedValue
),
expect.stringContaining(
submittedValue
)
])
)

expect(lines.findIndex((line) => line.includes(unsubmittedValue))).toBeLessThan(
lines.findIndex((line) => line.includes(submittedValue))
)
}).pipe(runEffect))

it("should accept the default value when the tab is pressed", () =>
Effect.gen(function*() {
const prompt = Prompt.text({
message: "Test Prompt",
default: "default-value"
})

const fiber = yield* Effect.fork(prompt)

yield* MockTerminal.inputKey("tab")
yield* MockTerminal.inputKey("enter")
yield* Fiber.join(fiber)

const lines = yield* MockConsole.getLines()

const unsubmittedValue = Doc.annotate(Doc.text("default-value"), Ansi.blackBright).pipe(Doc.render({
style: "pretty"
}))

const enteredValue = Doc.annotate(Doc.text("default-value"), Ansi.combine(Ansi.underlined, Ansi.cyanBright))
.pipe(Doc.render({
style: "pretty"
}))

expect(lines).toEqual(
expect.arrayContaining([
expect.stringContaining(
unsubmittedValue
),
expect.stringContaining(
enteredValue
)
])
)

expect(lines.findIndex((line) => line.includes(unsubmittedValue))).toBeLessThan(
lines.findIndex((line) => line.includes(enteredValue))
)
}).pipe(runEffect))
})

describe("hidden", () => {
it("should use the prompt value when no default is provided", () =>
Effect.gen(function*() {
const prompt = Prompt.hidden({
message: "This does not have a default"
})

const fiber = yield* Effect.fork(prompt)
yield* MockTerminal.inputKey("enter")
const result = yield* Fiber.join(fiber)

expect(result).toEqual(Redacted.make(""))
}).pipe(runEffect))

it("should use the default value when the default is provided", () =>
Effect.gen(function*() {
const prompt = Prompt.hidden({
message: "This should have a default",
default: "default-value"
})

const fiber = yield* Effect.fork(prompt)
yield* MockTerminal.inputKey("enter")
const result = yield* Fiber.join(fiber)

expect(result).toEqual(Redacted.make("default-value"))
}).pipe(runEffect))

it("should not render the default value when the default is provided", () =>
Effect.gen(function*() {
const prompt = Prompt.hidden({
message: "Test Prompt",
default: "default-value"
})

const fiber = yield* Effect.fork(prompt)

yield* MockTerminal.inputKey("enter")
yield* Fiber.join(fiber)

const lines = yield* MockConsole.getLines({ stripAnsi: true })

expect(lines).not.toEqual(
expect.arrayContaining([
expect.stringContaining(
"default-value"
)
])
)
}).pipe(runEffect))
})

describe("list", () => {
it("should use the prompt value when no default is provided", () =>
Effect.gen(function*() {
const prompt = Prompt.list({
message: "This does not have a default"
})

const fiber = yield* Effect.fork(prompt)
yield* MockTerminal.inputKey("enter")
const result = yield* Fiber.join(fiber)

expect(result).toEqual([""])
}).pipe(runEffect))

it("should use the default value when the default is provided", () =>
Effect.gen(function*() {
const prompt = Prompt.list({
message: "This should have a default",
default: "default-value"
})

const fiber = yield* Effect.fork(prompt)
yield* MockTerminal.inputKey("enter")
const result = yield* Fiber.join(fiber)

expect(result).toEqual(["default-value"])
}).pipe(runEffect))

it("should render the default value when the default is provided", () =>
Effect.gen(function*() {
const prompt = Prompt.list({
message: "Test Prompt",
default: "default-value"
})

const fiber = yield* Effect.fork(prompt)

yield* MockTerminal.inputKey("enter")
yield* Fiber.join(fiber)

const lines = yield* MockConsole.getLines()

const unsubmittedValue = Doc.annotate(Doc.text("default-value"), Ansi.blackBright).pipe(Doc.render({
style: "pretty"
}))

const submittedValue = Doc.annotate(Doc.text("default-value"), Ansi.white).pipe(Doc.render({
style: "pretty"
}))

expect(lines).toEqual(
expect.arrayContaining([
expect.stringContaining(
unsubmittedValue
),
expect.stringContaining(
submittedValue
)
])
)

expect(lines.findIndex((line) => line.includes(unsubmittedValue))).toBeLessThan(
lines.findIndex((line) => line.includes(submittedValue))
)
}).pipe(runEffect))
})

describe("password", () => {
it("should use the prompt value when no default is provided", () =>
Effect.gen(function*() {
const prompt = Prompt.password({
message: "This does not have a default"
})

const fiber = yield* Effect.fork(prompt)
yield* MockTerminal.inputKey("enter")
const result = yield* Fiber.join(fiber)

expect(result).toEqual(Redacted.make(""))
}).pipe(runEffect))

it("should use the default value when the default is provided", () =>
Effect.gen(function*() {
const prompt = Prompt.password({
message: "This should have a default",
default: "default-value"
})

const fiber = yield* Effect.fork(prompt)
yield* MockTerminal.inputKey("enter")
const result = yield* Fiber.join(fiber)

expect(result).toEqual(Redacted.make("default-value"))
}).pipe(runEffect))

it("should render the redacted default value when the default is provided", () =>
Effect.gen(function*() {
const prompt = Prompt.password({
message: "Test Prompt",
default: "default-value"
})

const fiber = yield* Effect.fork(prompt)

yield* MockTerminal.inputKey("enter")
yield* Fiber.join(fiber)

const lines = yield* MockConsole.getLines()

const redactedValue = "*".repeat("default-value".length)
const unsubmittedValue = Doc.annotate(Doc.text(redactedValue), Ansi.blackBright).pipe(Doc.render({
style: "pretty"
}))

const submittedValue = Doc.annotate(Doc.text(redactedValue), Ansi.white).pipe(Doc.render({
style: "pretty"
}))

expect(lines).toEqual(
expect.arrayContaining([
expect.stringContaining(
unsubmittedValue
),
expect.stringContaining(
submittedValue
)
])
)

expect(lines.findIndex((line) => line.includes(unsubmittedValue))).toBeLessThan(
lines.findIndex((line) => line.includes(submittedValue))
)
}).pipe(runEffect))
})
})