A C library for building terminal user interfaces. boba is the C equivalent of Charm's three v2 projects rolled into a single library: the Bubbletea runtime, the Lipgloss style/layout system, and the Bubbles component collection. The shared spine is the Elm Architecture, translated into idiomatic C.
The Charm ecosystem (now at charm.land) has released a
v2 generation with an evolved API. boba
follows that direction: view() returns a TuiView that declares cursor
placement, alt-screen, mouse mode, keyboard enhancements, focus reporting,
bracketed paste, and window title each frame, and the runtime reconciles
against tracked terminal state. Focus is message-driven (TUI_MSG_FOCUS /
TUI_MSG_BLUR) and a Lipgloss-shaped TuiStyle covers colors, text
attributes, padding/margin, alignment, and borders. A handful of legacy
imperative setters remain marked BOBA_DEPRECATED with their
declarative replacements documented inline.
The name pays homage to Bubbletea. Boba are the tapioca pearls in bubble tea.
boba has three parts:
Runtime (TuiRuntime) — The event loop that:
- Receives input and converts it to messages
- Calls your model's
update()function - Executes returned commands
- Calls
view()to get aTuiView(content + terminal-mode declarations) - Reconciles the requested terminal state and writes the next frame
- Supports two rendering modes: alt-screen (traditional full-screen TUI) and inline (renders in the primary buffer like Python's pyrepl — no alt screen, cursor-up + erase + repaint per frame)
- Repeats
Style (TuiStyle) — A Lipgloss-shaped, value-typed style record covering
colors (ANSI / 256 / truecolor / adaptive), text attributes, padding/margin,
alignment, and borders. Composable without mutation.
Components — Reusable UI building blocks:
textinput— Text input with history, completion, Unicode support, multi-line editing, soft-wrap, syntax-highlighting callback, focus-aware stylingviewport— Scrollable content area with software scrolling and tmux-style copy-mode
boba implements the Elm Architecture, a pattern for building interactive programs that emerged from the Elm programming language. The architecture consists of three parts:
- Model — the state of your application
- View — a way to turn your state into terminal output
- Update — a way to update your state based on messages
Data flows in one direction:
flowchart LR
Input([Input bytes]) --> Msg[Msg]
Msg --> Update["update(Model, Msg)"]
Update -- mutates --> Model[(Model)]
Update --> Cmd[Cmd]
Model --> View["view(Model)"]
View --> TV["TuiView<br/>(content + cursor + mode flags)"]
TV --> Reconcile["reconcile against<br/>tracked terminal state"]
Reconcile --> Output([Output bytes])
Cmd --> Exec[execute]
Exec -. callback Msg .-> Msg
This unidirectional flow makes programs predictable and easy to reason about.
Since C lacks garbage collection, sum types, and method chaining, boba makes pragmatic choices:
- Mutable models — Update modifies the model in place rather than returning a copy
- Tagged unions —
TuiMsgandTuiCmduseenum+unionto simulate sum types - Explicit memory — Components provide matching
*_create()/*_free()functions - Declarative
TuiView—view()returns a small struct (content buffer + terminal-mode flags + cursor) that the runtime diffs against tracked state. This is the C analogue of returning a Bubbletea v2Viewvalue. - Value-typed styles —
TuiStyleis a plain struct; setters take and return a value (s = tui_style_bold(s, 1)) instead of mutating, giving Lipgloss-style composition without method chains.
The runtime can be used in two modes:
Bubbletea-style — tui_runtime_run() owns the event loop, raw mode, and signal handling:
TuiRuntime *rt = tui_runtime_create(&my_component, NULL, NULL);
tui_runtime_run(rt); /* Blocks until quit */
tui_runtime_free(rt);The runtime handles SIGWINCH (resize), SIGINT, stdin polling, and optional external FD
polling via TuiRuntimeConfig callbacks (on_tick, on_resize, get_external_fd,
on_external_ready, on_stdin_processed, get_tick_timeout_ms).
On Windows, the event loop uses WaitForMultipleObjects with a CreateEvent wakeup
mechanism. It auto-detects ConPTY pipe handles vs real console handles: under ConPTY
(e.g. portty), raw bytes pass through and VT sequences work natively; on a real
console, SetConsoleMode enables virtual-terminal processing.
A background reader thread handles stdin. ReadFile on a console input handle
blocks until key events arrive, even though WaitForMultipleObjects signals the
handle for non-key events (focus changes, mode changes, resize). Blocking
ReadFile in the main loop starves other wait handles — notably the socket
event from WSAEventSelect, so server data goes unread until the user presses a
key. The reader thread does the blocking ReadFile in a separate thread and
signals a manual-reset event when bytes are available; the main loop waits on
that event alongside the socket and wakeup events, so external FDs are serviced
immediately regardless of stdin state.
Lower-level — caller owns the event loop, drives the runtime manually:
tui_runtime_start(rt); /* Enter raw mode */
tui_runtime_process_input(rt, buf, len); /* Feed raw bytes */
tui_runtime_flush(rt); /* Render + write */
tui_runtime_stop(rt); /* Restore terminal */Components can render in the primary terminal buffer instead of the alternate
screen by setting v.render_mode = TUI_RENDER_INLINE on the TuiView. This is
useful for REPL-style applications (like Python's pyrepl) where output should
flow into the terminal's own scrollback.
The runtime tracks how many visual lines were rendered and where the cursor was
placed, so each frame can cursor-up to the first line and repaint in place.
tui_runtime_finish_inline() moves the cursor past all rendered content and
writes \r\n, letting the application print output below the input before the
next flush renders a fresh prompt:
/* Before printing eval results: */
tui_runtime_finish_inline(rt);
printf("result: %s\n", value);
/* The next flush renders the new prompt below the output */External code (callbacks, signal handlers, other modules) can schedule work for the
event loop to process on its next iteration, following Bubbletea's p.Send(msg) pattern.
Posting messages — goes through the full Elm Architecture cycle (update() → command execution):
/* From a callback, signal handler, or another thread's context */
TuiMsg msg = tui_msg_custom(MY_MSG_TYPE, my_data);
tui_runtime_post(rt, msg); /* Wakes up select() immediately */Scheduling commands — executed directly, bypassing update():
/* e.g. push something onto the system clipboard from a worker thread */
TuiCmd *cmd = tui_cmd_clipboard_copy(text, len);
tui_runtime_schedule(rt, cmd); /* Runtime takes ownership */When using tui_runtime_run(), queued items are drained automatically each iteration.
Use tui_runtime_wakeup(rt) to wake the event loop from select() when external state
changes and the tick timeout needs recomputing (thread-safe, async-signal-safe).
For lower-level usage where the caller owns the event loop, add the wakeup FD to your
select()/poll() and call tui_runtime_drain() when it becomes readable:
int wakeup_fd = tui_runtime_wakeup_fd(rt); /* -1 if unavailable */
/* In your select() loop: */
if (wakeup_fd >= 0)
FD_SET(wakeup_fd, &read_fds);
/* After select() returns: */
if (wakeup_fd >= 0 && FD_ISSET(wakeup_fd, &read_fds)) {
tui_runtime_drain(rt);
tui_runtime_flush(rt);
}#include <boba/tui.h>
int main(void) {
/* Initialize component */
TuiTextInput *input = tui_textinput_create(NULL);
/* Handle a key press */
TuiMsg msg = tui_msg_key(0, 'H', 0);
tui_textinput_update(input, msg);
/* Render */
DynamicBuffer *out = dynamic_buffer_create(256);
tui_textinput_view(input, out);
printf("%s", dynamic_buffer_data(out));
/* Cleanup */
tui_textinput_free(input);
dynamic_buffer_destroy(out);
return 0;
}The component interface follows the Elm Architecture pattern:
typedef struct TuiComponent {
TuiInitResult (*init)(void *config); /* Create model + initial command */
TuiUpdateResult (*update)(TuiModel *model, TuiMsg msg); /* Handle message */
TuiView (*view)(const TuiModel *model, DynamicBuffer *out); /* Render content + declare modes */
void (*free)(TuiModel *model); /* Cleanup */
} TuiComponent;view() writes content bytes to out and returns a TuiView describing the
desired terminal state for the frame:
typedef struct TuiView {
DynamicBuffer *layer; /* Rendered content (== out) */
int alt_screen; /* 1 = alternate screen (legacy) */
TuiRenderMode render_mode; /* ALT_SCREEN (default) or INLINE */
TuiMouseMode mouse_mode; /* NONE / CELL_MOTION / ALL_MOTION */
TuiKeyboardEnhancements kbd_enhancements; /* Kitty keyboard protocol bitmask */
int report_focus; /* 1 = enable focus events */
int bracketed_paste; /* 1 = enable bracketed paste */
const char *window_title; /* NULL = leave alone */
TuiCursor cursor; /* visible=0 = hidden */
} TuiView;TuiRenderMode is an enum: TUI_RENDER_ALT_SCREEN (0, default — full-screen
TUI in the alternate buffer) or TUI_RENDER_INLINE (renders in the primary
buffer with cursor-up repaint). The alt_screen field is kept for backward
compatibility; new code should use render_mode.
The runtime diffs each frame's TuiView against the terminal state it tracks
and emits only the bytes needed to reach the requested state — no imperative
"enter alt screen" / "show cursor" commands. Use tui_view_default(out) to
start with everything off, set the fields you care about, and return the
struct. Set cursor.visible = 0 (or use tui_cursor_hidden()) to keep the
cursor hidden for that frame.
Following Elm's init : () -> (Model, Cmd Msg), the init function returns both
a model and an optional initial command:
typedef struct {
TuiModel *model; /* Initialized model */
TuiCmd *cmd; /* Initial command (NULL for none) */
} TuiInitResult;This allows components to trigger effects at startup (e.g., start a timer, fetch initial data).
Messages represent events flowing into the update function:
| Type | Description |
|---|---|
TUI_MSG_KEY_PRESS |
Key press with modifiers (Ctrl, Alt, Shift, Meta) |
TUI_MSG_MOUSE |
Mouse button/wheel/motion with SGR coordinates |
TUI_MSG_WINDOW_SIZE |
Terminal resized |
TUI_MSG_FOCUS / TUI_MSG_BLUR |
Focus state — dispatched by parents to children |
TUI_MSG_PASTE_START / TUI_MSG_PASTE / TUI_MSG_PASTE_END |
Bracketed paste (opt in via TuiView.bracketed_paste) |
TUI_MSG_LINE_SUBMIT |
Line submitted from text input |
TUI_MSG_EOF |
End of input (Ctrl+D) |
TUI_MSG_INTERRUPT |
Interrupt (Ctrl+C) |
TUI_MSG_CUSTOM_BASE |
Base value for application-defined messages |
Commands represent effects returned from the update function:
| Type | Description |
|---|---|
TUI_CMD_NONE |
No-op (returned by helpers when no work needed) |
TUI_CMD_QUIT |
Exit the application |
TUI_CMD_BATCH |
Run multiple commands |
TUI_CMD_LINE_SUBMIT |
Line submitted (contains text) |
TUI_CMD_TAB_COMPLETE |
Tab completion request (prefix + word position) |
TUI_CMD_CLIPBOARD_COPY |
Copy text to system clipboard (OSC 52) |
TUI_CMD_CUSTOM_BASE |
Base value for application-defined commands |
Terminal-mode toggles (alt screen, mouse, keyboard enhancements, cursor
visibility, focus reporting, bracketed paste, window title) are no longer
commands — declare them on the TuiView returned from view() and the
runtime reconciles each frame.
A text input field with Emacs-style editing, similar to an HTML <input type="text"> but with advanced terminal capabilities.
Features:
- Multi-line support - Toggle between single-line and multi-line text areas
- Unicode/UTF-8 support - Full international character handling with proper cursor positioning
- Emacs keybindings - Ctrl+A/E (line start/end), Ctrl+B/F (char movement), Ctrl+P/N (history/line navigation), Ctrl+K/U/W (kill), Ctrl+Y (yank), Ctrl+T (transpose), Ctrl+Space (set/toggle mark), Alt+w (copy region or whole input), Ctrl+G / Esc (clear mark)
- Selection -
C-SPCsets a mark; the active region (between mark and cursor) is rendered withSGR_REVERSE. Motion extends the selection; any edit clears it. - System clipboard - Both
Alt+wand every kill (Ctrl+K/U/W) emitTUI_CMD_CLIPBOARD_COPY, going through the runtime's OSC 52 default orclipboard_handleroverride (see the Clipboard section). Matches graphical emacs'sinterprogram-cut-function = gui-select-textdefault — every cut is also a copy. - Command history - Up/down navigation with saved current input
- Tab completion - Emits
TUI_CMD_TAB_COMPLETEwith prefix and word position - Kill ring - Consecutive kills append to the same buffer (also reflected in the clipboard cmd as the kill ring grows)
- Undo - Multiple undo levels with Ctrl+_ or Ctrl+X Ctrl+U
- Absolute cursor positioning - Flicker-free rendering at a parent-supplied row
- Prompt support - Custom prompt strings with proper UTF-8 width calculation
- Configurable word characters - Whitelist-based word boundaries for completion and movement
- Echo mode - Password masking (shows
*per codepoint) - Focus-aware styling - Separate focused / blurred
TuiStylefor the prompt (Bubbles parity) - Continuation prompt - Custom prompt for lines after the first in multi-line mode
- Soft-wrap mode - Long lines wrap to the next visual row instead of horizontal scrolling (
tui_textinput_set_soft_wrap) - Syntax-highlighting callback - Host applications can inject per-token styling via
tui_textinput_set_text_renderer; the callback returns a malloc'd ANSI-escaped string that replaces raw text in the rendered output - Ctrl+D deletes char - The textinput handles Ctrl+D as
delete-char(never decides to quit; the component receivesTUI_MSG_EOFand decides)
For decorative horizontal lines above or below the input, parents
compose tui_border_render_horizontal() (see Styles) — the
textinput renders only the input line(s).
TuiTextInput *input = tui_textinput_create(NULL);
tui_textinput_set_prompt(input, "> ");
tui_textinput_set_history_size(input, 100);
tui_textinput_set_terminal_row(input, 23); /* Absolute positioning */
tui_textinput_set_word_chars(input, "abc..."); /* Word boundary chars */
tui_textinput_set_echo_mode(input, 1); /* Password masking */
/* Lipgloss-shaped focus-aware prompt styling */
TuiStyle pink = tui_style_foreground(tui_style_new(), tui_color_hex("#ff06b7"));
TuiStyle dim = tui_style_faint(tui_style_new(), 1);
tui_textinput_set_focused_prompt_style(input, pink);
tui_textinput_set_blurred_prompt_style(input, dim);A sophisticated scrollable content area that stores lines in memory and renders with absolute cursor positioning. This is the recommended component for displaying scrollable output (like a terminal's main content area) with advanced features.
Features:
- Software-based scrolling - No ANSI scroll regions for maximum compatibility
- Line storage - Configurable maximum line count with automatic trimming
- Auto-scroll - Automatically scrolls to bottom when new content is added (optional)
- Manual scrolling - Scroll up/down/page commands with proper boundary checking
- Wrap/clip modes - Choose between line wrapping or truncation at viewport width
- ANSI sequence support - Proper handling of VT100/ANSI color codes and SGR sequences
- UTF-8 aware - Correct display width calculation for international characters
- Memory efficient - Automatic cleanup of old lines when exceeding maximum
- Visual line calculation - Handles long lines that wrap across multiple screen rows
- State preservation - Maintains ANSI SGR state across wrapped line segments
- Copy-mode (tmux-style) - When focused,
C-SPCenters a navigation mode with cursor + mark in scrollback coordinates, so selections can extend across content scrolled off the visible window. Emacs keybindings:C-n/p/f/b/a/e, arrow keys,Home/End,C-v/M-vpage,M-</M->top/bottom,M-wcopy,C-g/Esccancel - Selection rendering - Selected range overlaid with
SGR_REVERSEwhile preserving existing per-line SGR state - Mouse selection - Left-click + drag selects (entering copy-mode automatically); drag past the top/bottom edge autoscrolls; mouse wheel scrolls without disturbing the selection
- System clipboard -
M-wreturns aTUI_CMD_CLIPBOARD_COPYcommand; the runtime emits OSC 52 by default or calls a user-provided handler (see "Clipboard" below)
TuiViewport *vp = tui_viewport_create();
tui_viewport_set_size(vp, 80, 20);
tui_viewport_set_render_position(vp, 1, 1); /* Start at row 1, col 1 */
tui_viewport_set_max_lines(vp, 1000); /* Limit memory usage */
tui_viewport_set_wrap_mode(vp, 1); /* Enable line wrapping */
tui_viewport_append(vp, "Hello, world!\n", 14);
/* Enable copy-mode: the parent decides who is focused and dispatches
* TUI_MSG_FOCUS / TUI_MSG_BLUR through the viewport's update path. */
tui_viewport_component()->update((TuiModel *)vp, tui_msg_focus());
/* Hit-test for routing mouse events from a parent component: */
if (tui_viewport_contains(vp, mouse_row, mouse_col)) {
/* forward the mouse event to the viewport */
}Components emit TUI_CMD_CLIPBOARD_COPY (e.g., the viewport's M-w in copy-mode). The runtime handles it in one of two ways:
- Default — OSC 52 (
ESC ] 52 ; c ; <base64> ESC \): the bytes are written to the configured output. Modern terminals (kitty, alacritty, wezterm, iTerm2, foot, ghostty, recent xterm) honor this and push to the system clipboard. VTE-based terminals (GNOME Terminal, XFCE Terminal, Terminator) silently drop it. - App-supplied handler: install
clipboard_handleronTuiRuntimeConfigto override (e.g., shell out toxclip/wl-copy/pbcopyon terminals that don't support OSC 52). When set, the runtime calls the handler instead of emitting OSC 52.
static void my_clipboard_copy(const char *text, size_t len, void *user_data) {
/* Pipe to xclip, wl-copy, pbcopy, or store somewhere else. */
}
TuiRuntimeConfig cfg = { 0 };
cfg.clipboard_handler = my_clipboard_copy;
TuiRuntime *rt = tui_runtime_create(&my_component, NULL, &cfg);Apps can also emit the command directly:
return tui_update_result(tui_cmd_clipboard_copy(text, len));TuiStyle is boba's Lipgloss equivalent: a value-typed style record
covering colors, text attributes, padding/margin, alignment, and borders.
Setters take and return a TuiStyle so styles compose without mutation:
TuiStyle title = tui_style_padding_x(
tui_style_bold(
tui_style_foreground(tui_style_new(),
tui_color_hex("#ff06b7")),
1),
1);
DynamicBuffer *out = dynamic_buffer_create(64);
tui_style_render(&title, "Hello, world", out);Colors come from tui_color_ansi(n) (16-color), tui_color_rgb(r,g,b),
tui_color_hex("#rrggbb"), or tui_color_adaptive(light, dark) (picks the
right one based on detected background). Borders ship as five prefab
TuiBorder styles (normal, rounded, thick, double, hidden). Use
tui_style_get_width() / tui_style_get_height() to measure rendered
content without producing the bytes.
For single horizontal-line dividers — the lipgloss
strings.Repeat(border.Top, n) styled idiom, used when you want a
decorative line above or below another component rather than a full
4-sided box — use tui_border_render_horizontal(). It tiles the chosen
edge across the requested width, applies a TuiStyle inline, and
optionally embeds a title at left/center/right alignment (boba's
small extension over lipgloss, which has no built-in title-in-border
API):
char *line = tui_border_render_horizontal(
&TUI_BORDER_NORMAL, /* top= */ 1, /* width= */ 80,
&my_dim_style,
/* title= */ "Session", TUI_BORDER_TITLE_CENTER,
/* pad_left= */ 1, /* pad_right= */ 1);
/* position cursor with CSI <row>;1H, write line, free(line) */The textinput component consumes TuiStyle values directly via
tui_textinput_set_focused_prompt_style() and friends; the same shape is
how user code is expected to style its own components.
boba follows the same composition pattern as Bubbletea: the runtime manages ONE model, and composition happens inside that model.
A parent component embeds children as struct fields:
typedef struct {
TuiModel base; /* Component base type */
TuiViewport *viewport; /* Child: scrollable output */
TuiTextInput *textinput; /* Child: user input */
} MyAppModel;The parent's update function routes messages to children:
TuiUpdateResult my_app_update(MyAppModel *app, TuiMsg msg) {
/* Handle window resize at parent level */
if (msg.type == TUI_MSG_WINDOW_SIZE) {
tui_viewport_set_size(app->viewport,
msg.data.size.width, msg.data.size.height - 3);
return tui_update_result_none();
}
/* Route key messages to focused child */
if (msg.type == TUI_MSG_KEY_PRESS) {
return tui_textinput_update(app->textinput, msg);
}
return tui_update_result_none();
}The parent's view function writes children's content into out and
returns a TuiView carrying the desired terminal-mode declarations:
TuiView my_app_view(const TuiModel *m, DynamicBuffer *out) {
const MyAppModel *app = (const MyAppModel *)m;
/* Children render via absolute positioning into `out` */
tui_viewport_view(app->viewport, out);
tui_textinput_view(app->textinput, out);
/* Parent declares terminal state for the frame */
TuiView v = tui_view_default(out);
v.bracketed_paste = 1;
v.cursor = tui_textinput_cursor_pos(app->textinput);
return v;
}tui_textinput_cursor_pos() and tui_viewport_cursor_pos() return
tui_cursor_hidden() when the child is unfocused, so for two-pane layouts
the focused child's cursor naturally wins:
TuiCursor pick_cursor(const MyAppModel *app) {
return app->focus_idx == 0
? tui_textinput_cursor_pos(app->input)
: tui_viewport_cursor_pos(app->viewport);
}When children return commands, use tui_cmd_batch2 to combine them:
TuiCmd *cmd1 = child1_result.cmd;
TuiCmd *cmd2 = child2_result.cmd;
TuiCmd *combined = tui_cmd_batch2(cmd1, cmd2); /* Handles NULL gracefully */
return tui_update_result(combined);Like Bubbletea's examples/textinputs, focus is owned by the parent. There is no library-side focus router — the parent tracks which child is active and dispatches TUI_MSG_FOCUS / TUI_MSG_BLUR when focus moves. Components that care about focus (textinput, viewport) update their own focused flag in response.
typedef struct {
TuiModel base;
TuiTextInput *input;
TuiViewport *viewport;
int focus_idx; /* 0 = input, 1 = viewport */
} App;
static void cycle_focus(App *app, int dir) {
/* Tell the previously focused child it lost focus. */
if (app->focus_idx == 0)
tui_textinput_update(app->input, tui_msg_blur());
else
tui_viewport_component()->update((TuiModel *)app->viewport, tui_msg_blur());
app->focus_idx = (app->focus_idx + dir + 2) % 2;
/* And tell the new one it gained focus. */
if (app->focus_idx == 0)
tui_textinput_update(app->input, tui_msg_focus());
else
tui_viewport_component()->update((TuiModel *)app->viewport, tui_msg_focus());
}
/* In the parent's update: cycle on Tab / Shift+Tab. */
if (msg.type == TUI_MSG_KEY_PRESS && msg.data.key.key == TUI_KEY_TAB) {
cycle_focus(app, (msg.data.key.mods & TUI_MOD_SHIFT) ? -1 : +1);
return tui_update_result_none();
}The parser produces Shift+Tab as {TUI_KEY_TAB, mods: TUI_MOD_SHIFT} (xterm CSI Z and the kitty keyboard protocol), and Ctrl+Space as {rune: ' ', mods: TUI_MOD_CTRL}.
Terminals offer ANSI scroll regions (DECSTBM) for hardware-assisted scrolling, but boba's viewport redraws content with absolute cursor positioning instead. This follows Bubbletea's approach. ANSI scroll regions behave inconsistently across terminal emulators — cursor positioning at region boundaries causes visual glitches, and the host terminal controls what happens in the scrollback buffer. Software scrolling avoids all of this: the viewport owns every pixel it draws, wrapping, clipping, and scroll position are just arithmetic on an in-memory line buffer.
In Elm, commands are opaque values the runtime interprets. boba splits
"things that happen" into two channels: discrete one-shot effects flow as
TuiCmd values returned from update() (TUI_CMD_QUIT, TUI_CMD_LINE_SUBMIT,
TUI_CMD_CLIPBOARD_COPY, etc.) and the runtime switches over the tag, while
ongoing terminal state (alt screen, mouse, keyboard enhancements, cursor,
focus reporting, bracketed paste, window title) is declared per frame on the
TuiView returned from view(). The runtime tracks current terminal state
and emits only the bytes needed to reach the requested state, the same way
Bubbletea v2 reconciles its View. Application-defined custom commands add a
callback, because that is the simplest way for application code to define
arbitrary effects without the runtime needing to know about them in advance.
Elm's subscriptions : Model -> Sub Msg lets a program declaratively describe
ongoing event sources that change based on model state — subscribe to a WebSocket
only when connected, start a timer only in a certain mode. boba covers the
same use cases through runtime config callbacks:
| Elm subscription | boba equivalent |
|---|---|
Time.every 1000 Tick |
on_tick + get_tick_timeout_ms |
| Window resize | Automatic TUI_MSG_WINDOW_SIZE + on_resize |
| Ports / external event sources | get_external_fd + on_external_ready |
| Post-input hooks | on_stdin_processed |
| Any external source | tui_runtime_post() from callbacks, threads, or signal handlers |
Two properties of terminal programs make this a good fit:
-
Event sources are static. A terminal program listens to stdin, signals, and maybe one external FD. These don't change based on model state, so a config struct set once at startup matches the reality better than a function re-evaluated after every update.
-
C already has event loop primitives. Callbacks compose directly with
select()/poll(), signal handlers, and threads. A declarative subscription layer would need an interpreter that adds indirection without adding expressiveness for these use cases.
The input parser (TuiInputParser) converts raw terminal bytes into typed messages:
- ANSI CSI sequences (cursor keys, function keys, modifiers)
- SS3 sequences (alternate cursor encoding)
- SGR extended mouse sequences (
CSI < Cb;Cx;Cy M/m) - Kitty keyboard protocol (
CSI keycode;modifiers u) - UTF-8 multi-byte sequences
- Control characters with modifier detection
Ctrl+C(0x03) →TUI_MSG_INTERRUPT— semantic interrupt eventCtrl+D(0x04) →TUI_MSG_EOF— semantic end-of-file event
Required:
gcc— C compilerautoconf— Generate configure scriptsautomake— Generate Makefile.in filesmake— Build system
Optional (for development):
bear— Generatecompile_commands.jsonfor clang toolingclang-tools-extra— Providesclang-formatfor code formattingshfmt— Shell script formattingprettier— Markdown formatting
On Fedora 41+:
sudo dnf install gcc autoconf automake make
sudo dnf install bear clang-tools-extra shfmt # optionalPure GNU Autotools — no wrapper script. From a clean checkout:
./autogen.sh
mkdir build && cd build
../configure --prefix=$HOME/.local
make -j$(nproc)
make check # run the test suite
make install # install library + headers + pkg-config fileUseful targets, all run from build/:
make— buildlibboba.amake check— run the test suite (folds in tmux end-to-end tests when tmux is detected)make install— install to--prefixmake format— clang-format on C sources, shfmt on shell, prettier on Markdownmake bear— producecompile_commands.jsonfor clangd
Release build: omit --enable-debug and pass CFLAGS="-O2 -DNDEBUG" to configure.
Output: build/src/libboba.a (static library). After make install, use
pkg-config to get the correct flags:
gcc -o myapp myapp.c $(pkg-config --cflags --libs boba)make check runs both suites in one go:
cd build && make checkFast, hermetic state-based tests under tests/. Each test binary links libboba.a, constructs a component directly, drives it with messages, and asserts on internal state.
Purpose-built mini-apps that run inside a real tmux session. A bash driver sends keystrokes with tmux send-keys, captures the visible grid via tmux capture-pane -p, and inspects the cursor with tmux display-message -p '#{cursor_x}'. This catches rendering regressions that unit tests miss — e.g., a malformed CSI sequence, off-by-one cursor positioning, or content overflowing the visible area.
These tests are folded into make check automatically when tmux is on PATH at configure time. If tmux is absent, the unit-test suite still runs and the tmux scripts are simply not registered with automake.
tests/
├── apps/ # mini-apps (one per scenario)
│ ├── tmux_textinput_multi.c
│ └── tmux_focus_swap.c
└── tmux/
├── lib.sh # bash helpers
├── scroll_multiline.sh
└── focus_shift_tab.sh
A mini-app is a few dozen lines of C that wraps a component, handles TUI_MSG_WINDOW_SIZE to call the relevant set_terminal_width, and calls tui_runtime_run(). No quit key is needed — the driver tears down with tmux kill-session.
lib.sh exposes tmux_start, tmux_send, tmux_capture, tmux_cursor, tmux_kill, tmux_wait_for, dump_pane, assert_pane_contains, assert_pane_lacks, and assert_cursor_x_lt.
-
Write
tests/apps/foo.c. -
Register the binary under
if HAVE_TMUXintests/Makefile.am:check_PROGRAMS += apps/foo apps_foo_SOURCES = apps/foo.c
-
Write
tests/tmux/foo.sh. Sourcelib.sh, launch the mini-app withtmux_start, drive it, assert. -
Append the script to
TMUX_TESTSintests/Makefile.am.
Two patterns worth reusing from scroll_multiline.sh:
- Sentinel sync: append a unique character to a
send-keysbatch, thentmux_wait_forit. Confirms every keystroke was processed before assertions run. - Cleanup trap:
trap 'tmux_kill "$SESSION"' EXITalways fires, even on assertion failure.tmux_startenablesremain-on-exitso a crashed mini-app leaves a debuggable pane — pair withdump_panein failure paths.
See AUTHORS for the list of contributors.
This project is licensed under the MIT License.