Terminal
The Terminal service abstracts standard input and output: reading a line of
text, reading raw key events, displaying messages, and inspecting the terminal
dimensions. It is the foundation for interactive command-line programs. Provide
NodeTerminal.layer on Node.js, then write your program against the abstract
Terminal interface.
import { NodeRuntime, NodeTerminal } from "@effect/platform-node"import { Effect, Terminal } from "effect"
const greet = Effect.gen(function*() { const terminal = yield* Terminal.Terminal
// `display` writes text to stdout. It does not add a newline, so include // one yourself when you want the cursor to move down. yield* terminal.display("What is your name? ")
// `readLine` reads one line from stdin. It fails with `QuitError` if the // user cancels (typically Ctrl+C), which is why it lives in the error // channel rather than throwing. const name = yield* terminal.readLine
yield* terminal.display(`Hello, ${name.trim()}!\n`)})
NodeRuntime.runMain(greet.pipe(Effect.provide(NodeTerminal.layer)))Reading input
Section titled “Reading input”readLine is the workhorse for prompts: it resolves with the typed line and
fails with Terminal.QuitError when the user quits. Build
prompt loops by recursing on the effect — validation failures simply re-prompt:
import { Effect, PlatformError, Terminal } from "effect"
// Repeatedly prompt until the user enters an integer in range.const promptNumber = Effect.fn("promptNumber")(function*( message: string): Effect.Effect<number, Terminal.QuitError | PlatformError.PlatformError> { const terminal = yield* Terminal.Terminal
yield* terminal.display(`${message} `) const input = yield* terminal.readLine
const parsed = Number.parseInt(input.trim(), 10)
if (Number.isNaN(parsed) || parsed < 1 || parsed > 100) { // Re-prompt on invalid input. yield* terminal.display("Please enter an integer from 1 to 100.\n") return yield* promptNumber(message) }
return parsed})A complete interactive program
Section titled “A complete interactive program”Putting prompting and output together, here is a number-guessing game written in
idiomatic v4 style. The secret is drawn from Random (never Math.random), and
the game loop is a self-recursive effect:
import { NodeRuntime, NodeTerminal } from "@effect/platform-node"import { Effect, PlatformError, Random, Terminal } from "effect"
const guessingGame = Effect.gen(function*() { const terminal = yield* Terminal.Terminal
// Draw the secret from the Effect Random service for determinism in tests. // `nextIntBetween` is inclusive on both ends by default. const secret = yield* Random.nextIntBetween(1, 100)
const loop: Effect.Effect< void, Terminal.QuitError | PlatformError.PlatformError > = Effect.gen(function*() { yield* terminal.display("Guess a number (1-100): ") const guess = Number.parseInt((yield* terminal.readLine).trim(), 10)
if (guess === secret) { yield* terminal.display("Correct! Well played.\n") } else { yield* terminal.display(guess < secret ? "Too low.\n" : "Too high.\n") yield* loop // keep going until the guess is right } })
yield* terminal.display("I'm thinking of a number...\n") yield* loop})
NodeRuntime.runMain(guessingGame.pipe(Effect.provide(NodeTerminal.layer)))Raw key events
Section titled “Raw key events”When a line-based prompt is not enough — for menus, single-keystroke commands,
or custom editors — use readInput. It yields a scoped Queue
of UserInput events, each carrying the raw character (as an Option) and
parsed key metadata including modifier state:
import { Effect, Option, Queue, Terminal } from "effect"
const readKeys = Effect.gen(function*() { const terminal = yield* Terminal.Terminal
// `readInput` is scoped: the input subscription is released when the scope // closes. `Queue.take` blocks until the next key event arrives. const queue = yield* terminal.readInput
yield* terminal.display("Press keys (q to quit)...\n")
const loop: Effect.Effect<void> = Effect.gen(function*() { const event = yield* Queue.take(queue)
const char = Option.getOrElse(event.input, () => "") yield* terminal.display( `key=${event.key.name} char=${JSON.stringify(char)} ctrl=${event.key.ctrl}\n` )
if (event.key.name !== "q") yield* loop })
yield* loop}).pipe(Effect.scoped) // provide and close the scope that owns the subscriptionTerminal dimensions
Section titled “Terminal dimensions”columns and rows report the size of the terminal, which is useful for
formatting output such as progress bars or tables:
import { Effect, Terminal } from "effect"
const printSize = Effect.gen(function*() { const terminal = yield* Terminal.Terminal const columns = yield* terminal.columns const rows = yield* terminal.rows yield* terminal.display(`Terminal is ${columns}x${rows}\n`)})For richer command-line applications — argument parsing, subcommands, and
built-in prompts — see the CLI section, which builds on Terminal.