TermFlow

termflow is a small, functional terminal UI (TUI) framework for Scala 3.

It is designed for building interactive command-line apps with a simple architecture borrowed from Elm:

  • a pure update function (state transitions),
  • a view function returning a small virtual DOM,
  • Cmd for async work,
  • Sub for event streams (keys, timers, terminal resize).

The project started as the TUI layer for the llm4s sample applications, and is now usable on its own.

Where to go next

  • New here? Start with What is TermFlow? for the elevator pitch and an architecture diagram, then jump into the Hello, World tutorial.
  • Coming from another TUI library? The Architecture page maps TermFlow's layers onto Lanterna and bubbletea equivalents.
  • Looking for a specific widget? The Widgets guide catalogues every shipped component.
  • Need to test a TUI app? See the Testing guide.

Project status

TermFlow is pre-1.0. Stage 3 (component breadth) is complete; Stage 4 (stabilisation) is in progress, and this site is part of it. APIs are still allowed to change between minor versions until 1.0.0; see the Migration notes when upgrading.

What is TermFlow?

TermFlow is a small, functional terminal UI framework for Scala 3. You describe your app as three pure functions — init, update, and view — and TermFlow turns that description into a fully-rendered, mouse-aware, diff-painted ANSI terminal program.

┌──────────────┐  Msg   ┌──────────┐ Model  ┌────────┐
│   Sub.*      │───────▶│  update  │───────▶│  view  │──▶ ANSI
│ (input/time) │        └──────────┘        └────────┘
└──────────────┘             │                  ▲
       ▲                     │ Cmd              │
       └─── async / FCmd ◀───┘                  │
                            (next frame) ───────┘

When TermFlow is a good fit

  • Prompt-driven CLIs. Anything where you'd reach for readline plus fancy output. TermFlow's Prompt widget gives you a real input field with cursor, history, paste support, and grapheme-aware editing.
  • Streaming output. LLM-token streaming, log tails, build output. Cmd.FCmd lets you fire async work and merge the results into the model.
  • Dashboards. Multi-pane layouts with live counters, progress bars, spinners, and a status bar — Sub.Every drives the heartbeat.
  • Multi-step flows. Wizards, file pickers, action menus. FocusManager plus the Dialogs helpers handle most form patterns out-of-the-box.

When to reach for something else

  • You need plain printf output. TermFlow takes over the terminal — it switches to the alternate buffer, hides the cursor, raw-mode-locks input. For "print-and-exit" tools just use println.
  • You need a real GUI. TermFlow's a TUI; it can't render images, only cells. If you need pixels, use Swing, JavaFX, or Compose.
  • You need to support a Scala 2.13 runtime. Use the legacy-213-track branch instead — main is Scala 3 only.

Compared with other libraries

TermFlow's three-layer model (terminalscreenapp+widgets) mirrors Lanterna more closely than bubbletea — every layer is publicly usable on its own. The Elm-style update / view shape comes from bubbletea and from Scala-side experiments like indigo. The result is a library that you can drop down into when you need raw terminal access, or use at the top level for a fully-managed TUI.

Next up: Architecture walks the layers in detail.

Architecture

TermFlow is split into four published modules, layered so each one is publicly usable without forcing you to climb the whole stack:

ModuleWhat it gives youKey types
termflow-terminalRaw access to the underlying TTY: backend, key decoding, capability detection.TerminalBackend, KeyDecoder, Capabilities, Grapheme, WCWidth
termflow-screenA character grid you draw into, plus diff-rendering to ANSI.RenderFrame, AnsiRenderer, Layout, HitTest
termflow-appThe Elm-style runtime: TuiApp, Cmd, Sub, FocusManager, Dialogs.TuiApp, TuiRuntime, Cmd, Sub, Tui
termflow-widgetsReusable components built on top of the app layer.Button, TextField, ListView, Table, Tree, Tabs, Form, ...

Plus an umbrella termflow artefact that depends on all four, and a termflow-testkit artefact for testing apps without a real terminal.

The runtime loop

TuiRuntime.run(app) drives the loop:

                  ┌─────────────────────┐
                  │  TuiApp[Model, Msg] │
                  │   init / update /   │
                  │     view / toMsg    │
                  └─────────────────────┘
                           ▲   │
                       Msg │   │ Cmd / Tui
                           │   ▼
                  ┌─────────────────────┐
       Sub.* ────▶│      CmdBus         │
   (Sub.Every,    │  (blocking queue)   │
   Sub.InputKey,  └─────────────────────┘
   Sub.Resize)              │
                            ▼
                  ┌─────────────────────┐
                  │     AnsiRenderer    │
                  │   (frame diffing)   │
                  └─────────────────────┘
                            │
                            ▼
                          ANSI
  1. init produces an initial Tui[Model, Msg] — the starting model plus an optional startup Cmd (typically registering subscriptions).
  2. Subscriptions push Cmds onto the CmdBus. Examples: every key press becomes a Cmd.GCmd(msg), every timer tick the same.
  3. The runtime drains the bus, dispatches each Msg to update, and gets back a new Tui[Model, Msg] — the next model and an optional follow-up command.
  4. view renders the new model into a RootNode virtual DOM.
  5. AnsiRenderer diffs the new RootNode against the previous frame and emits the minimum ANSI escape sequence to bring the terminal up to date.

Loop, repeat.

What runs where

  • Update is synchronous and pure. No I/O. If you need async work, return a Cmd.FCmd[A, Msg] (or the friendlier Cmd.asyncResult) and the runtime will await the Future, then deliver the result back as a Msg.
  • View is pure. It returns a RootNode from a Model. The renderer takes care of cursor placement, colour emission, and clipping.
  • Subscriptions live outside update. Sub.InputKey reads from JLine on a background thread; Sub.Every schedules ticks. Both push Cmds onto the bus so update only ever sees Msgs.

Why three layers?

The split lets you opt out of higher layers when they get in the way:

  • Building a one-shot CLI utility that just needs raw key reads and capability detection? Depend on termflow-terminal only.
  • Building a custom layout engine on top of a screen buffer? Depend on termflow-screen.
  • Building a typical interactive TUI? Depend on the umbrella termflow artefact, which pulls in all four.

The boundaries also mean each module can carry its own MiMa baseline once 1.0 ships, so a breaking change in widgets doesn't force a major bump on the whole stack.

For deeper architectural rationale, see the contributor design doc and the render pipeline doc.

Next up: Install.

Install

TermFlow is published to Maven Central. You'll need:

  • JVM 21 or newer (TermFlow uses java.text.BreakIterator and modern Locale APIs).
  • sbt (or any tool that can resolve Maven artefacts).
  • A real interactive terminal. Piping stdin/stdout into TermFlow drops JLine into dumb-terminal mode and the rendering breaks.

sbt

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

The termflow umbrella pulls in all four modules. To depend on a single layer instead:

libraryDependencies ++= Seq(
  "org.llm4s" %% "termflow-terminal" % "0.4.0",
  "org.llm4s" %% "termflow-screen"   % "0.4.0",
  "org.llm4s" %% "termflow-app"      % "0.4.0",
  "org.llm4s" %% "termflow-widgets"  % "0.4.0"
)

For tests, add the testkit on the test classpath:

libraryDependencies += "org.llm4s" %% "termflow-testkit" % "0.4.0" % Test

Scala versions

The main branch is Scala 3 only. If you need Scala 2.13, use artefacts published from the legacy-213-track branch instead.

Verifying

Drop a minimum app into your project and run it:

import termflow.tui.*
import termflow.tui.Tui.*
import termflow.tui.TuiPrelude.*

object Hello:
  case class Model(message: String, input: Sub[Msg])

  enum Msg:
    case Quit

  object App extends TuiApp[Model, Msg]:
    def init(ctx: RuntimeCtx[Msg]): Tui[Model, Msg] =
      val keys = Sub.InputKey[Msg](_ => Msg.Quit, _ => Msg.Quit, ctx)
      Model("Hello, TermFlow!", keys).tui

    def update(m: Model, msg: Msg, ctx: RuntimeCtx[Msg]) = Tui(m, Cmd.Exit)
    def view(m: Model): RootNode =
      RootNode(40, 3, List(TextNode(2.x, 1.y, List(m.message.text))), input = None)
    def toMsg(input: PromptLine): Result[Msg] = Right(Msg.Quit)

  def main(args: Array[String]): Unit = TuiRuntime.run(App)

If it draws, you're ready. The Hello, World tutorial takes this skeleton and walks it line by line.

Hello, World

This tutorial walks through the smallest TermFlow app worth writing: a window that prints "Hello, TermFlow!" and exits when you press q or Ctrl-C.

By the end of it you'll have:

  • a runnable Scala 3 sbt project,
  • a single TuiApp definition,
  • enough vocabulary to follow the rest of the tutorials.

Reading time: ~10 minutes. Working code is at the bottom of the page if you want to skim first.

1. Set up a project

hello-termflow/
├─ build.sbt
└─ src/main/scala/HelloApp.scala

build.sbt:

ThisBuild / scalaVersion := "3.7.1"

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

If you've never used sbt before, install it via coursier (cs install sbt) or sdkman (sdk install sbt).

2. Anatomy of a TuiApp

Every TermFlow program implements a single trait, TuiApp[Model, Msg], with four methods:

MethodPurpose
init(ctx)Build the initial model and an optional startup Cmd.
update(m, msg, ctx)Apply a Msg to the model and return the next state.
view(m)Render the model as a virtual DOM (RootNode).
toMsg(input)Convert a submitted prompt line into a Msg.

Model is your application state (any type — typically a case class). Msg is the closed set of events your app reacts to (any sealed type — typically a Scala 3 enum).

Inside the runtime:

  1. init runs once at startup.
  2. update runs every time a Msg arrives.
  3. view runs after every update, producing a frame.

Three pure functions plus the runtime — that is the whole surface.

3. Decide on a model and messages

The model needs the message we want to display, plus a reference to the keyboard subscription (so it stays live for the lifetime of the app and can be cancelled cleanly on exit):

final case class Model(message: String, input: Sub[Msg])

For messages we need two — a key event and an exit signal:

enum Msg:
  case KeyPressed(key: KeyDecoder.InputKey)
  case Quit

4. Subscribe to keys in init

Subscriptions are the bridge from "things that happen outside update" (timers, keystrokes, async results) into your update function.

Sub.InputKey(...) constructs a keyboard subscription: every keypress is turned into a Msg and pushed onto the runtime's command bus. When you pass the RuntimeCtx as the third argument, the subscription auto-registers itself with the runtime — no Cmd.RegisterSub plumbing needed:

def init(ctx: RuntimeCtx[Msg]): Tui[Model, Msg] =
  val keys = Sub.InputKey[Msg](
    msg     = key => Msg.KeyPressed(key),
    onError = _   => Msg.Quit,
    ctx     = ctx
  )
  Model("Hello, TermFlow!", keys).tui

The .tui extension lifts a model into a Tui[Model, Msg] with no follow-up command — equivalent to Tui(model, Cmd.NoCmd).

5. Handle keys in update

Pattern-match on the inbound Msg to decide what to do:

def update(m: Model, msg: Msg, ctx: RuntimeCtx[Msg]): Tui[Model, Msg] =
  msg match
    case Msg.KeyPressed(KeyDecoder.InputKey.CharKey('q')) => Tui(m, Cmd.Exit)
    case Msg.KeyPressed(KeyDecoder.InputKey.Ctrl('C'))    => Tui(m, Cmd.Exit)
    case Msg.KeyPressed(_)                                => m.tui
    case Msg.Quit                                         => Tui(m, Cmd.Exit)

Cmd.Exit tells the runtime to break out of the loop, cancel subscriptions, restore the terminal, and return.

6. Draw something

view returns a RootNode — a width-by-height container holding child nodes. The simplest child is a TextNode placed at a coordinate. TermFlow's renderer diffs the new RootNode against the previous frame and emits the minimum ANSI sequence needed.

def view(m: Model): RootNode =
  RootNode(
    width    = 40,
    height   = 3,
    children = List(
      TextNode(2.x, 1.y, List(m.message.text))
    ),
    input    = None
  )

Notes:

  • 2.x and 1.y come from import termflow.tui.TuiPrelude.* — they are extension conversions to the opaque XCoord / YCoord types so you can't accidentally swap rows and columns. Coordinates are 1-based and frame-local.
  • m.message.text lifts the String into a Text(value, Style.empty) fragment. The text extension lives in TuiPrelude too.

7. Implement toMsg

TuiApp requires a toMsg even though our hello-world app never submits a prompt line. We can return Quit for everything:

def toMsg(input: PromptLine): Result[Msg] = Right(Msg.Quit)

Result[A] is Either[TermFlowError, A], defined in TuiPrelude.

8. Run it

Wire everything into a main:

def main(args: Array[String]): Unit =
  val _ = args
  TuiRuntime.run(App)

TuiRuntime.run switches the terminal into the alternate buffer, sets up the CmdBus, runs init, and starts the loop. On Cmd.Exit it restores the terminal and returns.

sbt run

You should see Hello, TermFlow! near the top-left of the window. Press q or Ctrl-C to exit.

If the terminal looks corrupted after exit, run reset — that's a sign the app crashed before Cmd.Exit could restore the terminal state. Wrapping TuiRuntime.run is rarely necessary; the runtime has a shutdown hook that handles SIGINT gracefully.

Full source

import termflow.tui.*
import termflow.tui.Tui.*           // brings the .tui extension into scope
import termflow.tui.TuiPrelude.*    // brings .x, .y, .text into scope

object HelloApp:

  final case class Model(message: String, input: Sub[Msg])

  enum Msg:
    case KeyPressed(key: KeyDecoder.InputKey)
    case Quit

  object App extends TuiApp[Model, Msg]:

    def init(ctx: RuntimeCtx[Msg]): Tui[Model, Msg] =
      val keys = Sub.InputKey[Msg](
        msg     = key => Msg.KeyPressed(key),
        onError = _   => Msg.Quit,
        ctx     = ctx
      )
      Model("Hello, TermFlow!", keys).tui

    def update(m: Model, msg: Msg, ctx: RuntimeCtx[Msg]): Tui[Model, Msg] =
      msg match
        case Msg.KeyPressed(KeyDecoder.InputKey.CharKey('q')) => Tui(m, Cmd.Exit)
        case Msg.KeyPressed(KeyDecoder.InputKey.Ctrl('C'))    => Tui(m, Cmd.Exit)
        case Msg.KeyPressed(_)                                => m.tui
        case Msg.Quit                                         => Tui(m, Cmd.Exit)

    def view(m: Model): RootNode =
      RootNode(
        width    = 40,
        height   = 3,
        children = List(
          TextNode(2.x, 1.y, List(m.message.text))
        ),
        input    = None
      )

    def toMsg(input: PromptLine): Result[Msg] = Right(Msg.Quit)

  def main(args: Array[String]): Unit =
    val _ = args
    TuiRuntime.run(App)

What's next?

You have now seen the whole TuiApp contract — init, update, view, toMsg. The next tutorial swaps the static message for a model that changes over time:

Counter tutorial

Counter

The Hello World tutorial showed the four TuiApp methods. This tutorial takes the next step: a model that changes over time in response to input. By the end you'll have a counter you can drive with keystrokes and with a typed prompt.

The full source lives at termflow.apps.counter.SyncCounter in the sample module — launch with sbt counterDemo if you want to see the finished version first.

What you'll build

A small panel showing:

┌──────────────────────────────────────┐
│ Current count: 3                     │
│                                      │
│ Commands:                            │
│   increment | + -> increase counter  │
│   decrement | - -> decrease counter  │
│   exit          -> quit              │
└──────────────────────────────────────┘
[]> _

Type increment (or +) at the prompt, hit Enter, the count goes up. decrement / - decreases. exit quits.

1. Model the domain

We want the count to live in its own type so the rest of the app can treat it as opaque. An opaque type alias is perfect:

opaque type Counter = Int

object Counter:
  def apply(count: Int): Counter = count

  extension (c: Counter)
    def count: Int                   = c
    def syncIncrement(): Counter     = Counter(c.count + 1)
    def syncDecrement(): Counter     = Counter(c.count - 1)

The application model wraps the counter together with the runtime bits we need to keep alive — the keyboard subscription, the prompt state, and the latest known terminal size:

final case class Model(
  terminalWidth: Int,
  terminalHeight: Int,
  counter: Counter,
  input: Sub[Msg],
  prompt: Prompt.State
)

Sub[Msg] and Prompt.State are stored on the model on purpose:

  • The Sub keeps the keyboard pump alive for the lifetime of the app and gives the runtime something to cancel on exit.
  • Prompt.State is the typed-but-not-yet-submitted buffer plus its cursor — every keystroke is funnelled through Prompt.handleKey and the new state is stored back on the model.

2. Define the message vocabulary

enum Msg:
  case Increment
  case Decrement
  case Exit
  case ConsoleInputKey(key: KeyDecoder.InputKey)
  case ConsoleInputError(error: Throwable)

Two interesting things compared to Hello World:

  • Domain messages and infra messages live in the same enum. Increment / Decrement / Exit are what your domain cares about; ConsoleInputKey is a low-level key event the runtime delivers. Keeping both in the same Msg keeps update total — the compiler enforces that every event has a handler.
  • Errors get a Msg too. Sub.InputKey accepts an onError callback; mapping errors into a regular Msg means update decides what to do (here: silently ignore).

3. Wire up init

override def init(ctx: RuntimeCtx[Msg]): Tui[Model, Msg] =
  Model(
    terminalWidth  = ctx.terminal.width,
    terminalHeight = ctx.terminal.height,
    counter        = Counter(0),
    input          = Sub.InputKey(
                       msg     = ConsoleInputKey.apply,
                       onError = ConsoleInputError.apply,
                       ctx     = ctx
                     ),
    prompt         = Prompt.State()
  ).tui

Sub.InputKey constructor signatures take msg: KeyDecoder.InputKey => Msg and onError: Throwable => Msg. We can pass ConsoleInputKey.apply and ConsoleInputError.apply directly — Scala 3 happily eta-expands the case-class constructors into functions of the right shape.

The third argument, ctx, is the RuntimeCtx[Msg] — by passing it here, the subscription auto-registers with the runtime, so we don't need a Cmd.RegisterSub.

4. The shape of update

update is the only place state changes. With domain plus infra messages it becomes a five-arm pattern match:

override def update(m: Model, msg: Msg, ctx: RuntimeCtx[Msg]): Tui[Model, Msg] =
  val sized = syncTerminalSize(m, ctx)
  msg match
    case Increment =>
      sized.copy(counter = sized.counter.syncIncrement()).tui
    case Decrement =>
      sized.copy(counter = sized.counter.syncDecrement()).tui
    case Exit =>
      Tui(sized, Cmd.Exit)
    case ConsoleInputKey(k) =>
      val (nextPrompt, maybeCmd) = Prompt.handleKey[Msg](sized.prompt, k)(toMsg)
      maybeCmd match
        case Some(cmd) => Tui(sized.copy(prompt = nextPrompt), cmd)
        case None      => sized.copy(prompt = nextPrompt).tui
    case ConsoleInputError(_) => sized.tui

Walk through it:

  • syncTerminalSize is a tiny helper that re-reads the terminal size from ctx.terminal and updates the model if it changed. We do this on every message, not just resize events — the cost is negligible and it catches the "terminal resized while no key was pressed" case.
  • Increment / Decrement call into Counter and use the .tui extension to lift the new model into a Tui[Model, Msg] with no follow-up command.
  • Exit pairs the model with Cmd.Exit, which tells the runtime to break out of the loop.
  • ConsoleInputKey(k) delegates to Prompt.handleKey. The Prompt widget owns the cursor, history, paste, and everything else a real text input needs. It returns the next state plus an optional Cmd produced when the user pressed Enter — that's where toMsg runs.

5. Prompts and toMsg

Prompt.handleKey calls toMsg(input: PromptLine) whenever the user presses Enter. The Prompt-widget API is "give me a function from a submitted line to either an error or a Msg":

override def toMsg(input: PromptLine): Result[Msg] =
  Try {
    input.value.trim.toLowerCase match
      case "increment" | "+" => Increment
      case "decrement" | "-" => Decrement
      case "exit"            => Exit
  }.toEither.left.map(e => TermFlowError.Unexpected(e.getMessage))

Result[A] is Either[TermFlowError, A]. The Try { … }.toEither form turns MatchError into a Left so a typo in the prompt surfaces a validation message instead of crashing the runtime.

If you want richer parsing — multiple commands, arguments, history — the Cookbook covers that. For a counter, three branches and a fall-through is plenty.

6. Drawing the panel with Layout.Column

Hand-positioning every TextNode doesn't scale beyond a few rows. Layout lets you describe a vertical (or horizontal) stack and resolve it to a list of children at a given coordinate:

val textColumn = Layout.Column(
  gap = 0,
  children = List(
    Layout.Elem(
      TextNode(
        1.x, 1.y,
        List(
          "Current count: ".text,
          Text(s"${m.counter.count}", renderCount(m.counter.count))
        )
      )
    ),
    Layout.Spacer(1, 1),
    Layout.Elem(TextNode(1.x, 1.y, List("Commands:".text(fg = Yellow)))),
    Layout.Elem(TextNode(1.x, 1.y, List("  increment | + -> increase counter".text))),
    Layout.Elem(TextNode(1.x, 1.y, List("  decrement | - -> decrease counter".text))),
    Layout.Elem(TextNode(1.x, 1.y, List("  exit          -> quit".text)))
  )
)
val textChildren = textColumn.resolve(Coord(2.x, 2.y))

Notes:

  • Layout.Elem wraps a VNode so it lives in the layout tree.
  • Layout.Spacer(1, 1) is a one-row vertical gap.
  • The (1.x, 1.y) coordinates inside each TextNode are layout-local — the resolve(Coord(2.x, 2.y)) call adds the column's origin so everything ends up in the right absolute spot.
  • Style your text using the .text extension ("foo".text(fg = Yellow)) or build a Text(string, style) directly when you need different styles in one row.

7. Putting view together

override def view(m: Model): RootNode =
  val prefix         = "[]> "
  val renderedPrompt = Prompt.renderWithPrefix(m.prompt, prefix)
  val boxWidth       = math.max(2, m.terminalWidth - 4)

  // … textColumn defined above …
  val textChildren = textColumn.resolve(Coord(2.x, 2.y))

  val border = BoxNode(
    1.x, 1.y, boxWidth, 6,
    children = Nil,
    style    = Style(border = true, fg = Blue)
  )

  RootNode(
    m.terminalWidth,
    13,
    children = border :: textChildren,
    input = Some(
      InputNode(
        2.x, 10.y,
        renderedPrompt.text,
        Style(fg = Green),
        cursor       = renderedPrompt.cursorIndex,
        prefixLength = renderedPrompt.prefixLength
      )
    )
  )

A few things to call out:

  • BoxNode is visual-only. It draws a border but doesn't lay out its children. We keep it as a sibling of the resolved column rather than wrapping it.
  • Box width tracks the terminal. boxWidth = terminalWidth - 4 plus the resize sync from update means the panel grows and shrinks with the window.
  • InputNode is the prompt. It carries the rendered prefix + buffer text, the cursor index, and the prefix length so AnsiRenderer can position the hardware cursor correctly. Prompt.renderWithPrefix returns all three in one shot.
  • renderCount(c) is a tiny styling helper that picks a colour based on sign — black for zero, red for negative, green for positive. Live styling like that is one of the wins of having view be a pure function of the model.

8. Wiring it up

Same as Hello World:

def main(args: Array[String]): Unit =
  val _ = args
  TuiRuntime.run(App)

Run it:

sbt counterDemo

You can also drive the same logic from update keystrokes if you'd rather not type commands. That extension is left as an exercise — the cookbook covers it.

What you've learned

  • State that changes over time. update is a pattern match on Msg returning the next model.
  • Layout DSL. Layout.Column + resolve(at) beats hand-positioning every node.
  • Prompts. Prompt.handleKey turns key events into either a new buffer or a Submit-time Cmd driven by toMsg.
  • Adaptive sizing. syncTerminalSize keeps view honest when the user resizes their window.

Next up: Async work — replacing the synchronous counter with a Cmd.FCmd that does work on a background thread, plus a spinner driven by Sub.Every.

Async work

The Counter tutorial built a synchronous counter — Increment ran inline on the runtime thread. Real apps rarely have that luxury: HTTP calls, LLM streams, file I/O, and database queries all need to happen off the event loop while the UI keeps drawing.

This tutorial swaps the synchronous counter for an asynchronous one and shows three patterns:

  1. Cmd.FCmd — bridge a Future into the runtime so its result shows up as a Msg.
  2. Sub.Every — fire a recurring Msg on a fixed cadence, which we'll use to animate a spinner while async work is in flight.
  3. Subscription lifecycle — start a sub when work begins, cancel it when work ends.

Source: termflow.apps.counter.FutureCounter. Run with sbt futureDemo.

What you'll build

┌──────────────────────────────────────┐
│ Current count: 2                     │
│ incrementing::14:22:07 /             │
│                                      │
│ Commands:                            │
│   increment | + -> increase counter  │
│   decrement | - -> decrease counter  │
│   exit          -> quit              │
└──────────────────────────────────────┘
[]> _

The / rotates through | / - \ while the async work runs (~5 seconds for increment, ~10 for decrement). Once the work completes, the spinner disappears and the count updates.

1. Two Counter shapes

The domain stays minimal — same Counter opaque type, with async variants alongside the sync ones:

opaque type Counter = Int

object Counter:
  def apply(count: Int): Counter = count

  extension (c: Counter)
    def count: Int = c

    def asyncIncrement()(using ec: ExecutionContext): Future[Counter] =
      Future:
        Thread.sleep(5000)
        Counter(c.count + 1)

    def asyncDecrement()(using ec: ExecutionContext): Future[Counter] =
      Future:
        Thread.sleep(10000)
        Counter(c.count - 1)

Future is a stand-in for "any async work". In a real app it would be an HTTP call or a database round-trip, not Thread.sleep.

2. A richer model

The model carries everything Counter had, plus enough state to drive a spinner:

final case class Model(
  terminalWidth: Int,
  terminalHeight: Int,
  count: Counter,
  status: String,         // human-readable status line
  input: Sub[Msg],        // keyboard subscription
  prompt: Prompt.State,
  spinner: Sub[Msg],      // timer subscription — Sub.NoSub when idle
  spinnerIndex: Int       // current frame of the spinner animation
)

Two new fields worth highlighting:

  • status: String is the line under the count. We'll write "incrementing::14:22:07" while work is in flight, "done::14:22:12" when it completes. Useful for both humans and for asserting in tests.
  • spinner: Sub[Msg] is the live timer subscription. When idle it's Sub.NoSub — a no-op sub. When async work starts we replace it with Sub.Every(200ms, …). When the work finishes we call spinner.cancel().

3. Five new messages

enum Msg:
  case Increment
  case Decrement
  case Exit
  case UpdateWith(counter: Counter)
  case Busy(action: String)
  case SpinnerTick
  case ConsoleInputKey(key: KeyDecoder.InputKey)
  case ConsoleInputError(error: Throwable)

Compared to Counter:

  • Increment / Decrement no longer carry the state change inline — they just kick off the async work.
  • UpdateWith(c) is the message produced when the Future completes with a new counter value.
  • Busy(action) is dispatched the moment work is enqueued so the UI can flip into "working" mode immediately, not after the future completes.
  • SpinnerTick is the Msg the timer fires on every tick.

The split between Increment / Busy / UpdateWith is the bracketing pattern that comes up over and over in async TUIs:

Increment ─▶ FCmd kicks off Future + dispatches Busy
                                          │
                                          ▼
                                    Busy ─▶ start spinner
                                              │
                                              ▼
                                       SpinnerTick × N
                                              │
                                              ▼
                                  UpdateWith ─▶ stop spinner

4. Cmd.FCmd — bridging Future into the runtime

Inside update, the Increment arm returns this:

case Increment =>
  Tui(
    sized,
    Cmd.FCmd(
      sized.count.asyncIncrement(),
      (c: Counter) => Cmd.GCmd(UpdateWith(c)),
      onEnqueue = Some(Busy(s"incrementing::${TimeFormatter.getCurrentTime}"))
    )
  )

Cmd.FCmd takes three things:

ParameterWhat
taskThe Future[A] you want the runtime to await.
toCmdA function from the future's result to the next Cmd.
onEnqueueAn optional Msg to dispatch immediately when the FCmd is enqueued — before the future completes.

What happens at runtime:

  1. update returns the Tui(model, FCmd(...)).
  2. The runtime sees the FCmd, registers a callback on the future, and if onEnqueue was supplied, immediately dispatches that Msg.
  3. When the future completes (after ~5 seconds here), toCmd(result) runs and the resulting Cmd (here Cmd.GCmd(UpdateWith(c))) is enqueued.

The result: Busy("incrementing::...") arrives within microseconds of pressing Enter, and UpdateWith(newCount) arrives ~5 seconds later.

If your future already returns Result[A] (i.e. it can fail with a TermFlowError), reach for Cmd.asyncResult(task, onSuccess, onError, onEnqueue) instead — it folds the Either for you so the call site stays one expression. Future-level exceptions still surface as a TermFlowErrorCmd overlay automatically. See the app-layer guide for the full signature.

5. Starting a Sub.Every

The Busy handler decides whether the spinner needs to start:

case Busy(action) =>
  if sized.spinner.isActive then
    sized.copy(status = action).tui
  else
    sized.copy(
      status  = action,
      spinner = Sub.Every(200, () => SpinnerTick, ctx)
    ).tui

Sub.Every(millis, () => Msg, ctx) schedules a single-thread executor that publishes Cmd.GCmd(SpinnerTick) to the bus every 200 ms. The thunk re-evaluates each tick — useful when the message itself is time-stamped or counter-keyed, although here SpinnerTick is constant.

Like Sub.InputKey, passing ctx auto-registers the sub for cleanup when the runtime exits — you don't have to remember to cancel it on Exit (though we will, defensively).

The spinner.isActive guard matters. If the user fires Increment twice in quick succession, two Busy messages arrive. Without the guard you'd start two timers and leak the first one.

6. Animating with SpinnerTick

Each tick advances the frame index modulo the frame count:

case SpinnerTick =>
  sized.copy(spinnerIndex = (sized.spinnerIndex + 1) % 4).tui

And in view:

private def statusWithSpinner(m: Model): String =
  val frames = Array("|", "/", "-", "\\")
  if m.spinner.isActive then s"${m.status} ${frames(m.spinnerIndex % frames.length)}"
  else m.status

The m.spinner.isActive check means the spinner glyph disappears the instant we cancel the sub.

7. Stopping the spinner — Sub.cancel

When the future completes, UpdateWith runs:

case UpdateWith(c) =>
  if sized.spinner.isActive then sized.spinner.cancel()
  sized.copy(
    count        = c,
    status       = s"done::${TimeFormatter.getCurrentTime}",
    spinner      = Sub.NoSub,
    spinnerIndex = 0
  ).tui

Two things happen here:

  • spinner.cancel() stops the executor. It's idempotent — calling it on an already-cancelled sub or on Sub.NoSub is safe.
  • spinner = Sub.NoSub clears the model field so subsequent ticks (if any are in flight) become no-ops.

We also defensively cancel on Exit:

case Exit =>
  if sized.spinner.isActive then sized.spinner.cancel()
  Tui(sized, Cmd.Exit)

Strictly speaking the runtime would clean it up because ctx auto-registered the sub, but explicit cancellation is a safe habit — especially if you have multiple subs and want predictable shutdown order.

8. Drawing the status line

The view is barely changed from Counter — we just slot statusWithSpinner(m) into a new row:

TextNode(
  2.x, 3.y,
  List(Text(statusWithSpinner(m), Style(fg = Green)))
)

One subtle point: the spinner advances at 5 frames per second (200 ms = 5 fps). That's deliberate — slow enough to be readable, fast enough to feel alive. If you push it down to 50 ms you'll notice the ANSI repaint flicker; if you push it up to 1 s it stops feeling responsive.

9. Run it

sbt futureDemo

Type increment, hit Enter, and watch the / rotate for ~5 seconds before the count updates. Try decrement next — that one waits ~10 seconds. Both can be queued back-to-back; the FCmd machinery handles that correctly.

What you've learned

  • Cmd.FCmd is the bridge from Future[A] to the runtime. It takes the future, a result mapper to a follow-up Cmd, and an optional immediate Msg to dispatch at enqueue time.
  • Sub.Every is the timer subscription. Auto-registers when given a RuntimeCtx; cancellable explicitly via .cancel().
  • Sub fields on the model keep async lifecycles addressable — always know which subs are live, store them so you can cancel them.
  • Busy / UpdateWith bracketing is the standard pattern for signalling "work in progress" before the work itself completes.

Next up: Forms and dialogs — the Wizard sample with multi-step focus management, validation, and modal dialogs.

Forms and dialogs

The first three tutorials covered the runtime, state transitions, and async work. The hard part of real TUI apps is everything that wraps those primitives: focus management, validation, multi-step navigation, and modal dialogs.

This tutorial walks through the wizard sample — a three-step account-creation flow with per-step focus, per-field validation, a radio-group, and a final review screen. Source: termflow.apps.wizard.WizardApp. Run with sbt wizardDemo.

What you'll build

TermFlow Wizard
●  Account  ─  ○  Plan  ─  ○  Confirm

 Tab focus   Enter activate   ↑/↓ (Plan)   q quit

Name:    [Alice                ]
Email:   [alice@example.com    ]
                                      [ Next → ]

Three screens — Account, Plan, Confirm — connected by Next/Back buttons. Tab cycles focus inside the current step; Enter activates the focused control; arrow keys move the radio selection on the Plan step. Validation errors appear inline next to fields when you try to advance past Account with empty inputs.

1. The step state machine

A wizard is a state machine over its steps. Model the steps as an enum and store the current one on the model:

enum Step:
  case Account, Plan, Confirm

val stepOrder: Vector[Step] = Vector(Step.Account, Step.Plan, Step.Confirm)

stepOrder is the canonical ordering — used both for the progress indicator at the top and for next/back navigation. Don't compare ordinals directly; query stepOrder.indexOf(step) so that adding a new step in the middle is a one-line change.

2. Focus IDs per step

Each focusable control needs a FocusId. Group them by step so the focus order on each screen is local:

// Account step
val NameId         = FocusId("wiz-name")
val EmailId        = FocusId("wiz-email")
val NextAccountId  = FocusId("wiz-account-next")
val accountFocusOrder = Vector(NameId, EmailId, NextAccountId)

// Plan step
val PlanRadioId  = FocusId("wiz-plan")
val BackPlanId   = FocusId("wiz-plan-back")
val NextPlanId   = FocusId("wiz-plan-next")
val planFocusOrder = Vector(PlanRadioId, BackPlanId, NextPlanId)

// Confirm step
val BackConfirmId  = FocusId("wiz-confirm-back")
val SubmitId       = FocusId("wiz-submit")
val confirmFocusOrder = Vector(BackConfirmId, SubmitId)

Two design choices worth noting:

  • FocusId is opaque over String. You can't accidentally pass an arbitrary string where a FocusId is wanted, but two FocusIds built from the same string are equal — useful for testing.
  • String prefixes scope IDs. wiz- keeps the wizard's IDs from colliding with other parts of the app when this gets embedded as a sub-component (which is exactly what the showcase tab does).

3. A FocusManager per step

The model carries one FocusManager per step:

final case class Model(
  step:       Step,
  name:       widgets.TextField.State,
  email:      widgets.TextField.State,
  planIndex:  Int,
  submitted:  Boolean,
  focus:      Map[Step, FocusManager],
  pendingKey: Option[KeyDecoder.InputKey],
  errors:     Map[String, String]
):
  def currentFocus: FocusManager = focus(step)

Why one per step instead of one global manager?

  • Stable per-screen focus. When the user steps from Plan back to Account, focus returns to whichever field they last touched, not to the screen-zero default.
  • Clean tab cycle. Tab on the Plan screen mustn't loop through Account fields. Per-step orders make this fall out naturally.

The currentFocus helper just looks up the manager for the active step. Every focus-related operation in update calls it.

4. Pure update via step(model, msg)

This sample factors update into a pure step(m, msg): Model function that the embeddable showcase reuses:

def step(m: Model, msg: Msg): Model =
  msg match
    case NextStep    => /* validate, advance */
    case PrevStep    => /* go back */
    case NextFocus   => m.copy(focus = m.focus.updated(m.step, m.currentFocus.next))
    case PrevFocus   => m.copy(focus = m.focus.updated(m.step, m.currentFocus.previous))
    case Activate    => stepActivate(m)
    case PlanUp      => m.copy(planIndex = math.max(0, m.planIndex - 1))
    case PlanDown    => m.copy(planIndex = math.min(planOptions.size - 1, m.planIndex + 1))
    case Submitted   => m.copy(submitted = true)
    case Key(k)      => stepKey(m, k)
    case _           => m

Then the runtime update becomes a thin shim that delegates to step for everything except quit:

override def update(m: Model, msg: Msg, ctx: RuntimeCtx[Msg]): Tui[Model, Msg] =
  msg match
    case Quit        => Tui(m, Cmd.Exit)
    case Key(k)      => /* check quit-on-q-when-not-in-text-field, else delegate */
    case _           => step(m, msg).tui

This factoring is the embeddable wizard pattern: the model, the messages, and the pure transition function are all reusable; only the runtime glue ties to Cmd.Exit. The wizard ships as a tab inside the showcase by reusing step directly.

5. Per-step validation

Validation is a pure function from Model to a Map[String, String] keyed by FocusId.value:

def validateAccount(m: Model): Map[String, String] =
  val builder = Map.newBuilder[String, String]
  if m.name.buffer.trim.isEmpty then
    builder += (NameId.value -> "Name is required")
  val emailRaw = m.email.buffer.trim
  if emailRaw.isEmpty then
    builder += (EmailId.value -> "Email is required")
  else if !emailRaw.contains("@") then
    builder += (EmailId.value -> "Email must contain '@'")
  builder.result()

The keying-by-FocusId.value matters because widgets.Form.column takes the same map shape — error text is drawn next to the field with that id. No extra wiring.

NextStep consumes the result:

case NextStep =>
  m.step match
    case Step.Account =>
      val errs = validateAccount(m)
      if errs.nonEmpty then m.copy(errors = errs)
      else m.copy(step = Step.Plan, errors = Map.empty)
    case Step.Plan    => m.copy(step = Step.Confirm, errors = Map.empty)
    case Step.Confirm => m

The pattern is validate-on-advance: errors don't appear while typing, only when the user tries to move forward with bad data. Less noisy, gives the user space to think.

6. Focus dispatch

The single most important pattern in this sample:

private def stepKeyForStep(m: Model, k: KeyDecoder.InputKey): Model =
  m.step match
    case Step.Account =>
      m.currentFocus.current match
        case Some(id) if id == NameId =>
          val (next, _) = widgets.TextField.handleKey[Msg](m.name, k)(_ => None)
          m.copy(name = next)

        case Some(id) if id == EmailId =>
          val (next, _) = widgets.TextField.handleKey[Msg](m.email, k)(_ => None)
          m.copy(email = next)

        case Some(id) if id == NextAccountId =>
          k match
            case Enter | CharKey(' ') => step(m, NextStep)
            case ArrowLeft            => step(m, PrevFocus)
            case _                    => m

        case _ => m
    /* … other steps … */

A keystroke is dispatched by current focus:

  • If a TextField is focused, the key feeds through TextField.handleKey and updates the corresponding field.
  • If a button is focused, the key activates it (Enter/Space) or navigates focus (arrow keys).

Two implementation details that matter:

  • TextField.handleKey returns (nextState, Option[Msg]) — the same shape as Prompt.handleKey. The widget owns its cursor and buffer. Mapping the optional Msg through _ => None says "this TextField never produces a Submit message" — pressing Enter inside the field is just a literal character.
  • Top-level Tab dispatch is in stepKey, not here:
    k match
      case Tab     => step(m, NextFocus)
      case BackTab => step(m, PrevFocus)
      case _       => stepKeyForStep(m, k)
    
    Tab is always focus navigation, regardless of which control owns focus. It's tempting to put Tab handling inside each focus arm; one central handler keeps it consistent.

7. Quit semantics — quit-when-not-in-text-field

private def stepKey(m: Model, k: KeyDecoder.InputKey): Model =
  val isQuitKey = k match
    case CharKey('q') | CharKey('Q') | Escape => !m.isFocusedTextField
    case _                                    => false
  if isQuitKey then m
  else /* … */

q quits — except when the user is typing into a TextField, where it's just the letter q. The isFocusedTextField helper checks the current focus:

def isFocusedTextField: Boolean =
  step == Step.Account && (currentFocus.isFocused(NameId) || currentFocus.isFocused(EmailId))

This is one of those small details that makes a TUI feel professional. Without it, typing quentin@example.com into the Email field would quit the wizard halfway through.

8. Building forms with widgets.Form.column

The Account screen is rendered with the Form.column helper:

private def accountStep(m: Model)(using theme: Theme): List[VNode] =
  val rows = Vector(
    widgets.Form.Row(
      NameId,
      "Name:",
      focused => widgets.TextField.view(m.name, lineWidth = 28, focused = focused)
    ),
    widgets.Form.Row(
      EmailId,
      "Email:",
      focused => widgets.TextField.view(m.email, lineWidth = 28, focused = focused)
    ),
    widgets.Form.Row(
      NextAccountId,
      "",
      focused => widgets.Button(label = "Next →", focused = focused),
      height = 1
    )
  )
  widgets.Form.column(
    rows         = rows,
    focusManager = m.currentFocus,
    at           = Coord(2.x, 6.y),
    labelWidth   = 8,
    gap          = 1,
    errors       = m.errors
  )

Each Form.Row is (focusId, label, focused => VNode). The focused => VNode closure is a small but powerful trick — the row doesn't know whether it owns focus until Form.column resolves it against the focus manager. That keeps focus state out of every widget's constructor.

Form.column then:

  • Looks up focusManager.isFocused(row.focusId) for each row.
  • Calls row.viewFor(focused) to get the actual widget.
  • Stacks rows vertically with gap between them.
  • For each errors.get(row.focusId.value), draws the error in red beneath the row.

9. The Plan step — RadioGroup

val radio = widgets.RadioGroup(
  options       = planOptions,
  selectedIndex = m.planIndex,
  focusedIndex  = m.planIndex,
  at            = Coord(4.x, 8.y)
)

RadioGroup is a stateless renderer — you pass the option list, the selected index, and the focused index. State updates happen in update:

case Some(id) if id == PlanRadioId =>
  k match
    case ArrowUp   => step(m, PlanUp)
    case ArrowDown => step(m, PlanDown)
    case Enter | CharKey(' ') =>
      // Commit the selection and skip the Back button —
      // the user is advancing, not retreating.
      m.copy(focus = m.focus.updated(m.step, m.currentFocus.focus(NextPlanId)))
    case _ => m

The focus(NextPlanId) call explicitly sets focus to a specific id — equivalent to "Tab past Back, land on Next". This skips the Back button in the focus order because the user just committed to advance. A small touch that turns radio + button into a smooth one-keystroke flow.

10. The Confirm step — read-only summary + Submit

private def confirmStep(m: Model)(using theme: Theme): List[VNode] =
  val title    = TextNode(2.x, 6.y, List("Review:".themed(_.primary)))
  val summary  = m.summary.zipWithIndex.map { case (line, i) =>
    TextNode(4.x, (8 + i).y, List(line.text))
  }
  val backBtn  = widgets.Button(label = "← Back", focused = m.currentFocus.isFocused(BackConfirmId))
  val submit   = widgets.Button(label = "Submit", focused = m.currentFocus.isFocused(SubmitId))
  /* … plus a "✓ Submitted!" line if m.submitted … */

Notes:

  • m.summary is a tiny pure helper on Model that returns the three review lines as List[String]. Keeping it on the model makes it trivially testable.
  • Layout.translate(node, dx, dy) offsets a sub-node — a quick way to position two buttons side-by-side on the same row.
  • The submitted state is just a flag. Once Submit runs, the model's submitted = true and view adds a confirmation line. We don't navigate away — the user reads "Submitted!" then presses q to quit.

11. Step indicator at the top

private def renderStepIndicator(m: Model)(using theme: Theme): List[Text] =
  stepOrder.zipWithIndex.flatMap { case (s, i) =>
    val visited = i <= m.stepIndex
    val current = i == m.stepIndex
    val glyph   = if visited then "●" else "○"
    val style   =
      if current then Style(fg = theme.primary, bold = true)
      else if visited then Style(fg = theme.success)
      else Style(fg = theme.foreground)
    val sep = if i == stepOrder.size - 1 then "" else "  ─  "
    List(Text(s"$glyph ", style), Text(stepLabel(s), style), Text(sep, Style(fg = theme.border)))
  }.toList

A trio of glyph / label / separator per step, joined into a single List[Text] that goes inside one TextNode. The progress shape — filled-and-bold for the current step, filled-and-coloured for visited, empty for upcoming — is the kind of detail you can swap to your taste without touching the logic.

What you've learned

  • FocusManager per screen keeps tab cycles local and lets each screen restore its own focus when revisited.
  • Focus dispatch in update is the central pattern — the focused widget id determines how a keystroke is interpreted.
  • widgets.Form.column turns rows of (focusId, label, viewFor) into a styled, focus-aware, error-decorated form.
  • Validate-on-advance, not on every keystroke. Map FocusId.value to error strings; pass the same map to Form.column.
  • Quit-when-not-in-text-field is what makes q feel right.
  • Pure step(model, msg) factors the wizard so it can be embedded somewhere else (the showcase) without dragging the runtime in.

Beyond the tutorial

The wizard is just the start of what widgets.Form and the dialog helpers can do. Keep going:

  • Modal dialogs. Dialogs.confirm / Dialogs.textInput / Dialogs.listSelect / Dialogs.fileDialog — see the showcase app's Dialogs tab for every variant.
  • Dialogs.actionList(title, actions, position) — a vertical menu of Choice items with mouse hit-testing.
  • Keymaps. Replace ad-hoc pattern matches with Keymap.focus bindings — see the Keymap guide (stub for now).
  • Multi-line input. widgets.MultiLineInput for full editor embedding — see the showcase's Editor tab.

That wraps the four tutorials. Heading back to the user guide index is a good next step, or jump straight into the Widgets guide for the full component catalogue.

Terminal layer

termflow-terminal is the lowest layer — direct access to the TTY, key decoding, capability detection, Unicode width and grapheme math. Most apps never need to touch this module directly: the app layer takes care of opening the terminal, registering input subscriptions, and switching to the alternate buffer. You reach for the terminal layer when those abstractions are in the way.

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

When to use it directly

  • Building a CLI utility that needs raw key reads but no full-screen rendering.
  • Detecting whether the user's terminal supports true colour, mouse, bracketed paste before deciding which features to enable.
  • Implementing a custom backend (telnet server, browser bridge) that feeds an upper-layer app.
  • Writing tests for grapheme- or width-sensitive code without spinning up the runtime.

TerminalBackend

TerminalBackend is the trait every terminal implementation satisfies. It bundles a Reader / Writer pair, current size, ANSI emission, and capability detection.

trait TerminalBackend extends TerminalInfo:
  def reader: Reader
  def writer: Writer
  def width: Int
  def height: Int
  def write(text: String): Unit
  def flush(): Unit
  def close(): Unit
  def capabilities: Capabilities
  def onResize(listener: () => Unit): Option[() => Unit]

The default implementation is JLineTerminalBackend, backed by JLine. It honours SIGWINCH, so onResize fires the moment the user resizes their window — no polling.

File: modules/termflow-terminal/src/main/scala/termflow/tui/TerminalBackend.scala.

KeyDecoder.InputKey

The decoded keystroke ADT. Pattern-match on it to react to user input:

import termflow.tui.KeyDecoder.InputKey
import termflow.tui.KeyDecoder.InputKey.*

key match
  case CharKey(c)             => /* printable */
  case Ctrl(c)                => /* control modifier */
  case Enter | Tab | Escape   => /* named keys */
  case ArrowUp | ArrowDown    => /* navigation */
  case Modified(inner, mods)  => /* shift/alt/ctrl/meta combinations */
  case Mouse(event)           => /* SGR-1006 mouse events */
  case Paste(text)            => /* bracketed-paste payload */
  case _                      => /* F1–F12, BackTab, Insert, Home, End, PageUp/Down, NoOp, EndOfInput, Unknown */

Modifiers(shift, alt, ctrl, meta) decodes xterm's modifier byte; use Modifiers.fromXtermCode(code) if you're decoding raw CSI parameters.

File: modules/termflow-terminal/src/main/scala/termflow/tui/KeyDecoder.scala.

Capabilities

A small struct describing what the terminal can do, with conservative defaults so apps that ignore it still work:

final case class Capabilities(
  colorDepth:     ColorDepth, // Mono | Ansi8 | Ansi16 | Indexed256 | Truecolor
  unicode:        Boolean,
  mouse:          Boolean,
  extendedStyles: Boolean = true,    // italic / dim / strike / blink
  bracketedPaste: Boolean = true,
  notifications:  NotificationKind = NotificationKind.BellOnly
)

object Capabilities:
  def detect(env: Map[String, String]): Capabilities

notifications controls how Cmd.RequestAttention and Cmd.Notify reach the user — values: Disabled, BellOnly, ITerm2, Kitty, Vte. The runtime resolves the right OSC sequence per kind; see the notifications cookbook.

Capabilities.detect(sys.env) honours NO_COLOR, COLORTERM, TERM, and LANG / LC_*. The AnsiRenderer calls this for you and downgrades colour emission accordingly — true-colour Rgb(...) styles become indexed-256 or 16-colour ANSI as needed.

ColorDepth is monotonic: td.supports(other) returns true when td's depth is at least other's. Use it to gate features:

if caps.colorDepth.supports(ColorDepth.Truecolor) then highColourTheme
else                                                  ansi8FallbackTheme

File: modules/termflow-terminal/src/main/scala/termflow/tui/Capabilities.scala.

Mouse events

Mouse events are multiplexed onto the keystroke stream as InputKey.Mouse(event), so you handle them in the same Sub.InputKey handler as everything else.

enum MouseEvent:
  case Press(button: MouseButton, col: Int, row: Int, modifiers: Modifiers)
  case Release(button: MouseButton, col: Int, row: Int, modifiers: Modifiers)
  case Drag(button: MouseButton, col: Int, row: Int, modifiers: Modifiers)
  case Move(col: Int, row: Int, modifiers: Modifiers)
  case Scroll(direction: ScrollDirection, col: Int, row: Int, modifiers: Modifiers)

MouseButton is Left | Middle | Right | Other(code). ScrollDirection is Up | Down | Left | Right.

For the standard click-and-drag flow, see the screen layer's HitTest cache and Layout.Zone — together they map a coordinate to a logical zone id without you re-deriving rectangles.

File: modules/termflow-terminal/src/main/scala/termflow/tui/Mouse.scala.

WCWidth — column-width arithmetic

Some characters take up two columns (CJK ideographs, most emoji), some take zero (combining marks), some are control codes. Naïve String.length lies about the visual width.

import termflow.tui.WCWidth

WCWidth.codePointWidth('A'.toInt)  // 1
WCWidth.codePointWidth('好'.toInt) // 2
WCWidth.charWidth('́')        // 0 (combining acute)
WCWidth.stringWidth("こんにちは")   // 10 (5 wide chars × 2)

The renderer uses these for layout, cursor placement, and diff emission. Prompt cursor math (Prompt.cursorColumn) is one place this shows up — typing advances the cursor by two columns, not one.

File: modules/termflow-terminal/src/main/scala/termflow/tui/WCWidth.scala.

Grapheme — UAX #29 cluster boundaries

Backspace shouldn't delete half of a character. é written as e + U+0301 is two code points but one grapheme — pressing Backspace once should remove both. Grapheme wraps java.text.BreakIterator to give you the right boundaries:

import termflow.tui.Grapheme

Grapheme.previousBoundary("café", 4)  // 3   — back across "é"
Grapheme.nextBoundary("café", 0)      // 1
Grapheme.count("👋🏽")                // 1   (skin-tone modifier)

Used by Prompt.handleKey (Backspace, Delete, Arrow-left/right) and MultiLineInput. If you're building your own text widget, route your navigation through these.

File: modules/termflow-terminal/src/main/scala/termflow/tui/Grapheme.scala.

A working example: capability sniffer

A standalone CLI that prints what your terminal can do:

import termflow.tui.{Capabilities, JLineTerminalBackend}

@main def termcaps(): Unit =
  val backend = new JLineTerminalBackend()
  val caps    = backend.capabilities
  println(s"size:           ${backend.width} × ${backend.height}")
  println(s"colour depth:   ${caps.colorDepth}")
  println(s"unicode:        ${caps.unicode}")
  println(s"mouse:          ${caps.mouse}")
  println(s"extended styles:${caps.extendedStyles}")
  println(s"bracketed paste:${caps.bracketedPaste}")
  backend.close()

This is the kind of utility that doesn't need the runtime, doesn't need the alt buffer, just wants to look at the TTY for a moment. The terminal layer alone is enough.

Reaching higher

When you need a draw surface and diff rendering, the Screen layer guide is next. When you need an event loop, model + update + view, focus management, dialogs — that's the Application layer guide.

The full per-type API is in the Scaladoc.

Screen layer

termflow-screen sits one layer above the TTY. It gives you:

  • a virtual DOM (VNode ADT — TextNode, BoxNode, InputNode, RootNode),
  • a layout DSL for stacking, gaps, fills, and zones,
  • an ANSI renderer that diff-paints frames into minimal terminal patches,
  • a hit-test cache for routing mouse events to logical zones,
  • a theme model with semantic colour slots and box-drawing chars.
libraryDependencies += "org.llm4s" %% "termflow-screen" % "0.4.0"

You reach for the screen layer directly when you want a draw surface and rendering, but not the Elm-style runtime — for example, a one-shot report renderer that emits ANSI to stdout and exits.

VNode — the virtual DOM

enum VNode:
  case TextNode(x: XCoord, y: YCoord, txt: List[Text])
  case BoxNode(
    x: XCoord, y: YCoord, width: Int, height: Int,
    children: List[VNode], style: Style, chars: BorderChars
  )
  case InputNode(
    x: XCoord, y: YCoord, prompt: String, style: Style,
    cursor: Int = -1, lineWidth: Int = 0, prefixLength: Int = 0
  )

Plus the wrapper:

final case class RootNode(
  width:    Int,
  height:   Int,
  children: List[VNode],
  input:    Option[InputNode],
  overlays: List[Overlay] = Nil,
  layout:   Option[Layout] = None
)

Every render produces a RootNode. AnsiRenderer.render(prev, next) diffs the two and emits the ANSI patch.

A few invariants:

  • Coordinates are 1-based. XCoord and YCoord are opaque types over Int so you can't accidentally swap them. Use the .x / .y extension methods in ScreenPrelude (2.x, 3.y) to construct.
  • BoxNode is visual-only. It draws a border but does not position its children. If you want layout, use Layout.Column or Layout.Row instead and attach the box as a sibling.
  • Only the topmost InputNode is live. It carries the hardware cursor position. Nested input nodes are drawn but inert.

File: modules/termflow-screen/src/main/scala/termflow/tui/vdom.scala.

Layout DSL

Layout describes the stacking shape. Build a tree, then resolve it at an origin to flatten it into absolute-coordinate VNodes.

enum Layout:
  case Elem(vnode: VNode)
  case Row(gap: Int, children: List[Layout])
  case Column(gap: Int, children: List[Layout])
  case Spacer(width: Int, height: Int)
  case Fill(content: Layout)
  case Zone(id: Any, content: Layout)
  case Grid(columns: Int, rowGap: Int, colGap: Int, cells: List[GridCell])
  case Border(top: Option[Layout], left: Option[Layout],
              center: Option[Layout], right: Option[Layout],
              bottom: Option[Layout], gap: Int)

Concrete usage:

import termflow.tui.{Layout, BoxNode, TextNode, Style}
import termflow.tui.TuiPrelude.*

val column = Layout.Column(gap = 1, children = List(
  Layout.Elem(TextNode(1.x, 1.y, List("Counter".text(fg = Yellow)))),
  Layout.Elem(TextNode(1.x, 1.y, List(s"value: $count".text))),
  Layout.Spacer(1, 1),
  Layout.Elem(TextNode(1.x, 1.y, List("press + / -".text)))
))

val children: List[VNode] = column.resolve(Coord(2.x, 2.y))

Inside the layout tree the inner (1.x, 1.y) are layout-localresolve adds the origin to produce the right absolute coordinates.

The fluent factories Layout.row(gap)(vnode1, vnode2, …) and Layout.column(gap)(...) skip the Elem wrappers when you have a flat list of vnodes.

Fill — eat remaining space

Fill(content) consumes whatever's left on the major axis. Useful for splits and panels:

Layout.Row(gap = 1, children = List(
  Layout.Elem(sidebar),    // natural width
  Layout.Fill(mainPanel)   // takes the rest of the row
))

Resolve Fill regions with Layout.resolveTo(layout, at, w, h) — the form that knows the available width / height. For full-screen apps, the idiomatic shape is layout.toBudgetedRootNode(width, height), which puts the layout into RootNode.layout so the renderer applies the budget at render time and the layout reflows on resize. The eager layout.toRootNode(width, height) form intentionally does not apply a budget — see the full-screen layout cookbook for when each form is appropriate.

Grid — fixed-column grid with optional span

Cells flow left-to-right, top-to-bottom. Column widths split the available width evenly (with a budget) or fall back to per-column natural widths. colSpan / rowSpan reserve a rectangle of slots and the cursor skips past them.

Layout.grid(columns = 3, rowGap = 1, colGap = 2)(
  TextNode(1.x, 1.y, List("a".text)),
  TextNode(1.x, 1.y, List("b".text)),
  TextNode(1.x, 1.y, List("c".text))
)

// With spans:
Layout.Grid(columns = 2, rowGap = 0, colGap = 0, cells = List(
  GridCell(Layout.Elem(headerNode), colSpan = 2),
  GridCell(Layout.Elem(leftNode)),
  GridCell(Layout.Elem(rightNode))
))

Cells with colSpan > 1 cover the spanned columns inside resolveTo / resolveTracked; their content can itself be a Zone so mouse clicks land back on a logical id.

Border — five-zone layout

Layout.border(
  top    = Layout.Elem(headerNode),
  left   = Layout.Elem(sidebar),
  center = Layout.Elem(mainPanel),
  right  = Layout.Elem(detailsPanel),
  bottom = Layout.Elem(statusBar),
  gap    = 1
)

Sizing under a budget:

  • Top / bottom — natural height, full width.
  • Left / right — natural width, middle-band height.
  • Center — fills the remainder.

Pass null (or omit the named argument) for any zone you don't need; its space collapses. Designed for "header / sidebar / main / footer" shells where you don't want to compute the band heights by hand.

Zone — tag for hit-test

Wrap a sub-layout in Zone(id, content) and Layout.resolveTracked will register the resulting rectangle in a HitTest[Id] so you can route mouse clicks back to the same id:

val (vnodes, hits) = Layout.resolveTracked[String](
  layout, at = Coord(1.x, 1.y), availableWidth = 80, availableHeight = 24
)

mouseEvent match
  case Mouse(MouseEvent.Press(_, col, row, _)) =>
    hits.hit(col, row).foreach(handleZone)

File: modules/termflow-screen/src/main/scala/termflow/tui/Layout.scala.

HitTest

final case class Rect(x: Int, y: Int, width: Int, height: Int):
  def right:  Int
  def bottom: Int
  def contains(col: Int, row: Int): Boolean
  def at: Coord

final case class HitTest[Id](zones: Vector[(Id, Rect)]):
  def add(id: Id, rect: Rect): HitTest[Id]
  def ++(other: HitTest[Id]): HitTest[Id]
  def hit(col: Int, row: Int): Option[Id]

hit(col, row) returns the topmost zone (last-inserted wins) at the given coordinate. The cache is built during the layout pass — you never have to maintain a parallel zone list by hand.

File: modules/termflow-screen/src/main/scala/termflow/tui/HitTest.scala.

Theme — semantic colour slots

final case class Theme(
  primary:    Color,  secondary:  Color,
  error:      Color,  warning:    Color,
  success:    Color,  info:       Color,
  border:     Color,  background: Color,  foreground: Color,
  chars: BorderChars = BorderChars.sharp
)

object Theme:
  val dark:    Theme
  val light:   Theme
  val mono:    Theme
  val rounded: Theme

A widget that takes (using Theme) can draw against the active theme without knowing anything about the user's palette. Override by making your own given Theme = … in scope, or by passing a theme explicitly to dialog/widget builders.

BorderChars controls box-drawing glyphs:

object BorderChars:
  val sharp:   BorderChars  // ┌─┐│└┘
  val rounded: BorderChars  // ╭─╮│╰╯
  val double:  BorderChars  // ╔═╗║╚╝
  val ascii:   BorderChars  // +-+|-+ — for terminals without unicode

Capability-aware: when Capabilities.unicode is false, the renderer substitutes ascii automatically.

Files: Theme.scala, BorderChars.scala.

RenderFrame and AnsiRenderer

The renderer turns a RootNode into a RenderFrame (a width × height grid of RenderCell(ch, style, width)), then diffs against the previous frame and emits ANSI.

final case class RenderCell(ch: Char, style: Style, width: Int = 1)

final case class RenderFrame(
  width:  Int,
  height: Int,
  cells:  Array[Array[RenderCell]],
  cursor: Option[Coord]
)

You normally don't construct a RenderFrame by hand — the runtime does it for you. Where it shows up is in tests: TuiTestDriver.frame returns the RenderFrame produced by the last view call, and GoldenSupport.assertGoldenFrame compares it to a snapshot.

The wide-cell handling is critical: RenderCell.width = 2 means the next cell is consumed by the same glyph. This is what makes render correctly.

File: modules/termflow-screen/src/main/scala/termflow/tui/AnsiRenderer.scala.

ScreenPrelude — sugar

import termflow.tui.TuiPrelude.*  // lifts ScreenPrelude transitively

2.x                                  // XCoord
3.y                                  // YCoord
"hello".text                         // Text(value, Style.empty)
"warning".text(fg = Color.Yellow)    // styled text
"OK".text(fg, bg, bold = true)       // multiple style args

These are the conversions every example uses. Always import TuiPrelude.* at the top of view code; you'll thank yourself.

When to climb up

The screen layer plus your own loop is enough for read-only tools (report renderers, status dashboards, pipe viewers). The moment you want input — keystrokes, timers, async work — you want the Application layer. Everything in this layer is reused verbatim by the app layer, so nothing is wasted.

The full per-type API is in the Scaladoc.

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.

Widgets

termflow-widgets ships a catalogue of reusable components. Every widget is built on top of termflow-screen and termflow-app, follows the same (State, handleKey, view) shape, and takes a given Theme so it picks up your colour scheme.

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

The umbrella termflow artefact already depends on this module — most apps don't need to depend on it directly.

Every widget is exercised by the showcase app. Run sbt showcase to see them rendered side-by-side.

The widget protocol

Most widgets follow a three-piece pattern:

  1. State — the widget's data (cursor positions, scroll offsets, selected index). You store this on your model.
  2. handleKey(state, key)(...) — pure function from a key to the next state, optionally producing a Msg (e.g. on Enter).
  3. view(state, …, focused: Boolean = true) — pure function from state to VNode or List[VNode]. The focused flag controls which highlight to use.

Stateless widgets (Button, RadioGroup, ProgressBar, Spinner, StatusBar, Separator) skip step 2 — they only render.

Inputs

TextField

Single-line text input. Handles cursor, paste, grapheme-aware backspace.

import termflow.tui.widgets.TextField

val initial = TextField.State.withPlaceholder("alice@example.com")
val (next, _) = TextField.handleKey[Msg](initial, key)(_ => None)
val node = TextField.view(next, lineWidth = 28, focused = true)

Placeholder text renders dim+italic until the user types.

MultiLineInput

Multi-line editor. Cursor row uses reverse-video (TextField-style) so it embeds cleanly in any layout. Tab inserts a literal \t. Grapheme-aware navigation across all four arrows, Backspace, Delete.

val state = MultiLineInput.State(lines = Vector("hello", "world"))
val (next, _) = MultiLineInput.handleKey[Msg](state, key)
val node = MultiLineInput.view(next, width = 60, height = 12, focused = true)

Button

Inline [ Label ]. Focus = primary background bar. Stateless — Button(label, focused) returns a VNode directly.

widgets.Button(label = "Submit", focused = focusManager.isFocused(SubmitId))

CheckBox / RadioGroup

widgets.CheckBox(label = "Remember me", checked = true, focused = true)

widgets.RadioGroup(
  options       = Vector("Free", "Pro", "Enterprise"),
  selectedIndex = 1,
  focusedIndex  = 1,
  at            = Coord(4.x, 8.y)
)

RadioGroup returns List[VNode], one per option. Use Layout.Column or absolute coordinates if you want different placement.

Capability-aware glyphs: ☐/☒ and ◯/◉ on Unicode terminals, [ ]/[x] and ( )/(*) on ASCII-only.

Select / Autocomplete

Select is a closed-state dropdown — single click opens, click again or pick an item closes. Autocomplete is the open-state variant: a filterable list always visible underneath an input.

val acState = Autocomplete.State.of(Vector("apple", "banana", "cherry"))
val r       = Autocomplete.handleKey(acState, key)
val nodes   = Autocomplete.view(r.state, width = 16, maxVisible = 6, focused = true)

Both clamp selectedIdx into the visible filtered range. The viewport scrolls so the cursor row stays visible.

Prompt

Strictly speaking Prompt is in termflow-app, not widgets, but it's the workhorse for REPL-style apps — see the Counter tutorial for the full integration.

Data display

ListView

Scrollable, selectable list. cursor when focused, just colour when blurred.

val state = ListView.State(items = Vector("apple", "banana", "cherry"))
val (next, _) = ListView.handleKey[Msg](state, key)(_ => None)
val node = ListView.view(next, width = 24, maxVisible = 8, focused = true)

Table

Selectable rows + columns with Align.Left | Right | Center.

val cols = Vector(
  Table.Column("Name",  width = 20, align = Align.Left),
  Table.Column("Score", width = 8,  align = Align.Right)
)
val state = Table.State(columns = cols, rows = Vector(Vector("alice","42")))

Tree

Recursive collapsible tree. Stateless — the app owns expanded: Set[Id] and selectedIndex: Int; the renderer takes a typeclass Children[A, Id] describing how to traverse your data structure.

  • Expanded glyph: [-]
  • Collapsed glyph: [+]
  • Leaf glyph: (four spaces)
case class Node(name: String, kids: Vector[Node])
given widgets.Tree.Children[Node, String] with
  def id(n: Node):   String        = n.name
  def kids(n: Node): Vector[Node]  = n.kids

val nodes = widgets.Tree(
  roots         = Vector(Node("src", Vector(Node("Main.scala", Vector.empty)))),
  expanded      = Set("src"),
  selectedIndex = 0,
  render        = _.name,
  at            = Coord(2.x, 2.y)
)

// Mouse: distinguish chevron clicks from label clicks
val rows = widgets.Tree.visibleRows(roots, expanded)
widgets.Tree.hitTest(rows, at = Coord(2.x, 2.y), indentWidth = 2, col, row) match
  case Some(widgets.Tree.HitResult.Chevron(idx)) => /* toggle expansion */
  case Some(widgets.Tree.HitResult.Label(idx))   => /* select */
  case None                                      => /* miss */

LogView

Stateless line-buffer viewer. The app owns the Vector[String] buffer and the current scrollOffset; LogView wraps and clips into the requested viewport.

val node = widgets.LogView(
  lines        = m.logBuffer,            // Seq[String]
  width        = 80,
  height       = 16,
  scrollOffset = m.scrollOffset,
  wrap         = true
)

Use LogView.maxScroll(lines, width, height, wrap) when the user scrolls so you clamp the offset correctly.

For a Claude-Code / Cursor-style transcript with a fixed bottom prompt and auto-tail behaviour, see the rolling console recipe.

Layout

Tabs

Stateless tab-header renderer — the app owns the active and focused indices and handles tab-switching keys itself.

val node = widgets.Tabs(
  labels       = Seq("Inputs", "Data", "Layout"),
  activeIndex  = m.activeTab,
  focusedIndex = if m.headerFocused then m.activeTab else -1,
  at           = Coord(2.x, 1.y),
  separator    = " │ "
)

SplitPane

Horizontal/vertical pane divider. Resize via mouse drag (Stage 3 §6.2) or via keyboard ([ / ]).

val ds = SplitPane.DragState(splitRatio = 0.5, dragging = false)
val ds2 = SplitPane.handleMouse(
  state = ds, event = mouseEvent,
  direction = SplitPane.Vertical, width = 80, height = 24,
  at = Coord(1.x, 1.y), gap = 1
)

Separator

widgets.Separator.horizontal(width = 60, at = Coord(1.x, 5.y), title = Some("Section"))
widgets.Separator.vertical(height = 12, at = Coord(40.x, 1.y))

ScrollBar

Visual thumb track. Use alongside ListView, Table, or MultiLineInput when content exceeds the visible area.

val sb = ScrollBar.State(offset = 0, visible = 12, total = 60)
if sb.needed then
  val node = ScrollBar(sb, at = Coord(80.x, 2.y), height = 12)

Feedback

ProgressBar

widgets.ProgressBar(at = Coord(2.x, 5.y), width = 40, fraction = 0.6)

filled, empty on Unicode terminals; # / - on ASCII.

Spinner

val frames = Spinner.Braille  // or .Line, .Dots
val frame  = Spinner.frame(frames, tickIndex)

Stateless — you advance tickIndex on each Sub.Every tick. See the async tutorial for the full pattern.

StatusBar

3-column header/footer in inverse video.

widgets.StatusBar(
  width  = 80,
  left   = " ▌ TermFlow ",
  center = " connected ",
  right  = "  q quit "
)

Top-of-screen menu bar with dropdown items.

val state = widgets.MenuBar.State(
  menus = Vector(
    widgets.MenuBar.Menu("File", items = Vector("Open…", "Save", "Quit")),
    widgets.MenuBar.Menu("Edit", items = Vector("Undo", "Redo"))
  )
)
val widgets.MenuBar.KeyResult(next, picked) = widgets.MenuBar.handleKey(state, key)
val node = widgets.MenuBar(next, at = Coord(1.x, 1.y), focused = true)

KeyResult.picked: Option[(menuIdx, itemIdx)] is Some(...) only on the keystroke that committed a selection — fold it into your domain Msg and dispatch.

Form

The composite Form.column helper renders multi-row forms with labels, focus-aware widgets, and inline validation. The forms tutorial walks through it end-to-end.

widgets.Form.column(
  rows         = Vector(
    Form.Row(NameId,  "Name:",  focused => TextField.view(name,  lineWidth = 28, focused = focused)),
    Form.Row(EmailId, "Email:", focused => TextField.view(email, lineWidth = 28, focused = focused))
  ),
  focusManager = fm,
  at           = Coord(2.x, 4.y),
  labelWidth   = 8,
  gap          = 1,
  errors       = Map("wiz-email" -> "Email must contain '@'")
)

Coverage

The complete file list is checked by scripts/check_widget_docs.sh in CI: any new file under modules/termflow-widgets/src/main/scala/termflow/tui/widgets/ that isn't mentioned in this guide will fail the build.

For full per-widget API, see the Scaladoc.

Keymap

Keymap turns ad-hoc match blocks against KeyDecoder.InputKey into declarative bindings. The win is a single Map[InputKey, Msg] you can pass around, merge, and override — useful for global shortcuts, modal overrides, and feature flags.

import termflow.tui.Keymap
import termflow.tui.KeyDecoder.InputKey

Building a keymap

val km: Keymap[Msg] = Keymap(
  InputKey.CharKey('q')   -> Msg.Quit,
  InputKey.CharKey('?')   -> Msg.ToggleHelp,
  InputKey.Tab            -> Msg.NextFocus,
  InputKey.BackTab        -> Msg.PrevFocus
)

km.lookup(InputKey.Tab)   // Some(NextFocus)
km.lookup(InputKey.F1)    // None

Keymap[Msg] is just Map[InputKey, Msg] under the hood — equality, size, etc. work as you'd expect.

Composing keymaps

Two keymaps merge with ++. The right-hand side wins on conflict — so put global bindings first, mode-specific bindings last:

val global    = Keymap.quit(Msg.Quit) + (InputKey.CharKey('?') -> Msg.ToggleHelp)
val editing   = Keymap(InputKey.Escape -> Msg.LeaveEditMode)

// In edit mode: editing's Escape wins over a global Escape, if any.
val active    = global ++ editing

The + operator adds or replaces a single binding:

val withSave  = global + (InputKey.Ctrl('S') -> Msg.Save)

Builders for common patterns

Keymap.empty[Msg]                                  // {}
Keymap(bindings: (InputKey, Msg)*)                 // arbitrary
Keymap.quit(Msg.Quit)                              // Ctrl+C, Esc, q, Q
Keymap.focus(next = Msg.NextFocus, previous = Msg.PrevFocus)
                                                   // Tab + Shift+Tab
Keymap.focusVertical(previous = Msg.Up, next = Msg.Down)
                                                   // ArrowUp + ArrowDown
Keymap.focusHorizontal(previous = Msg.Left, next = Msg.Right)
                                                   // ArrowLeft + ArrowRight

These are the four most common shapes — quitting, tab focus, vertical focus, horizontal focus. Stack them with ++ to assemble an app's global keymap.

Wiring into update

case Msg.KeyPressed(key) =>
  km.lookup(key) match
    case Some(mapped) => Tui(m, Cmd.GCmd(mapped))
    case None         =>
      // Unmapped key — fall through to widget-specific handling.
      m.tui

The Cmd.GCmd(mapped) re-enters update with the mapped message, which update handles like any other domain event. This decouples "which keys do what" from "what happens when something is done".

For widgets that swallow keystrokes (TextField, MultiLineInput, Prompt), feed the key into the widget first, and only fall back to Keymap.lookup when the widget doesn't consume it. The forms tutorial walks through this pattern.

Mode stacks

Apps with insert / normal / command modes can stack keymaps:

final case class Model(
  mode: EditorMode,                 // Normal | Insert | Command
  /* … */
):
  def keymap: Keymap[Msg] = mode match
    case Normal  => globalKm ++ normalKm
    case Insert  => globalKm ++ insertKm
    case Command => globalKm ++ commandKm

update always calls model.keymap.lookup(key). Mode transitions just change which keymap is active.

Help overlays

Because Keymap is just a map, you can render a help screen straight from the bindings:

def renderHelp(km: Keymap[Msg], labelOf: Msg => String): List[VNode] =
  km.bindings.toList.sortBy(_._2.toString).map { case (key, msg) =>
    TextNode(2.x, 1.y, List(
      describe(key).text(fg = Theme.dark.primary),
      "  ".text,
      labelOf(msg).text
    ))
  }

describe(InputKey) is whatever pretty-printer you want — the Keymap.scala source has a renderChord helper that returns human-readable strings ("Tab", "Ctrl+S", "Esc").

Reference

File: modules/termflow-app/src/main/scala/termflow/tui/Keymap.scala.

For a working example, see apps.forms.FormDemoApp — it builds a global keymap, layers a per-field keymap on top, and renders the active bindings as a help footer.

Theming

Theme is a small struct of named colour slots plus a BorderChars glyph set. Widgets and dialogs that take (using Theme) pick up the ambient theme without needing to know what your palette looks like.

import termflow.tui.Theme

The shape

final case class Theme(
  primary:    Color,  secondary:  Color,
  error:      Color,  warning:    Color,
  success:    Color,  info:       Color,
  border:     Color,  background: Color,  foreground: Color,
  chars:      BorderChars = BorderChars.sharp
)

object Theme:
  val dark:    Theme   // shipped — default
  val light:   Theme   // shipped — light palette
  val mono:    Theme   // shipped — single-colour terminal fallback
  val rounded: Theme   // shipped — rounded box-drawing chars

Pick one as your given at the top of your view code:

import termflow.tui.{Theme, Color}
import termflow.tui.Theme.themed

given Theme = Theme.dark

val tag = "PRIMARY".themed(_.primary)
val ok  = "✓".themed(_.success)

The .themed(slot) extension takes a Theme => Color and returns a styled Text. This keeps view code free of hard-coded colours and gives you palette-swap-by-import.

Slot semantics

The names are deliberately abstract — they mean roles, not literal colours:

SlotUsed for
primaryHeadings, focus chrome, active tab
secondarySubtle headings, placeholder text
errorValidation messages, exit codes
warningCaution states
successConfirmation messages, "✓ Submitted"
infoNeutral status (timestamps, counts)
borderBox outlines, separators
backgroundFills (rare — the terminal default usually wins)
foregroundDefault text colour

The four shipped themes pick concrete Color values for each slot that look good together. When you add your own theme, copy Theme.dark and override what you need:

val termflowBranded: Theme = Theme.dark.copy(
  primary  = Color.Rgb(0xff, 0x6f, 0x3d),  // brand orange
  border   = Color.Indexed(238)
)

Capability gating

Color.Rgb(...) only emits true-colour ANSI when the terminal supports it. The AnsiRenderer reads Capabilities.colorDepth (from the Capabilities.detect(env) pass at startup) and downgrades:

Truecolor → Indexed256 → Ansi16 → Ansi8 → Mono

So an Rgb(0xff, 0x6f, 0x3d) becomes the closest 256-colour approximation on terminals that don't speak true-colour, and a basic 8-colour ANSI on really old terminals.

NO_COLOR is honoured — set the env var and the renderer drops to Mono. For everything colour-blind-related, prefer the mono theme rather than rolling your own.

Box-drawing chars

object BorderChars:
  val sharp:   BorderChars  // ┌─┐│└┘
  val rounded: BorderChars  // ╭─╮│╰╯
  val double:  BorderChars  // ╔═╗║╚╝
  val ascii:   BorderChars  // +-+|-+

When Capabilities.unicode is false, the renderer auto-substitutes ascii regardless of which set the theme picked. This means you can ship Theme.dark (which uses sharp) and it still renders correctly on a vt100.

To switch glyphs in code:

val rounded = Theme.dark.copy(chars = BorderChars.rounded)

A theme-aware widget

Most widgets in the catalogue already take (using Theme) — that's all you have to know to use them. If you're building your own widget, the same pattern applies:

def MyBadge(label: String)(using theme: Theme): VNode =
  TextNode(1.x, 1.y, List(
    " ".text,
    label.text(fg = theme.background, bg = theme.primary, bold = true),
    " ".text
  ))

Switching at runtime

Because Theme is just a value, you can swap themes from update and pass it through to view via the model:

final case class Model(theme: Theme, /* … */)

def view(m: Model): RootNode =
  given Theme = m.theme
  // … render against the active theme

The apps.themes.ThemeDemoApp sample shows side-by-side previews of every shipped theme — useful both as a smoke test for new themes and as a starting point if you're designing your own palette.

Reference

File: modules/termflow-screen/src/main/scala/termflow/tui/Theme.scala.

For the full per-slot API, see the Scaladoc.

Accessibility

TermFlow targets terminal apps, where the assistive-technology story is genuinely different from the web: screen readers read the terminal buffer directly, so the practical levers a TUI library has are around colour, motion, and predictable structure.

This page documents the levers TermFlow exposes today.

Colour

  • Theme.dark, Theme.light, and Theme.mono ship as built-in palettes.
  • Capability detection downgrades cleanly: true-colour → 256 → 16 → 8 → mono. Apps don't need to branch on the terminal — pick a Theme and let the renderer downgrade.
  • The NO_COLOR environment variable is honoured: when set, the runtime forces Theme.mono-equivalent rendering regardless of detected capability.

Reduced motion

Some users find ambient animation actively unhelpful — vestibular sensitivity, low-bandwidth sessions, screen-reader environments that re-announce on every frame, or just personal preference. TermFlow exposes a single flag for this: reducedMotion.

Activating reduced motion

Either set the environment variable:

TERMFLOW_REDUCED_MOTION=1

…or set it in the HOCON config:

termflow {
  accessibility {
    reduced-motion = true
  }
}

The env var takes precedence over the config value when set. Truthy values are anything other than "0" or "false" (case-insensitive).

What it affects

The flag is plumbed through RuntimeCtx.config.accessibility.reducedMotion so apps and widgets can read it.

Spinner accepts a reducedMotion: Boolean parameter that, when true, pins the rendered frame to frames(0) regardless of the tick:

import termflow.tui.widgets.Spinner

def view(model: Model)(using ctx: RuntimeCtx[Msg]): VNode =
  Spinner(
    Spinner.Braille,
    frame = model.tick,
    reducedMotion = ctx.config.accessibility.reducedMotion
  )

App-level animation (sine wave demos, custom progress effects, anything driven by Sub.Every purely for cosmetic reasons) should query the flag and either skip rendering or fall back to a static representation.

What it does not affect

  • Functional motion — cursor movement, focus changes, scroll position, dialog open/close. Those are part of the app's behaviour, not cosmetic animation.
  • Determinate ProgressBar — already non-cycling. Only indeterminate progress indicators count as cosmetic motion, and TermFlow's ProgressBar is determinate-only.

Predictable structure

The Elm-style architecture (update is pure, view is a function of the model) means the rendered frame is fully determined by the model. That's a foundation accessibility tooling can build on — semantic annotations, a linear/announcement renderer, or a screen-reader bridge. Those are post-1.0 work; see the roadmap for current status.

Testing

termflow-testkit is the test-only companion to termflow-app. It gives you a synchronous driver that runs your TuiApp without a real terminal, captures rendered frames as cell matrices for snapshot testing, and provides KeySim / MouseSim constructors so you don't have to hand-build KeyDecoder.InputKey values in test code.

libraryDependencies += "org.llm4s" %% "termflow-testkit" % "0.4.0" % Test

TuiTestDriver

The single entry point for testing apps:

import termflow.testkit.{TuiTestDriver, KeySim}
import termflow.tui.KeyDecoder.InputKey

val driver = TuiTestDriver(MyApp.App, width = 80, height = 24)
driver.init()

driver.send(MyApp.Msg.Increment)
assert(driver.model.count == 1)

The driver:

  • Runs app.init(ctx) synchronously, captures the Tui[Model, Msg].
  • Drains the command bus on every send, processing GCmds recursively so chained transitions complete before send returns.
  • Renders the current model after every senddriver.frame returns the latest RenderFrame.
  • Suppresses subscription start-up — Sub.Every timers never tick, Sub.InputKey never reads stdin, so tests stay deterministic.

Key methods:

class TuiTestDriver[Model, Msg]:
  def init():    Unit
  def send(msg:  Msg):  Unit
  def model:     Model
  def exited:    Boolean
  def cmds:      List[Cmd[Msg]]
  def observedErrors: List[TermFlowError]
  def frame:     RenderFrame

Asserting on the frame

The simplest assertion is "did the rendered text contain X?":

val rendered = (0 until driver.frame.height)
  .map(r => driver.frame.cells(r).map(_.ch).mkString)
  .mkString("\n")

assert(rendered.contains("count: 1"))

For style assertions, drop into RenderCell:

val cell = driver.frame.cells(2)(15)
assert(cell.ch == '✓')
assert(cell.style.fg == Color.Green)

Golden snapshots

For higher-fidelity tests, mix in GoldenSupport:

import termflow.testkit.GoldenSupport
import org.scalatest.funsuite.AnyFunSuite

class MyAppSpec extends AnyFunSuite with GoldenSupport:
  override def goldenSuiteName = "MyApp"

  test("initial frame matches golden") {
    val d = TuiTestDriver(MyApp.App, width = 80, height = 24)
    d.init()
    assertGoldenFrame(d.frame, "initial")
  }

First run: the golden file doesn't exist, the test fails. Run with -Dtermflow.update-goldens=true (or UPDATE_GOLDENS=1) and the driver writes it. Commit the golden, future runs assert against it.

Goldens live under src/test/resources/termflow/golden/<suite>/<name>.txt by default. Override via goldenDir and goldenPath(name).

KeySim — typed key constructors

import termflow.testkit.KeySim

KeySim.Tab           // InputKey.Tab
KeySim.Enter         // InputKey.Enter
KeySim.Escape        // InputKey.Escape

KeySim.char('q')     // InputKey.CharKey('q')
KeySim.ctrl('S')     // InputKey.Ctrl('S')
KeySim.f(2)          // InputKey.F2
KeySim.paste("hi")   // InputKey.Paste("hi")

Modifier wrappers:

KeySim.shift(KeySim.Tab)              // BackTab equivalent (Shift+Tab)
KeySim.ctrl(KeySim.char('c'))         // Ctrl+C
KeySim.modified(InputKey.End, shift = true, ctrl = true)

Sequences:

KeySim.typeString("hello")
// → List(CharKey('h'), CharKey('e'), CharKey('l'), CharKey('l'), CharKey('o'))

KeySim.typeString("a\tb")
// → List(CharKey('a'), Tab, CharKey('b'))   // \t becomes Tab

KeySim.typeString("a\nb")
// → List(CharKey('a'), Enter, CharKey('b')) // \n becomes Enter

This is what the showcase, wizard, and form-demo specs use to drive their drivers — typing a string and asserting on the resulting model is by far the most readable test shape.

MouseSim — typed mouse constructors

Mouse events live inside InputKey.Mouse(event). MouseSim wraps that for you:

import termflow.testkit.MouseSim

MouseSim.click(col = 10, row = 5)               // Press at (10, 5)
MouseSim.scrollOnce(ScrollDirection.Down, 10, 5) // Single wheel click

MouseSim.clickPair(col = 10, row = 5)
// → Vector(InputKey.Mouse(Press(...)), InputKey.Mouse(Release(...)))

MouseSim.scroll(ScrollDirection.Down, 10, 5, ticks = 3)
// → three Mouse(Scroll(Down, ...)) events

For drag-resize tests of SplitPane:

val events = Vector(
  MouseSim.press(col = 40, row = 12),
  MouseSim.drag(col = 50, row = 12),
  MouseSim.release(col = 50, row = 12)
)
events.foreach(e => driver.send(MyApp.Msg.KeyPressed(e)))

TestRuntimeCtx

TuiTestDriver constructs a TestRuntimeCtx[Msg] for you, but if you want to drive a TuiApp lower-level, the ctx is exposed directly. Useful when you're testing a sub-component that takes a RuntimeCtx[Msg] parameter without going through the full app.

val ctx = TestRuntimeCtx[MyApp.Msg](width = 80, height = 24)

// Register a sub manually if you need to assert on it:
val keys = Sub.InputKey[MyApp.Msg](
  msg     = k => MyApp.Msg.KeyPressed(k),
  onError = _ => MyApp.Msg.Quit,
  ctx     = ctx
)
assert(ctx.registeredSubs.contains(keys))

A complete spec

import org.scalatest.funsuite.AnyFunSuite
import termflow.testkit.{TuiTestDriver, KeySim}

class CounterSpec extends AnyFunSuite:

  private def driver = {
    val d = TuiTestDriver(SyncCounter.App, width = 80, height = 24)
    d.init()
    d
  }

  test("increment via keystroke") {
    val d = driver
    KeySim.typeString("increment").foreach(k =>
      d.send(SyncCounter.Msg.ConsoleInputKey(k))
    )
    d.send(SyncCounter.Msg.ConsoleInputKey(KeySim.Enter))
    assert(d.model.counter.count == 1)
  }

  test("exit on Ctrl+C") {
    val d = driver
    d.send(SyncCounter.Msg.ConsoleInputKey(KeySim.ctrl('C')))
    assert(d.exited)
  }

Testing async work

Cmd.FCmd futures don't run automatically inside the driver — that would make tests non-deterministic. Instead, the driver records the FCmd in cmds and you assert on it:

test("Increment dispatches an FCmd with onEnqueue = Busy") {
  val d = driver
  d.send(FutureCounter.Msg.Increment)
  val fcmd = d.cmds.collectFirst {
    case f: Cmd.FCmd[?, ?] => f
  }.getOrElse(fail("expected an FCmd"))
  assert(fcmd.onEnqueue.exists(_.isInstanceOf[FutureCounter.Msg.Busy]))
}

For end-to-end async tests, use Await.result on the captured future and feed the resolved value back via driver.send. This is the pattern the FutureCounter spec uses.

Reference

Files: modules/termflow-testkit/src/main/scala/termflow/testkit/{TuiTestDriver,TestRuntimeCtx,KeySim,MouseSim,GoldenSupport}.scala.

For working examples, every spec under modules/termflow-sample/src/test/scala uses the testkit — the wizard, showcase, form-demo, and dashboard specs cover the full surface.

Cookbook

Short "how do I…" recipes. Each one is one or two screens of explanation plus a self-contained snippet, grounded in actual TermFlow APIs and (where possible) a sample app you can run.

Recipes

Want more?

Recipe gaps are tracked as GitHub issues. If you've got a "how do I do X" that isn't covered, please open an issue — most recipes start as a question that came up twice.

Show a confirm dialog and act on the answer

Dialogs.confirm returns an Overlay value — it does not take callbacks. The app owns the dialog's open/closed state and the focused button; key events route to whichever side owns focus.

Pattern

  1. Add a flag (or sealed case class) to your model that says "the confirm dialog is open and waiting for an answer".
  2. In view, when that flag is set, append the Dialogs.confirm overlay to RootNode.overlays.
  3. In update, route key events to the dialog when it's open: arrow keys flip the focused button, Enter/Space commits, Escape cancels.

Code

import termflow.tui.{Dialogs, Theme}
import termflow.tui.KeyDecoder.InputKey.*

final case class Model(
  /* … domain fields … */,
  confirm: Option[ConfirmState]
)

enum ConfirmState:
  case Open(yesFocused: Boolean)   // dialog visible, button focus tracked here

enum Msg:
  case AskDelete             // user requested the destructive action
  case ConfirmYes
  case ConfirmNo
  case ConfirmKey(k: InputKey)

def update(m: Model, msg: Msg, ctx: RuntimeCtx[Msg]): Tui[Model, Msg] =
  msg match
    case Msg.AskDelete =>
      m.copy(confirm = Some(ConfirmState.Open(yesFocused = false))).tui

    case Msg.ConfirmKey(k) =>
      m.confirm match
        case Some(ConfirmState.Open(yesFocused)) =>
          k match
            case ArrowLeft | ArrowRight | Tab | BackTab =>
              m.copy(confirm = Some(ConfirmState.Open(!yesFocused))).tui
            case Enter | CharKey(' ') =>
              if yesFocused then m.copy(confirm = None).gCmd(Msg.ConfirmYes)
              else              m.copy(confirm = None).gCmd(Msg.ConfirmNo)
            case Escape =>
              m.copy(confirm = None).gCmd(Msg.ConfirmNo)
            case _ => m.tui
        case None => m.tui

    case Msg.ConfirmYes =>  /* perform the destructive action */
    case Msg.ConfirmNo  =>  m.tui /* no-op */

def view(m: Model): RootNode =
  given Theme = Theme.dark
  val baseChildren = /* … */
  val overlays = m.confirm match
    case Some(ConfirmState.Open(yesFocused)) =>
      List(Dialogs.confirm(
        prompt     = "Delete this file?",
        yesFocused = yesFocused,
        title      = "Confirm delete",
        yesLabel   = "Yes",
        noLabel    = "No"
      ))
    case None => Nil

  RootNode(80, 24, children = baseChildren, input = None, overlays = overlays)

Notes

  • Why Option[ConfirmState] and not just Boolean? It scales — add Open(yesFocused, prompt: String) later and any caller of AskDelete can pass its own prompt.
  • Always re-route keys when a dialog is open. Don't forget to intercept the global keymap so q doesn't quit while the dialog is modal. The simplest pattern is: if m.confirm.isDefined, route the whole keystream into ConfirmKey and skip your normal handlers.
  • Mouse clicks on overlays. Dialogs.confirm is mouse-aware in the renderer; if you want clicks on the buttons to flip focus, pair the dialog with Layout.resolveTracked and dispatch ConfirmKey from the hit-test result.

For a working example, see the showcase's Dialogs tab — Stage1ShowcaseApp.scala.

Stream output into a scrollback view

LogView is a stateless renderer that takes a Seq[String] and a scrollOffset. The app owns the buffer and decides whether to auto-tail (stay pinned to the latest line) or pause (when the user has scrolled up).

This pattern fits any streaming source: LLM tokens, build output, log tails, network frames.

Pattern

  1. Hold the buffer (Vector[String]) plus a scrollOffset and an autoTail: Boolean flag on the model.
  2. As new lines arrive (via Cmd.GCmd(NewLine(...)) or Sub.Every polling), append to the buffer; if autoTail is set, bump scrollOffset to keep the bottom in view.
  3. ArrowUp / ArrowDown adjust scrollOffset and toggle autoTail.

Code

import termflow.tui.widgets

final case class Model(
  buffer:       Vector[String],
  scrollOffset: Int,
  autoTail:     Boolean
):
  def append(line: String, viewW: Int, viewH: Int): Model =
    val nextBuf  = (buffer :+ line).takeRight(2000)   // bound the history
    val maxScr   = widgets.LogView.maxScroll(nextBuf, viewW, viewH, wrap = true)
    val nextScr  = if autoTail then maxScr else math.min(scrollOffset, maxScr)
    copy(buffer = nextBuf, scrollOffset = nextScr)

enum Msg:
  case TokenArrived(text: String)
  case Up
  case Down

def update(m: Model, msg: Msg, ctx: RuntimeCtx[Msg]): Tui[Model, Msg] =
  msg match
    case Msg.TokenArrived(t) =>
      m.append(t, viewW = ctx.terminal.width - 4, viewH = 16).tui

    case Msg.Up =>
      val maxScr = widgets.LogView.maxScroll(m.buffer, ctx.terminal.width - 4, 16, true)
      m.copy(
        scrollOffset = math.max(0, m.scrollOffset - 1),
        autoTail     = false
      ).tui

    case Msg.Down =>
      val maxScr = widgets.LogView.maxScroll(m.buffer, ctx.terminal.width - 4, 16, true)
      val next   = math.min(maxScr, m.scrollOffset + 1)
      m.copy(scrollOffset = next, autoTail = next == maxScr).tui

def view(m: Model): RootNode =
  given Theme = Theme.dark
  val viewW = 80 - 4
  val nodes = widgets.LogView(
    lines        = m.buffer,
    width        = viewW,
    height       = 16,
    scrollOffset = m.scrollOffset,
    at           = Coord(2.x, 2.y),
    wrap         = true
  )
  RootNode(80, 24, children = nodes, input = None)

Notes

  • Bound the buffer. takeRight(2000) keeps the history finite — important when streams can run for hours. The actual cap depends on your domain.
  • Token vs line. If your source emits tokens (LLM-style), keep a partial-line string on the side and only append to buffer when you see a \n. Otherwise every token becomes its own row.
  • Auto-tail toggles automatically. When the user scrolls back to the bottom (offset == maxScroll), Down flips autoTail = true again — the convention every chat client uses.
  • Dropping wrap = true truncates rather than wrapping; the maxScroll math still works either way.
  • Mouse-wheel scrollback. Wire mouse events through LogView.scrollDelta(event, viewport, ticksPerDetent) — it returns Some(delta) only when the scroll lands inside the viewport rectangle you describe with LogView.Viewport(at, width, height). Reuse the same at / width / height you passed to LogView.apply, and feed the returned delta into the same scroll-update path the keyboard uses. Outside-the-viewport events return None so a wheel hovering over the prompt or the status row won't accidentally page through history. Default ticksPerDetent = 3 matches the speed terminal users expect; pass 1 for one-line-per-detent. Keyboard equivalents (↑/↓, PageUp / PageDown, End) keep working when mouse reporting is unavailable — always wire both.

For an end-to-end example, see apps.echo.EchoApp, which uses a hand-rolled line column instead of LogView but implements the same scrollback semantics — you can swap to LogView in 20 lines.

Build a rolling console / agent UI

The Claude Code / Cursor pattern: a transcript scrolls upward as the agent works, the prompt stays pinned at the bottom, and new output auto-tails while the user is at the bottom — but pauses if they scroll back up to read history.

This recipe builds that out of widgets.LogView + a bottom-row InputNode (driven by Prompt).

What "rolling" means here

TermFlow owns its own scrollback viewport inside the alternate buffer. When TuiRuntime.run starts, it switches the terminal into the alt buffer; from that point on, the terminal emulator's own scrollback bar shows nothing useful — your app is in charge of "what's above the prompt." This recipe is about doing that well.

What this is not: native terminal scrollback (where output appends to the terminal's real history and the emulator's scrollbar / copy / search keep working). That's a deliberately different runtime contract — there's a sketch under §5.3 of the roadmap for a post-1.0 rolling- console renderer, but it's not the default.

For 99% of agent / REPL / build-runner UIs the in-app viewport is what you want, because it gives you total control over how the transcript re-renders on resize, how auto-tail behaves, and what stays pinned to the bottom.

Pattern at a glance

  1. Hold an append-only Vector[String] buffer (or richer line records), plus a scrollOffset: Int and an autoTail: Boolean flag.
  2. As new output arrives (streamed tokens, completed lines, agent events): append to the buffer, bound it, and — if autoTail is on — clamp scrollOffset to the live tail.
  3. Arrow keys / PageUp / PageDown / mouse wheel adjust scrollOffset and turn autoTail off if the user moves up.
  4. End (or scrolling back to the bottom) re-enables autoTail.
  5. Render through Layout.Border so the prompt row stays pinned even as the terminal resizes.

Model

import termflow.tui.*
import termflow.tui.widgets

final case class Model(
  width:        Int,
  height:       Int,
  buffer:       Vector[String],
  scrollOffset: Int,            // display lines from the tail
  autoTail:     Boolean,        // pinned to the bottom
  prompt:       Prompt.State
)

enum Msg:
  case OutputLine(text: String)
  case ScrollBy(delta: Int)
  case ScrollToEnd
  case Submit(text: String)
  case Quit

The buffer is Vector[String] here; in a real agent UI you'd typically use Vector[Entry] where Entry carries role / timestamp / styling and gets flattened to lines just before rendering.

Auto-tail logic

Three small helpers keep the scroll state honest. They're pure, so they're trivial to test.

val MaxHistory = 5_000   // bound the buffer

def transcriptHeight(m: Model): Int =
  // Border shell: 1 row title, 1 row prompt, 1 row status → 3 reserved.
  math.max(1, m.height - 3)

def maxScroll(m: Model): Int =
  widgets.LogView.maxScroll(m.buffer, m.width, transcriptHeight(m), wrap = true)

def clampedScroll(m: Model, candidate: Int): Int =
  math.max(0, math.min(maxScroll(m), candidate))

def appendLine(m: Model, line: String): Model =
  val nextBuf  = (m.buffer :+ line).takeRight(MaxHistory)
  val nextMax  = widgets.LogView.maxScroll(nextBuf, m.width, transcriptHeight(m), wrap = true)
  val nextScr  = if m.autoTail then nextMax else math.min(m.scrollOffset, nextMax)
  m.copy(buffer = nextBuf, scrollOffset = nextScr)

takeRight(MaxHistory) is what bounds the retained transcript — pick a number large enough that scrolling back feels useful but small enough that an all-day session doesn't grow unbounded. 5–20k lines is typical.

Update

def update(m: Model, msg: Msg, ctx: RuntimeCtx[Msg]): Tui[Model, Msg] = msg match
  case Msg.OutputLine(text) =>
    appendLine(m, text).tui

  case Msg.ScrollBy(delta) =>
    val mx     = maxScroll(m)
    val next   = math.max(0, math.min(mx, m.scrollOffset + delta))
    val tail   = next == 0   // 0 == pinned to live tail
    m.copy(scrollOffset = next, autoTail = tail).tui

  case Msg.ScrollToEnd =>
    m.copy(scrollOffset = 0, autoTail = true).tui

  case Msg.Submit(text) =>
    appendLine(m, s"> $text").tui   // and kick whatever runs the work

  case Msg.Quit =>
    Tui(m, Cmd.Exit)

The "auto-tail toggles off when you scroll up, back on when you reach the bottom" rule lives entirely inside Msg.ScrollBy. No flag-setting ceremony elsewhere.

scrollOffset = 0 is the canonical "pinned to live tail" position — that's the convention LogView uses.

Wiring keys

Scrollback / lifecycle keys go straight to Msgs; printable keys and Enter fall through to Prompt.handleKey, which owns the input line and emits Msg.Submit on Enter:

case Msg.ConsoleInputKey(k) =>
  val mapped: Option[Msg] = k match
    case InputKey.ArrowUp   => Some(Msg.ScrollBy(-1))
    case InputKey.ArrowDown => Some(Msg.ScrollBy(+1))
    case InputKey.PageUp    => Some(Msg.ScrollBy(-transcriptHeight(m)))
    case InputKey.PageDown  => Some(Msg.ScrollBy(+transcriptHeight(m)))
    case InputKey.End       => Some(Msg.ScrollToEnd)
    case InputKey.Ctrl('C') => Some(Msg.Quit)
    case _                  => None

  mapped match
    case Some(next) => update(m, next, ctx)
    case None       =>
      // Printable keys + Enter belong to the prompt buffer.
      val (nextPrompt, maybeCmd) = Prompt.handleKey[Msg](m.prompt, k)(toSubmit)
      // On Enter, maybeCmd is Cmd.GCmd(Msg.Submit(text)); otherwise None.
      Tui(m.copy(prompt = nextPrompt), maybeCmd.getOrElse(Cmd.NoCmd))

ChatStreamApp (linked below) is the live version of this routing, and Prompt.handleKey's docstring covers the full contract (Ctrl+C / Ctrl+D emit Cmd.Exit, Enter clears the buffer + dispatches the parsed message, etc.).

Mouse-wheel scrollback

LogView.scrollDelta does the rectangle test for you:

case Msg.MouseEvent(ev) =>
  val viewport = widgets.LogView.Viewport(
    at     = Coord(1.x, 2.y),
    width  = m.width,
    height = transcriptHeight(m)
  )
  widgets.LogView.scrollDelta(ev, viewport) match
    case Some(d) => update(m, Msg.ScrollBy(d), ctx)
    case None    => m.tui   // wheel was over the prompt or status — ignore

Defaults to 3 lines per detent. Wheel events outside the transcript rectangle (e.g. over the prompt) are ignored, which is what you want.

View

The transcript renders as a list of VNodes from LogView; the prompt goes into the InputNode slot on RootNode so the runtime knows where to place the hardware cursor.

import termflow.tui.*
import termflow.tui.TuiPrelude.*    // brings in the 1.x / "string".text helpers

def view(m: Model): RootNode =
  given Theme = Theme.dark

  val title = TextNode(
    1.x, 1.y,
    List(s" termflow-agent · ${m.buffer.size} lines ".text(fg = Theme.dark.primary))
  )

  val statusLabel = if m.autoTail then "auto-tail"
                   else s"paused @ ${m.scrollOffset} — End to tail"
  val statusRow = TextNode(
    1.x, (m.height - 1).y,
    List(statusLabel.text(fg = Theme.dark.secondary))
  )

  val transcript: List[VNode] = widgets.LogView(
    lines        = m.buffer,
    width        = m.width,
    height       = transcriptHeight(m),
    scrollOffset = m.scrollOffset,
    at           = Coord(1.x, 2.y),     // below the title row
    wrap         = true
  )

  val rendered = Prompt.renderWithPrefix(m.prompt, "> ")

  RootNode(
    width    = m.width,
    height   = m.height,
    children = title :: statusRow :: transcript,
    input = Some(InputNode(
      x            = 1.x,
      y            = m.height.y,        // last row
      text         = rendered.text,
      style        = Style(fg = Theme.dark.success),
      cursor       = rendered.cursorIndex,
      lineWidth    = math.max(1, m.width - 1),
      prefixLength = rendered.prefixLength
    ))
  )

The Prompt lives in the InputNode slot rather than as a regular VNode, so the runtime keeps the cursor on the input line and lets horizontal scrolling kick in if you type past the visible width.

If you'd rather express the title / transcript / prompt as a structured layout (and let the screen layer reflow on resize), use Layout.border(top = …, center = …, bottom = …).toBudgetedRootNode(width, height, input = Some(promptInput)) — the full-screen layout recipe walks the eager-vs- deferred trade-off in detail.

Worked example

ChatStreamApp (modules/termflow-sample/.../apps/chat/ChatStreamApp.scala) is the live version of every pattern on this page: token-by-token streaming via Sub.Every, auto-tail / pause / resume on End, mouse-wheel scrollback, Ctrl+L to clear, Layout.Border shell. Run it with:

sbt chatDemo

If you're building an agent UI, that's the closest thing to a starter template TermFlow ships.

Native terminal scrollback?

Sometimes you genuinely want the terminal emulator's own scrollback — the user's existing Cmd-K clear, Cmd-F search, copy-paste, and shell history all keep working. That's a different runtime model: the app appends to the normal buffer rather than painting fixed frames in the alt buffer.

It's tracked under §5.3 (post-1.0) on the roadmap as a constrained RollingConsoleApp / renderer. The 1.0 contract is the in-app viewport pattern this recipe describes; if and when the rolling renderer ships, recipes here will be updated.

Pause and resume a Sub.Every timer

Sub.Every has no native pause/resume — cancel() is final. To "resume" you create a new subscription. This is fine because subscription construction is cheap and Sub.NoSub is a safe placeholder.

Pattern

  1. Store the timer Sub[Msg] on your model — Sub.NoSub when inactive.
  2. To start: replace the field with Sub.Every(...).
  3. To pause: cancel() the existing sub and write Sub.NoSub back.
  4. To resume: build a fresh Sub.Every and store it.

Code

import termflow.tui.{Cmd, Sub, Tui}
import termflow.tui.Tui.*

final case class Model(
  ticks:     Int,
  ticker:    Sub[Msg],    // Sub.NoSub when paused
  intervalMs: Long
)

enum Msg:
  case Tick
  case Pause
  case Resume
  case SetInterval(ms: Long)
  case Quit

def update(m: Model, msg: Msg, ctx: RuntimeCtx[Msg]): Tui[Model, Msg] =
  msg match
    case Msg.Tick =>
      m.copy(ticks = m.ticks + 1).tui

    case Msg.Pause =>
      if m.ticker.isActive then m.ticker.cancel()
      m.copy(ticker = Sub.NoSub).tui

    case Msg.Resume =>
      if m.ticker.isActive then m.tui   // already running
      else m.copy(ticker = Sub.Every(m.intervalMs, () => Msg.Tick, ctx)).tui

    case Msg.SetInterval(ms) =>
      // Changing interval = cancel + restart with the new rate.
      if m.ticker.isActive then m.ticker.cancel()
      m.copy(
        intervalMs = ms,
        ticker     = Sub.Every(ms, () => Msg.Tick, ctx)
      ).tui

    case Msg.Quit =>
      if m.ticker.isActive then m.ticker.cancel()
      Tui(m, Cmd.Exit)

Notes

  • isActive check before cancel() is paranoia — cancel() is idempotent, but the guard makes the intent explicit.
  • Sub.Every auto-registers via ctx so the runtime cancels it on Cmd.Exit regardless of whether you remembered to. The explicit cancel above is defensive.
  • Don't capture m.intervalMs at sub construction — pass it through. The thunk () => Msg.Tick re-evaluates per tick, but the millis argument is fixed for the sub's lifetime.
  • Multiple timers don't share a thread — each Sub.Every spins up its own ScheduledExecutorService. Cheap, but worth knowing if you're building a thousand-timer app.

For a working example, see the Async work tutorial — the FutureCounter spinner uses exactly this pattern, except the start / stop boundary is "async work in flight" rather than user-driven pause.

Open a file picker and load the result

Dialogs.fileDialog is a presentational overlay — like Dialogs.confirm, it returns an Overlay and doesn't take callbacks. The app owns the current path, the directory listing, the selected index, and rebuilds the listing when the user steps into a subdirectory.

Pattern

  1. Hold a picker: Option[PickerState] on the model with the path, entries, and selected index.
  2. On Open, list the start directory and store it in the picker.
  3. Route keys to the picker while it's open — Enter dives into a directory or returns the selected file; Esc cancels.
  4. On commit, close the picker and dispatch a domain Msg carrying the chosen path.

Code

import java.nio.file.{Files, Path}
import scala.jdk.CollectionConverters.*

import termflow.tui.{Dialogs, FileEntry, Theme}
import termflow.tui.KeyDecoder.InputKey.*

final case class PickerState(
  path:          Path,
  entries:       Seq[FileEntry],
  selectedIndex: Int,
  okFocused:     Boolean = true
)

final case class Model(
  loadedFile: Option[Path],
  picker:     Option[PickerState]
)

enum Msg:
  case OpenPicker
  case PickerKey(k: InputKey)
  case FileChosen(p: Path)
  case PickerCancelled

def listDir(p: Path): Seq[FileEntry] =
  // FileEntry has fields name, path, isDir, sizeBytes — see
  // termflow.tui.FileEntry. Skipping sort/hidden filtering for brevity.
  Files.list(p).iterator.asScala.toList.map(FileEntry.fromPath).sortBy(e => (!e.isDir, e.name))

def update(m: Model, msg: Msg, ctx: RuntimeCtx[Msg]): Tui[Model, Msg] =
  msg match
    case Msg.OpenPicker =>
      val cwd = Path.of(System.getProperty("user.dir"))
      m.copy(picker = Some(PickerState(cwd, listDir(cwd), 0))).tui

    case Msg.PickerKey(k) =>
      m.picker match
        case None => m.tui
        case Some(p) =>
          k match
            case ArrowDown =>
              val next = math.min(p.entries.size - 1, p.selectedIndex + 1)
              m.copy(picker = Some(p.copy(selectedIndex = next))).tui

            case ArrowUp =>
              val next = math.max(0, p.selectedIndex - 1)
              m.copy(picker = Some(p.copy(selectedIndex = next))).tui

            case Enter | CharKey(' ') =>
              p.entries.lift(p.selectedIndex) match
                case Some(entry) if entry.isDir =>
                  // Step in: rebuild listing
                  m.copy(picker = Some(p.copy(
                    path          = entry.path,
                    entries       = listDir(entry.path),
                    selectedIndex = 0
                  ))).tui
                case Some(entry) =>
                  m.copy(picker = None).gCmd(Msg.FileChosen(entry.path))
                case None => m.tui

            case Escape =>
              m.copy(picker = None).gCmd(Msg.PickerCancelled)

            case _ => m.tui

    case Msg.FileChosen(p) =>
      m.copy(loadedFile = Some(p)).tui

    case Msg.PickerCancelled =>
      m.tui

def view(m: Model): RootNode =
  given Theme = Theme.dark
  val baseChildren = /* … */
  val overlays = m.picker.toList.map { p =>
    Dialogs.fileDialog(
      title         = "Open file",
      currentPath   = p.path,
      entries       = p.entries,
      selectedIndex = p.selectedIndex,
      okFocused     = p.okFocused,
      maxVisible    = 12,
      showSizes     = true
    )
  }
  RootNode(80, 24, children = baseChildren, input = None, overlays = overlays)

Notes

  • FileEntry is a small case class shipped in termflow.tui with name, path, isDir, sizeBytes. Build your own list any way you like — flat directory listing, recursive search, pre-filtered globs. The dialog only renders.
  • Hidden / dotfiles. Filter at the listDir level so the dialog never shows them. Adding a showHidden: Boolean to PickerState is straightforward.
  • Read the file inside FileChosen if it's small. For larger loads, hand it to Cmd.asyncResult(future, Msg.LoadDone.apply, Msg.LoadFailed.apply) and treat it as async work — the Async tutorial covers the pattern.
  • Symlink loops are not detected by listDir above — guard with Files.readAttributes(..., LinkOption.NOFOLLOW_LINKS) if you walk user-supplied trees.

For a working example, see apps.dialog.FileDialogDemoApp.

Two-pane layout with a draggable splitter

SplitPane ships with a DragState and a pure handleMouse that turns a MouseEvent stream into a new splitRatio. Pair it with keyboard shortcuts ([ / ]) and you get a fully interactive divider in ~20 lines.

Pattern

  1. Hold a SplitPane.DragState on the model — splitRatio: Double in [0.0, 1.0], dragging: Boolean for "we're mid-gesture".
  2. Route mouse events into SplitPane.handleMouse to update drag state.
  3. Map [ / ] (or whatever keys you like) to splitRatio - 0.05 and +0.05 for keyboard control.
  4. Use m.split.splitRatio when laying out the two panes.

Code

import termflow.tui.widgets.SplitPane
import termflow.tui.{Coord, KeyDecoder, MouseEvent}
import termflow.tui.KeyDecoder.InputKey.*

final case class Model(
  split:    SplitPane.DragState,
  /* … */
)

enum Msg:
  case Mouse(e: MouseEvent)
  case Wider, Narrower, ResetSplit

def update(m: Model, msg: Msg, ctx: RuntimeCtx[Msg]): Tui[Model, Msg] =
  msg match
    case Msg.Mouse(e) =>
      val next = SplitPane.handleMouse(
        state     = m.split,
        event     = e,
        direction = SplitPane.Direction.Vertical,  // pane divider runs vertically
        width     = ctx.terminal.width,
        height    = ctx.terminal.height,
        at        = Coord(1.x, 1.y),
        gap       = 1
      )
      m.copy(split = next).tui

    case Msg.Wider =>
      m.copy(split = m.split.copy(splitRatio = math.min(0.9, m.split.splitRatio + 0.05))).tui

    case Msg.Narrower =>
      m.copy(split = m.split.copy(splitRatio = math.max(0.1, m.split.splitRatio - 0.05))).tui

    case Msg.ResetSplit =>
      m.copy(split = SplitPane.DragState(splitRatio = 0.5, dragging = false)).tui

def view(m: Model): RootNode =
  given Theme = Theme.dark
  val w        = 80
  val h        = 24
  val leftCols = math.max(1, (w * m.split.splitRatio).toInt)

  val left  = Layout.column(gap = 0)(
    /* left-pane vnodes */
  )
  val right = Layout.column(gap = 0)(
    /* right-pane vnodes */
  )

  val divider = TextNode((leftCols + 1).x, 1.y, List(
    "│".text(fg = if m.split.dragging then Color.Yellow else Color.White)
  ))

  val nodes = left.resolve(Coord(1.x, 1.y)) ++
              right.resolve(Coord((leftCols + 2).x, 1.y)) ++
              List(divider)

  RootNode(w, h, children = nodes, input = None)

Notes

  • Direction.Vertical means a vertical divider that splits the width — left pane / right pane. Direction.Horizontal is a horizontal divider (top / bottom split).
  • Recolouring during dragm.split.dragging is true between Press and Release; rendering the divider in theme.warning gives immediate feedback that the gesture is live.
  • Clamp the ratio to [0.05, 0.95] if you want to keep both panes visible. SplitPane.handleMouse doesn't clamp — it lets you drag the divider all the way to the edge.
  • Keystroke vs mouse — pick reasonable shortcuts for users who don't have a mouse or are working over an SSH session that doesn't pass mouse events. The showcase uses [ / ] / =.

For a working example, see the showcase's Layout tab — Stage1ShowcaseApp.scala.

Capture mouse clicks on a custom widget

The screen layer ships a hit-test cache built into the layout pass. Wrap any subtree in Layout.Zone(id, content) and call Layout.resolveTracked[Id] instead of resolve — you get a HitTest[Id] mapping click coordinates back to your zone IDs.

This is how the showcase routes clicks on its action-list dialog, its tree, and its tab headers.

Pattern

  1. Tag every interactive sub-layout with Layout.Zone(id, content). IDs can be any type — String, an enum, a generic Int.
  2. Use Layout.resolveTracked[Id](layout, at, w, h) when rendering; keep the returned HitTest[Id] on the model (or rebuild it every frame).
  3. On a MouseEvent.Press, ask the cache hits.hit(col, row) and dispatch the matching Msg.

Code

import termflow.tui.{HitTest, Layout, Coord, MouseEvent, KeyDecoder}
import termflow.tui.KeyDecoder.InputKey.*

enum WidgetZone:
  case ButtonA, ButtonB, ListRow(idx: Int)

final case class Model(
  hits:        HitTest[WidgetZone],
  selected:    Option[Int],
  /* … */
)

enum Msg:
  case Mouse(e: MouseEvent)
  case ClickedA
  case ClickedB
  case ClickedRow(idx: Int)

def buildLayout(rowCount: Int): Layout =
  Layout.column(gap = 1)(
    Layout.zone(WidgetZone.ButtonA,
      Layout.Elem(TextNode(1.x, 1.y, List("[ A ]".text(fg = Color.Green))))
    ),
    Layout.zone(WidgetZone.ButtonB,
      Layout.Elem(TextNode(1.x, 1.y, List("[ B ]".text(fg = Color.Cyan))))
    ),
    Layout.column(gap = 0)(
      (0 until rowCount).toList.map(i =>
        Layout.zone(WidgetZone.ListRow(i),
          Layout.Elem(TextNode(1.x, 1.y, List(s"row $i".text)))
        )
      )*
    )
  )

def view(m: Model): RootNode =
  given Theme = Theme.dark
  val (nodes, hits) = Layout.resolveTracked[WidgetZone](
    buildLayout(rowCount = 10),
    at              = Coord(2.x, 2.y),
    availableWidth  = 78,
    availableHeight = 22
  )
  // Stash the hit-test cache on the next render — store it in the model
  // via a Cmd.GCmd or accept the rebuild cost (which is tiny).
  RootNode(80, 24, children = nodes, input = None)

def update(m: Model, msg: Msg, ctx: RuntimeCtx[Msg]): Tui[Model, Msg] =
  msg match
    case Msg.Mouse(MouseEvent.Press(_, col, row, _)) =>
      m.hits.hit(col, row) match
        case Some(WidgetZone.ButtonA)        => m.gCmd(Msg.ClickedA)
        case Some(WidgetZone.ButtonB)        => m.gCmd(Msg.ClickedB)
        case Some(WidgetZone.ListRow(idx))   => m.gCmd(Msg.ClickedRow(idx))
        case None                            => m.tui
    case Msg.Mouse(_)                        => m.tui
    case Msg.ClickedA                        => /* ... */
    case Msg.ClickedB                        => /* ... */
    case Msg.ClickedRow(i)                   => m.copy(selected = Some(i)).tui

Notes

  • The cache is per-frame. Rebuild it inside view (or via a helper called from view) and either pass it through to a click-handler in the same closure, or stash it in the model so update can look it up next time. The showcase uses the second approach — see actionListHitTest in Stage1ShowcaseApp.scala.
  • Topmost wins. When zones nest, hits.hit(col, row) returns the last-inserted match — overlays naturally win over the background.
  • Layout.zone(id, vnode) is shorthand for Layout.Zone(id, Layout.Elem(vnode)) when you want to tag a single vnode without wrapping it in a layout.
  • No Zone for keyboard. Hit-test is mouse-only. Focus navigation routes through FocusManager, which uses FocusId. The two are deliberately separate concerns.

The full hit-test surface lives in modules/termflow-screen/src/main/scala/termflow/tui/HitTest.scala.

Wide-character (CJK / emoji) input handling

CJK ideographs and most emoji take two terminal cells. Combining marks like the acute accent in é = e + ́ take zero. Naïve String.length is wrong about both. Render and edit code that ignores this clips characters, miscounts cursor positions, and leaves stray cells when text shrinks.

TermFlow's prompt and multi-line widgets handle this for you out of the box. If you're building your own text widget, the rules are straightforward.

Pattern

Three primitives:

NeedUse
How wide is this char/string?WCWidth.charWidth(c) / stringWidth(s)
Where's the previous grapheme boundary?Grapheme.previousBoundary(s, idx)
Where's the next?Grapheme.nextBoundary(s, idx)

Apply them in two places:

  1. Cursor column math. When rendering a text input, the cursor's visual column is WCWidth.stringWidth(text.take(charIndex)), not charIndex itself.
  2. Backspace / Delete. Move by graphemes, not by chars or code points.

Code: cursor column

import termflow.tui.WCWidth

def cursorColumn(buffer: String, charIndex: Int): Int =
  WCWidth.stringWidth(buffer.take(charIndex))

cursorColumn("hello", 3)        // 3
cursorColumn("こんにちは", 3)   // 6  (3 wide chars × 2)
cursorColumn("👋🏽 hi", 3)      // 4  (👋🏽 = 2 cells, space = 1)

Prompt.cursorColumn(state) does exactly this — see modules/termflow-app/src/main/scala/termflow/tui/Prompt.scala.

Code: grapheme-aware Backspace

import termflow.tui.Grapheme

def backspace(buffer: String, cursor: Int): (String, Int) =
  if cursor == 0 then (buffer, 0)
  else
    val prev    = Grapheme.previousBoundary(buffer, cursor)
    val without = buffer.substring(0, prev) + buffer.substring(cursor)
    (without, prev)

backspace("café", 4)   // ("caf", 3) — drops "é" cleanly even though it's 2 chars
backspace("👋🏽hi", 4)  // ("hi", 0)  — drops the whole emoji+modifier

Prompt.handleKey already does this internally for Backspace, Delete, ArrowLeft, ArrowRight. If you reach for it, you don't have to.

Code: rendering wide cells in a custom widget

import termflow.tui.WCWidth

// When laying out one row of text into cells, advance by width(c):
def stringToCells(text: String, style: Style): List[RenderCell] =
  val out = List.newBuilder[RenderCell]
  text.foreach { ch =>
    WCWidth.charWidth(ch) match
      case 1 => out += RenderCell(ch, style, width = 1)
      case 2 => out += RenderCell(ch, style, width = 2)  // takes two cells
      case 0 => /* combining; skip — should fold into prior cell */
      case _ => /* control; skip */
  }
  out.result()

The RenderCell.width = 2 flag is what tells AnsiRenderer to skip the next cell — it knows the wide glyph already covers it.

Notes

  • Don't mix String.length with cell math. They aren't the same. If you find yourself comparing one to the other, you almost certainly have a bug.
  • Grapheme.count(s) returns the number of user-visible characters, not cells. It's the right thing for "how many characters has the user typed?", which is rarely what you actually want for layout.
  • NO_COLOR and LANG=C. Some terminals are configured to refuse Unicode. The Capabilities.unicode flag tells you whether to use ASCII fallbacks (Theme already does this for box-drawing glyphs).
  • Combining sequences. Grapheme.previousBoundary walks across full graphemes including ZWJ sequences (👨‍👩‍👧‍👦), skin-tone modifiers (👋🏽), and regional indicators (🇯🇵🇺🇸).

For a working example, see the showcase's Inputs tab — type CJK and emoji into the MultiLineInput and watch the cursor track correctly.

Clean shutdown on Ctrl-C and on resize

Two events that should always restore the terminal cleanly:

  1. Ctrl-C — user wants to quit, fast.
  2. Window resize — not a shutdown event itself, but a frequent trigger for "my app crashed and now my terminal is broken".

The runtime handles most of this for you. The remaining work is making sure your app dispatches Cmd.Exit from the right places.

What the runtime already does

  • TuiRuntime.run registers a JVM shutdown hook that restores the terminal (alt-buffer off, cursor visible, raw mode off, mouse tracking off) even on uncaught exceptions or kill.
  • All Subs registered via RuntimeCtx (Sub.Every, Sub.InputKey, Sub.TerminalResize) auto-cancel on Cmd.Exit and on shutdown.
  • The renderer watches for SIGWINCH (size change) and re-renders without you doing anything.

What you have to wire

  • A Quit Msg and a Cmd.Exit follow-up for it.
  • A keystroke that produces Quit for Ctrl-C (and ideally q).
  • An optional re-read of ctx.terminal.{width, height} on every update so the model knows the current size.

Code: the canonical wiring

import termflow.tui.{Cmd, KeyDecoder, RuntimeCtx, Sub, Tui, TuiApp, TuiRuntime, RootNode}
import termflow.tui.KeyDecoder.InputKey.*
import termflow.tui.Tui.*
import termflow.tui.TuiPrelude.*

object MyApp:

  final case class Model(
    width:  Int,
    height: Int,
    input:  Sub[Msg]
  )

  enum Msg:
    case KeyPressed(k: KeyDecoder.InputKey)
    case Quit

  object App extends TuiApp[Model, Msg]:

    private def syncSize(m: Model, ctx: RuntimeCtx[Msg]): Model =
      val w = ctx.terminal.width
      val h = ctx.terminal.height
      if w == m.width && h == m.height then m else m.copy(width = w, height = h)

    def init(ctx: RuntimeCtx[Msg]): Tui[Model, Msg] =
      val keys = Sub.InputKey[Msg](
        msg     = Msg.KeyPressed.apply,
        onError = _ => Msg.Quit,                  // hard-quit on input pump errors
        ctx     = ctx
      )
      Model(ctx.terminal.width, ctx.terminal.height, keys).tui

    def update(m: Model, msg: Msg, ctx: RuntimeCtx[Msg]): Tui[Model, Msg] =
      val sized = syncSize(m, ctx)
      msg match
        case Msg.KeyPressed(Ctrl('C'))  => Tui(sized, Cmd.Exit)
        case Msg.KeyPressed(CharKey('q')) => Tui(sized, Cmd.Exit)
        case Msg.KeyPressed(_)          => sized.tui
        case Msg.Quit                   => Tui(sized, Cmd.Exit)

    def view(m: Model): RootNode =
      RootNode(m.width, m.height, children = /* … */, input = None)

    def toMsg(input: PromptLine): Result[Msg] = Right(Msg.Quit)

  def main(args: Array[String]): Unit =
    val _ = args
    TuiRuntime.run(App)

Notes

  • syncSize on every update is the cheapest way to stay correct on resize. The cost is a single == comparison per message — effectively free.
  • Ctrl-C should always quit. Don't be tempted to swallow it: if your app hangs, the user will reach for Ctrl-C and expect a graceful exit.
  • Quit during a modal dialog. If you have an open Dialogs overlay, Ctrl-C can either close the dialog or quit the whole app. Pick one and be consistent. Most apps treat Ctrl-C as unconditional quit and Esc as "close the dialog".
  • Quit during text input. Inside a Prompt or TextField, q is a literal letter — only quit on q outside text fields. The wizard sample shows the quit-when-not-in-text-field idiom.
  • Don't System.exit. It bypasses the runtime's cleanup. Always return Cmd.Exit from update; the runtime takes it from there.
  • Crash recovery. If your app crashes despite all of this, reset in your shell will get the terminal back. The shutdown hook makes this rare.

For a working example, every demo app under modules/termflow-sample/.../apps follows this pattern.

Flag a session as needing attention

Long-running TUIs often want to ping the user when something interesting happens — a build finished, a streaming reply landed, an alert fired. The classic UX is the terminal bell (which most modern terminals translate into a tab-bar activity indicator) plus, where supported, a real desktop notification with a title and body.

TermFlow exposes two Cmd variants for this:

  • Cmd.RequestAttention — minimum signal. Emits BEL on every backend except Disabled; iTerm2 also bounces the dock icon via OSC 1337 RequestAttention.
  • Cmd.Notify(title, body) — desktop notification when the terminal has a protocol for it; falls back to BEL otherwise.

Quick example

import termflow.tui.*
import termflow.tui.Tui.*

enum Msg:
  case StreamFinished(reply: String)
  case BackgroundJobFailed(err: String)
  case Quit

def update(model: Model, msg: Msg, ctx: RuntimeCtx[Msg]): Tui[Model, Msg] =
  msg match
    case Msg.StreamFinished(reply) =>
      Tui(
        model.copy(reply = Some(reply)),
        Cmd.Notify("Reply ready", reply.take(60))
      )

    case Msg.BackgroundJobFailed(err) =>
      // BEL + iTerm2 RequestAttention. Cheaper than a full notification —
      // useful when the user is more likely to be watching the tab bar
      // than a notification centre.
      Tui(model.copy(error = Some(err)), Cmd.RequestAttention)

    case Msg.Quit => Tui(model, Cmd.Exit)

That's it. The runtime delegates to TerminalBackend.notify / TerminalBackend.requestAttention, which select the right escape sequence from the detected Capabilities.notifications.

What gets emitted

TerminalDetectionSequence
iTerm2TERM_PROGRAM=iTerm.appOSC 9 ; <message> BEL and OSC 1337 ; RequestAttention=yes BEL
kittyKITTY_WINDOW_ID set or TERM=xterm-kittyOSC 99 ; <metadata> ; <body> ST
GNOME Terminal / Tilix / xfce4-terminalVTE_VERSION setOSC 777 ; notify ; <title> ; <body> BEL
Anything else (xterm, Alacritty, WezTerm, …)TERM looks like a real terminalBEL (\x07)
dumb / unset TERMnothing (Disabled)

The current detection lives in Capabilities.detect; override the result by constructing Capabilities directly, or set TERMFLOW_NOTIFICATIONS=off|bell|auto to force a kind:

  • off — never emit anything.
  • bell — always emit just BEL, even on iTerm2 / kitty / VTE.
  • auto (default) — sniff the environment, pick the richest available kind.

Inside tmux / screen

tmux and screen translate BEL into an in-status-bar window-bell or activity flag (subject to set -g monitor-bell on). That covers the "flag this pane needs attention" use case directly — no extra wiring required.

The richer OSC sequences (OSC 9, OSC 99, OSC 777) do not reach the outer terminal by default. tmux can be configured to forward them via DCS passthrough (set -g allow-passthrough on) but this is off by default and TermFlow does not currently auto-wrap. If you need desktop notifications from inside tmux, either enable passthrough on the target session or rely on the BEL fallback.

Testing notifications

termflow-testkit's TuiTestDriver records every Cmd.RequestAttention and Cmd.Notify so apps can assert on them without a real terminal:

val driver = TuiTestDriver(MyApp, width = 80, height = 24)
driver.init()
driver.send(Msg.StreamFinished("hello"))

assert(driver.observedNotifications == List("Reply ready" -> "hello"))
assert(driver.attentionCount == 0)

No real escape bytes leave the harness — the driver intercepts the commands at dispatch time.

When to use which

  • Reach for Cmd.RequestAttention for cheap, frequent signals: a job failed, an alert fired, an error came back from a streaming call. Most terminals will silently fold a flurry of BELs into one activity flag.
  • Reach for Cmd.Notify for events the user genuinely wants to read in a notification centre: a build finished after several minutes, an agent finished a long task, a pull request is ready for review. Most OSes rate-limit notifications, so aggressive use will get the user to silence the app.

Caveats

  • Headless / SSH. BEL still works (it is just a byte). Desktop notifications reach whichever terminal the user is sitting at — the remote host has no idea whether the local terminal renders them.
  • No newlines / control bytes in titles or bodies. TermFlow strips C0 controls before emitting, so unsanitised user input will not break the OSC envelope.
  • Always opt in. Don't fire on every keystroke or every frame. Tie notifications to model transitions the user actually cares about.

Full-screen layouts that reflow on resize

If you build your view with Layout.row / Layout.column / Layout.Fill and want the result to fill the terminal — and keep filling it when the user resizes — you need TermFlow to resolve the layout against the actual frame size at render time, not when view runs.

The two RootNode shapes

ShapeWhen the layout resolvesLayout.Fill behaviour
RootNode(width, height, children, input)At view time, eagerlyCollapses to natural (zero) size
RootNode(width, height, Nil, input, layout = Some(l))At render time, against the frame's (width, height)Expands to fill the available space

Two helpers on Layout produce each shape:

val eager     = layout.toRootNode(width = w, height = h)              // children = resolve(...)
val budgeted  = layout.toBudgetedRootNode(width = w, height = h)      // layout = Some(layout), children = Nil

toRootNode is the right tool for fixed-size sub-regions, dialogs, and golden-snapshot tests where you want deterministic positions. For full-screen apps containing Fill / Grid / Border, use toBudgetedRootNode — that's the only form where the renderer knows the budget the layout should reflow into.

Take the canonical "header on top, scrolling middle, status bar pinned to the bottom" layout. The middle section uses Layout.Fill so it grows and shrinks with the terminal.

import termflow.tui.*
import termflow.tui.ScreenPrelude.*
import termflow.tui.Tui.*

case class Model(width: Int, height: Int, log: List[String])

enum Msg:
  case Resize(w: Int, h: Int)
  case Quit

object App extends TuiApp[Model, Msg]:

  def init(ctx: RuntimeCtx[Msg]): Tui[Model, Msg] =
    Model(ctx.terminal.width, ctx.terminal.height, log = Nil).tui

  def update(m: Model, msg: Msg, ctx: RuntimeCtx[Msg]): Tui[Model, Msg] =
    msg match
      case Msg.Resize(w, h) => m.copy(width = w, height = h).tui
      case Msg.Quit         => Tui(m, Cmd.Exit)

  def view(m: Model): RootNode =
    val header = TextNode(1.x, 1.y, List(" TermFlow demo ".text(bold = true)))
    val body   = TextNode(1.x, 1.y, List(m.log.lastOption.getOrElse("(idle)").text))
    val footer = TextNode(1.x, 1.y, List(s" ${m.width}×${m.height}  q: quit ".text))

    // Fill takes the rest of the column once header / footer are sized.
    // Without toBudgetedRootNode the Fill region would collapse to zero rows.
    val layout = Layout.Column(
      gap = 0,
      children = List(
        header.asLayout,
        Layout.Fill(body.asLayout),
        footer.asLayout
      )
    )
    layout.toBudgetedRootNode(width = m.width, height = m.height)

  def toMsg(input: PromptLine): Result[Msg] = Right(Msg.Quit)

Wire Sub.TerminalResize so Msg.Resize keeps (width, height) current and the renderer's frame budget tracks the live terminal — Fill then absorbs the extra rows automatically. No bespoke "what's the available height after the header" arithmetic in view.

Why both forms exist

Eager resolution is genuinely useful:

  • Fixed-size widgets and dialogs. A 40×10 confirm dialog drawn at a computed offset doesn't need to know the frame width.
  • Golden-snapshot tests. Asserting on RootNode.children is much easier when the children list is populated.
  • Composition. You can resolve a sub-layout to a list of vnodes, mix with hand-positioned nodes, and pass the lot to RootNode(children = ...).

The deferred toBudgetedRootNode form trades that ergonomics for the guarantee that Fill actually fills and the layout reflows on resize. For most "the whole TUI is one layout" apps, that's the right trade.

Mixing both

Both fields on RootNode may be set at once. children paints first, then the resolved layout is composited on top:

RootNode(
  width = m.width,
  height = m.height,
  children = staticBackground,         // eager — fixed positions
  input = focusedInput,
  layout = Some(reflowableForeground)  // budget-aware — reflows on resize
)

This is the seam most non-trivial apps end up using: a static decor behind a layout-driven main panel.

See also

API reference (Scaladoc)

The full API documentation is generated from source via sbt unidoc and deployed alongside this site:

Browse the Scaladoc

The Scaladoc covers the four published modules:

ModuleCoordinates
termflow-terminalorg.llm4s::termflow-terminal:0.4.0
termflow-screenorg.llm4s::termflow-screen:0.4.0
termflow-apporg.llm4s::termflow-app:0.4.0
termflow-widgetsorg.llm4s::termflow-widgets:0.4.0

Plus the umbrella termflow artefact that depends on all four, and the termflow-testkit artefact for testing.

The Scaladoc link above resolves once the docs site is deployed via the GitHub Pages workflow. Reading this page locally? Run sbt unidoc to generate Scaladoc into target/scala-3.X/unidoc/.

Migration notes

0.2.0 / 0.3.0 / 0.4.0 → 1.0 — no migration required. TermFlow 1.0 is source-compatible with the 0.2.x / 0.3.x / 0.4.x line. Apps that compiled and ran against any pre-1.0 release should compile and run against 1.0 unchanged.

The pre-1.0 cycle deliberately froze the public API surface; every landing in the 0.2.x → 1.0 window has been additive. The list below catalogues the user-visible additions you can opt into in a 1.0 app that was originally written against 0.2.0.

What's new since 0.2.0

Recoverable error overlay

Cmd.TermFlowErrorCmd(err: TermFlowError) raises a transient red banner above the next frame and clears it on the following render — the runtime keeps looping. See the app-layer guide for the rendered shape and the testkit hook (TuiTestDriver.observedErrors).

Cmd.asyncResult(task, onSuccess, onError, onEnqueue) is the preferred bridge for fallible async work — it folds an AsyncResult[A] (i.e. Future[Result[A]]) into the runtime, routing Right(a) through onSuccess and Left(err) through onError. Future failures themselves still surface via TermFlowErrorCmd automatically.

Notifications

Two new commands plus matching capability detection:

  • Cmd.RequestAttention — ring the bell or pop the terminal's attention indicator.
  • Cmd.Notify(title, body) — desktop notification on iTerm2 / kitty / GNOME Terminal (VTE), with BEL fallback.

Capabilities gained a notifications: NotificationKind field (Disabled | BellOnly | ITerm2 | Kitty | Vte). The full pattern is documented in the notifications cookbook. Override the protocol via TERMFLOW_NOTIFICATIONS=off|bell|auto.

Layout: Grid, Border, budgeted resolve

Layout.Grid(columns, rowGap, colGap, cells) and Layout.Border(top, left, center, right, bottom, gap) join the existing Row / Column / Fill / Zone cases. GridCell expresses row / column spans. Both flow through Layout.resolveTo so the renderer reflows them on resize.

Layout.toBudgetedRootNode(width, height, input) is the new deferred sibling to the eager toRootNode — it puts the layout into RootNode.layout so the renderer resolves it against the actual frame budget at render time. This is the right form for full-screen apps and any layout containing Fill / Grid / Border. See the full-screen-layout cookbook for the eager-vs-deferred trade-off.

Mouse-wheel scrollback

LogView.scrollDelta(event, viewport, ticksPerDetent) plus LogView.Viewport(at, width, height) map a MouseEvent.Scroll onto a clamp-friendly scroll delta when the wheel lands inside the viewport, returning None for outside-the-rect or non-scroll events. Wired into the chatDemo sample; covered in the streaming-output cookbook.

Reduced-motion flag

TermFlowConfig.accessibility.reducedMotion (resolved from the TERMFLOW_REDUCED_MOTION env var or HOCON termflow.accessibility.reduced-motion) lets apps suppress cosmetic animation. Spinner accepts a reducedMotion: Boolean parameter that pins it to frames(0) when true; apps can read the flag for their own animation budgets. See the accessibility guide.

Post-1.0 — when MiMa lights up

Binary-compatibility enforcement (sbt-mima-plugin) is scheduled for the 1.0.x / 1.1 cycle, with 1.0.0 as the baseline. From the first post-1.0 release onwards this page will list:

  • A summary of breaking changes from the most recent line.
  • The mimaBinaryIssueFilters rationale for any deliberately accepted breaks.
  • Code-level before / after recipes for the most common patterns affected.
  • Deprecation timelines for symbols slated for removal in the next major release.

Until then, the release notes remain the source of truth for additive changes.

Thread model

TermFlow's runtime uses several threads behind a deliberately small set of synchronisation points. This page documents the topology and the invariants apps and widget authors can rely on, so you don't have to reverse-engineer them from TuiRuntime.scala.

Topology

                        ┌─────────────────────────┐
                        │   runtime thread        │
                        │   (the main loop)       │
                        │                         │
                        │   - drain CmdBus        │
                        │   - run TuiApp.update   │
                        │   - run TuiApp.view     │
                        │   - render frame        │
                        └────┬────────────┬───────┘
                             ▲            │
                             │ publish    │ writes ANSI
                             │            ▼
                        ┌────┴───┐    ┌───────────────┐
                        │ CmdBus │    │ TerminalBackend│
                        │ (queue)│    │ (JLine writer) │
                        └────▲───┘    └───────────────┘
                             │
              ┌──────────────┼──────────────┬──────────────────┐
              │              │              │                  │
   ┌──────────┴──┐   ┌───────┴────┐  ┌──────┴──────┐  ┌────────┴────────┐
   │ InputKey    │   │ Sub.Every  │  │ FCmd Future │  │ TerminalResize  │
   │ producer    │   │ scheduler  │  │ executor    │  │ SIGWINCH        │
   │ (one        │   │ (one       │  │ (global EC) │  │ listener        │
   │  thread     │   │  scheduled │  │             │  │ (JLine          │
   │  per Sub)   │   │  thread    │  │             │  │  callback       │
   │             │   │  per Sub)  │  │             │  │  thread)        │
   └─────────────┘   └────────────┘  └─────────────┘  └─────────────────┘

Every off-main-thread component talks to the runtime by publishing to CmdBus. The runtime is the only consumer.

Threads in detail

ThreadLifetimeRole
runtime threadTuiRuntime.run(app) start → Cmd.Exit (or shutdown hook)Executes update, view, and renders. Single-threaded.
InputKey producerLazy: started by the first RuntimeCtx.registerSub of a Sub.InputKeyReads keystrokes off the TerminalBackend.reader and publishes Cmd.GCmd per parsed key.
Sub.Every schedulerLazy: per-Sub.Every, started on registerSubA ScheduledExecutorService ticks at the configured period and publishes a Cmd.GCmd per tick.
FCmd Future executorPer Cmd.FCmd — uses ExecutionContext.global by defaultRuns the user's Future body off the runtime thread; the continuation publishes a result Cmd back.
Resize listenerLazy on Sub.TerminalResizeJLine's SIGWINCH callback runs on its own thread; the listener publishes a Cmd.GCmd per resize.
JVM shutdown hookRegistered once by TuiRuntime.runRestores cursor / leaves alt-buffer / disables mouse on abrupt exit.

Invariants you can rely on

  1. update and view always run on the runtime thread. No two update calls overlap — the bus is a single consumer. Mutating Model inside update is therefore safe by construction (and the model should be immutable anyway).
  2. Cmd.FCmd continuations come back through the bus. When your Future[A] completes, the result mapper runs off the runtime thread, but the produced Cmd is published — so the update that sees it runs on the runtime thread again. You don't need to synchronise on shared state, only on whatever the Future's body itself touches.
  3. Sub callbacks run off their respective threads. A Sub.Every tick fires on the scheduler thread, an InputKey parse fires on the producer thread. Whatever you do in the callback that produces a Cmd runs off-main, but the resulting Cmd always arrives at update on the runtime thread.
  4. Subscriptions start lazily. Sub.InputKey, Sub.Every, and Sub.TerminalResize don't spawn threads or schedule timers until RuntimeCtx.registerSub is called. Tests using TestRuntimeCtx keep them dormant deliberately.
  5. CmdBus is a serialising queue. Multiple producer threads can publish concurrently; the runtime's take / poll is the single consumer. Order across producers is FIFO by enqueue time.

Gotchas

  • Don't block the runtime thread. Anything synchronous inside update blocks the entire frame. Use Cmd.FCmd (or Cmd.asyncResult) for I/O.
  • Don't close over mutable state inside FCmd. The body runs off-main, possibly concurrently with another FCmd body, and the continuation also runs off-main before the resulting Cmd is published. Pass the data you need by value into the Future.
  • Sub.Every tick drift is not corrected. The scheduler uses scheduleAtFixedRate, which can drift if the runtime is slow to drain ticks. If exact wall-clock cadence matters, use Sub.Every for triggering and read System.currentTimeMillis() inside update for the timestamp.
  • JLine callbacks (resize, signals) are not on a TermFlow-managed thread. Don't do anything in those callbacks except publish to CmdBus.

Subscription cleanup

CmdBus.cancelAllSubscriptions() is called by the runtime on exit (both clean exit via Cmd.Exit and abrupt exit via the shutdown hook). Each Sub's cancel() is best-effort: it may interrupt the producer thread, shut down a scheduler, or close a Reader. If one cancel throws, the rest still run.

Testing

For deterministic tests, use the testkit:

  • TestRuntimeCtx keeps subs dormant — registerSub does not call start().
  • TuiTestDriver advances the model by feeding Msgs directly, bypassing the bus and threading concerns.
  • KeySim and MouseSim synthesise input events.

Real-time behaviour (Sub.Every cadence, JLine reader latency) is deliberately not covered by the testkit — that's what the sample apps exist for.

TermFlow Design

TermFlow is a small terminal UI (TUI) framework for Scala built around a simple, functional architecture.

This document captures the current design and the intended direction.

Core Architecture

TermFlow apps follow a familiar “model / update / view” structure:

  • Model: your application state (plain case classes)
  • Update: a function that takes (model, msg) and returns a new model plus an optional command
  • View: a function that takes the model and returns a render tree (a small virtual DOM)

Tui

The main return type from update is:

  • Tui[Model, Msg] which contains:
    • model: Model
    • cmd: Cmd[Msg]

Cmd (commands)

Commands represent side effects that happen “after” an update:

  • Cmd.GCmd(msg) – enqueue a message for the next update loop
  • Cmd.FCmd(...) – run an async task and publish its result as a message
  • Cmd.asyncResult(task, onSuccess, onError) – idiomatic helper for the common case where the async task returns AsyncResult[A] (i.e. Future[Result[A]]). Routes successes through onSuccess, logical failures through onError, and lets the runtime surface infrastructure failures via Cmd.TermFlowErrorCmd automatically.
  • Cmd.Exit – exit the runtime
  • Cmd.TermFlowErrorCmd(err) – surface a recoverable error to the renderer

This keeps update mostly pure and makes long-running work explicit.

Effect-system stance. TermFlow uses scala.concurrent.Future and the framework's Result[A] = Either[TermFlowError, A], exposed as type AsyncResult[+A] = Future[Result[A]] in TuiPrelude. The alias mirrors the llm4s core 1:1 so values pass between the two libraries without an adapter. We do not ship cats-effect or ZIO integration modules; apps that use those bridge to a Future at the Cmd boundary.

Sub (subscriptions)

Subscriptions are event sources that publish messages over time:

  • input keypress stream
  • timers (e.g., spinners, uptime tick)

Subscriptions can be started/stopped by holding them in the model and calling cancel() when they’re no longer needed.

Prompt

Prompt handling is integrated as a reusable helper:

  • it consumes normalized InputKey
  • maintains a buffer + cursor
  • provides render output and a cursor position
  • supports command-like flows where pressing Enter yields a Msg or validation error

Rendering

TermFlow renders a small virtual DOM consisting of nodes like:

  • TextNode, InputNode, BoxNode

The runtime uses an ANSI renderer with:

  • command coalescing in the runtime loop
  • framebuffer materialization
  • frame-to-frame diffing that emits minimal ANSI patches

See RENDER_PIPELINE.md for render flow, invariants, and regression focus areas.

Testing

Most logic should be testable by:

  • unit-testing update functions (pure state transitions)
  • unit-testing prompt editing behavior (PromptSpec)
  • unit-testing history/search behavior (HistorySpec)

The runtime loop is intentionally small; most behavior should live in update/view helpers.

Render Pipeline

TermFlow now uses a coalesced runtime scheduler with framebuffer diff rendering to reduce flicker under high-frequency updates.

Pipeline

state events
  -> mark frame dirty
  -> coalesce queued commands
  -> build latest view (once per commit)
  -> materialize next frame buffer
  -> diff(previous, current)
  -> emit minimal ANSI patch
  -> restore logical input cursor

Key Invariants

  1. At most one render commit is active at a time.
  2. Rendering is latest-state wins; intermediate states can be dropped during bursts.
  3. Diff output includes cleanup for shrink cases (line tail and removed rows).
  4. Cursor visibility is runtime-owned (startup/shutdown/interrupt), not frame-owned.
  5. Each frame writes one buffered ANSI payload from renderer perspective.

Why Flicker Dropped

  • Most frames update a small subset of cells instead of repainting everything.
  • Command bursts are coalesced before render, avoiding redundant intermediate paints.
  • Cursor movement is deterministic and restored after body updates.

Regression Focus Areas

  • Prompt/input updates must not leave stale tail characters.
  • Shrinking content must clear removed rows/columns.
  • Borders at terminal edges must remain stable across rapid updates.
  • Ctrl-C / unexpected exits must always restore cursor visibility.

Snapshot Testing

The termflow.testkit package (published on the test->test classpath) drives a TuiApp synchronously — bypassing TuiRuntime — and lets tests compare the resulting RenderFrame against on-disk golden files. This is the primary regression safety net for the items above.

Writing a snapshot test

import org.scalatest.funsuite.AnyFunSuite
import termflow.testkit.{GoldenSupport, TuiTestDriver}

class MyAppSnapshotSpec extends AnyFunSuite with GoldenSupport:
  test("initial frame"):
    val d = TuiTestDriver(MyApp.App, width = 40, height = 10)
    d.init()
    d.send(MyApp.Msg.DoSomething)
    assertGoldenFrame(d.frame, "after-do-something")
  • TuiTestDriver exposes model, frame, send(msg), cmds, exited, and observedErrors.
  • Subscriptions (Sub.Every, Sub.InputKey, Sub.TerminalResize) are never started — tests stay deterministic. For prompt-driven apps, construct the wrapping Msg directly (e.g. Msg.ConsoleInputKey(KeyDecoder.InputKey.CharKey('+'))) instead of waiting on the input thread.
  • Cmd.FCmd must wrap a pre-resolved Future.successful(...); the driver will not block on unresolved futures.

Golden file format

Goldens are stored under src/test/resources/termflow/golden/<SuiteName>/<name>.golden and use a pipe-wrapped, chars-only format:

# width=40 height=13
# cursor=6,10
|┌──────────────────────────────────┐    |
|│Current count: 0                  │    |
...

The leading/trailing | markers make every row's width unambiguous and preserve trailing whitespace across editors and CI. Style/colour information is intentionally omitted — layout is what the render pipeline regresses on.

Narrow assertions for prompt/cursor rows

Full-frame snapshots are brittle for tests that care about a single row (e.g. a prompt repaint regression). Use assertGoldenString with a one-row extract:

private def row(frame: RenderFrame, rowIndex: Int): String =
  val cells = frame.cells(rowIndex - 1)
  val sb    = new StringBuilder("|")
  (0 until frame.width).foreach(c => sb.append(cells(c).ch))
  sb.append("|").toString

assertGoldenString(row(d.frame, 7), "cursor-row-after-abc")

InputLineReproSpec uses this pattern to pin the exact cursor-math regressions behind #73 and #74.

Updating goldens

Record or refresh a snapshot:

sbt -Dtermflow.update-goldens=true "termflowSample/testOnly *MyAppSnapshot*"
# or:
UPDATE_GOLDENS=1 sbt "termflowSample/testOnly *MyAppSnapshot*"

Always review the resulting git diff before committing. The -Dtermflow.update-goldens=true system property is forwarded into forked test JVMs by build.sbt.

What snapshot tests catch

  • Repaint regressions (stale cells left behind on shrink/clear)
  • Cursor-position drift when the prompt viewport scrolls
  • Layout drift when terminal dimensions change behaviour
  • Border / text overflow interactions at box edges

They do not exercise timer-driven or random sources — those apps (DigitalClock, SineWaveApp, FutureCounter) are deliberately excluded from the golden suite.

TermFlow Roadmap

Status: 2026-05-01 · Current release: 0.4.0 · Working towards 1.0.

Stages 1–3 are complete. Stage 4 (1.0 stabilisation) is nearly done: §3.2 (sample apps), §3.3 (killer demo, hosted in llm4s), §3.4 (Grid

  • Border layouts), and §3.5 (migration guide — "no migration needed for 0.2.x → 1.0") have all landed, and §4.1 / §4.3 / §4.4 / §4.5 / §4.6 / §4.7 / §4.8 / §4.9 / §4.10 of the pre-1.0 release-hardening checklist are closed. §4.2 (release-doc sweep) is the last open item before cutting v1.0.0-RC1. MiMa (§3.1) was deferred to the post-1.0 cycle (baseline = 1.0.0).

This document is forward-looking: it describes the work left, not the history of the work done.


1. Vision

Pure-FP TUIs for Scala. Deterministic, golden-tested, type-safe — with mouse, themes, and modal dialogs when you need them.

TermFlow's defensible niche is the Elm Architecture done well in Scala: immutable model, pure update, declarative view, async via Cmd/Sub, plus a snapshot test harness no other Scala TUI library offers.

Non-goals

  • A Lanterna port. No mutable widgets, no shared GUI thread, no listener callbacks as the primary event mechanism.
  • A general-purpose toolkit. TermFlow targets interactive CLIs, REPLs, dashboards, installers, chat clients, and admin tools — not full-screen text editors or game engines.
  • Curses parity. Just enough primitives to render and diff correctly.

2. Current state (0.4.0)

What ships today:

  • Five published modulestermflow-terminal / -screen / -app / -widgets plus the umbrella termflow artefact, and termflow-testkit for tests. Maven Central as org.llm4s::*.
  • Elm-style runtimeTuiApp[Model, Msg], Cmd, Sub, RuntimeCtx, 60-fps frame-diffed ANSI renderer with capability downgrade.
  • Capability detection — true-colour / 256-colour / 16-colour / 8-colour / mono, bracketed paste, mouse (SGR-1006), extended modifier parsing, SIGWINCH-driven resize, terminal-attention notifications (iTerm2 OSC 9 / 1337, kitty OSC 99, VTE OSC 777, BEL fallback).
  • 20+ widgetsTextField, MultiLineInput, Button, CheckBox, RadioGroup, Select, Autocomplete, ListView, Table, Tree, Tabs, SplitPane (drag-resize), Separator, ScrollBar, ProgressBar, Spinner, StatusBar, LogView, MenuBar, Form. Plus Layout DSL (Row / Column / Fill / Zone), HitTest[Id], Theme + BorderChars.
  • Seven dialog helpersmessage, confirm, textInput, listSelect, waiting, fileDialog, directoryDialog, actionList.
  • Grapheme-aware text editing — UAX #29 cluster boundaries via BreakIterator, wide-cell math via WCWidth.
  • TestkitTuiTestDriver, KeySim, MouseSim, GoldenSupport. Published as termflow-testkit.
  • ~22 sample apps — counter, async counter, clock, dashboard, echo, hello, forms, wizard, dialog, file-dialog, themes, unicode, stress, sine, hub, input, tabs, task, catalog, widgets, showcase, tree, editor, chat.
  • Docs site — live at https://llm4s.github.io/termflow with Introduction, four tutorials, eight layer guides (including Accessibility), ten cookbook recipes (including the rolling-console / agent-UI walkthrough), a thread-model reference, and aggregated Scaladoc.

No outstanding architectural debts for 1.0. Remaining work is in §3 and the pre-1.0 release-hardening checklist in §4.


3. Stage 4 — Stabilisation (target: 1.0.0)

The lock-in stage. Goal: produce an API stable enough to keep unchanged for years.

3.1 MiMa for binary compatibility — deferred to post-1.0

Originally planned as a 1.0 gate, but moved out of the critical path (decision: 2026-04-30). The simpler workflow is to ship 1.0 first, then wire sbt-mima-plugin against 1.0.0 as the baseline so every subsequent release (1.0.x, 1.1.0, …) is checked.

Why the change:

  • 0.2.0 is already API-stable, so the 0.2.0 → 1.0 filter list would mostly be noise.
  • Avoids the bookkeeping cost of maintaining mimaBinaryIssueFilters during the 0.2.x → 1.0 window.
  • Removes the chicken-and-egg between §3.1 and §3.5 — see §3.5 for the simplified migration-guide stance.

Plan once 1.0 ships:

  1. Add sbt-mima-plugin to project/plugins.sbt.
  2. Set mimaPreviousArtifacts := Set("org.llm4s" %% name % "1.0.0") on each of termflow-terminal, termflow-screen, termflow-app, termflow-widgets, termflow, and termflow-testkit.
  3. Append mimaReportBinaryIssues to the ciCheck alias.

This becomes part of the 1.0.1 / 1.1.0 release prep, not 1.0.

3.2 Sample app catalogue gaps — ☑ landed

All three planned samples shipped (#188 / #189 / #190): tree/ (file-tree explorer over the Tree widget), editor/ (multi-buffer text editor exercising MultiLineInput + SplitPane + MenuBar), and chat/ (streaming chat with scrollback per the streaming-output cookbook recipe). Catalogue now stands at 22 apps.

3.3 Killer demo — ☑ landed

The llm4s streaming chat client shipped at modules/samples/.../chat/tui in the llm4s repo. It exercises the headline TermFlow surface — live streaming via the LogView auto-tail pattern, mouse-wheel scrollback, slash commands (/help, /clear, /theme), light/dark theme toggle, and a pinned bottom prompt — against a real Anthropic-API backend.

Termflow's README now leads with the chat client screenshot (docs/assets/chat-ui-screenshot.png) plus a "Built with TermFlow — source at llm4s/llm4s/.../chat/tui" link, exactly as the §3.6 DoD asks.

The original handover spec (docs/design/chat-tui-demo-spec.md in llm4s) and the implementation plan that grew out of it remain useful reference material; the live source supersedes them as the canonical example.

3.4 GridLayout + BorderLayout — ☑ landed

Shipped in #187. Layout.Grid(columns, rowGap, colGap, cells) with GridCell row/column spans, and Layout.Border(top, left, center, right, bottom) with five-zone resolution. Both flow through Layout.resolveTo so existing resolve callers are unchanged. LayoutGridSpec and LayoutBorderSpec cover sizing, gaps, spans, and zone omission.

3.5 Migration guide

With MiMa deferred (§3.1), there is no automated source of breakage data for 0.2.0 → 1.0. The guide page stays useful as a hand-written record of any deliberate API changes during the run-up to 1.0.

For 1.0:

  • Walk the public API by hand, note any intentional breaks, and write before/after recipes for each.
  • If nothing broke, collapse the page to "no migration needed; 1.0 is source-compatible with 0.2.0."
  • Once §3.1 is wired post-1.0, future entries are driven by MiMa filters as originally intended.

3.6 Definition of done for 1.0

  • ☑ Docs site live at https://llm4s.github.io/termflow.
  • ☑ Tutorial ladder complete (Hello World, Counter, Async, Forms).
  • ☑ Sample app count ≥ 20 (22 today, including tree, editor, chat).
  • Layout.Grid + Layout.Border shipped (§3.4).
  • ☑ Migration guide populated — docs/reference/migration.md carries the explicit "no migration needed for 0.2.x → 1.0" statement plus the additive-changes catalogue (§3.5 / §4.3).
  • ☑ README headline screenshot is the llm4s chat client (§3.3 — demo hosted in llm4s, screenshot + link in this README).
  • ☑ Pre-1.0 release-hardening checklist complete (§4) — interim release-doc sweep at 0.4.0 finished §4.2; the final 0.4.0 → 1.0.0 bump rides with the release PR itself.

MiMa (§3.1) was originally on this list; it is now scheduled for the 1.0.1 / 1.1.0 cycle with 1.0.0 as the baseline.


4. Stage 5 — Pre-1.0 release requirements

Final hardening before tagging v1.0.0. These are not new feature tracks; they are release-quality gates for the current library surface.

4.1 User-visible error path — ☑ landed

Cmd.TermFlowErrorCmd now reaches the user. SimpleANSIRenderer overlays a red, bold banner across the top row of the next frame and clears it on the following render — long messages truncate with an ellipsis. SimpleANSIRendererSpec covers banner content, styling, truncation, and frame immutability. TuiTestDriver.observedErrors gives tests the same assertion path without a real terminal. The app-layer guide has a new "How errors reach the user" section that shows the rendered banner and the testkit hook.

4.2 Version and release-doc sweep — ☑ landed (interim 0.4.0 pass)

Every copy-pasteable coordinate in the published docs has been bumped from 0.2.0 to the current released baseline (0.4.0): README quick- start, all four layer guides, the testkit guide, the install page, the Hello-World tutorial, and the API reference module table. Migration notes acknowledge 0.2.0 / 0.3.0 / 0.4.0 → 1.0 as the no-migration window, and the new reduced-motion entry is catalogued. The roadmap headline now reads Current release: 0.4.0. Release-instructions still describe the v[0-9]* tag path, which already covers v1.0.0.

A second, narrower sweep happens at 1.0 tag time: bump every 0.4.01.0.0, drop the "no migration needed" wrapper from migration.md (or re-cast it as historical), and update the §2 "Current state" heading. That bump is mechanical — same files, same patterns — and is deferred to the release PR rather than landed pre-emptively, so the coordinates always reflect what's actually on Maven Central.

Historical mentions of 0.2.0 / 0.3.0 are kept in places where they describe the migration story (e.g. early-semver examples in the README, the pre-1.0 cycle catalogue in migration.md, 0.2.0 → 1.0 references in §3.5 / §4.7 of this roadmap) — those are intentional and explained in context.

4.3 Public API and docs example audit — ☑ landed

Walked the public-facing APIs and every tutorial / guide / cookbook snippet against the source. Eight signature drifts and dead-code blocks in the docs were corrected (Cmd.asyncResult, Sub.TerminalResize, Cmd.FCmd type params, MultiLineInput.handleKey curried form, Capabilities.notifications, missing RootNode.input arg, mistyped Cmd.FCmd in the file-picker recipe, orphaned Layout.column block in the full-screen-layout recipe). Cmd.RequestAttention and Cmd.Notify are now reflected in the app-layer Cmd table. docs/reference/migration.md is populated with the explicit no-migration-required statement plus a catalogue of additive changes since 0.2.0. mdbook build (with linkcheck) is green. SPI markers on TuiRenderer / CmdConsumer / CmdBus / EventSink / EventSource were already in place; any further Scaladoc polish belongs in §4.8.

4.4 Layout ergonomics audit — ☑ landed

Layout.toBudgetedRootNode(width, height, input) now expresses the deferred form alongside the existing eager toRootNode, putting the layout into RootNode.layout so the renderer resolves it against the frame's full budget at render time. toRootNode keeps its eager semantics; its Scaladoc flags the trap and points at the budgeted form. A new cookbook recipe (full-screen-layout.md) walks the header / fill / footer pattern, the eager-vs-deferred trade-off, and the both-fields composition seam. LayoutSpec covers both forms with a Fill-collapses vs Fill-expands comparison.

4.5 Rolling console / agent UI recipe — ☑ landed

docs/cookbook/rolling-console.md walks the Claude-Code / Cursor-style transcript pattern end-to-end: model shape (Vector[String] buffer + scrollOffset + autoTail), auto-tail logic, key routing (Arrow / PageUp / PageDown / End / Ctrl+C), mouse-wheel scrollback through LogView.scrollDelta, view composition with LogView + an InputNode prompt, and the Layout.Border alternative for resize-clean layouts. The recipe is explicit that TermFlow owns an in-app scrollback viewport inside the alternate buffer; native terminal scrollback is the post-1.0 §5.3 idea, not the default runtime contract. Linked from docs/cookbook/index.md, docs/SUMMARY.md, and the LogView section of the widgets guide. ChatStreamApp (sbt chatDemo) is the live worked example referenced from the recipe.

4.6 Mouse-wheel scrolling for LogView-style views — ☑ landed

LogView.scrollDelta(event, viewport, ticksPerDetent) plus LogView.Viewport(at, width, height) map a MouseEvent.Scroll onto a clamp-friendly scroll delta when the wheel lands inside the viewport, returning None for outside-the-rect or non-scroll events. chatDemo wires it in (3 lines per detent, ignored over prompt / status row). LogViewSpec exercises every helper branch; ChatStreamAppSpec adds two MouseSim.scrollUp tests for the in-pane and out-of-pane paths. The streaming-output cookbook recipe documents the pattern next to the keyboard fallbacks.

4.7 Test coverage review and quick wins — ☑ landed

Because TermFlow is mostly deterministic UI logic, coverage should be higher than a typical terminal integration project. Run sbt --batch coverageLib, inspect the lowest-covered files/branches, and take the low-risk wins before 1.0.

Current local coverage snapshot (2026-04-30, post a971a94):

  • termflow-terminal: 87.79% statements / 90.48% branches.
  • termflow-screen: 90.76% statements / 77.45% branches.
  • termflow-app: 86.99% statements / 81.20% branches.
  • termflow-widgets: 92.69% statements / 85.23% branches.

The coverage-uplift branch (a971a94) lifted termflow-terminal from 66.04% → 87.79% statements (63.32% → 90.48% branches), termflow-screen from 71.81% → 90.76% statements (65.22% → 77.45% branches), and termflow-app from 80.29% → 86.99% statements (68.61% → 81.20% branches). All four published modules are now ≥87% statement coverage, with termflow-terminal and termflow-screen — the §4.7 priority targets — both above 87%.

Acceptance:

  • Add focused tests for pure/render/update logic with obvious gaps.
  • Prefer deterministic tests over brittle real-terminal integration.
  • Record any accepted low-coverage areas with rationale (JLine/raw TTY integration, shutdown-hook paths, genuinely platform-specific code).
  • Keep coverageLib green and ensure the combined trend moves up, with particular attention to termflow-terminal and termflow-screen.

4.8 Zero-warning build and Scaladoc polish — ☑ landed

sbt --batch ciCheck is warning-free across all published modules and the sample/testkit projects.

sbt --batch unidoc is clean of unresolved-link warnings: five Scaladoc [[…]] references that pointed at out-of-scope or unqualified members were corrected — [[InputKey]] / [[InputKey.Tab]] now spell KeyDecoder.InputKey…, Theme.box's [[chars]] / [[border]] references the slot via [[Theme.chars]] / [[Theme.border]], [[Move]] and friends in MouseEvent are qualified as [[MouseEvent.Move]] etc., and TerminalBackend.onResize's JLine Terminal.Signal.WINCH reference dropped its broken [[…]] wrapper (JLine is on the runtime classpath but not a unidoc target). A non-exhaustive match warning in Keymap.renderKey (missing the InputKey.NoOp case) was closed at the same time.

The remaining [warn] Flag -classpath set repeatedly is a structural sbt-unidoc / Scala-3-doctool quirk (the doc invocation receives -classpath once from sbt-unidoc and once from the dotty front-end). It is not an unresolved-link warning and there is no in-tree fix; track upstream if it becomes load-bearing.

mdBook/linkcheck stay green; the docs site builds unchanged.

4.9 Reduced-motion flag — ☑ landed

A TERMFLOW_REDUCED_MOTION=1 env var (and TermFlowConfig field defaulting to it) that disables cosmetic animation: Spinner renders a static frame, indeterminate ProgressBar stops ticking, and apps can read the flag to drop their own animation. Useful for vestibular accessibility, screen-reader environments, and bandwidth-constrained sessions.

Acceptance:

  • Env var honoured at runtime construction.
  • Spinner renders a static frame when the flag is set.
  • Indeterminate ProgressBar ticks slow or become static.
  • Apps can query the flag via RuntimeCtx.
  • Documented in an accessibility.md docs page (also a home for any future a11y notes).

Tracked as #139.

4.10 Thread-model documentation — ☑ landed

A docs/reference/thread-model.md (or a new section in the app-layer guide) explaining the runtime's thread topology — runtime thread, input producer, Sub.Every scheduler, Future executor, CmdBus — and the invariants apps and widget authors can rely on (update / view always run on the runtime thread; Cmd.FCmd continuations return via the bus; Sub callbacks run off their respective threads; don't close over mutable state inside FCmd; don't block the runtime thread).

Doc-only; no code changes. Pairs naturally with §4.5.

Tracked as #134.


5. Stage 6 — Alternative backends and renderers (post-1.0, speculative)

Each backend or renderer is opt-in. None are on the 1.0 critical path.

5.1 Telnet backend

termflow-backend-telnet. Bind a port, accept connections, each connection becomes a TerminalBackend. Telnet option negotiation (NAWS for size, ECHO suppression, binary). Small library; medium effort.

Use case. Self-hosted admin consoles, MUDs, BBS-style apps, oncall debug shells. Not encrypted — operators wrap it in stunnel / WireGuard / SSH-jumphost for production.

5.2 Web backend (xterm.js over WebSocket)

termflow-backend-web. Serve xterm.js plus a WebSocket; the WS frames become the terminal stream. Run a TUI in a browser tab.

Effort. Large but cleanly bounded. Probably the most "wow factor" of the bunch — a Scala TUI in the browser is unique.

5.3 Rolling console renderer

A constrained normal-buffer renderer/runtime mode for agent and command runner apps that want native terminal scrollback: output appends to the terminal's real history while a live prompt/status area remains pinned near the bottom.

This should not try to support arbitrary full-screen TermFlow VDOM. The current default runtime enters the alternate buffer and the default renderer diffs fixed frames by absolute coordinates; native scrollback needs a different contract built around append-only transcript events, prompt repainting, cursor save/restore, and possibly terminal scroll regions.

Use case. Claude Code / Cursor-style agents, build runners, REPLs, and long-running command logs where users expect their terminal emulator's own scrollbar, copy/search behaviour, and shell history context to keep working.

Shape. Likely a dedicated RollingConsoleApp or renderer API, not a flag on SimpleANSIRenderer. It should support bounded app-side history for replay/testing, normal-buffer append, fixed bottom prompt, keyboard input, resize handling, and a graceful fallback when terminals handle scroll regions poorly.

Effort. Medium-large and compatibility-sensitive. Worth prototyping after 1.0, but too risky to make part of the 1.0 contract.

5.4 Explicitly not planned (third-party PRs welcome)

  • Swing / AWT emulator backend — desktop window with a TUI emulator inside. Skipped because §5.2 covers most of the same use cases at similar effort and reaches more users.
  • SSH backend — wrap Apache MINA SSHD or sshj. Skipped because key management, auth, and connection state are a perpetually-supported surface area we don't want to own. Compose §5.1 Telnet behind an external SSH jump host instead.

The TerminalBackend trait stays public so external contributors can ship either as a separate artefact.


6. Open questions

  1. Native image. sbt-native-image build for graalvm-native-image? The shutdown hook + JLine reflection make this non-trivial. Investigate post-1.0.
  2. Cross-publish for Scala 2.13. Already on legacy-213-track. Keep parity through 1.0, then re-evaluate based on adoption.
  3. Resizing semantics. When the terminal shrinks, do we clip or rewrap? Currently clip. The Layout pass makes rewrap cheap if we want to switch.
  4. i18n. Right-to-left text? Bidi? Probably out of scope for 1.0; document as a known limitation.
  5. Accessibility. Screen readers don't really hit a TUI — they read the terminal directly. Worth an "Accessibility notes" docs section before 1.0.
  6. Windows native. Currently relies on JLine for cmd.exe / Windows Terminal. May need a JNA-backed WindowsConsoleBackend if JLine's behaviour proves insufficient.

7. Lanterna comparison reference

The original 0.1.x roadmap was structured around a comparison with Lanterna. That comparison drove Stages 1–3, and almost every Lanterna component now has a TermFlow equivalent (TextFieldTextBox, ListViewActionListBox, SplitPaneSplitPanel, etc.). Two Lanterna things we deliberately don't match:

  • The shared GUI thread / listener-callback event model. Replaced by the Elm-style update loop. Not coming back.
  • Mutable widgets. Replaced by stateless renderers + state on the app's model. Not coming back.

Two TermFlow-only wins worth preserving through 1.0:

  • Golden-snapshot testing (TuiTestDriver + GoldenSupport).
  • HitTest[Id] cache built from the layout pass — Lanterna has no equivalent.

8. Recent decisions (rolling, last ~3 months)

  • 2026-05-01 — Stage 4 §4.2 closed (interim 0.4.0 pass): every copy-pasteable install coordinate in README + layer guides + tutorials + install page + API reference now reads 0.4.0 instead of 0.2.0. Migration notes acknowledge 0.2.0 / 0.3.0 / 0.4.0 → 1.0 as the no-migration window. Roadmap headline + §2 heading + cookbook / guide counts updated to reflect the post-§4.5 / §4.10 / a11y state. The final 0.4.0 → 1.0.0 mechanical bump is deferred to the release PR. With this, §3.6 DoD is complete and 1.0-RC1 is unblocked.
  • 2026-05-01 — Stage 4 §3.3 closed: the llm4s chat client shipped at modules/samples/.../chat/tui in the llm4s repo. Termflow's README now leads with the screenshot (docs/assets/chat-ui-screenshot.png) and links to the source. The §3.6 DoD's "README headline screenshot" box is ticked.
  • 2026-04-30 — Stage 4 §4.5 closed: rolling-console / agent-UI cookbook recipe landed at docs/cookbook/rolling-console.md. Walks the buffer + scrollOffset + autoTail pattern with key routing, mouse-wheel scrollback, and bounded history; ChatStreamApp is the worked example. Native terminal scrollback explicitly stays a post-1.0 §5.3 idea, not the default contract.
  • 2026-04-30 — Stage 4 §4.9 closed: reduced-motion flag landed. TERMFLOW_REDUCED_MOTION env var (and HOCON termflow.accessibility.reduced-motion) flows through RuntimeCtx.config.accessibility.reducedMotion; Spinner accepts reducedMotion: Boolean and pins to frames(0) when true. New docs/guide/accessibility.md page covers the colour, motion, and predictable-structure stance.
  • 2026-04-30 — Stage 4 §4.10 closed: thread-model documentation landed at docs/reference/thread-model.md. Diagrams the topology (runtime thread, InputKey producer, Sub.Every scheduler, FCmd executor, resize listener, shutdown hook) and the invariants apps can rely on (`update` / `view` always on the runtime thread, FCmd continuations come back via the bus, subs lazy-start, CmdBus serialises, etc.).
  • 2026-04-30 — Issue triage: 25 completed issues closed; #154 (relative-coordinate VDom), #119 (Layout v1→v2 migration guide), #116 (horizontal scroll), and #117 (partial left-edge clip) all closed as superseded — the first two because the current Layout DSL already supplies the relative-coordinate tree and there are no v1 users; the latter two because Layout.Scroll / Layout.Clip only ever existed on the abandoned feature/layout-cookbook branch and never shipped publicly. Two new entries added to §4 as 1.0 scope: §4.9 (reduced-motion flag — #139) and §4.10 (thread-model documentation — #134). #141 (TuiRuntime error-path tests) scoped down to two remaining gaps (Sub mid-stream exception, CmdBus overflow); not a 1.0 blocker.
  • 2026-04-30 — Stage 4 §4.7 closed: coverage-uplift branch merged (a971a94). termflow-terminal 66% → 88% stmts / 63% → 90% branches, termflow-screen 72% → 91% stmts / 65% → 77% branches, termflow-app 80% → 87% stmts / 69% → 81% branches. All four published modules now ≥87% statement coverage; the priority targets (-terminal, -screen) are both above 87%.
  • 2026-04-30 — Stage 4 §4.8 closed: ciCheck is warning-free and unidoc clean of unresolved-link warnings. Five Scaladoc [[…]] references rewritten to qualified forms; a non-exhaustive Keymap.renderKey match closed by adding the InputKey.NoOp arm. The remaining Flag -classpath set repeatedly is a structural sbt-unidoc / Scala-3-doctool warning with no in-tree fix.
  • 2026-04-30 — Stage 4 §4.3 closed: public-API and docs-example audit swept eight signature drifts out of the tutorials, guides, and cookbook; docs/reference/migration.md populated with the explicit "no migration required for 0.2.x → 1.0" statement plus a catalogue of additive changes. mdbook build (with linkcheck) clean.
  • 2026-04-30 — Killer demo (§3.3) will live in llm4s, not in this repo. Reason: llm4s already depends on termflow for samples, so hosting an llm4s-backed demo here would create a sibling cycle even if the published-artifact graph stays acyclic. Termflow's README will link to the demo and show its screenshot.
  • 2026-04-30 — Stage 4 §4.1 closed: SimpleANSIRenderer now overlays a red, bold banner for Cmd.TermFlowErrorCmd and clears it on the next frame; testkit captures the same path via observedErrors.
  • 2026-04-30 — Stage 4 §4.4 closed: Layout.toBudgetedRootNode is the deferred sibling to the eager toRootNode; new full-screen-layout cookbook recipe explains when each form is appropriate.
  • 2026-04-30 — Stage 4 §4.6 closed: LogView.scrollDelta + LogView.Viewport give apps a one-call hook for mouse-wheel scrollback; wired into chatDemo and covered with MouseSim.
  • 2026-04-30 — Terminal-attention notifications shipped: Cmd.RequestAttention and Cmd.Notify(title, body) with detection for iTerm2 (OSC 9 / 1337), kitty (OSC 99), VTE (OSC 777), and a BEL fallback. Override via TERMFLOW_NOTIFICATIONS=off|bell|auto. Wired through the showcase Help tab and a new notifications cookbook recipe.
  • 2026-04-30 — Added rolling console renderer (§5.3) as a post-1.0 idea: native terminal scrollback for agent / command-runner UIs via a constrained normal-buffer runtime, not the default full-screen renderer.
  • 2026-04-30 — Added Stage 5 (§4) as the pre-1.0 release-hardening checklist: user-visible errors, release-doc accuracy, API/docs audit, layout ergonomics, rolling-console UX, mouse-wheel scrollback, coverage quick wins, and zero-warning builds.
  • 2026-04-30 — MiMa (§3.1) deferred to post-1.0; baseline becomes 1.0.0. Reason: 0.2.0 is already API-stable, so the 0.2.0 → 1.0 filter list would be mostly noise, and decoupling §3.1 from §3.5 removes a chicken-and-egg dependency.
  • 2026-04-30 — Stage 4 §3.2 closed: tree/ (#188), editor/ (#189), and streaming chat/ (#190) sample apps landed. Catalogue at 22.
  • 2026-04-30 — Stage 4 §3.4 closed: Layout.Grid + Layout.Border shipped in #187 with LayoutGridSpec / LayoutBorderSpec coverage.
  • 2026-04-29 — Docs site launched (Stage 4 §3 complete except for migration guide). mdBook + sbt-unidoc on GitHub Pages.
  • 2026-04-28 — Stage 3 final components landed: actionList dialog, ScrollBar, Separator, SplitPane drag-resize, hit-test cache (HitTest[Id] + Layout.Zone + resolveTracked), grapheme-aware navigation, MultiLineInput.
  • 2026-04-28Cmd.FCmd decision: stay on scala.concurrent.Future
    • Result[A]; no cats-effect / zio modules planned. Apps bridge to Future at the Cmd boundary.
  • 2026-04-27 — Stages 1 and 2 closed. Module split shipped early as Stage 4 prep so MiMa filters can be wired per-module on day one.
  • 2026-04-26 — Decision to deprioritise Swing/AWT and SSH backends (now §5.4); Telnet (§5.1) and Web (§5.2) remain.

Run TermFlow Sample Apps Quickly

Working sbt commands for launching the sample apps in modules/termflow-sample.

Run these in a real interactive terminal. TermFlow apps are full-screen TUIs driven by JLine; if sbt is invoked with stdin/stdout piped or redirected, JLine falls back to a dumb terminal and the demos won't render correctly. If a TUI exits abnormally and leaves your terminal in a weird state, run reset.

build.sbt defines short command aliases for the headline apps. From the sbt prompt or as a one-shot:

sbt widgetsDemo       # Layout + Theme + Button/ProgressBar/Spinner/StatusBar
sbt formDemo          # TextField + FocusManager + Keymap multi-field form
sbt catalogDemo       # ListView + Select + Table + TextField task catalog
sbt hubDemo           # Sample hub dashboard (launches other demos)
sbt echoDemo          # Echo / chat-style scrollback
sbt counterDemo       # Sync counter (Layout-based)
sbt futureDemo        # Async counter using Cmd.FCmd
sbt clockDemo         # Digital clock driven by Sub.Every
sbt tabsDemo          # Multi-tab dashboard
sbt taskDemo          # Task manager
sbt stressDemo        # High-frequency renders for repaint stress testing
sbt sineDemo          # Animated sine wave
sbt inputDemo         # Prompt / cursor regression repro (#73 / #74)
sbt treeDemo          # File-tree explorer (Tree widget + Layout.Border)
sbt editorDemo        # Multi-buffer editor (MultiLineInput + SplitPane + MenuBar)
sbt chatDemo          # Streaming chat (LogView scrollback + Sub.Every token stream)

Inside an sbt session you can chain them: sbt> widgetsDemo.

Full runMain form

If you need to launch an app the aliases don't cover, the explicit form is:

sbt "termflowSample/runMain <fully.qualified.AppObject>"

For example:

# Diagnostics demo (logging + render metrics)
sbt "termflowSample/runMain termflow.apps.diagnostics.LoggingMetricsDemoApp"

# Clock variant driven by a custom random source
sbt "termflowSample/runMain termflow.apps.clock.DigitalClockWithRandomSource"

# Provider-chat repro reproduction (chat scrollback + render edge cases)
sbt "termflowSample/runMain termflow.apps.chat.ProviderChatRenderReproMain"

What each headline demo shows

AliasAppDemonstrates
widgetsDemoWidgetsDemoAppThe full new stack: Layout.column/row, a given Theme with toggleable dark/light, Button focus, Spinner + ProgressBar driven by Sub.Every, StatusBar at the bottom, FocusManager + Keymap for input dispatch
formDemoFormDemoAppMulti-field form using three TextFields plus Submit / Reset buttons; demonstrates Tab focus cycling and Enter routing across input + button elements
catalogDemoCatalogDemoAppTask manager combining TextField + Select dropdown + ListView (scrollable, selectable) + Table (live summary). Add tasks, remove them, switch priority — exercises every stateful widget at once.
hubDemoSampleHubAppMenu launcher; pick a sub-app by name or number
counterDemoSyncCounterMinimal Elm-style app; the simplest example to read
futureDemoFutureCounterCmd.FCmd for async work, with a spinner while pending
tabsDemoTabsDemoAppMultiple tabs with independent state, layout-driven
clockDemoDigitalClockSub.Every ticking at 1 Hz
stressDemoRenderStressAppHigh-frequency updates — useful for spotting flicker
sineDemoSineWaveAppAnimated sine wave; same purpose as stressDemo with smoother motion
inputDemoInputLineReproAppPinned reproduction of the prompt/cursor regressions behind #73 and #74
treeDemoFileTreeAppFile-tree explorer over the Tree widget on a Layout.Border shell
editorDemoEditorAppMulti-buffer text editor combining MultiLineInput + SplitPane + MenuBar
chatDemoChatStreamAppStreaming chat with LogView scrollback and Sub.Every token streaming

Widgets demo keys

The widgets demo (sbt widgetsDemo) is interactive:

KeyAction
Tabcycle button focus (Save ↔ Cancel)
Enter / Spaceactivate the focused button
ttoggle dark / light theme
+ / -nudge progress ± 10 %
q / Ctrl+C / Escquit

Form demo keys

The form demo (sbt formDemo) is interactive:

KeyAction
Tab / Shift+Tabcycle focus forward / backward (Name → Email → Bio → Submit → Reset)
/ same as Shift+Tab / Tab — work anywhere, including inside a text field
/ (on a button)previous / next focus
/ (in a text field)move the in-field cursor (does not change focus)
Enter (in field)submit the form (capture all field values)
Enter / Space (button)activate Submit or Reset
Backspace / Delete / Home / Endstandard text editing in the focused field
Ctrl+T (anywhere) / t (on a button)toggle dark / light theme
q (on a button) / Ctrl+C / Escquit

Catalog demo keys

The catalog demo (sbt catalogDemo) is interactive:

KeyAction
Tab / Shift+Tabcycle focus (Task field → Priority → Add → Clear → Task list)
Enter (in Task field)add a task with the selected priority
Enter / Space (on Priority)open the dropdown (or, when open, commit the selection and close)
/ (in open Priority or Task list)navigate items within that widget
Enter (on a list row)remove the selected task
Enter / Space (on a button)activate Add / Clear
/ (on a button)previous / next focus
Ctrl+T (anywhere) / t (on a button)toggle dark / light theme
q (on a button) / Ctrl+C / Escquit

Convenience shell snippet (optional)

If you'd rather have shell-level shortcuts, drop this in ~/.zshrc / ~/.bashrc:

termflow-run() {
  local app="$1"
  case "$app" in
    hub|widgets|form|catalog|echo|counter|future|clock|tabs|task|stress|sine|input|tree|editor|chat)
      sbt "${app}Demo" ;;
    *)
      echo "Usage: termflow-run {hub|widgets|form|catalog|echo|counter|future|clock|tabs|task|stress|sine|input|tree|editor|chat}"
      return 1
      ;;
  esac
}

Then run:

termflow-run widgets

Notes

  • These apps run in an interactive TUI; use a normal terminal.
  • If terminal state looks odd after interruption, run reset.
  • termflow.run.TermFlowMain defaults to the sample hub when run without args, so sbt "termflowSample/runMain termflow.run.TermFlowMain" is equivalent to sbt hubDemo.