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.