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 ../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.

module Main exposing (main)

import Sky.Core.Prelude exposing (..)
import Sky.Core.Task as Task
import Sky.Core.System as System
import Std.Tui as Tui
import Std.Cmd as Cmd
import Std.Sub as Sub
import Std.Ui as Ui
import Std.Ui exposing (Element)
import Std.Ui.Background as Background
import Std.Ui.Font as Font


type alias Model = { count : Int }

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.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

main =
    Tui.app
        (Tui.config
            { init = init, update = update, view = view
            , subscriptions = subscriptions
            }
        )
        |> Task.run

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.

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
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.

Logical-pixel canvas

Attach a logical-pixel canvas via the Tui.withCanvasWidth / Tui.withCanvasHeight builders. 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 via Tui.withCanvasWidth 800 for denser layout.

main =
    Tui.app
        (Tui.config
            { init = init, update = update, view = view
            , subscriptions = subscriptions
            }
            |> Tui.withCanvasWidth 1024
            |> Tui.withCanvasHeight 768
        )
        |> Task.run

Auth guard middleware

The Tui.withGuard builder attaches a guard with the same shape as Live.app's — 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 both backends, so authentication logic stays portable.

Sky.Cli — line-oriented TEA

For apps that DON'T want raw-mode and full-screen rendering, Sky.Cli provides a line-oriented variant: the view returns a String, update consumes lines from stdin, and the runtime runs on a regular non-raw terminal. Useful for piped scripts and CI diagnostics.

Cli.readPassword : () -> Task Error String 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-counterSky.Cli — TEA on stdin lines
21tui-stopwatchSky.Tui — bubbletea-style stopwatch
22tui-stopwatch-uiSky.Tui — Std.Ui-driven stopwatch (same view function works under Sky.Live 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