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>,
}Parses tg arguments: zero or one file path plus --help / --version.
pub fn parse(args: impl IntoIterator<Item = OsString>) -> Result<Self, CliError> {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 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),
}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> {Three document models:
- Scratch — no file, no save prompt,
tgwith no arguments - PlainFile — the whole file is one diagram
- MarkdownBacked — only the first recognized
textagramfence 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:
- Render the current frame
- Poll for events (with an optional timeout for timer ticks)
- Collect all queued events
- Dispatch each event
- 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>,
}Starts quit handling, opening a save prompt only for dirty file-backed documents.
pub fn begin_quit(&mut self, current_editable_text: &str) -> QuitRequest {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> {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,
}Rebuilds the full markdown file with only the first fence body replaced.
pub fn reconstruct_with(&self, current_editable_text: &str) -> String {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<()> {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 {The terminal host injects two chrome slots:
- Left title:
tgbranding, 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) {The render path is:
- Ask the core for a
ScreenFrameviacomposed_frame_with_overrides - Walk each
RenderLineand itsRenderSegments - Map each
SemanticStyleto a ratatuiStyle(colors, bold) - 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> {#[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>> {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.