tui4s
Guide

The assistant

The flagship example, a chat with a language model, and the shape of a real app.

The main of the assistant only wires. It gives the layers of the JVM and runs the shared UI:

Assistant.scala
    def ui: UI < (Async & Scope) =
        val mode =
          if args.contains("--fake") then StartMode.FakeOnly
          else StartMode.Detect
        Tui.size.map(size =>
          Memo.run(
            Env.runLayer(
              AssistantJvm.storage,
              AssistantJvm.secrets,
              Wiring.conversations,
              Wiring.log,
              Wiring.providers(AssistantJvm.live, FakeProvider.make(), mode),
              Layer(Surface.Terminal),
            )(
              AssistantUi.run(
                mode,
                Tui.quit,
                size.map(t => ScreenSize(t.width, t.height)),
              ),
            ),
          ),
        )
    end ui

The assistant is a chat with a language model in the terminal: Anthropic, OpenAI, or a scripted fake provider. Start it with no network and no key:

examples/target/bin/assistant --fake

With --fake, the app reads no key. Send /fail to see an error, and push Esc while a reply streams to stop it. With no flag, the app uses a real provider when it finds a key (ANTHROPIC_API_KEY, OPENAI_API_KEY, or the key file of the app). The examples/README.md file of the repository tells how to give a key.

The assistant is also the blueprint of a real app. Follow its shape for a new app.

The domain is pure

The types are values: Conversation, Message, ChatEvent, ChatError and Settings. The fold of a streamed reply is a pure state machine with laws. DomainPropertyTest checks the laws on the JVM and on Node.js.

Transcript.scala
    /** The message after `event`. */
    def apply(message: Message, event: ChatEvent): Message =
      message.status match
          case MessageStatus.Streaming =>
            event match
                case ChatEvent.TextDelta(t) =>
                  message.copy(content = message.content + t)
                case ChatEvent.Stop(r, u)   =>
                  message.copy(status = MessageStatus.Done(r), usage = u)
                case ChatEvent.Error(e)     =>
                  message.copy(status = MessageStatus.Failed(e))
          case _                       => message

The services are traits

A service is a trait: a capability. Its errors are values (Abort[ChatError]):

Services.scala
/** A chat provider: the capability to stream the reply to a request. The stream
  * gives the pieces of the text, then one [[ChatEvent.Stop]] or one
  * [[ChatEvent.Error]]; or it fails with a [[ChatError]] (an HTTP status, no
  * connection). An interrupt of the fiber that runs it stops it, and its
  * connection closes with it.
  *
  * The interpreters: [[FakeProvider]] (shared, scripted), and on the JVM the
  * Anthropic and OpenAI providers ([[SseProvider]] over the HTTP client of
  * kyo-http).
  */
// claude 2026-10-19: created (L3)
trait ChatProvider:
    /** The events of the reply to `request`. */
    def stream(request: ChatRequest)(using
        Frame,
    ): Stream[ChatEvent, Async & Abort[ChatError]]

An interpreter is in the make of the companion. Each platform has its interpreters, and the tests have interpreters in memory:

Services.scala
    /** A log that keeps the last `limit` lines; each line starts with its time
      * (UTC, `HH:MM:SS`) and its level.
      */
    def make(limit: Int = 500)(using Frame): AppLog < Sync =
      Signal
        .initRef(Chunk.empty[String])
        .map(ref =>
          new AppLog:
              val lines: Signal[Chunk[String]] = ref

              private def add(level: String, message: String): Unit < Sync =
                Clock.now.map(now =>
                  ref
                    .updateAndGet(ls =>
                      (ls :+ s"${now.show.slice(11, 19)} $level $message")
                        .takeRight(limit.max(1)),
                    )
                    .unit,
                )

              def info(message: String)(using Frame): Unit < Sync =
                add("info", message).andThen(Log.info(message))

              def warn(message: String)(using Frame): Unit < Sync =
                add("warn", message).andThen(Log.warn(message)),
        )

One store

Assistant is the one store of the app. It groups the state, the derived values and the actions. A reply runs on a fiber of its own:

Assistant.scala
    /** Starts the fiber of the reply to `request`. The fiber waits for a gate,
      * so the run state names it before it can end.
      */
    private def start(
        id: ConversationId,
        request: ChatRequest,
        s: Settings,
    ): Unit < Sync =
      providers.get(s.provider) match
          case Absent            =>
            fold(id, ChatEvent.Error(ChatError.Auth))
              .andThen(save(id))
              .andThen(log.warn(s"${s.provider.label} has no key"))
          case Present(provider) =>
            for
                gate  <- Latch.init(1)
                gen   <- generation.incrementAndGet
                fiber <- Fiber.initUnscoped(
                           gate.await.andThen(reply(id, gen, request, provider)),
                         )
                _     <- run.set(Run.Busy(id, gen, fiber))
                _     <- streamingRef.set(Streaming.Running(id))
                _     <- log.info(s"${s.provider.label}: a request to ${s.model}")
                _     <- gate.release
            yield ()

A cancel interrupts that fiber. The stream and its HTTP connection stop with it:

Assistant.scala
    /** Stops the reply that streams: its fiber ends, the message is marked
      * stopped (its text stays), and the conversation is saved.
      */
    def cancel: Unit < Async =
      run.getAndSet(Run.Idle).map {
        case Run.Idle               => Kyo.unit
        case Run.Busy(id, _, fiber) =>
          for
              _ <- fiber.interrupt
              _ <- fold(id, ChatEvent.Error(ChatError.Cancelled))
              _ <- streamingRef.set(Streaming.Idle)
              _ <- save(id)
              _ <- log.info("the reply was stopped")
          yield ()
      }

The wiring is Kyo layers

The shared part gives the layers that build on the services of a platform:

Assistant.scala
    /** The providers: with [[StartMode.Detect]], each provider whose key
      * `Secrets` finds (`live` makes it); always the fake `fake`.
      */
    def providers(
        live: (ProviderChoice, ApiKey) => ChatProvider,
        fake: ChatProvider,
        mode: StartMode,
    )(using Frame): Layer[Providers, Env[Secrets & AppLog] & Sync] =
      Layer.from[Secrets, AppLog, Providers, Sync]((s, log) =>
        mode match
            case StartMode.Detect   => Providers.make(s, live, fake, log)
            case StartMode.FakeOnly =>
              Providers.fakeOnly(fake): Providers < Sync,
      )

A test wires the same app with the storage and the keys in memory and the fake provider:

AssistantSuite.scala
    /** The app on a `width` x `height` screen: the storage and the keys in
      * memory, the scripted provider `fake` (also as the real providers).
      */
    private def start(
        fake: ChatProvider = FakeProvider.make(delay = 5.millis),
        keys: Map[ProviderChoice, String] = Map.empty,
        width: Int = 100,
        height: Int = 30,
    )(using Frame): Headless < (Async & Scope) =
      Storage.inMemory.map { storage =>
        val (files, secrets) = Memory.wiring(storage, keys)
        Headless.start(width, height, stylesheet = Look.palette)(quit =>
          Tui.size.map(size =>
            Memo.run(
              Env.runLayer(
                files,
                secrets,
                Wiring.conversations,
                Wiring.log,
                Wiring.providers((_, _) => fake, fake, StartMode.Detect),
                Layer(Surface.Terminal),
              )(
                AssistantUi.run(
                  StartMode.Detect,
                  quit,
                  size.map(t => ScreenSize(t.width, t.height)),
                ),
              ),
            ),
          ),
        )
      }

Where the code is

PartWhere
The domain, the services, the store and the UI (plain kyo-ui with tui4s-use and tui4s-widgets)modules/assistant/shared
The interpreters of the JVM (kyo-http, the keys, the files)modules/assistant/jvm
The interpreters of the browser (fetch, localStorage)modules/assistant/js
The terminal mainexamples/src/main/scala/examples/assistant/Assistant.scala
The web mainexamples/web
Copyright © 2026