Skip to content

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)))

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
})

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)))

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 subscription

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.