Skip to content
This repository has been archived by the owner on Jul 16, 2024. It is now read-only.

Commit

Permalink
Adds a basic Terminal interface for prompting for user input (#255)
Browse files Browse the repository at this point in the history
  • Loading branch information
IMax153 authored Nov 13, 2023
1 parent 0e89cf3 commit fea76da
Show file tree
Hide file tree
Showing 9 changed files with 267 additions and 1 deletion.
7 changes: 7 additions & 0 deletions .changeset/short-ants-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@effect/platform-node": patch
"@effect/platform-bun": patch
"@effect/platform": patch
---

add basic Terminal interface for prompting user input
36 changes: 36 additions & 0 deletions packages/platform-bun/src/Terminal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* @since 1.0.0
*/
/**
* @since 1.0.0
*/
export type {
/**
* @since 1.0.0
* @category model
*/
Key,
/**
* @since 1.0.0
* @category model
*/
UserInput
} from "@effect/platform-node/Terminal"

export {
/**
* @since 1.0.0
* @category layer
*/
layer,
/**
* @since 1.0.0
* @category constructors
*/
make,
/**
* @since 1.0.0
* @category tag
*/
Terminal
} from "@effect/platform-node/Terminal"
42 changes: 42 additions & 0 deletions packages/platform-node/src/Terminal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* @since 1.0.0
*/
import type { Terminal, UserInput } from "@effect/platform/Terminal"
import type { Effect } from "effect/Effect"
import type { Layer } from "effect/Layer"
import type { Scope } from "effect/Scope"
import * as InternalTerminal from "./internal/terminal.js"

export type {
/**
* @since 1.0.0
* @category model
*/
Key,
/**
* @since 1.0.0
* @category model
*/
UserInput
} from "@effect/platform/Terminal"

export {
/**
* @since 1.0.0
* @category tag
*/
Terminal
} from "@effect/platform/Terminal"

/**
* @since 1.0.0
* @category constructors
*/
export const make: (shouldQuit?: (input: UserInput) => boolean) => Effect<Scope, never, Terminal> =
InternalTerminal.make

/**
* @since 1.0.0
* @category layer
*/
export const layer: Layer<never, never, Terminal> = InternalTerminal.layer
5 changes: 5 additions & 0 deletions packages/platform-node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ export * as Sink from "./Sink.js"
*/
export * as Stream from "./Stream.js"

/**
* @since 1.0.0
*/
export * as Terminal from "./Terminal.js"

/**
* @since 1.0.0
*
Expand Down
86 changes: 86 additions & 0 deletions packages/platform-node/src/internal/terminal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import * as Error from "@effect/platform/Error"
import * as Terminal from "@effect/platform/Terminal"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as Option from "effect/Option"
import * as readline from "node:readline"

const defaultShouldQuit = (input: Terminal.UserInput): boolean =>
input.key.ctrl && (input.key.name === "c" || input.key.name === "d")

/** @internal */
export const make = (
shouldQuit: (input: Terminal.UserInput) => boolean = defaultShouldQuit
) =>
Effect.gen(function*(_) {
const input = yield* _(Effect.sync(() => globalThis.process.stdin))
const output = yield* _(Effect.sync(() => globalThis.process.stdin))

// Create a readline interface and force it to emit keypress events
yield* _(Effect.acquireRelease(
Effect.sync(() => {
const rl = readline.createInterface({ input, escapeCodeTimeout: 50 })
readline.emitKeypressEvents(input, rl)
if (input.isTTY) {
input.setRawMode(true)
}
return rl
}),
(rl) =>
Effect.sync(() => {
if (input.isTTY) {
input.setRawMode(false)
}
rl.close()
})
))

const readInput = Effect.async<never, Terminal.QuitException, Terminal.UserInput>((resume) => {
const handleKeypress = (input: string | undefined, key: readline.Key) => {
const userInput: Terminal.UserInput = {
input: Option.fromNullable(input),
key: {
name: key.name || "",
ctrl: key.ctrl || false,
meta: key.meta || false,
shift: key.shift || false
}
}
if (shouldQuit(userInput)) {
resume(Effect.fail(new Terminal.QuitException()))
}
resume(Effect.succeed(userInput))
}
input.once("keypress", handleKeypress)
return Effect.sync(() => {
input.removeListener("keypress", handleKeypress)
})
})

const display = (prompt: string): Effect.Effect<never, Error.PlatformError, void> =>
Effect.uninterruptible(
Effect.async((resume) => {
output.write(prompt, (err) => {
if (err) {
resume(Effect.fail(Error.BadArgument({
module: "Terminal",
method: "display",
message: (err as Error).message ?? String(err)
})))
}
resume(Effect.unit)
})
})
)

return Terminal.Terminal.of({
readInput,
display
})
})

/** @internal */
export const layer: Layer.Layer<never, never, Terminal.Terminal> = Layer.scoped(
Terminal.Terminal,
make(defaultShouldQuit)
)
2 changes: 1 addition & 1 deletion packages/platform/src/Error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export declare namespace PlatformError {
export interface Base extends Data.Case {
readonly [PlatformErrorTypeId]: typeof PlatformErrorTypeId
readonly _tag: string
readonly module: "Command" | "FileSystem" | "Path" | "KeyValueStore" | "Clipboard" | "Stream"
readonly module: "Clipboard" | "Command" | "FileSystem" | "KeyValueStore" | "Path" | "Stream" | "Terminal"
readonly method: string
readonly message: string
}
Expand Down
80 changes: 80 additions & 0 deletions packages/platform/src/Terminal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* @since 1.0.0
*/
import type { Tag } from "effect/Context"
import { TaggedError } from "effect/Data"
import type { Effect } from "effect/Effect"
import type { Option } from "effect/Option"
import type { PlatformError } from "./Error.js"
import * as InternalTerminal from "./internal/terminal.js"

/**
* A `Terminal` represents a command-line interface which can read input from a
* user and display messages to a user.
*
* @since 1.0.0
* @category models
*/
export interface Terminal {
/**
* Reads a single input event from the default standard input.
*/
readonly readInput: Effect<never, QuitException, UserInput>
/**
* Displays text to the the default standard output.
*/
readonly display: (text: string) => Effect<never, PlatformError, void>
}

/**
* @since 1.0.0
* @category model
*/
export interface Key {
/**
* The name of the key being pressed.
*/
readonly name: string
/**
* If set to `true`, then the user is also holding down the `Ctrl` key.
*/
readonly ctrl: boolean
/**
* If set to `true`, then the user is also holding down the `Meta` key.
*/
readonly meta: boolean
/**
* If set to `true`, then the user is also holding down the `Shift` key.
*/
readonly shift: boolean
}

/**
* @since 1.0.0
* @category model
*/
export interface UserInput {
/**
* The character read from the user (if any).
*/
readonly input: Option<string>
/**
* The key that the user pressed.
*/
readonly key: Key
}

/**
* A `QuitException` represents an exception that occurs when a user attempts to
* quit out of a `Terminal` prompt for input (usually by entering `ctrl`+`c`).
*
* @since 1.0.0
* @category model
*/
export class QuitException extends TaggedError("QuitException")<{}> {}

/**
* @since 1.0.0
* @category tag
*/
export const Terminal: Tag<Terminal, Terminal> = InternalTerminal.tag
5 changes: 5 additions & 0 deletions packages/platform/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ export * as Path from "./Path.js"
*/
export * as Runtime from "./Runtime.js"

/**
* @since 1.0.0
*/
export * as Terminal from "./Terminal.js"

/**
* @since 1.0.0
*/
Expand Down
5 changes: 5 additions & 0 deletions packages/platform/src/internal/terminal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Tag } from "effect/Context"
import type * as Terminal from "../Terminal.js"

/** @internal */
export const tag = Tag<Terminal.Terminal>("@effect/platform/FileSystem")

0 comments on commit fea76da

Please sign in to comment.