Sky.Tui overview

Status: the Rust compiler (rust/, cargo build --release -p sky) is the primary Sky compiler; the Haskell compiler is preserved under legacy-haskell-compiler/. Verified by the example sweep + compiler test suite (cargo test + xtask gates). See ../history/compiler/journey.md for the changelog.

Terminal-rendering TEA backend. Sky.Tui runs an init / update / view / subscriptions app the same way Sky.Live runs a web app — but the view function paints to ANSI terminal cells instead of HTML. The same Std.Ui element tree renders in both backends, so a counter, a stopwatch, or a small dashboard ports between the browser and the terminal with no view rewrites.

You write it through Std.App. Don't import Std.Tui in app code — write one App.app (or App.tui) value and pick the terminal at build time with --target:

--targetBackendViewBuilder
terminal:tuiSky.Tui — full-screen, raw mode, alt-screenmodel -> Element msg (Std.Ui) or model -> StringApp.app / App.tui
terminal:cliSky.Cli — line-oriented, non-raw terminalmodel -> StringApp.cli

A Std.Ui Element view under App.app renders full-screen ANSI on terminal:tui — the exact same view function that renders HTML on --target web and a native window on --target desktop. Std.Tui / Std.Cli themselves are the low-level runtimes Std.App composes; you never call them directly.

module Main exposing (main)

import Sky.Core.Prelude exposing (..)
import Sky.Core.String as String
import Sky.Core.System as System
import Std.App as App
import Std.Cmd as Cmd
import Std.Sub as Sub
import Std.Ui as Ui exposing (Element)
import Std.Ui.Font as Font


type alias Model =
    { count : Int }


type alias KeyEvent =
    { kind : String, value : String }


type Msg
    = Increment
    | Decrement
    | Quit
    | NoOp


init : () -> ( Model, Cmd Msg )
init _ =
    ( { count = 0 }, Cmd.none )


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Increment -> ( { model | count = model.count + 1 }, Cmd.none )
        Decrement -> ( { model | count = model.count - 1 }, Cmd.none )
        Quit      -> ( model, Cmd.perform (System.exit 0) (\_ -> NoOp) )
        NoOp      -> ( model, Cmd.none )


view : Model -> Element Msg
view model =
    Ui.column [ Ui.padding 16, Ui.spacing 8 ]
        [ Ui.el [ Font.bold ] (Ui.text ("Count: " ++ String.fromInt model.count))
        , Ui.row [ Ui.spacing 8 ]
            [ Ui.button [] { onPress = Just Decrement, label = Ui.text "-" }
            , Ui.button [] { onPress = Just Increment, label = Ui.text "+" }
            ]
        ]


subscriptions : Model -> Sub Msg
subscriptions _ =
    Sub.none


onKey : KeyEvent -> Msg
onKey k =
    if k.kind == "char" && k.value == "q" then
        Quit

    else
        NoOp


appDef =
    App.app
        { init = init, update = update, view = view, subscriptions = subscriptions }
        |> App.withOnKey onKey
        -- The same App.app also builds for `--target web`, which needs a
        -- not-found page (a bare `sky check` / `sky build` checks that target).
        |> App.withNotFound ()


main =
    App.run appDef

Pin the terminal backend so a bare sky build / sky run picks it (an explicit --target still wins):

# sky.toml
[app]
target = "terminal:tui"

sky run src/Main.sky builds and launches the binary. The terminal switches to alt-screen, raw mode, and mouse tracking; teardown on exit (Ctrl-C, Quit, panic, SIGTERM) restores the user's primary screen. App.withOnKey onKey attaches the raw key-event handler (KeyEvent -> Msg) the terminal backend delivers — the runner maps it to the low-level Tui.withOnKey.

Runner-direct form. main = App.runTui appDef builds the TUI backend without a --target flag or sky.toml pin. App.run + --target terminal:tui is the portable default.

Status: stable

Sky.Tui shipped as part of v0.12 and has tracked Sky.Live in the 27-example regression sweep since. The surface follows the same backwards-compatibility discipline as Sky.Live — examples/21..24 exercise the full primitive set on every release.

What works

AreaDetails
Layoutrow, column, wrappedRow, paragraph (word-wrap), textColumn, grid + gridColumns, el
TextUi.text wraps at word boundaries within the cells it is given (v0.27.0; in a row, texts share the width the other children leave); Ui.textNoWrap stays one row, cut at its box
CanvasStd.Ui.Canvas scenes rasterised into Braille cells (2 × 4 dots per cell): shapes filled and stroked on the dot grid, a cell in the colour of the last shape that set a dot in it, text on the cell grid at its anchor. The scene is sized from its CSS px through the logical-pixel canvas and scaled down to fit, keeping its aspect ratio. Opacity below 0.2 hides a shape; stroke width and pointer events do not apply
Islandsa widget island (and so Std.Ui.Terminal) renders its empty element
Sized elementstext, link, image, button, input, form
Lengthpx, fill, fillPortion N, content, shrink, minimum N L, maximum N L, vh N, vw N
Padding / spacingpadding N, paddingXY x y, paddingEach { top, right, bottom, left }, spacing N
AlignmentcenterX/Y, alignLeft/Right/Top/Bottom
Borderssolid / dashed / dotted with widthEach, rounded, color
Text stylingbold, italic, underline, lineThrough, fg/bg colour (truecolour SGR; suppressed under NO_COLOR)
Headingsh1 – h6 with distinct visual markers (═ ─ ▌ ▎ ▏ ·)
Inputstext, password (masked), checkbox (☐/☑), radio (○/●), slider, multiline textarea
EventsonClick, onInput, onFocus, onSubmit (form record-decode), onKeyDown
Mouseleft-press, scroll wheel (3 cells/notch). Release / drag / middle / right-click deferred
Nearby overlaysabove, below, onLeft, onRight, inFront, behind
Wide charsCJK + emoji + ZWJ family — proper grapheme clusters via github.com/rivo/uniseg
Bracketed pasteup to 1 MiB; multi-line paste no longer fires phantom Enter
Modifier keysCtrl-Left/Right do word-jump; Shift/Alt/Ctrl flags reach user onKey
ResizeSIGWINCH triggers re-layout

The runtime restores the terminal in every exit path: panic, signal (SIGTERM/HUP/QUIT/INT), System.exit, normal Quit Msg. mosh sessions don't end up with a corrupted readline.

The terminal loop contract

The terminal backends (terminal:tui Element and String views, terminal:cli) share one update core (runtime-go/rt/tea_loop.go), so these rules hold on all of them:

ConcernRule
QuittingCtrl-C always quits and is never delivered to onKey (raw mode turns SIGINT into a byte only the runtime can act on). Without an onKey handler, q also quits, unless it is typed into an input. Stdin EOF quits.
No handlersApp.tui without withOnKey runs (quit with q / Ctrl-C). terminal:cli without withInput reads no input: it runs init, its Cmds and its Sub.every timers, and exits 0 when nothing is left to happen.
App.withInputterminal:cli reads stdin lines. terminal:tui shows a one-line prompt under the view (Element and String views); Enter sends the line and clears the prompt. The same source takes the same lines on both targets.
Stdin EOF (cli)The program exits only after every in-flight Cmd.perform has landed and rendered.
App.withGuardRuns before update on every terminal target, as on the web.
App.withDurableRestores the model at start and snapshots it after each update, on every terminal target. A stored snapshot that no longer decodes is logged as DurableRestoreFailed; the app boots from init and does NOT write snapshots for that run, so the stored one is kept until it is migrated or removed.
Sub.everyEvery Sub.every is honoured. A timer whose interval is still requested keeps running (and its phase) across updates, so a slow timer fires under a stream of faster Msgs. Two subscriptions on one interval both dispatch.
Cmd.publish / Sub.subscribeTopicIn-process bus: a publish is delivered to the app's own subscriber on that topic (echo by default, as on Sky.Live). Cmd.publishNoEcho has no other subscriber to reach in a single program.
KeysA read can split a UTF-8 rune, an escape sequence or the bracketed-paste end marker; the decoder keeps the tail across reads (a lone ESC is the Escape key after 50 ms). ESC + key is Alt+key (alt = True). Ctrl- and Alt-keys bypass a focused text input so global hotkeys reach onKey.
Focus (Element view)Focus follows the element, not its tab index: identity is the id attribute, else the tag, events and text. Keys queued behind a Msg act on the repainted frame.
Forms (Element view)A Ui.form with onSubmit is not a tab stop. Enter in one of its single-line inputs (or a type="submit" button) submits it: named controls (Ui.name) fill the handler's record (String / Int / Float / Bool fields). A field that does not decode is a classified InvalidInput in notification and the exit summary, never a zero-filled record. onEnter fires on Enter first.
InputsInput.multiline is an editable textarea (Enter inserts a newline). A slider shows its value between min and max; Left / Right step it by step, Home / End jump to the ends.
LayoutA fill height inside a content-sized parent sizes to its content (so Ui.width on an Input, which hoists a fill to the control, keeps it one row). A root height fill fills the viewport.
String viewsLine breaks are written as CR LF, so each line starts at column 0 in raw mode.

Logical-pixel canvas

Attach a logical-pixel canvas via a TerminalConfig passed to App.withConfig. The runtime computes pxPerCell from the live terminal size and scales every Ui.padding 8, Ui.spacing 4, Ui.px N to character cells. Default 1280×720 matches a typical web canvas — Std.Ui apps written for the browser look right in the terminal without re-tuning. Tweak canvasWidth for denser layout. (App.withConfig (App.TerminalConfig …) maps to the low-level Tui.withCanvasWidth / Tui.withCanvasHeight.)

appDef =
    App.app
        { init = init, update = update, view = view, subscriptions = subscriptions }
        |> App.withOnKey onKey
        |> App.withConfig
            (App.TerminalConfig { App.terminalDefaults | canvasWidth = 1024, canvasHeight = 768 })


main =
    App.run appDef

Auth guard middleware

The App.withGuard builder attaches a guard — Msg -> Model -> Result Error (). Returning Err reason skips the update and (if your model has notification / notificationType fields) writes the rejection into them for the view to render. The same guard function works under every backend (the runner maps it to Tui.withGuard on both terminal:tui views, Cli.withGuard on terminal:cli and Live.withGuard on the web), so authentication logic stays portable.

Sky.Cli — line-oriented TEA (--target terminal:cli)

For apps that DON'T want raw-mode and full-screen rendering, build with App.cli and --target terminal:cli: the view returns a String, App.withInput maps each stdin line to a Msg, and the runtime runs on a regular non-raw terminal. Useful for piped scripts and CI diagnostics.

appDef =
    App.cli
        { init = init, update = update, view = view, subscriptions = subscriptions }
        |> App.withInput onLine
# sky.toml
[app]
target = "terminal:cli"

Cli.readPassword : () -> Task Error Secret (a helper of the line-oriented runtime) reads a line from stdin with terminal echo disabled — wraps golang.org/x/term's ReadPassword. Falls back gracefully on non-TTY stdin.

Examples

#NameDescription
20cli-counterApp.cli — TEA on stdin lines (--target terminal:cli)
21tui-stopwatchApp.tui — bubbletea-style stopwatch, String view (terminal:tui)
22tui-stopwatch-uiApp.app — Std.Ui Element view rendered full-screen (same view works under --target web too)
23tui-todoSky.Tui — todo CRUD demo
24tui-kitchen-sinkSky.Tui — every supported Std.Ui primitive in one screen

Environment variables

VarPurpose
NO_COLORSuppress colour SGR (bold / underline / reverse retained)
TERM=dumbRefused with friendly error before raw mode (avoids corrupting non-TTY output)
SKY_TUI_QUIET=1Suppress unsupported-attribute warnings on exit
SKY_TUI_LOG=1Write a ledger of warnings to a log file

Reliability floor

These are enforced runtime invariants — every panic / signal / malformed input path was audited before the experimental tag.

ConcernFloor
Goroutine panicsafeGo wrapper restores TTY before exiting
External SIGTERM / SIGHUP / SIGQUIT / SIGINTTrapped → tuiTeardown → exit 128+signum
Panic on main goroutineDeferred tuiTeardown + DECSTR soft reset on exit
ANSI injection via user textsanitiseRune strips control bytes (0x00-0x1F, 0x7F)
Wide-char column driftuniseg-backed displayWidth / iterGraphemes
Resource exhaustion (runaway view height)Hard cap tuiMaxContentH = 50,000; soft warn at 10,000
TERM=dumb / non-TTY stdinRefused before raw mode
Readline corruption after exitDECSTR (\x1b[!p) + charset reset + scroll-region reset on every teardown path

Prior-art attribution

The TEA shape (init / update / view / subscriptions) is adapted from elm-lang's Browser.element. bubbletea's renderer inspired the safeGo + alt-screen lifecycle. See NOTICE.md.

See also