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
updatefunction (state transitions), - a
viewfunction returning a small virtual DOM, Cmdfor async work,Subfor 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
readlineplus fancy output. TermFlow'sPromptwidget 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.FCmdlets 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.Everydrives the heartbeat. - Multi-step flows. Wizards, file pickers, action menus.
FocusManagerplus theDialogshelpers handle most form patterns out-of-the-box.
When to reach for something else
- You need plain
printfoutput. TermFlow takes over the terminal — it switches to the alternate buffer, hides the cursor, raw-mode-locks input. For "print-and-exit" tools just useprintln. - 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-trackbranch instead — main is Scala 3 only.
Compared with other libraries
TermFlow's three-layer model (terminal ↔ screen ↔ app+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:
| Module | What it gives you | Key types |
|---|---|---|
termflow-terminal | Raw access to the underlying TTY: backend, key decoding, capability detection. | TerminalBackend, KeyDecoder, Capabilities, Grapheme, WCWidth |
termflow-screen | A character grid you draw into, plus diff-rendering to ANSI. | RenderFrame, AnsiRenderer, Layout, HitTest |
termflow-app | The Elm-style runtime: TuiApp, Cmd, Sub, FocusManager, Dialogs. | TuiApp, TuiRuntime, Cmd, Sub, Tui |
termflow-widgets | Reusable 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
initproduces an initialTui[Model, Msg]— the starting model plus an optional startupCmd(typically registering subscriptions).- Subscriptions push
Cmds onto theCmdBus. Examples: every key press becomes aCmd.GCmd(msg), every timer tick the same. - The runtime drains the bus, dispatches each
Msgtoupdate, and gets back a newTui[Model, Msg]— the next model and an optional follow-up command. viewrenders the new model into aRootNodevirtual DOM.AnsiRendererdiffs the newRootNodeagainst 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 friendlierCmd.asyncResult) and the runtime will await theFuture, then deliver the result back as aMsg. - View is pure. It returns a
RootNodefrom aModel. The renderer takes care of cursor placement, colour emission, and clipping. - Subscriptions live outside
update.Sub.InputKeyreads from JLine on a background thread;Sub.Everyschedules ticks. Both pushCmds onto the bus soupdateonly ever seesMsgs.
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-terminalonly. - Building a custom layout engine on top of a screen buffer? Depend on
termflow-screen. - Building a typical interactive TUI? Depend on the umbrella
termflowartefact, 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.BreakIteratorand modernLocaleAPIs). - 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
TuiAppdefinition, - 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:
| Method | Purpose |
|---|---|
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:
initruns once at startup.updateruns every time aMsgarrives.viewruns after everyupdate, 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.xand1.ycome fromimport termflow.tui.TuiPrelude.*— they are extension conversions to the opaqueXCoord/YCoordtypes so you can't accidentally swap rows and columns. Coordinates are 1-based and frame-local.m.message.textlifts theStringinto aText(value, Style.empty)fragment. Thetextextension lives inTuiPreludetoo.
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 beforeCmd.Exitcould restore the terminal state. WrappingTuiRuntime.runis 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
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
Subkeeps the keyboard pump alive for the lifetime of the app and gives the runtime something to cancel on exit. Prompt.Stateis the typed-but-not-yet-submitted buffer plus its cursor — every keystroke is funnelled throughPrompt.handleKeyand 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/Exitare what your domain cares about;ConsoleInputKeyis a low-level key event the runtime delivers. Keeping both in the sameMsgkeepsupdatetotal — the compiler enforces that every event has a handler. - Errors get a Msg too.
Sub.InputKeyaccepts anonErrorcallback; mapping errors into a regularMsgmeansupdatedecides 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:
syncTerminalSizeis a tiny helper that re-reads the terminal size fromctx.terminaland 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/Decrementcall intoCounterand use the.tuiextension to lift the new model into aTui[Model, Msg]with no follow-up command.Exitpairs the model withCmd.Exit, which tells the runtime to break out of the loop.ConsoleInputKey(k)delegates toPrompt.handleKey. The Prompt widget owns the cursor, history, paste, and everything else a real text input needs. It returns the next state plus an optionalCmdproduced when the user pressed Enter — that's wheretoMsgruns.
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.Elemwraps aVNodeso it lives in the layout tree.Layout.Spacer(1, 1)is a one-row vertical gap.- The
(1.x, 1.y)coordinates inside eachTextNodeare layout-local — theresolve(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
.textextension ("foo".text(fg = Yellow)) or build aText(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:
BoxNodeis 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 - 4plus the resize sync fromupdatemeans the panel grows and shrinks with the window. InputNodeis the prompt. It carries the rendered prefix + buffer text, the cursor index, and the prefix length soAnsiRenderercan position the hardware cursor correctly.Prompt.renderWithPrefixreturns 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 havingviewbe 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.
updateis a pattern match onMsgreturning the next model. - Layout DSL.
Layout.Column+resolve(at)beats hand-positioning every node. - Prompts.
Prompt.handleKeyturns key events into either a new buffer or a Submit-timeCmddriven bytoMsg. - Adaptive sizing.
syncTerminalSizekeepsviewhonest 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:
Cmd.FCmd— bridge aFutureinto the runtime so its result shows up as aMsg.Sub.Every— fire a recurringMsgon a fixed cadence, which we'll use to animate a spinner while async work is in flight.- 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: Stringis 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'sSub.NoSub— a no-op sub. When async work starts we replace it withSub.Every(200ms, …). When the work finishes we callspinner.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/Decrementno longer carry the state change inline — they just kick off the async work.UpdateWith(c)is the message produced when theFuturecompletes 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.SpinnerTickis 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:
| Parameter | What |
|---|---|
task | The Future[A] you want the runtime to await. |
toCmd | A function from the future's result to the next Cmd. |
onEnqueue | An optional Msg to dispatch immediately when the FCmd is enqueued — before the future completes. |
What happens at runtime:
updatereturns theTui(model, FCmd(...)).- The runtime sees the
FCmd, registers a callback on the future, and ifonEnqueuewas supplied, immediately dispatches thatMsg. - When the future completes (after ~5 seconds here),
toCmd(result)runs and the resultingCmd(hereCmd.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 aTermFlowError), reach forCmd.asyncResult(task, onSuccess, onError, onEnqueue)instead — it folds theEitherfor you so the call site stays one expression. Future-level exceptions still surface as aTermFlowErrorCmdoverlay 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 onSub.NoSubis safe.spinner = Sub.NoSubclears 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.FCmdis the bridge fromFuture[A]to the runtime. It takes the future, a result mapper to a follow-upCmd, and an optional immediateMsgto dispatch at enqueue time.Sub.Everyis the timer subscription. Auto-registers when given aRuntimeCtx; 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/UpdateWithbracketing 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:
FocusIdis opaque overString. You can't accidentally pass an arbitrary string where aFocusIdis wanted, but twoFocusIds 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
TextFieldis focused, the key feeds throughTextField.handleKeyand 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.handleKeyreturns(nextState, Option[Msg])— the same shape asPrompt.handleKey. The widget owns its cursor and buffer. Mapping the optional Msg through_ => Nonesays "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:
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.k match case Tab => step(m, NextFocus) case BackTab => step(m, PrevFocus) case _ => stepKeyForStep(m, k)
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
gapbetween 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.summaryis a tiny pure helper onModelthat returns the three review lines asList[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
Submitruns, the model'ssubmitted = trueandviewadds a confirmation line. We don't navigate away — the user reads "Submitted!" then pressesqto 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
FocusManagerper screen keeps tab cycles local and lets each screen restore its own focus when revisited.- Focus dispatch in
updateis the central pattern — the focused widget id determines how a keystroke is interpreted. widgets.Form.columnturns rows of(focusId, label, viewFor)into a styled, focus-aware, error-decorated form.- Validate-on-advance, not on every keystroke. Map
FocusId.valueto error strings; pass the same map toForm.column. - Quit-when-not-in-text-field is what makes
qfeel 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 ofChoiceitems with mouse hit-testing.- Keymaps. Replace ad-hoc pattern matches with
Keymap.focusbindings — see the Keymap guide (stub for now). - Multi-line input.
widgets.MultiLineInputfor 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 (
VNodeADT —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.
XCoordandYCoordare opaque types overIntso you can't accidentally swap them. Use the.x/.yextension methods inScreenPrelude(2.x,3.y) to construct. BoxNodeis visual-only. It draws a border but does not position its children. If you want layout, useLayout.ColumnorLayout.Rowinstead and attach the box as a sibling.- Only the topmost
InputNodeis 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-local —
resolve 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]
| Cmd | When |
|---|---|
NoCmd | Pure state transitions (the common case). |
Exit | Tear 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). |
RequestAttention | Ring 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.heightfor adaptive layout - register subscriptions
- publish commands directly (rare — usually you return a
Cmdfromupdate) - 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:
- Open the terminal (alternate buffer, hide cursor, raw mode).
- Set up the
CmdBusqueue. - Run
init. - Loop: drain the bus, run
updateper message, runview, diff-render to ANSI, sleep up to ~60 fps. - 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 showcaseto see them rendered side-by-side.
The widget protocol
Most widgets follow a three-piece pattern:
State— the widget's data (cursor positions, scroll offsets, selected index). You store this on your model.handleKey(state, key)(...)— pure function from a key to the next state, optionally producing aMsg(e.g. on Enter).view(state, …, focused: Boolean = true)— pure function from state toVNodeorList[VNode]. Thefocusedflag 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 "
)
MenuBar
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:
| Slot | Used for |
|---|---|
primary | Headings, focus chrome, active tab |
secondary | Subtle headings, placeholder text |
error | Validation messages, exit codes |
warning | Caution states |
success | Confirmation messages, "✓ Submitted" |
info | Neutral status (timestamps, counts) |
border | Box outlines, separators |
background | Fills (rare — the terminal default usually wins) |
foreground | Default 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, andTheme.monoship 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
Themeand let the renderer downgrade. - The
NO_COLORenvironment variable is honoured: when set, the runtime forcesTheme.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'sProgressBaris 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 theTui[Model, Msg]. - Drains the command bus on every
send, processingGCmds recursively so chained transitions complete beforesendreturns. - Renders the current model after every
send—driver.framereturns the latestRenderFrame. - Suppresses subscription start-up —
Sub.Everytimers never tick,Sub.InputKeynever 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
- Show a confirm dialog and act on the answer
—
Dialogs.confirm, model-owned dialog state, key routing while modal. - Stream output into a scrollback view —
widgets.LogView, append-and-tail buffering, auto-tail vs. paused scroll. - Build a rolling console / agent UI — Claude-Code-style transcript with a pinned bottom prompt, auto-tail, pause-on-scroll-up, mouse-wheel scrollback, bounded history.
- Pause and resume a
Sub.Everytimer — cancel + recreate,Sub.NoSubplaceholder, interval changes. - Open a file picker and load the result —
Dialogs.fileDialogintegration pattern, directory navigation, Esc-to-cancel. - Two-pane layout with a draggable splitter
—
SplitPane.handleMouse,DragState, keyboard-equivalent shortcuts. - Capture mouse clicks on a custom widget
—
Layout.Zone+Layout.resolveTracked+HitTest[Id]. - Wide-character (CJK / emoji) input handling
—
WCWidth,Grapheme,RenderCell.width = 2. - Clean shutdown on
Ctrl-Cand on resize — what the runtime does for you, where you still have to wireCmd.Exit. - Flag a session as needing attention —
Cmd.RequestAttention,Cmd.Notify, terminal detection (iTerm2 / kitty / VTE), tmux caveats. - Full-screen layouts that reflow on resize —
Layout.toBudgetedRootNodevs.toRootNode,Fillsemantics, header/fill/footer pattern.
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
- Add a flag (or sealed
case class) to your model that says "the confirm dialog is open and waiting for an answer". - In
view, when that flag is set, append theDialogs.confirmoverlay toRootNode.overlays. - In
update, route key events to the dialog when it's open: arrow keys flip the focused button,Enter/Spacecommits,Escapecancels.
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 justBoolean? It scales — addOpen(yesFocused, prompt: String)later and any caller ofAskDeletecan pass its own prompt. - Always re-route keys when a dialog is open. Don't forget to
intercept the global keymap so
qdoesn't quit while the dialog is modal. The simplest pattern is: ifm.confirm.isDefined, route the whole keystream intoConfirmKeyand skip your normal handlers. - Mouse clicks on overlays.
Dialogs.confirmis mouse-aware in the renderer; if you want clicks on the buttons to flip focus, pair the dialog withLayout.resolveTrackedand dispatchConfirmKeyfrom 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
- Hold the buffer (
Vector[String]) plus ascrollOffsetand anautoTail: Booleanflag on the model. - As new lines arrive (via
Cmd.GCmd(NewLine(...))orSub.Everypolling), append to the buffer; ifautoTailis set, bumpscrollOffsetto keep the bottom in view. - ArrowUp / ArrowDown adjust
scrollOffsetand toggleautoTail.
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
bufferwhen 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),
DownflipsautoTail = trueagain — the convention every chat client uses. - Dropping
wrap = truetruncates rather than wrapping; themaxScrollmath still works either way. - Mouse-wheel scrollback. Wire mouse events through
LogView.scrollDelta(event, viewport, ticksPerDetent)— it returnsSome(delta)only when the scroll lands inside the viewport rectangle you describe withLogView.Viewport(at, width, height). Reuse the sameat/width/heightyou passed toLogView.apply, and feed the returned delta into the same scroll-update path the keyboard uses. Outside-the-viewport events returnNoneso a wheel hovering over the prompt or the status row won't accidentally page through history. DefaultticksPerDetent = 3matches the speed terminal users expect; pass1for 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
- Hold an append-only
Vector[String]buffer (or richer line records), plus ascrollOffset: Intand anautoTail: Booleanflag. - As new output arrives (streamed tokens, completed lines, agent
events): append to the buffer, bound it, and — if
autoTailis on — clampscrollOffsetto the live tail. - Arrow keys / PageUp / PageDown / mouse wheel adjust
scrollOffsetand turnautoTailoff if the user moves up. End(or scrolling back to the bottom) re-enablesautoTail.- Render through
Layout.Borderso 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
- Store the timer
Sub[Msg]on your model —Sub.NoSubwhen inactive. - To start: replace the field with
Sub.Every(...). - To pause:
cancel()the existing sub and writeSub.NoSubback. - To resume: build a fresh
Sub.Everyand 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
isActivecheck beforecancel()is paranoia —cancel()is idempotent, but the guard makes the intent explicit.Sub.Everyauto-registers viactxso the runtime cancels it onCmd.Exitregardless of whether you remembered to. The explicit cancel above is defensive.- Don't capture
m.intervalMsat sub construction — pass it through. The thunk() => Msg.Tickre-evaluates per tick, but themillisargument is fixed for the sub's lifetime. - Multiple timers don't share a thread — each
Sub.Everyspins up its ownScheduledExecutorService. 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
- Hold a
picker: Option[PickerState]on the model with the path, entries, and selected index. - On
Open, list the start directory and store it in the picker. - Route keys to the picker while it's open — Enter dives into a directory or returns the selected file; Esc cancels.
- On commit, close the picker and dispatch a domain
Msgcarrying 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
FileEntryis a small case class shipped intermflow.tuiwithname,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
listDirlevel so the dialog never shows them. Adding ashowHidden: BooleantoPickerStateis straightforward. - Read the file inside
FileChosenif it's small. For larger loads, hand it toCmd.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
listDirabove — guard withFiles.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
- Hold a
SplitPane.DragStateon the model —splitRatio: Doublein[0.0, 1.0],dragging: Booleanfor "we're mid-gesture". - Route mouse events into
SplitPane.handleMouseto update drag state. - Map
[/](or whatever keys you like) tosplitRatio - 0.05and+0.05for keyboard control. - Use
m.split.splitRatiowhen 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.Verticalmeans a vertical divider that splits the width — left pane / right pane.Direction.Horizontalis a horizontal divider (top / bottom split).- Recolouring during drag —
m.split.draggingistruebetween Press and Release; rendering the divider intheme.warninggives immediate feedback that the gesture is live. - Clamp the ratio to
[0.05, 0.95]if you want to keep both panes visible.SplitPane.handleMousedoesn'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
- Tag every interactive sub-layout with
Layout.Zone(id, content). IDs can be any type —String, anenum, a genericInt. - Use
Layout.resolveTracked[Id](layout, at, w, h)when rendering; keep the returnedHitTest[Id]on the model (or rebuild it every frame). - On a
MouseEvent.Press, ask the cachehits.hit(col, row)and dispatch the matchingMsg.
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 fromview) and either pass it through to a click-handler in the same closure, or stash it in the model soupdatecan look it up next time. The showcase uses the second approach — seeactionListHitTestinStage1ShowcaseApp.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 forLayout.Zone(id, Layout.Elem(vnode))when you want to tag a single vnode without wrapping it in a layout.- No
Zonefor keyboard. Hit-test is mouse-only. Focus navigation routes throughFocusManager, which usesFocusId. 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:
| Need | Use |
|---|---|
| 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:
- Cursor column math. When rendering a text input, the cursor's
visual column is
WCWidth.stringWidth(text.take(charIndex)), notcharIndexitself. - 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.lengthwith 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_COLORandLANG=C. Some terminals are configured to refuse Unicode. TheCapabilities.unicodeflag tells you whether to use ASCII fallbacks (Themealready does this for box-drawing glyphs).- Combining sequences.
Grapheme.previousBoundarywalks 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:
Ctrl-C— user wants to quit, fast.- 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.runregisters a JVM shutdown hook that restores the terminal (alt-buffer off, cursor visible, raw mode off, mouse tracking off) even on uncaught exceptions orkill.- All
Subs registered viaRuntimeCtx(Sub.Every,Sub.InputKey,Sub.TerminalResize) auto-cancel onCmd.Exitand on shutdown. - The renderer watches for SIGWINCH (size change) and re-renders without you doing anything.
What you have to wire
- A
QuitMsgand aCmd.Exitfollow-up for it. - A keystroke that produces
QuitforCtrl-C(and ideallyq). - An optional re-read of
ctx.terminal.{width, height}on everyupdateso 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
syncSizeon every update is the cheapest way to stay correct on resize. The cost is a single==comparison per message — effectively free.Ctrl-Cshould always quit. Don't be tempted to swallow it: if your app hangs, the user will reach forCtrl-Cand expect a graceful exit.- Quit during a modal dialog. If you have an open
Dialogsoverlay,Ctrl-Ccan either close the dialog or quit the whole app. Pick one and be consistent. Most apps treatCtrl-Cas unconditional quit andEscas "close the dialog". - Quit during text input. Inside a
PromptorTextField,qis a literal letter — only quit onqoutside 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 returnCmd.Exitfromupdate; the runtime takes it from there. - Crash recovery. If your app crashes despite all of this,
resetin 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 exceptDisabled; iTerm2 also bounces the dock icon viaOSC 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
| Terminal | Detection | Sequence |
|---|---|---|
| iTerm2 | TERM_PROGRAM=iTerm.app | OSC 9 ; <message> BEL and OSC 1337 ; RequestAttention=yes BEL |
| kitty | KITTY_WINDOW_ID set or TERM=xterm-kitty | OSC 99 ; <metadata> ; <body> ST |
| GNOME Terminal / Tilix / xfce4-terminal | VTE_VERSION set | OSC 777 ; notify ; <title> ; <body> BEL |
| Anything else (xterm, Alacritty, WezTerm, …) | TERM looks like a real terminal | BEL (\x07) |
dumb / unset TERM | — | nothing (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.RequestAttentionfor 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.Notifyfor 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
| Shape | When the layout resolves | Layout.Fill behaviour |
|---|---|---|
RootNode(width, height, children, input) | At view time, eagerly | Collapses 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.
Recipe — header / fill / footer
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.childrenis 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
- Screen layer guide
for the full
LayoutDSL. Layout.Grid/Layout.Border— both produce real reflow only undertoBudgetedRootNode(or an explicitRootNode(layout = Some(...))).
API reference (Scaladoc)
The full API documentation is generated from source via sbt unidoc and
deployed alongside this site:
The Scaladoc covers the four published modules:
| Module | Coordinates |
|---|---|
termflow-terminal | org.llm4s::termflow-terminal:0.4.0 |
termflow-screen | org.llm4s::termflow-screen:0.4.0 |
termflow-app | org.llm4s::termflow-app:0.4.0 |
termflow-widgets | org.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 unidocto generate Scaladoc intotarget/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
mimaBinaryIssueFiltersrationale 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
| Thread | Lifetime | Role |
|---|---|---|
| runtime thread | TuiRuntime.run(app) start → Cmd.Exit (or shutdown hook) | Executes update, view, and renders. Single-threaded. |
| InputKey producer | Lazy: started by the first RuntimeCtx.registerSub of a Sub.InputKey | Reads keystrokes off the TerminalBackend.reader and publishes Cmd.GCmd per parsed key. |
| Sub.Every scheduler | Lazy: per-Sub.Every, started on registerSub | A ScheduledExecutorService ticks at the configured period and publishes a Cmd.GCmd per tick. |
| FCmd Future executor | Per Cmd.FCmd — uses ExecutionContext.global by default | Runs the user's Future body off the runtime thread; the continuation publishes a result Cmd back. |
| Resize listener | Lazy on Sub.TerminalResize | JLine's SIGWINCH callback runs on its own thread; the listener publishes a Cmd.GCmd per resize. |
| JVM shutdown hook | Registered once by TuiRuntime.run | Restores cursor / leaves alt-buffer / disables mouse on abrupt exit. |
Invariants you can rely on
updateandviewalways run on the runtime thread. No twoupdatecalls overlap — the bus is a single consumer. MutatingModelinsideupdateis therefore safe by construction (and the model should be immutable anyway).Cmd.FCmdcontinuations come back through the bus. When yourFuture[A]completes, the result mapper runs off the runtime thread, but the producedCmdis published — so theupdatethat sees it runs on the runtime thread again. You don't need to synchronise on shared state, only on whatever theFuture's body itself touches.Subcallbacks run off their respective threads. ASub.Everytick fires on the scheduler thread, anInputKeyparse fires on the producer thread. Whatever you do in the callback that produces aCmdruns off-main, but the resultingCmdalways arrives atupdateon the runtime thread.- Subscriptions start lazily.
Sub.InputKey,Sub.Every, andSub.TerminalResizedon't spawn threads or schedule timers untilRuntimeCtx.registerSubis called. Tests usingTestRuntimeCtxkeep them dormant deliberately. CmdBusis a serialising queue. Multiple producer threads canpublishconcurrently; the runtime'stake/pollis the single consumer. Order across producers is FIFO by enqueue time.
Gotchas
- Don't block the runtime thread. Anything synchronous inside
updateblocks the entire frame. UseCmd.FCmd(orCmd.asyncResult) for I/O. - Don't close over mutable state inside
FCmd. The body runs off-main, possibly concurrently with anotherFCmdbody, and the continuation also runs off-main before the resultingCmdis published. Pass the data you need by value into theFuture. Sub.Everytick drift is not corrected. The scheduler usesscheduleAtFixedRate, which can drift if the runtime is slow to drain ticks. If exact wall-clock cadence matters, useSub.Everyfor triggering and readSystem.currentTimeMillis()insideupdatefor 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:
TestRuntimeCtxkeeps subs dormant —registerSubdoes not callstart().TuiTestDriveradvances the model by feedingMsgs directly, bypassing the bus and threading concerns.KeySimandMouseSimsynthesise 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: Modelcmd: Cmd[Msg]
Cmd (commands)
Commands represent side effects that happen “after” an update:
Cmd.GCmd(msg)– enqueue a message for the next update loopCmd.FCmd(...)– run an async task and publish its result as a messageCmd.asyncResult(task, onSuccess, onError)– idiomatic helper for the common case where the async task returnsAsyncResult[A](i.e.Future[Result[A]]). Routes successes throughonSuccess, logical failures throughonError, and lets the runtime surface infrastructure failures viaCmd.TermFlowErrorCmdautomatically.Cmd.Exit– exit the runtimeCmd.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
renderoutput and a cursor position - supports command-like flows where pressing Enter yields a
Msgor 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
updatefunctions (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
- At most one render commit is active at a time.
- Rendering is latest-state wins; intermediate states can be dropped during bursts.
- Diff output includes cleanup for shrink cases (line tail and removed rows).
- Cursor visibility is runtime-owned (startup/shutdown/interrupt), not frame-owned.
- 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")
TuiTestDriverexposesmodel,frame,send(msg),cmds,exited, andobservedErrors.- Subscriptions (
Sub.Every,Sub.InputKey,Sub.TerminalResize) are never started — tests stay deterministic. For prompt-driven apps, construct the wrappingMsgdirectly (e.g.Msg.ConsoleInputKey(KeyDecoder.InputKey.CharKey('+'))) instead of waiting on the input thread. Cmd.FCmdmust wrap a pre-resolvedFuture.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 modules —
termflow-terminal/-screen/-app/-widgetsplus the umbrellatermflowartefact, andtermflow-testkitfor tests. Maven Central asorg.llm4s::*. - Elm-style runtime —
TuiApp[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+ widgets —
TextField,MultiLineInput,Button,CheckBox,RadioGroup,Select,Autocomplete,ListView,Table,Tree,Tabs,SplitPane(drag-resize),Separator,ScrollBar,ProgressBar,Spinner,StatusBar,LogView,MenuBar,Form. PlusLayoutDSL (Row/Column/Fill/Zone),HitTest[Id],Theme+BorderChars. - Seven dialog helpers —
message,confirm,textInput,listSelect,waiting,fileDialog,directoryDialog,actionList. - Grapheme-aware text editing — UAX #29 cluster boundaries via
BreakIterator, wide-cell math viaWCWidth. - Testkit —
TuiTestDriver,KeySim,MouseSim,GoldenSupport. Published astermflow-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/termflowwith 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
mimaBinaryIssueFiltersduring 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:
- Add
sbt-mima-plugintoproject/plugins.sbt. - Set
mimaPreviousArtifacts := Set("org.llm4s" %% name % "1.0.0")on each oftermflow-terminal,termflow-screen,termflow-app,termflow-widgets,termflow, andtermflow-testkit. - Append
mimaReportBinaryIssuesto theciCheckalias.
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.Bordershipped (§3.4). - ☑ Migration guide populated —
docs/reference/migration.mdcarries 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.0 →
1.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
coverageLibgreen and ensure the combined trend moves up, with particular attention totermflow-terminalandtermflow-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.
Spinnerrenders a static frame when the flag is set.- Indeterminate
ProgressBarticks slow or become static. - Apps can query the flag via
RuntimeCtx. - Documented in an
accessibility.mddocs 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
- Native image.
sbt-native-imagebuild forgraalvm-native-image? The shutdown hook + JLine reflection make this non-trivial. Investigate post-1.0. - Cross-publish for Scala 2.13. Already on
legacy-213-track. Keep parity through 1.0, then re-evaluate based on adoption. - Resizing semantics. When the terminal shrinks, do we clip or rewrap? Currently clip. The Layout pass makes rewrap cheap if we want to switch.
- i18n. Right-to-left text? Bidi? Probably out of scope for 1.0; document as a known limitation.
- Accessibility. Screen readers don't really hit a TUI — they read the terminal directly. Worth an "Accessibility notes" docs section before 1.0.
- Windows native. Currently relies on JLine for cmd.exe /
Windows Terminal. May need a JNA-backed
WindowsConsoleBackendif 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 (TextField ≈ TextBox, ListView ≈ ActionListBox,
SplitPane ≈ SplitPanel, etc.). Two Lanterna things we deliberately
don't match:
- The shared GUI thread / listener-callback event model. Replaced
by the Elm-style
updateloop. 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.0instead of0.2.0. Migration notes acknowledge0.2.0 / 0.3.0 / 0.4.0 → 1.0as the no-migration window. Roadmap headline + §2 heading + cookbook / guide counts updated to reflect the post-§4.5 / §4.10 / a11y state. The final0.4.0 → 1.0.0mechanical 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/tuiin 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+autoTailpattern with key routing, mouse-wheel scrollback, and bounded history;ChatStreamAppis 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_MOTIONenv var (and HOCONtermflow.accessibility.reduced-motion) flows throughRuntimeCtx.config.accessibility.reducedMotion;SpinneracceptsreducedMotion: Booleanand pins toframes(0)when true. Newdocs/guide/accessibility.mdpage 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.Cliponly ever existed on the abandonedfeature/layout-cookbookbranch 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-terminal66% → 88% stmts / 63% → 90% branches,termflow-screen72% → 91% stmts / 65% → 77% branches,termflow-app80% → 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:
ciCheckis warning-free andunidocclean of unresolved-link warnings. Five Scaladoc[[…]]references rewritten to qualified forms; a non-exhaustiveKeymap.renderKeymatch closed by adding theInputKey.NoOparm. The remainingFlag -classpath set repeatedlyis 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.mdpopulated 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:
SimpleANSIRenderernow overlays a red, bold banner forCmd.TermFlowErrorCmdand clears it on the next frame; testkit captures the same path viaobservedErrors. - 2026-04-30 — Stage 4 §4.4 closed:
Layout.toBudgetedRootNodeis the deferred sibling to the eagertoRootNode; new full-screen-layout cookbook recipe explains when each form is appropriate. - 2026-04-30 — Stage 4 §4.6 closed:
LogView.scrollDelta+LogView.Viewportgive apps a one-call hook for mouse-wheel scrollback; wired intochatDemoand covered withMouseSim. - 2026-04-30 — Terminal-attention notifications shipped:
Cmd.RequestAttentionandCmd.Notify(title, body)with detection for iTerm2 (OSC 9 / 1337), kitty (OSC 99), VTE (OSC 777), and a BEL fallback. Override viaTERMFLOW_NOTIFICATIONS=off|bell|auto. Wired through the showcase Help tab and a newnotificationscookbook 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 streamingchat/(#190) sample apps landed. Catalogue at 22. - 2026-04-30 — Stage 4 §3.4 closed:
Layout.Grid+Layout.Bordershipped in #187 withLayoutGridSpec/LayoutBorderSpeccoverage. - 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:
actionListdialog,ScrollBar,Separator,SplitPanedrag-resize, hit-test cache (HitTest[Id]+Layout.Zone+resolveTracked), grapheme-aware navigation,MultiLineInput. - 2026-04-28 —
Cmd.FCmddecision: stay onscala.concurrent.FutureResult[A]; nocats-effect/ziomodules planned. Apps bridge toFutureat theCmdboundary.
- 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
sbtis 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, runreset.
Short aliases (recommended)
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
| Alias | App | Demonstrates |
|---|---|---|
widgetsDemo | WidgetsDemoApp | The 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 |
formDemo | FormDemoApp | Multi-field form using three TextFields plus Submit / Reset buttons; demonstrates Tab focus cycling and Enter routing across input + button elements |
catalogDemo | CatalogDemoApp | Task manager combining TextField + Select dropdown + ListView (scrollable, selectable) + Table (live summary). Add tasks, remove them, switch priority — exercises every stateful widget at once. |
hubDemo | SampleHubApp | Menu launcher; pick a sub-app by name or number |
counterDemo | SyncCounter | Minimal Elm-style app; the simplest example to read |
futureDemo | FutureCounter | Cmd.FCmd for async work, with a spinner while pending |
tabsDemo | TabsDemoApp | Multiple tabs with independent state, layout-driven |
clockDemo | DigitalClock | Sub.Every ticking at 1 Hz |
stressDemo | RenderStressApp | High-frequency updates — useful for spotting flicker |
sineDemo | SineWaveApp | Animated sine wave; same purpose as stressDemo with smoother motion |
inputDemo | InputLineReproApp | Pinned reproduction of the prompt/cursor regressions behind #73 and #74 |
treeDemo | FileTreeApp | File-tree explorer over the Tree widget on a Layout.Border shell |
editorDemo | EditorApp | Multi-buffer text editor combining MultiLineInput + SplitPane + MenuBar |
chatDemo | ChatStreamApp | Streaming chat with LogView scrollback and Sub.Every token streaming |
Widgets demo keys
The widgets demo (sbt widgetsDemo) is interactive:
| Key | Action |
|---|---|
Tab | cycle button focus (Save ↔ Cancel) |
Enter / Space | activate the focused button |
t | toggle dark / light theme |
+ / - | nudge progress ± 10 % |
q / Ctrl+C / Esc | quit |
Form demo keys
The form demo (sbt formDemo) is interactive:
| Key | Action |
|---|---|
Tab / Shift+Tab | cycle 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 / End | standard text editing in the focused field |
Ctrl+T (anywhere) / t (on a button) | toggle dark / light theme |
q (on a button) / Ctrl+C / Esc | quit |
Catalog demo keys
The catalog demo (sbt catalogDemo) is interactive:
| Key | Action |
|---|---|
Tab / Shift+Tab | cycle 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 / Esc | quit |
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.TermFlowMaindefaults to the sample hub when run without args, sosbt "termflowSample/runMain termflow.run.TermFlowMain"is equivalent tosbt hubDemo.