Skip to content
The Terminal Host

The Terminal Host

tg is the native terminal host for Textagram. It is both the primary distribution target and a reference implementation of the host contract described in the earlier pages.

This page walks through tg’s internal architecture in the order things happen at runtime: startup, event loop, key dispatch, clipboard, file I/O, chrome, rendering, and recording.

Startup and initialization

CLI

Parsed process-level command-line options for the tg terminal host.

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliConfig {
    /// Print CLI help and exit before initializing the terminal.
    pub show_help: bool,
    /// Print the package version and exit before initializing the terminal.
    pub show_version: bool,
    /// Optional file path to edit in place; `None` starts scratch mode.
    pub file_path: Option<PathBuf>,
}
tg/src/cli.rs:6-15

Parses tg arguments: zero or one file path plus --help / --version.

    pub fn parse(args: impl IntoIterator<Item = OsString>) -> Result<Self, CliError> {
tg/src/cli.rs:18-19

Rejects redirected stdin/stdout so tg always owns an interactive terminal.

pub fn validate_startup_io(stdin_is_tty: bool, stdout_is_tty: bool) -> Result<(), CliError> {
tg/src/cli.rs:100-101

tg rejects redirected stdin/stdout upfront. It always owns an interactive terminal session.

File mode

Host-owned file model for scratch, whole-file, and first-fence markdown editing.

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileMode {
    /// No file path; saves are blocked and quits do not prompt.
    Scratch(ScratchDocument),
    /// The entire file is treated as one editable diagram.
    PlainFile(PlainFileDocument),
    /// Only the first recognized `textagram` fenced block body is editable.
    MarkdownBacked(MarkdownBackedDocument),
}
tg/src/file_mode.rs:127-136

Loads an existing file or creates a blank file-backed model for a missing path.

    pub fn load_from_path(path: PathBuf) -> Result<Self, FileLoadError> {
tg/src/file_mode.rs:144-145

Three document models:

  • Scratch — no file, no save prompt, tg with no arguments
  • PlainFile — the whole file is one diagram
  • MarkdownBacked — only the first recognized textagram fence body is editable; surrounding markdown is preserved on save

Terminal session

After CLI and file mode are resolved, tg enters raw mode and switches to the alternate screen. The TerminalSession struct wraps a ratatui Terminal and restores terminal state on drop — even on panic.

Core session

The host creates a Session with the terminal dimensions and optionally loads text from the file mode:

let mut app = Session::new(width, height);
if file_backed {
    app.load(file_mode.original_editable_text());
}

The event loop

The main loop follows a standard terminal-app pattern:

  1. Render the current frame
  2. Poll for events (with an optional timeout for timer ticks)
  3. Collect all queued events
  4. Dispatch each event
  5. Repeat

Timer integration

The core requests UI ticks for transient animations (footer messages, mode transitions). The host uses session.next_ui_tick_delay_ms() as the poll timeout and delivers elapsed time via session.tick_ui(delay_ms). The terminal host does not run a separate timer thread.

Movement coalescing

In Nav, Shape, and Connector modes, if multiple movement keys have queued up between frames, only the last one is dispatched. This prevents lag-induced cursor jumps without dropping non-movement keys that arrive in the same batch.

Key dispatch and host interception

Not every key goes to the core. The terminal host intercepts some keys for host-owned behavior before forwarding the rest.

The dispatch decision is modeled as:

enum HostKeyDecision {
    Consumed,       // host handled it, skip core
    ForwardToCore,  // pass to Session::handle_key
    Exit(RunOutcome),
}

Save shortcut

Ctrl+S is intercepted by the host. It calls EditorState::save_from_shortcut which writes the file atomically and refreshes the dirty baseline. In scratch mode it beeps instead.

Quit interception

Plain q in file-backed mode is intercepted. If the document is dirty, the host opens a save prompt instead of forwarding q to the core.

Host-owned file, save, and quit-prompt state for the terminal app.

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EditorState {
    file_mode: FileMode,
    quit_prompt: QuitPromptState,
    transient_message: Option<String>,
}
tg/src/editor_state.rs:37-43

Starts quit handling, opening a save prompt only for dirty file-backed documents.

    pub fn begin_quit(&mut self, current_editable_text: &str) -> QuitRequest {
tg/src/editor_state.rs:80-81

The prompt accepts y (save and exit), n (discard and exit), or Esc (cancel). While active, most keys are consumed by the host — only Ctrl+C/Ctrl+Q and shifted Q bypass to the core for force-quit and recording.

Clipboard sync

Document clipboard

Shifted Y, P, X in Nav mode are terminal-only document clipboard shortcuts. They do not go through handle_key; instead the host maps them to ClipboardIntent and calls the Session clipboard methods directly.

Maps shifted terminal document clipboard keys (Y/P/X) to intents.

pub fn terminal_document_clipboard_intent_from_crossterm(
    key_event: KeyEvent,
) -> Option<ClipboardIntent> {
tg/src/terminal_input.rs:46-49

For paste, the host reads the OS clipboard first (falling back to the session’s internal clipboard). For copy and cut, the host writes the result to the OS clipboard.

Selection clipboard

Plain y, x, c, and p go through normal handle_key. Around those keys, the host syncs the session clipboard with the OS:

  • Before p: refresh the session clipboard from the OS clipboard
  • After y/x/c: if the session clipboard changed, write it to the OS

This keeps terminal selection copy/paste aligned with the system clipboard without the host needing to intercept every key.

OS clipboard transport

All OS clipboard access goes through arboard. Failures are traced but never surface as user-visible errors — clipboard is best-effort in terminal environments. The TEXTAGRAM_DISABLE_OS_CLIPBOARD environment variable disables all OS clipboard access for headless/testing scenarios.

File mode and save

The three document models

A markdown file where only the first recognized textagram fence body is edited.

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarkdownBackedDocument {
    path: PathBuf,
    before_first_fence: String,
    opening_fence_line: String,
    original_editable_text: String,
    closing_fence_line: String,
    after_first_fence: String,
}
tg/src/file_mode.rs:44-53

Rebuilds the full markdown file with only the first fence body replaced.

    pub fn reconstruct_with(&self, current_editable_text: &str) -> String {
tg/src/file_mode.rs:110-111

The markdown-backed model preserves everything outside the first fence body byte-for-byte. On save, only the fence body is replaced; the opening fence line, closing fence line, and surrounding markdown are spliced back unchanged.

Atomic save

Writes a text file via create-new temp file plus same-directory rename.

fn write_text_file_atomically(path: &Path, text: &str) -> io::Result<()> {
tg/src/editor_state.rs:167-168

The save path uses create_new (exclusive create) for the temp file, sync_all to flush to disk, then rename for an atomic swap. A retry loop handles temp name collisions. If rename fails, the temp file is cleaned up.

This means a crash or power loss during save never leaves a half-written file at the target path.

Chrome overrides

Builds terminal chrome overrides plus optional host-owned top-center text.

pub fn chrome_overrides_with_center(
    file_mode: &FileMode,
    current_editable_text: &str,
    center_text: Option<&str>,
) -> ChromeOverrides {
tg/src/chrome.rs:16-21

The terminal host injects two chrome slots:

  • Left title: tg branding, optionally followed by a file-name chip with a * dirty marker
  • Top center: the save prompt text (save changes? y/n/esc) or transient host messages

These render inside the core’s shared compositor without forking any layout logic. The core still owns mode indicators, coordinates, and footer hints.

Rendering

Renders a Textagram session into the current ratatui frame.

pub fn draw_session_frame(session: &Session, frame: &mut Frame<'_>, chrome: &ChromeOverrides) {
tg/src/terminal_render.rs:12-13

The render path is:

  1. Ask the core for a ScreenFrame via composed_frame_with_overrides
  2. Walk each RenderLine and its RenderSegments
  3. Map each SemanticStyle to a ratatui Style (colors, bold)
  4. Write characters into the ratatui buffer cell-by-cell

The per-character loop exists because Textagram’s rendering model is single-width-cell oriented. Each segment’s text is written one character at a time with the segment’s mapped style, and remaining width is cleared.

Recording

The terminal host includes a built-in session recorder activated by Q (or R to reset the baseline mid-session).

Converts a processed key into a replay token, omitting recording-control keys.

pub fn keyspec_for_processed_key(key_event: AppKeyEvent, action: Action) -> Option<String> {
tg/src/recording.rs:49-50
#[cfg_attr(test, allow(dead_code))]
/// Writes a markdown e2e scenario that can replay the recorded terminal session.
pub fn write_e2e_recording(
    path: &Path,
    keys: &str,
    given: &[String],
    cursor: (u16, u16),
    clipboard: Option<&str>,
    expect: &[String],
) -> Result<String, Box<dyn Error>> {
tg/src/recording.rs:58-67

Recording captures:

  • a baseline canvas snapshot at session start (or after R)
  • every keystroke as a compact keyspec token
  • a final canvas snapshot at Q
  • cursor position and clipboard state

The output is a markdown file in the repository’s e2e scenario format, ready to be used as a regression test without manual transcription.

Recording-control keys (Q and R) are excluded from the keyspec stream so the recorded sequence is replayable without triggering recording again.