Skip to content

tui

The terminal UI. Namespace core::tui, directory src/core/tui/, targets core::tui_output (with CORE_CPP_WITH_TUI_OUTPUT) and core::tui (with CORE_CPP_WITH_TUI). Both are on by default and off under Emscripten; CORE_CPP_WITH_TUI forces CORE_CPP_WITH_TUI_OUTPUT on, and CORE_CPP_WITH_TUI_OUTPUT defaults to whatever CORE_CPP_WITH_TUI is on a first configure (a cache entry keeps its value after that, so turning the TUI off in an existing tree leaves the leaf on).

Imported from endo's src/tui at f774a210, which includes the coroutine-runtime work fastcached upstreamed there. The runtime has since been rewritten onto core::net::EventLoop.

Status

Available. Its runtime is composed on core::net::EventLoop, so core::tui links core::net; core::tui_output still links base alone.

core::tui_output

A static library that links base and nothing else: no libunicode, no coroutines, not even platform. Its row in the module table says DEPS base, so the configure refuses any other link from it. A program that only prints styled text and progress — Lightweight's dbtool — links this and takes nothing else with it: configured with CORE_CPP_WITH_TUI off and CORE_CPP_WITH_TUI_OUTPUT on, core-cpp builds the leaf alone and neither finds nor fetches libunicode. Before 0.2.1 the leaf had no option of its own and came only with CORE_CPP_WITH_TUI, which fetches libunicode and, through libunicode's configure, UCD.zip. tests/consumer-tui-output is that consumer, and CI's consumer-smoke (tui-output) builds it.

  • TerminalOutput buffers escape sequences and flushes them: styled text (Style, RgbColor, UnderlineStyle), cursor movement, erasing, the alternate screen, the scroll region, double-width and double-height lines, cursor shapes (CursorShape), sixel payloads, OSC 52 clipboard writes and OSC 8 hyperlinks, plus the queries a capability probe sends (cursor position, cell size, DECRQM, DA1).
  • writeToDestination() is the one seam every byte goes through. Override it to retarget the stream — a capture buffer in a test, a pipe, a second terminal — without reimplementing any of the composition. isTerminal() answers for that same destination: the default asks the operating system about the process's standard output, and a subclass answers for its own.
  • SyncGuard brackets a frame in DEC mode 2026 so a terminal does not paint a half-drawn one. It writes through the TerminalOutput it was made from, so a retargeted output is bracketed on its own stream, and only when that output's isTerminal() is true: on any other destination it flushes at both ends and writes no sequence, so a caller takes one unconditionally.
  • ClipboardProtocol.hpp composes both terminal clipboard protocols and reads their answers, with no I/O: encodeOsc52(), encodeOsc5522Write() (kitty's protocol: any MIME type, 4096-byte chunks), parseOsc5522WriteStatus(), and describe() for a ClipboardWriteError. TerminalOutput::copyToClipboard() uses the same OSC 52 encoder.
  • buildSgrSequence() turns a Style into one SGR sequence.
  • core::tui::protocols holds the sequence constants the input and output sides share (the Kitty keyboard protocol, bracketed paste, mouse and focus tracking, colour-scheme notification, win32-input-mode, OSC 8), appendHyperlinkOpen(), appendMouseTrackingChange(), which writes the change between two MouseTracking modes, and parseSixelFromDeviceAttributes(), which reads a DA1 answer.
  • Result<T> and VoidResult, the module's std::expected aliases.

core::tui

The rest, on top of core::tui_output, platform, async and libunicode, and on stb when CORE_CPP_WITH_IMAGES is on. Native only: there is no terminal under Emscripten.

  • Input. TerminalInput puts the terminal in raw mode, enables the protocols above and decodes what comes back through VtParser into an InputEvent — keys (KeyCode, Modifier), mouse, paste, focus, resize, and the protocol reports a query waits for. Terminal pairs it with a TerminalOutput and owns the query round-trips (queryCursorPosition(), queryCellSize(), queryDecMode(), queryDeviceAttributes()), each on an injected clock and each bounded.
  • Clipboard. ClipboardWriter copies through the terminal rather than an OS clipboard API, so it works over SSH: OSC 5522 where the terminal says it knows mode 5522, OSC 52 otherwise. The first copy asks with DECRQM followed by DA1, which every terminal answers, so the probe ends at the DA1 reply and times out only when nothing answers at all; the answer is kept for the writer's lifetime. An OSC 5522 copy waits for the terminal's status (DONE or an error such as EPERM); an OSC 52 copy cannot be confirmed, and a non-text MIME type over it is refused before anything is sent. Each copy opens a TerminalChannel from an injected factory: openControllingTerminal() opens /dev/tty (or CONIN$/CONOUT$) beside standard I/O, so redirected output does not stop a copy, with echo and line buffering off while it is open; a process outside the terminal's foreground process group gets a write-only channel and OSC 52. Before an exchange it will wait on -- the probe, an OSC 5522 write -- the writer discards input already queued (TerminalChannel::discardPendingInput()), so a reply that arrived after an earlier exchange gave up waiting does not answer this one; typeahead goes with it. An OSC 52 copy waits for nothing and discards nothing. A reply that arrives after the channel has closed cannot be caught: a terminal that confirms an OSC 5522 copy later than the status timeout leaves that reply for whatever reads the terminal next. VtParser decodes OSC replies into OscResponse only when constructed with OscRecognition::Response; by default ESC ] stays Alt+].
  • Mouse. TerminalInput::setMouseTracking(), or Terminal::setMouseTracking(), asks the terminal for a MouseTracking mode: Buttons (DEC 1000), Drag (1002) or AnyMotion (1003), each with SGR encoding (1006). The default is Off, because a terminal that reports the mouse stops selecting text on a click-and-drag; an application opts in. Contour's passive mode 2029 is enabled either way, and where the terminal confirms it, hover tooltips raise the mode to AnyMotion (mouseTracking() answers the raised mode). VtParser decodes every mode's reports into MouseEvents: motion with a button held is a Move of that button, motion with none a Move of button 3.
  • Pointer. Screen routes a mouse event to the top-most visible component under it -- overlays first, the one shown last on top, then the tree -- and componentAt(row, col) answers the same question for a caller, such as a drag source looking for its drop target. A press makes the component that handled it the capture target: until the release, every move and that release go to it, in its own coordinates and wherever the pointer is, so a component sees its whole drag. The release ends the capture, as do the next press, releasePointer(), and the component leaving the screen -- destroyed, removed from the tree or hidden as an overlay. Scroll events are never captured, and a press hides the tooltip, so none opens mid-drag.
  • Drawing. Buffer is a grid of Cells, Canvas a clipped view of one, and Screen the renderer that diffs a frame against the last and writes only what changed, inline, full-screen or in a fixed area. Theme, StyledText, Text, Box, Rect and HyperlinkEmitter sit under it.
  • Components. Component and the widgets over it: InputField (multi-line editing, undo, kill ring, ghost text, selection), List, TreeTableView, Dialog, StatusBar, LogPanel, Spinner, ProgressBar, Tooltip, QuestionComponent, and the popups CompletionPopup, CommandPalettePopup and FuzzyPickerPopup with PopupKeyDispatch and ScrollableSelection.
  • Completion. core::tui::completer: Completer with CompletionConfig, CompletionProvider, CompletionItem, FuzzyMatch with FuzzyConfig and FuzzyMatchResult, and SmartCaseMatch with SmartCaseConfig. The namespace is the one the directory names, as runtime/ is core::tui::runtime; endo's TUI is one flat namespace tui, and the import kept that until core-cpp#30.
  • Markdown and syntax. MarkdownRenderer with MarkdownTable, MarkdownHtml and the inline grammar, and GenericSyntaxHighlighter, a lexer per language, with SyntaxHighlighterRegistry for the languages core::tui does not ship (below).
  • Images. With CORE_CPP_WITH_IMAGES: loadImage(), resizeImage() and readClipboardImage() over stb, encodeSixel(), and FilesystemImageProvider, which implements the always-present ImageProvider interface MarkdownRenderer takes. A file name is UTF-8 on every platform: stb is built with STBI_WINDOWS_UTF8, so on Windows it opens the name through the wide API.
  • Runtime. core::tui::runtime: TuiRuntime gives the TUI its input vocabulary -- co_await runtime.nextEvent(), nextEventFor(), nextActivity(), nextAgentReady() -- and forwards everything else to the event loop it is constructed on: blockOn(), spawn(), delay(), sleepUntil(), waitReadable(), waitWritable(), the clock and the root stop source. Input arrives through an injected InputSource, which names the handles to watch and decodes what is ready behind them; TerminalInputSource is that over a Terminal, and TuiRuntime(loop, terminal) makes one for you. runModal() drives a modal to its result; for a deadline use core::net::withTimeout(&runtime.loop(), …).
  • The end of the input. When the input handle reaches its end -- a terminal that hung up (EIO, or an end of file that poll(2) confirms as a hangup), a pipe whose writer closed, a Windows console that was closed or detached, or a handle the loop refuses to watch -- the source's inputClosed() answers true, the runtime stops watching the handle, and runtime.inputClosed() answers true from then on. One end reaches no handle: a Windows console whose host process died is never signalled again. The runtime therefore asks the source, through probeEnd(), every TuiRuntimeOptions::endProbe (one second by default), and ends the input the same way once the console can no longer answer; a source whose end does arrive as readiness answers EndProbe::NotNeeded the first time and is not asked again. TerminalInput::poll() asks the same question between slices of at most a second of its wait, so a poll(-1) on such a console returns. The input waits deliver what was read before the end (and a pending agent message, for nextActivity()), and then throw core::async::OperationCancelled without parking. An application that catches that cancellation asks inputClosed() to tell "the terminal is gone, exit" from an interrupt. Before 0.2.1 the runtime re-parked on a handle at its end, which answers at once and yields nothing, so with SIGHUP ignored a hung-up terminal spun the process at 100% CPU (core-cpp#49).
  • Test doubles. MockTerminalOutput records what a renderer did semantically instead of emitting VT, runtime::testing::ScriptedInputSource scripts the decoding and, with closeInput(), the end of the input (readiness comes from core::net::testing::ScriptedBackend or from a real core::platform::SystemPipe), and TestHelpers.hpp reads a rendered Buffer back as text. testing::ScriptedTerminal is a terminal on a ManualClock for anything built on TerminalChannel: how it answers the OSC 5522 probe and a write, and every byte it was sent, decoded back with decodedPayload(), mimeType() and target().

Registering a language

GenericSyntaxHighlighter ships a lexer for the languages of LanguageId — C and C++, CMake, Python, Bash, Markdown, JSON, YAML, git diffs, assembly, PowerShell, CMD, XML and INI — and for no others. An application that has its own language teaches it to core::tui rather than core::tui shipping it, which is what keeps one application's vocabulary out of a library four of them link (core-cpp#24).

A SyntaxHighlighterRegistry is that seam. It is an ordinary object: construct one, fill it, and pass it to whatever renders the text. There is no process-wide registry, so two parts of one program can hold different ones and a test never has to undo a registration.

auto highlighters = core::tui::SyntaxHighlighterRegistry {};

auto const wobble = highlighters.registerLanguage({
    .name = "wobble",
    .extensions = { ".wob" },
    .fenceTags = { "wobble", "wob" },
    .highlight = [](std::string_view line, core::tui::HighlightState state) {
        return highlightWobbleLine(line, state); // the application's own lexer
    },
});
if (!wobble)
    log("wobble was refused: {}", wobble.error().token);

registerLanguage() returns a LanguageId of its own, from the reserved range that begins at FirstRegisteredLanguageId, or a LanguageRegistrationFailure saying which name, extension or fence tag was already claimed. It refuses rather than shadows — replacing would repoint an id already handed out, and whoever held that id would get a wrong answer that looks right — and a refused definition leaves the registry exactly as it was.

Every entry point then takes the registry as a trailing argument that defaults to nullptr, meaning the built-in languages alone:

auto renderer = core::tui::MarkdownRenderer { output, theme, &highlighters };  // ```wobble fences
auto const styled = core::tui::StyledText::fromMarkdown(text, width, &theme, &highlighters);
auto const language = core::tui::detectLanguageFromPath("draft.wob", &highlighters);
auto const [map, next] = core::tui::highlightLine(line, language, state, &highlighters);

A registry answers for the built-in languages too — built-in rows are consulted first — so registering one costs an application nothing it already had.

A registered id belongs to the registry that issued it

Registered ids are dense from FirstRegisteredLanguageId in registration order and carry nothing that identifies their registry, so passing one to a different registry is a precondition violation — the same contract a std::vector::iterator has with its container. If that registry issued an id in the same position, the line is highlighted as its language, silently and wrongly; only an id past the end of it gives plain text. A program that holds one registry, which is the shape this is designed for, cannot hit this. One that holds two keeps each id with its own registry.

Built-in ids — everything below FirstRegisteredLanguageId — are not issued by anybody and are portable: they mean the same language in any registry and in none.

The three built-in tables the module ships are ExtensionLanguageTable, FenceTagLanguageTable and FilenameLanguageTable, and a registered language claims extensions and fence tags but never a file name: CMakeLists.txt, .clang-format and .editorconfig are well known beyond any one project, whereas an application knows what its own configuration file is called and names the language for it itself, rather than asking detectLanguageFromPath() to guess. A registration whose extension would be shadowed by a file-name row is refused rather than left dead.

Layout

The module's root holds only platform-independent code. What goes through the operating system is in posix/ and windows/, which the per-platform source lists select, so no file there guards itself with an #ifdef of its platform:

  • posix/ — the termios raw mode, the SIGWINCH self-pipe, poll(2), the write/read retry loop, isatty, and the clipboard tools (wl-paste, xclip).
  • windows/ — the console modes and code pages, console input records decoded as UTF-8, WaitForMultipleObjects, the resize event, and GetConsoleScreenBufferInfo.

TerminalInput's own handles live in an opaque NativeState those two define, so <core/tui/TerminalInput.hpp> names neither <termios.h> nor <windows.h>. No file in this module chooses its platform with an #ifdef, and runtime/ has no platform directory at all: the multiplexed wait its two TerminalEventSource bodies held is the event loop's, on every platform.