Terminal platform internals

CNA snapshot 009d40f5  ·  Development › Platform internals  ·  source links pinned to 009d40f5

✓

Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. The tests named here drive pseudo-terminals; a live terminal-emulator/TTY matrix is not established here, and nothing was executed.

TerminalPlatform is the third substantive implementation of CNA::Platform::IPlatform: it has a real display, real keyboard and mouse input and real resize events, and all of them come from the POSIX terminal the process is attached to rather than from a window system. It has no GPU surface, no desktop window and no gamepad channel. This page is for maintainers who change the backend itself: its selection and renderer boundary, the terminal session and its restoration guarantees, the byte-stream input decoder and the character-cell presenter. To build and run a game on it, start with Tutorial 139: The Terminal Platform; for its place among the seven implementations, see Platform backends and the capability matrix.

Selection and the renderer boundary

CNA_PLATFORM=TERMINAL is offered only when the target is not Windows: cmake/PlatformSelection.cmake adds it to the available list on every non-Windows target and puts it on the reserved list for a Windows target, where asking for it fails with "a reserved identifier that is NOT implemented" instead of falling back to SDL3. The selection defines CNA_PLATFORM_TERMINAL, which makes modules/platform/src/PlatformFactory.cpp resolve its default name to "Terminal", so PlatformFactory::Create() constructs a Terminal::TerminalPlatform.

Being selected is not what compiles it. modules/platform/CMakeLists.txt globs src/Terminal/*.cpp into cna_platform on every non-Windows target, whatever CNA_PLATFORM says, and PlatformFactory::Create("Terminal") and PlatformFactory::GetAvailable() include it on every POSIX build. That is deliberate: the conformance suite needs more than one implementation live in one process, and this one links no third-party library at all (termios, poll(2), ioctl(TIOCGWINSZ) and sigaction are libc). A consequence for maintainers is that every Linux build compiles and, when tests are on, tests the terminal backend even when X11, Wayland or SDL3 is the selection.

At configure time cmake/RendererSelection.cmake refuses a TERMINAL build whose renderer is anything other than SOFTWARE, PORTABLEGL, HEADLESS or STUB, with the message "CNA_PLATFORM=TERMINAL has no native graphical window". The check sits at the top of the per-identity configuration macro cna_configure_renderer_identity, which the file runs once for every identity in the build, so each entry of a CNA_GRAPHICS_RENDERERS list is checked as well, and it runs before that identity's own dependency probes: a TERMINAL plus VULKAN request fails with this explanation rather than first asking for a Vulkan SDK.

Of the four accepted renderers only one draws. SOFTWARE is the only renderer descriptor that sets needsSurfacePresenter (modules/renderers/software/src/SoftwareRendererDescriptor.cpp). In modules/graphics/src/Xna/GraphicsDevice.cpp the device builds a platform window and later calls CreateSurfacePresenter for such a renderer only when the platform reports surfacePresentation true and nativeWindowHandle false, which reads as "the presenter is the only route to the screen". At this snapshot that describes exactly TERMINAL attached to a TTY; on SDL3, X11, Wayland and Win32 a CPU renderer stays off-screen. PORTABLEGL, HEADLESS and STUB configure and run on this platform but put nothing on the terminal. (A CMake comment next to the pseudo-TTY integration test records that the test was once registered disabled for a period in which no renderer requested a presenter; its assertions are meaningful again because SOFTWARE now does.)

The window's GetNativeHandle returns a NativeWindowHandle tagged NativeWindowSystem::Terminal with every pointer null, and both GetGlContext and GetVulkanSurface return null. A GPU backend must not assume that the tag hides an X11 or Wayland surface; there is none.

Presentable output is not guaranteed merely because the target was built. The constructor performs one free check, isatty(outputDescriptor), and GetCapabilities reports surfacePresentation true only when it succeeded. Redirected CI output therefore yields a valid platform object without a presenter: CreateSurfacePresenter throws PlatformNotSupportedException(SurfacePresentation), which is what keeps ANSI escape bytes out of logs. A window from another platform instance is refused earlier with a PlatformException ("window was not created by this Terminal platform"). The portable services are real: a Common::StandardFileSystem whose preferences root is named cna-terminal, and a Common::StandardSystemInfo.

CapabilityTerminal reportsDecided by
surfacePresentationTrue only when standard output is a TTYisatty(outputDescriptor) at construction
exactKeyboardStateTrue only after the Kitty keyboard probe confirmed all four requested enhancementsDetectTerminalCapabilities
every other field of PlatformCapabilitiesFalse, including multipleWindows, highDpi, nativeWindowHandle, textInput, pixelAccurateMouse, relativeMouse and cursorShapesDefault-false aggregate, never set

A keyboard and a mouse service exist on a queryable terminal even though exactKeyboardState may be false and pixelAccurateMouse is always false: the flags describe quality, not presence. Note also that GetCapabilities is not free on this platform. It calls EnsureCapabilitiesDetected, which runs the terminal probe the first time; Game's constructor reads the capability set once (modules/runtime/src/Game.cpp), so for a game on a TTY the probe happens while the Game object is being constructed.

PlatformFactory::Create()  ("Terminal")
  └─ TerminalPlatform            construction: isatty(stdout) only, no terminal state touched
       ├─ WindowSlot ⇄ TerminalWindow        one window; sized in pixels, not cells
       ├─ TerminalResizeWatcher              installed by CreateWindow on a TTY
       │     SIGWINCH → flag → PollEvents → Resized + PixelSizeChanged
       └─ EnsureCapabilitiesDetected()       first GetCapabilities / GetKeyboard / GetMouse /
             │                               PollEvents / CreateSurfacePresenter
             ├─ DetectTerminalCapabilities   colour depth, canQuery, Kitty keyboard
             └─ TerminalSessionController    (shared)
                   ├─ TerminalInputDecoder   Keyboard + Mouse leases  (only when canQuery)
                   │     ├─ TerminalKeyboard  held-key snapshot
                   │     └─ TerminalMouse     cell-quantised snapshot
                   └─ TerminalSurfacePresenter   Presenter lease
                         union of leases → one process-wide TerminalSession

GraphicsDevice + SOFTWARE  (surfacePresentation && !nativeWindowHandle)
  → CreateSurfacePresenter → Present(SurfaceFrame)
  → QuantizeInto → TerminalGrid → TerminalAnsiWriter (full frame or changed cells)
  → TerminalFrameBudget → write(output descriptor)

Window and resize: pixels versus cells

CreateWindow returns the single window a terminal can have; a second request while one exists throws PlatformNotSupportedException(MultipleWindows) rather than aliasing two callers onto one screen. The requested width and height are the game's drawing resolution, not the terminal's columns and rows: the window reports exactly that size, and the presenter later scales and quantises the image into whatever grid the terminal has. The requested size stands until the terminal itself changes size, so a game that asked for 800×480 presents at 800×480 (letterboxed into the grid) until the first resize event makes the terminal authoritative.

The window and the platform share a WindowSlot through a std::shared_ptr. The window's destructor clears the slot, which frees it for a new CreateWindow, and because the slot is jointly owned the window may outlive the platform without its destructor writing into dead memory; TerminalPlatformTest.AWindowOutlivingItsPlatformIsNotUndefinedBehaviour pins that. SetSize only records a pending request that Sync applies, matching the asynchronous window contract of the other backends. Title, visibility, focus and border state are modelled values: GetClientBounds is always at the origin, GetDisplayScale is 1.0, HasFocus is true when the window is visible and not minimised, and Maximize and Restore merely clear the minimised flag. SetFullscreenMode(BorderlessFullscreen) throws PlatformNotSupportedException(BorderlessFullscreen); SetBorderless only stores the flag. SetTitle stores the title and deliberately writes no OSC sequence to a standard output nobody has claimed; its comment defers emission to the session, but the session prologue at this snapshot carries no title sequence either, so the title is never shown.

On an attached terminal, CreateWindow also constructs a TerminalResizeWatcher when none is active in the process. The platform owns it (resizeWatcher_), so it stays installed until the platform is destroyed, not merely until the window is. Its SIGWINCH handler only sets a volatile sig_atomic_t flag and is installed with SA_RESTART, so a resize never turns an unrelated blocking read into EINTR. PollEvents consumes the flag with TakePendingResize, calls QueryTerminalSize outside the handler (ioctl(TIOCGWINSZ), falling back to 80×24 when a pseudo-terminal reports zero), converts cells to nominal pixels with CellGridToPixelSize at 8×16 per cell (clamped so a huge grid cannot overflow int), applies the size at once with ApplyTerminalSize (discarding any pending SetSize), and then emits a Resized and a PixelSizeChanged WindowEvent carrying the same width and height. Any number of signals between two polls coalesce into one pair of events, because what a caller wants is the current size. Game::PollEvents treats that pair as it would on any platform (window update, viewport update, OnSurfaceInvalidated); see One frame source trace.

The nominal pixels encode an assumed 1:2 cell aspect, not measured display pixels; no escape sequence reports a cell's pixel size. The absolute numbers are arbitrary, but the ratio is load-bearing: the presenter's letterboxing uses the same factor of two, and changing one without the other squashes or stretches every picture. The watcher is process-global: a second TerminalResizeWatcher throws PlatformException (the platform avoids that by checking IsWatching first), and its destructor reinstates the previous SIGWINCH disposition. SimulateResizeForTesting exists because SIGWINCH is delivered to the foreground process group of the controlling terminal, which a test process's pseudo-terminal is not.

The session is the critical lifetime boundary

Constructing the platform changes no terminal mode. Capability detection may briefly enter raw mode to send queries (see input) and restores it before returning; the actual takeover of the terminal happens only when input is pumped or a presenter is created. modules/platform/src/Terminal/TerminalSessionController.cpp hands out movable RAII TerminalSessionController::Lease tokens for three uses, TerminalSessionUse::Keyboard, Mouse and Presenter, and computes the union of what the active leases need:

Lease heldRaw modeAlternate screen and hidden cursorSGR mouse reporting (1000, 1003, 1006)Kitty keyboard (push >15u)
Keyboardyesnonoonly if the probe detected support
Mouseyesnoyesno
Presenteryesyesnono

When the union changes, the controller destroys the one TerminalSession and builds a new one with the new options instead of mutating the live session: the session's signal handler reads an immutable restoration record, and making that record mutable would put a race on exactly the crash path whose safety matters most. Every rebuild or teardown increments the controller's generation; TerminalSurfacePresenter::Present compares it with the generation it last saw and, because leaving and re-entering the alternate screen invalidated every cell it remembered, forces a full redraw. A failed acquisition rolls its use count back and restores the previous union (RestoreAfterFailedAcquire); if even that cannot be rebuilt, or a rebuild fails during a release, the controller keeps no session at all, which leaves a restored terminal as the degraded state.

modules/platform/src/Terminal/TerminalSession.cpp takes the terminal over in a fixed order. It refuses if a session is already active in the process, and refuses an output descriptor that is not a TTY. It builds the epilogue and requires it to fit the 128-byte signal-safe buffer, saves the current termios with tcgetattr, and fills the process-global restoration record completely. Only then does it install handlers for SIGINT, SIGTERM, SIGHUP, SIGQUIT, SIGABRT, SIGSEGV, SIGBUS, SIGFPE and SIGILL (without SA_RESTART) plus a std::set_terminate handler, mark the record active, enter raw mode and write the prologue. Raw mode clears ICANON, ECHO, ISIG, IXON and ICRNL and sets VMIN and VTIME to zero, so poll owns the timing.

Restoration (RestoreNow) uses only async-signal-safe calls over state captured before any handler existed: it atomically claims the active flag (so a second request is harmless), writes the epilogue and restores the saved termios. The epilogue undoes the prologue in reverse: pop the Kitty keyboard stack, disable mouse reporting, reset SGR attributes unconditionally, show the cursor, and leave the alternate screen last. The signal handler restores, reinstates the previous disposition for that signal and re-raises it, so the process still dies of the signal it was sent and a crash still produces a core dump. The terminate handler restores before chaining to the previous handler, so the runtime's message for an uncaught exception is printed on the user's normal screen instead of vanishing with the alternate one. Normal destruction restores and puts the previous handlers and terminate handler back.

A second simultaneous session is refused because it would save the first session's already-raw settings as the "original" ones and later hand the user a shell with no echo. Releasing the last lease destroys the session. TerminalSurfacePresenter's destructor releases its lease before it prints its diagnostics line to stderr, so the line survives leaving the alternate screen. Forced exits are outside the guarantee: _exit runs no destructor or handler, and TerminalRestoration.ExitingWithoutUnwindingLeavesTheTerminalRawAsItMust documents that limit explicitly; SIGKILL cannot be caught, and SIGTSTP and SIGCONT are not in the restoring set.

⚠

Because raw mode clears ISIG and IXON, Ctrl+C, Ctrl+Z, Ctrl+S and Ctrl+Q typed while a session is active are not signals or flow control: they reach the input decoder as bytes and become control-chord key events. A game on this platform must offer its own way out (the 2D demo exits on Escape). The SIGINT restoration path covers a signal sent from outside, for example with kill, which is what the restoration tests exercise.

In an ordinary Game::Run on a TTY with SOFTWARE, reading the call order gives this sequence (not executed): the Game constructor's capability read runs the probe; DoInitialize creates the graphics device, whose presenter takes the first lease and starts a session with raw mode, the alternate screen and a hidden cursor; the first Tick's PollEvents calls TerminalInputDecoder::PumpAll, whose keyboard and mouse leases change the union, so the session is rebuilt once and the next Present redraws the whole grid. On teardown the presenter's lease normally goes first (the session is rebuilt without the alternate screen if input leases remain, then the diagnostics line is printed), and the input leases go with the platform's decoder; whichever release is last restores the terminal.

These process-global signal and session facilities imply single active terminal ownership, not a thread-safe platform. Nothing in the Terminal sources takes a lock: the only synchronisation is the atomic "watching" and "active" flags and the sig_atomic_t resize flag. Treat polling and presentation as serial game-loop operations, and do not move them to independent threads without first designing synchronisation and signal ownership; the thread and callback map records the loop's single-thread assumption.

From terminal bytes to CNA input

EnsureCapabilitiesDetected runs DetectTerminalCapabilities, creates the shared TerminalSessionController and, when the terminal can be queried, the TerminalInputDecoder, TerminalKeyboard and TerminalMouse. It publishes the cached result only after every allocation succeeded, so a failed first call stays retryable instead of exposing a true capability whose service was never constructed.

modules/platform/src/Terminal/TerminalCapabilityProbe.cpp measures rather than trusts. If standard output is not a TTY it returns immediately with everything false. Otherwise the colour depth comes from the environment only: COLORTERM containing truecolor or 24bit means true colour; an empty or dumb TERM means monochrome; a TERM containing direct means true colour, 256color means the indexed palette, and anything else is assumed to have the 16 ANSI colours. Queries are possible only when standard input is a TTY too (canQuery). The probe then enters a scoped raw mode (clearing only ICANON and ECHO, restored on every exit path), writes its queries to the input descriptor so a redirected output can never swallow them, and waits up to 250 ms by default for the first byte of each reply (40 ms for continuation bytes, at most 4096 bytes). Primary Device Attributes (ESC [ c) is sent first as a control: if it is not answered, nothing else is asked, because silence would be ambiguous. The Kitty query pushes flags 15, asks which flags became active and pops them again even on silence; hasKittyKeyboard is set only if all four requested enhancements are reported, so a partially supporting terminal is not advertised as exact. An XTVERSION query records the terminal's self-reported name.

GetKeyboard and GetMouse return services only when canQuery is true, and canQuery requires both standard output and standard input to be terminals: a game whose output is redirected to a file has neither a presenter nor a keyboard service. PollEvents first clears the caller's batch, then handles a pending resize, then (after EnsureCapabilitiesDetected) calls TerminalInputDecoder::PumpAll, which acquires the keyboard and mouse leases on first use and reads everything available without blocking, and finally drains the decoded events, stamping each with the window's id. The same decoder backs the keyboard and mouse snapshots: TerminalKeyboard::Update calls Pump and TerminalMouse::Update calls PumpMouse, and Game::PollEvents calls both at its end, after the batch has gone through the input bridge (there is no separate Game::UpdateInput at this snapshot; see Input internals). Bytes are read from the descriptor once; events decoded during an Update wait in the decoder's queue for the next PollEvents, while the snapshot reflects them immediately. The input buffer keeps at most its last 4096 bytes, so a peer that starts a sequence and never terminates it cannot grow memory without bound.

With the Kitty protocol active the decoder accepts only CSI sequences, discarding plain bytes (all keys arrive as CSI … u in that mode). Press, repeat and release are real events, held-key state is exact, and the virtual KeyCode and physical Scancode are translated separately (the physical key comes from the base-layout alternate key). SGR mouse reports and Kitty key events share one ordered byte stream through the same decoder.

Without Kitty, DecodeLegacyKeyEvent handles printable ASCII, control chords (bytes 1 to 26 become Ctrl plus a letter, except backspace, tab and carriage return), xterm CSI navigation, editing and function sequences, SS3 F1 to F4, modifiers carried by xterm's ;N parameter, and Alt-prefixed keys. Legacy sequences normally report presses but no reliable releases, so each press starts a synthetic hold that expires 100 ms later; a repeated press refreshes the deadline and is flagged as a repeat, and expiry emits a synthetic release. The source explains the trade-off: shorter windows create false releases between auto-repeats, longer ones make taps look held, and a key held through the operating system's initial repeat delay can briefly stutter. exactKeyboardState therefore stays false. A lone Escape byte waits 30 ms before it is treated as the Escape key, so a split escape sequence is not misread. Momentary modifiers on a legacy terminal come from the modifiers recorded with the synthetic holds.

SGR-1006 mouse reports become MouseMotionEvent, MouseButtonEvent and MouseWheelEvent values plus a MouseSnapshot. Cell coordinates are expanded to nominal client coordinates, (column − 1) × 8 and (row − 1) × 16, matching the window's nominal pixels, so pixelAccurateMouse stays false. Wheel events carry one step per report while the snapshot's scroll accumulators advance by 120 per step. ConsumeRelativeDelta always returns zero. Pointer control refuses deterministically: SetPosition and SetCursorVisible throw PlatformException; SetCursor, SetRelativeMode, SetCapture, TryGetGlobalPosition and SetGlobalPosition throw PlatformNotSupportedException naming CursorShapes, RelativeMouse or GlobalPointer.

Text input, gamepad, joystick, haptics, sensors, input-device enumeration, displays, clipboard, dialogs, tray, camera, GL context and Vulkan surface services are all null. Do not infer availability of a device from an input event alone.

ℹ

Maintainer note, from reading only (not executed): Game's IsMouseVisible setter forwards a changed value to IPlatformMouse::SetCursorVisible whenever the graphics device holds a platform window, and its comment still describes the terminal's mouse service as null. On a queryable terminal it is not null, and TerminalMouse::SetCursorVisible always throws PlatformException (TerminalMouseTest.UnsupportedPointerControlsRefuseDeterministically asserts that). A game that changes IsMouseVisible on this platform would therefore see that exception. Decide whether the setter or the service should absorb the request before changing either.

How a CPU frame reaches the terminal

CreateSurfacePresenter rejects a foreign window or non-TTY output, detects capabilities if nothing has yet, and constructs a TerminalSurfacePresenter over the shared session controller, the detected colour depth and the window's current pixel size (which GetTargetSize reports as the resolution callers should rasterise at). The presenter's constructor refuses a non-positive target size before touching the terminal and then takes the Presenter lease. modules/platform/src/Terminal/TerminalSurfacePresenter.cpp is the whole frame path.

Present validates the SurfaceFrame with the shared SurfaceFrameValidation.hpp (null pixels, non-positive size, negative stride, a stride shorter than one RGBA row, an address span too large), notices a changed session generation, re-queries the grid size, and maps the frame according to the scale mode: Stretch, Letterbox (the default), Overscan, None or Native. Letterbox and Overscan fit against a cell twice as tall as it is wide (kCellAspect is 2), and None and Native use the same factor to crop one source column per cell and two source rows per cell. SetScaleMode ignores the filter argument, since quantisation already box-averages.

QuantizeInto (TerminalFrameGrid.cpp) box-averages the source pixels that fall in each cell and picks a glyph by luminance from the ten-step ramp " .:-=+*#%@", the same ramp as graphics-ext's kAsciiGlyphRamp. The platform keeps its own implementation because calling into cna_graphics_ext from cna_platform would close a dependency cycle through cna_graphics_core and cna_input. TerminalAnsiWriter.cpp encodes each cell's averaged colour as a foreground colour for the detected depth (24-bit, nearest of the 256-colour cube and grey ramp, nearest of the 16 ANSI colours, or no colour escape at all for monochrome), skips restating a colour the terminal is already in, homes the cursor instead of clearing (no flicker) and positions each row explicitly instead of writing newlines (no scrolling). WriteChangedCells sends runs of adjacent changed cells behind one positioning escape.

The presenter caches what it actually sent in onScreen_. The first frame, a grid-size change, a scale-mode change and a session rebuild force a full redraw (and make the writer forget its colour state). An unchanged frame writes nothing and costs nothing. TerminalFrameBudget.cpp estimates link throughput from its own completed writes: until something is measured it allows every frame; afterwards it uses the median of the last five observed write rates (capped at 109 bytes per second) with at most one second of accumulated burst, and may drop a costly diff, never a required full redraw. On a drop the onScreen_ grid is left untouched, so the next diff includes everything that was missed; queuing deltas instead would break that invariant. The write loop retries on EINTR and throws PlatformException if the output stops accepting bytes; the grids are swapped only after the whole frame was written. SetVSync returns false: no terminal swap interval is simulated. The destructor prints one line to stderr after releasing its lease, CNA terminal diagnostics: colour=… grid=…x… dropped_frames=… kitty_keyboard=…, which is what the pseudo-TTY integration test looks for.

Debugging and safe modification

  • No presentation in CI or a pipe. Check isatty on standard output and GetCapabilities().surfacePresentation before looking at renderer code, and confirm the renderer is SOFTWARE: the other accepted renderers never request a presenter.
  • No keyboard or mouse service. Both standard output and standard input must be terminals; redirecting either leaves canQuery false.
  • Shell left raw or cursor gone. Inspect the lease balance and the TerminalSession restoration path; reproduce with the pseudo-terminal restoration harness, not an ordinary redirected-stdout test. Remember the documented limits (_exit, SIGKILL, job-control stops).
  • Ctrl+C does not stop the game. Expected while a session is active (ISIG is cleared); the key arrives as a control chord.
  • Key-up sticks or keys stutter on a legacy terminal. First determine whether the Kitty probe succeeded (the diagnostics line prints kitty_keyboard=); synthetic release timing is intentionally inexact.
  • Resize changes image scale unexpectedly. Compare the SIGWINCH → PollEvents nominal-pixel conversion with the presenter's current grid and the shared 1:2 aspect assumption; remember the window keeps its requested size until the first resize.
  • Stale characters after changing input leases or presentation mode. The session generation, the forced full redraw and the onScreen_ cache are the first suspects.
  • Low frame rate over SSH. A non-zero dropped_frames means the budget, not the game, is limiting updates.

Changes to IPlatform must keep truthful capability and refusal behaviour and should be tested on this backend alongside SDL3 and Headless; the conformance suite already runs it. A new terminal escape mode belongs in the session's prologue and epilogue and in the lease option union, not in a window setter that could write to a redirected standard output; keep the epilogue within its 128-byte signal-safe buffer and in exact reverse order of the prologue. A new input sequence belongs in the one decoder and must preserve byte ordering between keyboard and mouse. A presenter optimisation must update the cached screen only after a successful write, or a later diff can never recover the missing cells. Anything that changes the 8×16 nominal cell must change the presenter's kCellAspect and the mouse's coordinate expansion with it. The general recipe is in I need to modify a platform backend.

Evidence and reading order

The terminal suites live in modules/platform/tests/CNA/Platform. cmake/UnitTests.cmake compiles them into CnaTests on every non-Windows build, whatever CNA_PLATFORM is (they are excluded only on Windows), where gtest_discover_tests registers each case, and the aggregate CTest entry CnaPlatformTests selects them through its filter (run with --gtest_shuffle --gtest_repeat=3). By reading the filter string, every terminal suite matches it except TerminalPresenterThroughPlatformTest, which is reached only through the per-case discovery. The tests were read, not executed, for this page.

SourceSuitesWhat they pin
TerminalPlatformTests.cppTerminalPlatformTestConstruction touches no terminal state, advertised capabilities, window resolution versus grid, single-window refusal, window outliving platform, null native handle, title not written, real filesystem with its own preferences path.
TerminalCapabilityProbeTests.cppTerminalCapabilityProbeTestsPipe versus TTY, silence after answered Device Attributes as a real negative, partial Kitty support not advertised, terminal restored after probing, colour depth from the environment.
TerminalKeyboardTests.cppTerminalKeyboardTest, TerminalPlatformTestKitty decoding, legacy decoding, one timed synthetic release and deadline refresh, split sequences and the Escape delay, shared session ownership between keyboard and presenter, exact versus fallback reporting, one read shared by event pump and snapshot, cell-grid overflow.
TerminalMouseTests.cppTerminalMouseTestSGR decoding, nominal cell coordinates, Kitty keys and SGR mouse in one ordered stream, deterministic refusals.
TerminalPresenterTests.cppTerminalFrameGridTest, TerminalAnsiWriterTest, TerminalPresenter, TerminalPresenterThroughPlatformTestQuantisation, colour encodings and diffs, scale modes, VSync refusal, session held exactly as long as the presenter, still pictures costing nothing, forced redraw on scale-mode change, refusal on a non-TTY.
TerminalFrameBudgetTests.cppTerminalFrameBudgetTest, TerminalBudgetPresenterMedian rate, burst cap, the first frame never dropped, drops rather than queues, a dropped frame recovered by the next diff.
TerminalResizeTests.cppTerminalResizeWatcherTest, TerminalResizeTestCoalescing, second watcher refused, handler removed, a real signal, the whole resize path in a spawned harness.
TerminalRestorationTests.cppTerminalRestoration, TerminalSessionTestNormal exit, SIGINT, SIGTERM, SIGHUP, abort and uncaught exception in a child process; the _exit limit; second session refused; exact raw-mode round trip; prologue and epilogue symmetry and order.

PseudoTerminalHarness.hpp gives tests a real pseudo-terminal whose size can be set and whose output can be read back, and spawns helpers where the code insists on the process's own descriptors. Two helper executables are defined in cmake/Harnesses.cmake when tests are on and the target is not Windows: cna_platform_terminal_restoration_harness (terminal_restoration_harness.cpp), which the restoration tests launch so the destroy-the-process exit paths can be observed from outside, and cna_platform_terminal_resize_harness (terminal_resize_harness.cpp), for the resize path. The implementation-neutral PlatformConformance and PlatformWindowConformance suites and the runtime's GameEventSemanticsGoldenTest are parameterised over PlatformFactory::GetAvailable(), so they include Terminal on every POSIX build.

The end-to-end test is TerminalSoftwareDemoIntegration, registered in modules/graphics/examples/CMakeLists.txt only when tests and examples are on, CNA_PLATFORM=TERMINAL, the renderer is SOFTWARE and Python 3 is found. Its runner, terminal_demo_integration_test.py, runs cna_demo_2d on a pseudo-terminal, waits for the alternate screen, resizes to 100×30 and sends SIGWINCH, feeds a key and then Escape, and checks the transcript for the alternate-screen enter and leave sequences and the diagnostics line with grid=100x30. .github/workflows/platform-ci.yml configures a "Terminal + Software + Null audio" matrix cell that runs CnaPlatformTests and the event-semantics oracle with DISPLAY and WAYLAND_DISPLAY unset, then the integration test; that is what the workflow is configured to do, not a record of passing runs. All of this is source-level and pseudo-terminal evidence: it does not establish any particular terminal emulator's colour handling, Kitty implementation or behaviour over a real network, so perform a live terminal smoke test after changing protocol handling. Test architecture and What to test after changing X place these entries among the rest; the test target index lists them.

  1. TerminalPlatform.hpp and TerminalPlatform.cpp: the contract entry, the window slot, resize handling in PollEvents and the lazy capability cache.
  2. TerminalCapabilityProbe.cpp: honest feature detection and why silence is an answer.
  3. TerminalSessionController.cpp, TerminalSession.hpp and TerminalSession.cpp: the lease union, rebuild-not-mutate and the restoration invariant.
  4. TerminalResizeWatcher.cpp: the signal flag and its process-global ownership.
  5. TerminalKeyboard.cpp and TerminalMouse.cpp: the shared byte-stream decoder, Kitty versus legacy, and pointer refusals.
  6. TerminalSurfacePresenter.cpp, then TerminalFrameGrid.cpp, TerminalAnsiWriter.cpp and TerminalFrameBudget.cpp: frame mapping, encoding, diff and drop semantics.
  7. TerminalRestorationTests.cpp and TerminalPresenterTests.cpp: what is enforced, and where the documented limits are.
  8. cmake/RendererSelection.cmake and the presenter decision in GraphicsDevice.cpp: build constraints and why only SOFTWARE draws here. Renderer-side context is in Software renderer internals, Headless renderer internals, Stub renderer internals and Renderer selection internals.

For the contract all seven implementations share, see Platform architecture; for a backend with a real window system and the same event-versus-snapshot split, compare SDL3 platform internals.

The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.