Application layer

termflow-app is the layer most apps live in. It ties the terminal and screen layers together with an Elm-style runtime, plus everything you need to build a real interactive program: focus management, keymaps, prompts, modal dialogs, async commands, timer subscriptions.

libraryDependencies += "org.llm4s" %% "termflow" % "0.4.0"

The umbrella termflow artefact pulls in the whole stack. Most apps should depend on it rather than the four modules separately.

TuiApp[Model, Msg]

The four-method contract:

trait TuiApp[Model, Msg]:
  def init(ctx: RuntimeCtx[Msg]): Tui[Model, Msg]
  def update(model: Model, msg: Msg, ctx: RuntimeCtx[Msg]): Tui[Model, Msg]
  def view(model: Model): RootNode
  def toMsg(input: PromptLine): Result[Msg]

The four tutorials cover this contract end-to-end: Hello, World, Counter, Async work, and Forms and dialogs. This page catalogues the surrounding pieces.

Tui[Model, Msg]

final case class Tui[Model, Msg](model: Model, cmd: Cmd[Msg] = Cmd.NoCmd)

extension [Model](m: Model)
  def tui[Msg]: Tui[Model, Msg]                = Tui(m)
  def gCmd[Msg](msg: Msg): Tui[Model, Msg]     = Tui(m, Cmd.GCmd(msg))

A Tui is a model paired with a Cmd. update returns one. The .tui and .gCmd extensions are the ergonomic way to construct them inline.

Cmd — effects

enum Cmd[+Msg]:
  case NoCmd                                                       extends Cmd[Nothing]
  case Exit                                                        extends Cmd[Nothing]
  case GCmd(msg: Msg)                                              extends Cmd[Msg]
  case FCmd[A, M](task: Future[A], toCmd: A => Cmd[M], onEnqueue: Option[M] = None) extends Cmd[M]
  case TermFlowErrorCmd(msg: TermFlowError)                        extends Cmd[Msg]
  case RequestAttention                                            extends Cmd[Nothing]
  case Notify(title: String, body: String)                         extends Cmd[Nothing]
CmdWhen
NoCmdPure state transitions (the common case).
ExitTear down the runtime, restore the terminal, return from TuiRuntime.run.
GCmd(msg)Dispatch a follow-up Msg through update. Used to chain transitions.
FCmd(task, toCmd, onEnqueue)Bridge a Future[A] into the runtime — covered in the async tutorial.
TermFlowErrorCmd(err)Surface a TermFlowError to the renderer (logged, not fatal).
RequestAttentionRing the bell / pop the terminal's "needs attention" indicator.
Notify(title, body)Send a desktop notification (iTerm2/kitty/VTE OSC, BEL fallback).

RequestAttention and Notify are documented end-to-end in the notifications cookbook, including the TERMFLOW_NOTIFICATIONS opt-out.

Plus the helper:

def Cmd.asyncResult[A, Msg](
  task:      AsyncResult[A],
  onSuccess: A => Msg,
  onError:   TermFlowError => Msg,
  onEnqueue: Option[Msg] = None
): Cmd[Msg]

AsyncResult[A] is Future[Result[A]]. Use asyncResult whenever the async work has a domain failure mode: Right(a) is mapped through onSuccess, Left(err) through onError. Future failures (network drops, JVM exceptions) still surface via the runtime's standard TermFlowErrorCmd overlay — there's no need to recover them here.

Sub — subscriptions

Subscriptions are how the outside world delivers events into update. They run on background threads and publish Cmds to the runtime's command bus.

trait Sub[+Msg]:
  def isActive: Boolean
  def cancel(): Unit
  def start(): Unit

The factories you use day-to-day:

Sub.Every(millis: Long, msg: () => Msg, sink: EventSink[Msg]): Sub[Msg]

Sub.InputKey(
  msg:     KeyDecoder.InputKey => Msg,
  onError: Throwable => Msg,
  ctx:     RuntimeCtx[Msg]
): Sub[Msg]

Sub.TerminalResize(
  millis: Long,
  mkMsg:  (Int, Int) => Msg,
  ctx:    RuntimeCtx[Msg]
): Sub[Msg]

val Sub.NoSub: Sub[Nothing]   // inert placeholder for model fields

When the sink/ctx argument is a RuntimeCtx[Msg], the sub auto-registers for cleanup on Cmd.Exit. When it's a bare EventSink, you're responsible for the lifecycle.

Sub.TerminalResize prefers the backend's resize signal (e.g. SIGWINCH on Unix) and only falls back to polling at millis for backends without signal support — typically the testkit's virtual backend.

.cancel() is idempotent — safe to call on NoSub or on an already-cancelled sub.

RuntimeCtx — the ambient context

trait RuntimeCtx[Msg] extends EventSink[Msg]:
  def terminal:    TerminalBackend
  def config:      TermFlowConfig
  def registerSub(sub: Sub[Msg]): Sub[Msg]
  def publish(cmd: Cmd[Msg]): Unit

The runtime hands a RuntimeCtx[Msg] to init and to every update. Use it to:

  • read terminal.width / terminal.height for adaptive layout
  • register subscriptions
  • publish commands directly (rare — usually you return a Cmd from update)
  • access config (logging, telemetry)

TuiRuntime.run

TuiRuntime.run(app: TuiApp[Model, Msg])  // simplest form

TuiRuntime.run(
  app:             TuiApp[Model, Msg],
  renderer:        Option[TuiRenderer]    = None,
  terminalBackend: Option[TerminalBackend] = None,
  config:          Option[TermFlowConfig] = None
): Unit

The driver does, in order:

  1. Open the terminal (alternate buffer, hide cursor, raw mode).
  2. Set up the CmdBus queue.
  3. Run init.
  4. Loop: drain the bus, run update per message, run view, diff-render to ANSI, sleep up to ~60 fps.
  5. On Cmd.Exit: cancel subs, restore terminal, return.

Pass a custom TerminalBackend for tests (the testkit's TestTerminalBackend is what TuiTestDriver uses).

Files: Tui.scala, Sub.scala, TuiRuntime.scala.

FocusManager

opaque type FocusId = String

object FocusId:
  def apply(s: String): FocusId
  extension (id: FocusId) def value: String

final case class FocusManager(ids: Vector[FocusId], current: Option[FocusId]):
  def isFocused(id: FocusId):    Boolean
  def next:                      FocusManager
  def previous:                  FocusManager
  def focus(id: FocusId):        FocusManager
  def clear:                     FocusManager
  def withIds(newIds: Vector[FocusId]): FocusManager

Pure value, immutable transitions, wraps at either end. Construct via FocusManager(Vector(NameId, EmailId)) — the first id becomes the initial focus.

The forms tutorial shows the full pattern: per-step FocusManagers, focus dispatch, explicit focus(id).

File: FocusManager.scala.

Dialogs — modal overlays

Dialogs builds Overlay values. Mount them on RootNode.overlays and the renderer composites them on top of the rest of the frame.

import termflow.tui.Dialogs

Dialogs.message(title, body: List[String], choices: List[Choice], position, theme): Overlay
Dialogs.confirm(prompt: String, yesFocused: Boolean, …): Overlay
Dialogs.textInput(title, prompt, value, cursor, okFocused, …): Overlay
Dialogs.listSelect[A](title, items, selectedIdx, visibleRows, itemLabel, …): Overlay
Dialogs.waiting(title, message, …): Overlay
Dialogs.fileDialog(basePath, onSelect, onCancel, …): Overlay
Dialogs.directoryDialog(basePath, onSelect, onCancel, …): Overlay
Dialogs.actionList[A](title, items, itemLabel, onSelect, …): Overlay

Every helper takes a position and an implicit Theme. The showcase's Dialogs tab exercises all of them — see Stage1ShowcaseApp for working examples of each one.

File: Dialogs.scala.

Prompt — single-line text input

final case class Prompt.State(buffer: Vector[Char] = Vector.empty, cursor: Int = 0)

def Prompt.handleKey[G](state: State, k: InputKey)(toMsg: PromptLine => Result[G])
  : (State, Option[Cmd[G]])

def Prompt.renderWithPrefix(state: State, prefix: String): RenderedLine

def Prompt.cursorColumn(state: State): Int

handleKey is grapheme-aware — Backspace deletes a whole cluster, not a code point. renderWithPrefix returns the rendered text plus the cursor index and prefix length so you can hand it straight to an InputNode. cursorColumn uses WCWidth for the column position (matters for CJK / emoji inputs).

File: Prompt.scala.

TuiPrelude — the import you always want

import termflow.tui.TuiPrelude.*

opaque type PromptLine = String
type Result[A]         = Either[TermFlowError, A]
type AsyncResult[+A]   = Future[Result[A]]

def AsyncResult.success[A](value: A): AsyncResult[A]
def AsyncResult.failure[A](err: TermFlowError): AsyncResult[A]
def AsyncResult.fromResult[A](r: Result[A]): AsyncResult[A]
def AsyncResult.fromFuture[A](task: Future[A])(using ec: ExecutionContext): AsyncResult[A]

Plus the screen-prelude conversions (.x, .y, .text).

TermFlowError is the closed error ADT: ConfigError | ModelNotFound | Unexpected | Validation | CommandError | UnknownApp.

File: TuiPrelude.scala.

How errors reach the user

A Cmd.TermFlowErrorCmd(err) does not crash the app or block the runtime loop. The runtime captures err as the pending error for the next frame; SimpleANSIRenderer then overlays a single-line, red, bold banner across the top row of the rendered frame and clears it on the following render. Long messages are truncated with an ellipsis to fit the terminal width.

┌──────────────────────────────────┐
│ Invalid input: name must be set… │  ← banner, single frame, then gone
│ Name: alice___                   │
│ ...                              │

Apps don't need to do anything to opt in — return Cmd.TermFlowErrorCmd from update (or use Cmd.asyncResult and let the runtime fold a Left(err) for you), and the banner appears.

For tests, TuiTestDriver.observedErrors: List[TermFlowError] records every error raised through this path without a real terminal.

The wording used by the banner is exposed as SimpleANSIRenderer.formatErrorBanner(err) so custom renderers can stay consistent with the default.

Where to next

  • Widgets. The component catalogue: Widgets guide.
  • Keymaps. Replace ad-hoc match-on-key with declarative bindings: Keymap guide.
  • Theming. Override colours and box-drawing glyphs: Theming guide.
  • Testing. Drive the runtime synchronously in tests: Testing guide.

The full per-type API is in the Scaladoc.