Last updated: 2026-02-24 Status: current implementation
tuispec is a Haskell framework for black-box testing of terminal UIs (TUIs) over PTY.
Primary goals:
- let developers write reliable TUI tests as normal Haskell programs
- make tests agent-friendly (explicit actions, explicit waits, deterministic artifacts)
- keep transport generic (no framework instrumentation inside the target app)
In scope:
- Linux terminal environments
- PTY-backed app execution
- single viewport per test/session
- text assertions and snapshots (
.ansi.txt+ metadata, optional PNG rendering) tastyintegration (tuiTest)- ad-hoc session mode (
withTuiSession) for REPL-like workflows - JSON-RPC server for interactive orchestration (
tuispec server)
Out of scope:
- Windows/macOS support
- browser/web UI for reports
- multi-pane orchestration
- in-process hooks into TUI frameworks
Main public modules:
TuiSpecTuiSpec.TypesTuiSpec.RunnerTuiSpec.Render
Key data types:
RunOptions: runtime and artifact behaviorApp: target command + args + optional env overridesKey/Modifier: input modelSelector: viewport query modelWaitOptions: polling behaviorSnapshotName: typed snapshot idTui: runtime handle passed to DSL actions
Default RunOptions:
timeoutSeconds = 5retries = 0stepRetries = 0terminalCols = 134terminalRows = 40artifactsDir = "artifacts"ambiguityMode = FailOnAmbiguousupdateSnapshots = FalsesnapshotTheme = "auto"
Tests are plain Haskell code, usually with tasty:
{-# LANGUAGE OverloadedStrings #-}
import Test.Tasty (defaultMain, testGroup)
import TuiSpec
main :: IO ()
main =
defaultMain $ testGroup "suite"
[ tuiTest defaultRunOptions "counter" $ \tui -> do
launch tui (app "my-tui" [])
waitForText tui (Exact "Ready")
press tui (CharKey '+')
expectSnapshot tui "counter-updated"
]Use withTuiSession for interactive scripts/tools:
withTuiSession defaultRunOptions "demo" $ \tui -> do
launch tui (app "sh" [])
sendLine tui "echo hello"
_ <- dumpView tui "hello"
pure ()launch :: Tui -> App -> IO ()app :: FilePath -> [String] -> Apppress :: Tui -> Key -> IO ()pressCombo :: Tui -> [Modifier] -> Key -> IO ()typeText :: Tui -> Text -> IO ()sendLine :: Tui -> Text -> IO ()
launch replaces any currently running app for that Tui handle.
When App.env is provided, launch inherits parent environment variables and:
Just "value"sets/overrides a variableNothingunsets an inherited variable
When App.cwd is provided, launch runs in that working directory.
waitFor :: Tui -> WaitOptions -> (Viewport -> Bool) -> IO ()waitForStable :: Tui -> WaitOptions -> Int -> IO ()waitForText :: Tui -> Selector -> IO ()waitForSelector :: Tui -> WaitOptions -> Selector -> IO ()expectVisible :: Tui -> Selector -> IO ()expectNotVisible :: Tui -> Selector -> IO ()
Behavior:
- polling-based
- default poll interval:
100ms waitForText/expectVisibleusetimeoutSecondsfromRunOptions- matching runs on rendered viewport text (ANSI control/style escapes are interpreted)
- ambiguity checking runs after positive selector match
waitForStable semantics:
- polls viewport at
pollIntervalMsintervals - returns once viewport text has been unchanged for
debounceMsconsecutive milliseconds - throws timeout error if overall
timeoutMsis exceeded - replaces brittle fixed
threadDelaycalls with semantic stability checks
Selector constructors:
Exact TextRegex TextAt Int IntWithin Rect SelectorNth Int Selector
Regex semantics are intentionally lightweight, not full PCRE:
- supports alternation via
| - supports wildcard segmenting via
.* - strips literal
(and)during matching
Ambiguity mode:
FailOnAmbiguous: fail if selector has multiple matches (except explicitAt/Nth)FirstVisibleMatch: tolerate multiple matchesLastVisibleMatch: tolerate multiple matches
Key supports:
- named keys:
Enter,Esc,Tab,Backspace - arrows:
ArrowUp,ArrowDown,ArrowLeft,ArrowRight - function keys:
FunctionKey 1..12 - char-oriented keys:
CharKey c,Ctrl c,AltKey c,NamedKey text
pressCombo supports current mappings:
[Control] + CharKey c[Alt] + CharKey c[Shift] + CharKey c
expectSnapshot :: Tui -> SnapshotName -> IO ()dumpView :: Tui -> SnapshotName -> IO FilePath
expectSnapshot:
- captures current ANSI buffer into test artifacts
- compares against baseline in
artifacts/snapshots/<test-slug>/ - creates baseline when missing or when
updateSnapshots = True
dumpView:
- captures only run artifact (no baseline compare)
- used for exploratory workflows and server orchestration
Both produce:
<name>.ansi.txt<name>.meta.json(rows,cols)
Test-level retry:
retriesinRunOptionsmeansretries + 1attempts- each attempt gets clean test artifact directory state
Step-level retry:
step :: StepOptions -> String -> IO a -> IO astepMaxRetriesandstepRetryDelayMs
Hard test timeout:
- each test body is wrapped with
timeoutSeconds
RunOptions can be overridden by env vars:
TUISPEC_TIMEOUT_SECONDSTUISPEC_RETRIESTUISPEC_STEP_RETRIESTUISPEC_TERMINAL_COLSTUISPEC_TERMINAL_ROWSTUISPEC_ARTIFACTS_DIRTUISPEC_UPDATE_SNAPSHOTSTUISPEC_AMBIGUITY_MODE(fail|first|first-visible|last|last-visible)TUISPEC_SNAPSHOT_THEME
Root/path helpers:
TUISPEC_PROJECT_ROOToverrides project root detection
Theme auto-resolution:
- when theme is
auto, background is inferred fromCOLORFGBGwhen available - fallback is dark (
pty-default-dark)
For test slug my-test under artifactsDir:
-
test attempt snapshots:
artifacts/tests/my-test/snapshots/<snapshot>.ansi.txtartifacts/tests/my-test/snapshots/<snapshot>.meta.json
-
baseline snapshots:
artifacts/snapshots/my-test/<snapshot>.ansi.txtartifacts/snapshots/my-test/<snapshot>.meta.json
For ad-hoc sessions (withTuiSession "session-name"):
artifacts/sessions/session-name/snapshots/<snapshot>.ansi.txtartifacts/sessions/session-name/snapshots/<snapshot>.meta.json
Console summary includes snapshot artifact paths on test completion.
CLI command: tuispec render
- input: ANSI snapshot (
.ansi.txt) - output: PNG
- metadata (
rows,cols) auto-loaded from adjacent.meta.json - optional overrides:
--rows,--cols,--theme,--font
CLI command: tuispec render-text
- input: ANSI snapshot
- output: visible viewport text
- same metadata and optional size overrides
PNG renderer implementation details:
- uses
python3+ Pillow - resolves font in this order:
--fontTUISPEC_FONT_PATH- built-in system fallback font paths
CLI command: tuispec replay
- input: JSONL recording file
- options:
--speed as-fast-as-possible|real-time(defaultreal-time)--show-input: display last input action on a status line below the viewport
- if recording contains
frameevents, replays them visually on the terminal (overwrite in place with original timing) - falls back to printing raw request lines when no frames are present
Recording JSONL files contain one event per line:
timestampMicros: microseconds since POSIX epochdirection:request|response|notification|frame|frame-deltaline: raw JSON-RPC line, viewport text, or delta payload
Frame events are captured by a background sampling thread during
recording.start at a configurable rate (default 200ms = 5 Hz).
Consecutive identical frames are deduplicated. Full keyframes (frame)
are emitted roughly every second; compact line-level deltas
(frame-delta) are emitted in between. Delta payloads are JSON arrays
of [lineIndex, "text"] pairs.
CLI command:
tuispec server --artifact-dir PATH [--cols N] [--rows N] [--timeout-seconds N] [--ambiguity-mode fail|first-visible|last-visible]Transport:
- newline-delimited JSON-RPC 2.0 on stdin/stdout
Session model:
- one active session at a time
- initialize with
initialize - all non-initialize methods require an active session
Methods:
initializelaunchsendKeysendTextsendLinecurrentViewdumpViewrenderViewwaitUntilwaitForStablediffViewexpectSnapshotwaitForTextexpectVisibleexpectNotVisibleviewSubscribeviewUnsubscribebatchrecording.start(optionalframeIntervalMs, default 200 = 5 Hz)recording.stoprecording.statusreplayserver.pingserver.shutdown
Server error codes:
- JSON-RPC standard: parse / invalid request / method not found / invalid params
- server domain:
-32001no active session-32002session already started-32004method failed
launch accepts:
command(string)args(array of strings, optional)env(object of string-to-(string|null) pairs, optional)cwd(string, optional)readySelector(selector, optional)readyTimeoutMs(int, optional)readyPollIntervalMs(int, optional)
Example:
{"method":"launch","params":{"command":"sh","args":[],"env":{"APP_MODE":"test","CLAUDECODE":null},"cwd":"."}}env string values override inherited process env variables for that launch.
env null values unset inherited variables for that launch.
Accepted string forms:
- base:
Enter,Esc,Tab,Backspace, arrows,F1..F12, single char - combos:
Ctrl+X,Alt+X,Shift+X
{"type":"exact","text":"Ready"}
{"type":"regex","pattern":"Ready|Done"}
{"type":"at","col":10,"row":2}
{"type":"within","rect":{"col":1,"row":1,"width":40,"height":10},"selector":{"type":"exact","text":"Ready"}}
{"type":"nth","index":1,"selector":{"type":"exact","text":"Task"}}Coordinate notes:
- server row/col coordinates are 1-based
currentViewrow/col filters support0wildcard for entire row/column ranges
server.shutdown:
- sends
SIGKILLto active child process group - exits server immediately
- does not wait for graceful teardown
SIGHUP handling:
- same behavior as hard shutdown (
SIGKILL+ immediate exit)
tuiTestis the canonical integration point fortasty- tests are black-box: interaction only via PTY I/O and visible viewport state
- tests are isolated by fresh PTY launch lifecycle per test
- framework remains generic to any TUI binary runnable from shell
README.md: usage overview and quick-startSKILL.md: REPL and server operator workflowSERVER.md: server protocol reference