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.