中文

Interactive applications

Integrate popular open-source terminal UI libraries with Func.

Updated

func does not select or wrap a terminal UI library. Install the library that fits the application and bind it to the current Context. This page uses several open-source libraries to demonstrate different integration boundaries; @clack/prompts is one example, not a default recommendation.

Bind a terminal application’s primary UI and prompts to the invocation-scoped stdout. Keep warnings, errors, and independent progress displays on stderr. Using Context.io rather than process-global streams preserves the invocation host’s output policy, stream replacement, and test isolation. Keep every renderer in one interactive session on the same output stream so cursor updates do not compete.

Render a terminal application with Ink

Ink is a React renderer. Bind its main render target to the invocation-scoped stdout and keep its stderr channel separate. Disable exitOnCtrlC and patchConsole because func owns signal handling and application output:

TypeScript
import { render } from 'ink'

const { stdin, stdout, stderr } = context.io

const application = render(view, {
  stdin,
  stdout,
  stderr,
  exitOnCtrlC: false,
  patchConsole: false,
})

try {
  await application.waitUntilExit()
} finally {
  application.unmount()
}

Unmount on every path, and let Context.signal unmount early when the command is cancelled. The copyable Ink service includes that signal binding.

Give stdin one owner with Inquirer

Inquirer accepts runtime streams and an abort signal separately from prompt options:

TypeScript
import { input } from '@inquirer/prompts'

const name = await input(
  { message: 'Project name' },
  {
    input: context.io.stdin,
    output: context.io.stdout,
    signal: context.signal,
  },
)

Check that both input and render streams are TTYs before prompting. In CI, a shell pipeline, or another non-interactive environment, use command options or defaults and fail fast when required data is missing. Do not consume the same stdin as both piped application data and interactive key input. See the copyable Inquirer service.

Awaiting a prompt suspends the command until input arrives; it does not block the Node.js event loop. Synchronous work still blocks input, signal handling, and all terminal animations.

Clean up progress rendering with Ora

Ora renders a single spinner to a writable stream. A standalone spinner describes work in progress rather than the command’s primary interface, so bind it to stderr and stop it in finally:

TypeScript
import ora from 'ora'

const progress = ora({
  text: 'Publishing',
  stream: context.io.stderr,
  discardStdin: false,
}).start()

try {
  await publish({ signal: context.signal })
  progress.succeed('Published')
} catch (error) {
  progress.fail('Publish failed')
  throw error
} finally {
  progress.stop()
}

Ora’s discardStdin feature operates on the process-wide stdin rather than a supplied stream, so disable it when streams are owned by Context. The operation itself should accept Context.signal. See the copyable Ora service.

Style at the output boundary with Chalk

Chalk produces styled strings; it does not decide which application channel owns them. Choose color support for the target stream, write decorative or diagnostic text to stderr, and keep structured stdout unstyled:

TypeScript
import { Chalk } from 'chalk'

const { stdout, stderr } = context.io
const style = new Chalk({ level: 'isTTY' in stderr && stderr.isTTY ? 1 : 0 })
stderr.write(`${style.green('✔')} Published\n`)
stdout.write(`${JSON.stringify(result)}\n`)

Custom and captured streams should default to color level 0 unless their terminal capability is known. See the copyable Chalk service.

Code snippet example

The following minimal service uses @clack/prompts only as a concrete example. Install it in the application rather than in func:

Terminal
npm install @clack/prompts
src/services/clack.service.ts
import { text } from '@clack/prompts'
import { Context, Injectable } from 'func'
import type { Readable, Writable } from 'node:stream'

@Injectable()
export class ClackService {
  static IsTTY(stream: Readable | Writable): boolean {
    return 'isTTY' in stream && stream.isTTY === true
  }

  constructor(private readonly context: Context) {}

  projectName() {
    const { stdin, stdout } = this.context.io
    if (!ClackService.IsTTY(stdin) || !ClackService.IsTTY(stdout)) {
      throw new TypeError('Interactive input requires a terminal.')
    }

    return text({
      message: 'Project name',
      input: stdin,
      output: stdout,
      signal: this.context.signal,
    })
  }
}

Register ClackService in the owning module’s providers, then inject it into the command. The command retains ownership of the final stdout result:

src/commands/create.command.ts
import { Command, Context, Handler } from 'func'
import { ClackService } from '../services/clack.service'

@Command('create')
export class CreateCommand {
  constructor(
    private readonly context: Context,
    private readonly prompts: ClackService,
  ) {}

  @Handler()
  async run() {
    const name = await this.prompts.projectName()
    if (typeof name === 'symbol') return

    this.context.io.stdout.write(`${name}\n`)
  }
}

The repository provides a copyable complete Clack service with prompt binding, cancellation helpers, TTY checks, and spinner cleanup. For the underlying contracts, see Input and output, Process signals, Testing, and the Ecosystem guide.