Skip to content

Terminal

@sectile/terminal maps normalized terminal input and Unicode-aware rendering to the same component semantics used by other hosts.

sh
pnpm add @sectile/terminal
ts
import * as checkbox from '@sectile/terminal/checkbox'

Terminal adapters own host input and projection, not application styling or persistence.

Product boundary

The package is primarily a semantic host adapter: it normalizes terminal input and projects Core effects so an application or an existing TUI renderer can share DOM interaction semantics. The screen, layout, appearance, and node subpaths form a small reference renderer for examples and compact applications. They are not a complete TUI framework and do not own application reconciliation, scrolling, routing, persistence, or process lifecycle.

Integrations for larger applications should keep their renderer in charge of layout and I/O, translate its input into TerminalKeyboardInput, and use component connections as the semantic boundary.

Form boundary

Sectile Form is available for DOM and Vue applications. @sectile/terminal does not expose a Form adapter or depend on @sectile/form.

Build a complete screen

The optional screen layer turns a layout tree into a fixed terminal frame. Rows, columns, boxes, padding, gaps, clipping, and fill sizing are composed the same way across components. Application code still decides the visual structure.

ts
import { createTerminalAppearance } from '@sectile/terminal/appearance'
import { createTerminalScreenWriter } from '@sectile/terminal/node'
import {
  renderTerminalScreen,
  terminalBox,
  terminalColumn,
  terminalRow,
  terminalText,
} from '@sectile/terminal/screen'

const appearance = createTerminalAppearance({
  theme: {
    accent: { foreground: 'bright-cyan', bold: true },
    current: { foreground: 'black', background: 'bright-cyan' },
  },
})

const view = terminalBox(
  terminalColumn([
    terminalText('Project settings', { style: 'accent' }),
    terminalRow([
      terminalText('Navigation', { width: 24 }),
      terminalText('Editor', { width: 'fill' }),
    ], { gap: 2, height: 'fill' }),
  ], { gap: 1, width: 'fill', height: 'fill' }),
  { title: 'Sectile', padding: 1, width: 'fill', height: 'fill' },
)

const writer = createTerminalScreenWriter(process.stdout, {
  appearance,
  alternateScreen: true,
})

writer.render(renderTerminalScreen(view, {
  columns: process.stdout.columns,
  rows: process.stdout.rows,
  appearance,
}))

Use semantic theme roles for reusable styling and pass a style object only for a local exception. Color automatically falls back from truecolor to 256 colors, 16 colors, or plain text according to terminal capability.

Caret and screen cursor

Editable text keeps its logical caret as a UTF-16 offset. Attach it to the text node and the renderer projects it through grapheme clusters, double-width characters, wrapping, padding, and clipping.

ts
terminalText(input, {
  cursor: {
    codeUnitOffset: selection.focusCodeUnitOffset,
    shape: 'bar',
  },
})

The Node writer updates only changed rows after the first frame. It positions the real TTY cursor at the projected cell, applies its shape and visibility, and restores terminal state when closed. This avoids clearing and repainting the entire screen on every keypress.

TTY ownership and cleanup

createTTYKeyboard acquires exclusive keyboard ownership of one stdin stream. A second active owner fails with tty-input-already-owned. Existing external keypress listeners remain installed, and close() removes only Sectile's listener, restores the stream's prior raw mode, and restores whether it was flowing or paused. Closing is idempotent; after it closes, another controller may acquire the stream.

The application owns process signals and must close both input and output resources. The screen writer restores cursor visibility and leaves the alternate screen exactly once when close() is called after rendering.

ts
import { createTTYKeyboard, createTerminalScreenWriter } from '@sectile/terminal/node'

const keyboardResult = createTTYKeyboard(process.stdin, handleKeyboardInput)
if (!keyboardResult.ok) throw new Error(keyboardResult.error.message)

const keyboard = keyboardResult.value
const writer = createTerminalScreenWriter(process.stdout, { alternateScreen: true })
let closed = false

function close(): void {
  if (closed) return
  closed = true
  keyboard.close()
  writer.close()
}

process.once('SIGINT', () => { close(); process.exitCode = 130 })
process.once('SIGTERM', () => { close(); process.exitCode = 143 })
process.once('exit', close)
process.stdout.on('resize', render)

Remove application-owned signal and resize listeners as part of the same lifecycle when the terminal view can unmount without ending the process.

Keyboard conventions

The key map follows the shape shown by the terminal interface. Vertical lists use /, horizontal lists use /, and vertical hierarchies use to enter and or Esc to return. Home/End stay within the current level; keyboards without those keys can use Fn+/ or Ctrl+A/E. Fn+/ is accepted as Page Up/Page Down. Enter or Space opens a branch or activates a command.

Component pages list extra editing, paging, and range shortcuts where applicable.

@sectile/terminal/reorder exposes move-up, move-down, move-start, move-end, indent, and outdent as explicit sequence/tree movement keys. @sectile/terminal/layer-stack creates an application-owned layer scope so mixed terminal popups share topmost dismissal and descendant close order.

Try the terminal adapter

This is a browser-hosted preview of terminal input and output, not a sectile CLI command. Its state transitions use the real @sectile/terminal checkbox connection. Click the row, or focus the preview and press Space or Enter.

Try Bash in the browser

Start an isolated Debian /bin/bash, then type commands at the prompt. The VM demonstrates the shell environment available to a browser-hosted terminal application; it cannot access files or shells on your computer. The first start downloads the runtime and streamed disk blocks.

Browser BashIsolated Debian · interactive /bin/bash

The VM runs inside the browser and cannot access local files or the host shell.

CheerpXxterm.js

Factory behavior

Use create* to receive a ready connection. Use tryCreate* only when invalid setup must be handled as a recoverable Result. A host create* call never needs an additional unwrap.

Released under the MIT License.