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.