Back to Fresh

Terminal & Terminal Emulation

docs/internal/terminal.md

0.4.316.8 KB
Original Source

Terminal & Terminal Emulation

Purpose: document Fresh's integrated terminal — PTY spawning, the embedded VT emulation layer, the live/scrollback per-buffer model, mouse/link/title/clipboard handling, and the Windows-specific VT input crate — marking what is implemented vs planned.

Orientation. Fresh does not ship a custom VT output emulator. VT100/ANSI parsing and grid management are delegated to the alacritty_terminal library (driven through its vte ANSI processor). The custom-built terminal code is the Windows VT input layer, the fresh-winterm crate — not output emulation. This document covers the library choice and the winterm rationale as they actually exist.


1. Component map

LayerRole
VT emulator wrapperWraps alacritty_terminal; grid → cells, scrollback streaming, OSC 7 sniffing, title capture
PTY lifecycle / threadsSpawns the PTY via portable-pty, owns reader/wait/writer threads
Key→bytescrossterm KeyEvent → PTY escape bytes
Path-link detectionText-only path:line:col detection in a grid line
Windows shell pickDefault shell selection on Windows
Per-buffer mode + actionsMode switching, sync-to-buffer, respawn, render
Terminal input handlerRoutes keys: capture / UI keybind / PTY
Mouse forwardingScreen→grid coords, alt-screen forwarding
Ctrl+Click linksResolve + open paths printed by the shell
Host terminal modescrossterm raw/alt-screen/mouse/kitty setup + teardown
Host title (OSC 2)Sets the outer terminal's window title
stdin spool`cat x
Windows VT inputfresh-winterm: ReadConsoleInputW VT-input reader, relay, size query

The terminal module re-exports TerminalManager, TerminalState, and TerminalCell.


2. Why alacritty_terminal, not a custom emulator

A correct VT/xterm emulator is a large, bug-prone surface (DEC private modes, SGR, alt-screen, scrollback reflow, charsets, mouse modes). Fresh embeds alacritty_terminal and feeds it bytes via its vte ANSI processor. The editor only consumes the public grid/mode API:

  • Grid read for render: a per-row read walks the grid at the current display offset and maps each cell to a TerminalCell.
  • Mode flags it cares about: alt-screen, mouse tracking, SGR mouse, alternate-scroll, and application-cursor.
  • DSR / cursor-report replies surface as PTY-write events and are queued back to the PTY.

Two things the library does not expose, handled by Fresh itself:

  • OSC 7 (cwd reports). alacritty_terminal's OSC dispatcher drops OSC 7, so a dedicated scanner sniffs ESC ] 7 ; file://host/path out of the raw byte stream before it reaches the emulator. Payload parsing handles file://, percent-escapes, and cross-OS absolute paths, so a Windows-host cwd parses on a Linux client and vice-versa.
  • Title (OSC 0/1/2). Surfaces as title/reset-title events into a shared pending slot, drained into the title handling on each output-processing pass.

portable-pty supplies the cross-platform PTY including Windows ConPTY (with a ConPTY/Windows-1809 hint surfaced on failure).


3. PTY spawning and the three threads

The PTY spawner builds a terminal as follows:

  1. Open a PTY at the requested cols/rows via the native PTY system.
  2. Build the shell command from the active authority's TerminalWrapper (command, args, manages_cwd). Local wraps shell detection; container / remote authorities re-parent the argv into docker exec -w …, ssh … --, etc. (see §10). manages_cwd wrappers skip both the working directory and the local FRESH_SESSION advert (their inner shell is on another host). Sets TERM=xterm-256color; applies the venv/direnv/mise environment delta first so TERM/FRESH_SESSION win.
  3. Spawn the child; capture its process id and a killer handle.
  4. Start three threads:
    • Reader: a read() loop → output processing (emulator + scrollback flush
      • DSR replies) → raw-log append → redraw notification (an async terminal-output message).
    • Wait: blocks on the child, firing the exit event exactly once. The reader deliberately does not fire exit, to avoid a racing exit_code: None.
    • Writer: owns the master, applies queued write/resize commands, and kills the child on shutdown.

A writer-thread command channel carries write/resize/shutdown. The terminal handle holds shared TerminalState, the command sender, and an alive flag.

Identity. Terminal ids restart at 0 per TerminalManager, so output is tagged with a (window, terminal) id to stay unambiguous across windows on the async bus.

Process-group signalling (Unix). The shell is its own session leader inside the PTY, so signalling the negated pid reaches forked subprocesses; the signal mapping covers SIGTERM/KILL/INT/HUP and treats ESRCH as "nothing to signal". Windows models only SIGKILL via shutdown.

Tab auto-naming (Linux). The foreground process name is read from the PTY master's foreground process group (via tcgetpgrp + /proc/<pgid>/comm) — the same signal tmux's #{pane_current_command} uses — so a tab can show python3 even with no OSC title.


4. Key encoding

Key encoding maps crossterm keys to PTY bytes: Ctrl+letter → control chars, Alt+key → ESC+key, and full CSI/SS3 tables for arrows/Home/End/PgUp/F-keys. Application-cursor mode (DECCKM) switches unmodified arrows to SS3 form, selected from the terminal state at send time. Shift+Tab is emitted for both Tab+SHIFT and the BackTab variant.


5. Incremental scrollback streaming (the core model)

Insight: scrollback is append-only, so the design avoids O(n) work on every mode switch / session restore.

Backing file under the per-user data directory, keyed by workdir and terminal id: append-only scrollback history (top) plus a rewritable visible-screen tail (bottom, present only in scrollback mode). A history-end marker records where history ends.

State counters:

  • A count of physical history rows already streamed this epoch.
  • A count of complete logical (unwrapped) lines streamed; invariant under width reflow, used to re-anchor after resize.
  • A deferred-resync flag for a width resize during alt-screen, re-anchored on alt-screen exit.

Flush writes only logical lines that fully scrolled into history. Wrapped rows are rejoined into one unwrapped logical line so the editor can re-wrap them at any view width. SGR colors are threaded across wrapped rows as truecolor and reset once per logical line.

Resize reconciliation: a pure height change leaves the streamed-history count alone (a flush guard suppresses pulled-back rows; spilled rows stream as new). A width change reflows persisted content, so a resync pass walks the reflowed history counting logical lines back to the streamed-logical-line count. Stated guarantee: never lose a line; bounded duplication (≤ screen height) is the accepted trade-off.

Epoch reset: ESC[3J / RIS shrink history → counters reset to 0; new output appends after existing file scrollback. Alt-screen's transient zero-history is excluded.

A separate optional raw .log captures unfiltered PTY bytes for restore replay.


6. Terminal as a buffer type; live vs scrollback

A terminal is a BufferId like any other and can sit in any split. Per-window state lives in a map from BufferId to TerminalBuffer.

Refactor (IMPLEMENTED): live vs scrollback was folded into per-buffer state. It replaced a free-floating "resume" set (presence-semantics, ambiguous, bookkeeping smeared across creation sites) with an explicit interaction-mode enum (Live / Scrollback) held on TerminalBuffer alongside the terminal id. A constructor seeds Live. Accessors expose the terminal id and interaction mode; a single writer sets the mode. Transitions: open/resume → Live; Ctrl+Space / scroll-up / process-exit → Scrollback; close drops the whole record. The backing-file/log maps stay keyed by TerminalId (genuine I/O-layer state). Follow-up work moved per-record logic onto TerminalBuffer and restores a terminal's remembered mode when it regains focus.

A separate per-window boolean tracks whether the active buffer is currently being driven live, for input routing.

Live → Scrollback (sync_terminal_to_buffer): flush pending scrollback, record the history-end byte, append the visible screen, then reload the backing file as a read-only buffer (forced text mode so control bytes don't trip binary detection). The viewport anchors to the history-end byte so exit is pixel-identical to the last live tick.

Scrollback → Live (enter_terminal_mode): set mode Live, re-enable editing, disable line-wrap, truncate the backing file back to the history-end marker (dropping the appended visible screen), scroll to bottom, and resize PTYs. O(1).

Input routing: F9 toggles keyboard-capture (all keys → PTY). With capture off, UI keybindings are checked first (TerminalEscape exits; split-nav exits non-explicitly); Shift+PageUp enters scrollback mode. In scrollback, any plain char / Enter / Tab / Backspace resumes live; nav keys scroll instead; Ctrl+Space, ], and ` enter terminal mode.


7. Mouse handling

send_terminal_mouse:

  • SGR vs X10 encoding chosen from the terminal's SGR-mouse mode.
  • Alternate-scroll (wheel → arrow keys for pagers like less) is applied only when the program is not itself tracking the mouse. Alternate-scroll is on by default in alacritty, so a guard against mouse-aware programs prevents leaking synthesized arrows into full-screen programs (e.g. Claude Code's no-flicker mode) — mirroring xterm/alacritty.

Mouse forwarding only happens when in terminal mode and the buffer is in alternate screen — i.e. full-screen programs own the mouse. Coordinates are content-rect-relative. Crossterm button/event kinds map to Fresh's enums; horizontal scroll is dropped.


A Ctrl+Left-click over a resolvable path opens it in Fresh, jumping to any :line:col. Ctrl+hover underlines a path only if it resolves.

Detection: the live grid path is disabled in alt-screen so it never shadows a program's clicks; the scrollback view maps screen position to buffer position. Resolution order: absolute (after ~ expansion) → OSC 7 cwd → Fresh cwd; existence is checked through the active authority's filesystem so it works on remote/SSH hosts.


9. Title and host terminal modes

Two distinct title concepts. OSC 0/1/2 from the embedded program updates the buffer's tab name. The host-title handling does the opposite: it sets the outer host terminal's window title via OSC 2. It sanitizes control chars and truncates on a char boundary; it is a no-op when stdout isn't a TTY. The format is <name> — <project> — Fresh.

Host modes: centralizes raw-mode / alt-screen / mouse / kitty-keyboard / bracketed-paste setup with tracked teardown (on undo and on drop). Notable decisions:

  • Alt-screen is entered before kitty keyboard flags so push/pop land on the same screen's stack.
  • Kitty flags are pushed optimistically (no probe) — crossterm's detection has a multi-second timeout on common terminals (gnome/konsole/xterm/tmux).
  • On Windows, crossterm's mouse-capture enable/disable is skipped — it replaces the whole console mode and writes no VT sequences; winterm handles it instead.
  • Suspend/resume (SIGTSTP/fg) and an emergency cleanup on the panic hook are handled; sequence constants live in a dedicated module.

10. Authority / remote spawning and reconnect restore

Cross-ref: AUTHORITY_DESIGN.md, PER_SESSION_BACKENDS_DESIGN.md.

Terminal spawning routes through the single Authority, whose TerminalWrapper is the only thing that differs per backend: local = shell detection (manages_cwd:false); container/SSH/k8s wrap the argv as docker exec … / kubectl exec … / ssh … -- … (manages_cwd:true). Authority transitions are destructive — installing a new authority tears down all terminals; in practice Fresh rebuilds the Editor (or, in daemon mode, the editor server swaps the editor without disconnecting clients).

Reconnect respawn (respawn_terminals_through_authority): for each dead PTY, re-spawn through the current authority, reusing the same backing/log files so the new PTY appends to existing scrollback rather than starting blank. cwd and size carry over from the dead handle. Argv precedence: agent-resume argv (when agent resume is configured) → launch command → plain shell. The new PTY id is remapped in place across all TerminalId-keyed maps while the buffer's remembered interaction mode is preserved. On restore, the history-end marker is seeded from existing file length so the first enter_terminal_mode doesn't truncate scrollback to 0, and the backing writer opens in append mode when the file has content. End-to-end tests cover both manual and automatic remote reconnect.


11. Windows VT input crate (fresh-winterm)

Compiles to nothing off Windows. It encapsulates the Windows console "horror story" — this is the genuinely custom terminal code.

  • VT input — enabling VT input sets virtual-terminal-input and window-input console flags (deliberately not mouse-input — mouse arrives as VT sequences in key-event records — and not extended-flags, which would disable Quick Edit and leave it off after exit). Mouse tracking is requested via stdout sequences: cell-motion tracking by default (low-volume), all-motion tracking for hover. A dedicated reader thread reads ReadConsoleInputW into an input-record buffer, decoding UTF-16 surrogates, honoring repeat counts, and converting buffer-size and focus events. A periodic heartbeat re-asserts console mode if it drifts.
  • Corrupt-mouse strip — under heavy all-motion movement the Windows console sporadically drops the leading ESC from SGR mouse sequences (confirmed via raw input-record dumps). The fix detects an SGR mouse body without a preceding ESC arriving in a single ReadConsoleInputW batch (a human couldn't type that in one batch) and discards it. The source documents the long list of approaches that did not work. See windows-mouse-input.md.
  • Relay — a client/server relay loop. Raw VT bytes are forwarded straight to the server's data pipe (the server's input parser does all parsing, matching the Unix relay). It is decoupled from IPC via a relay-connection trait so winterm has no editor dependency. It drains reader events, forwards bytes/resizes, and polls size as a fallback.
  • Terminal size — queried via GetConsoleScreenBufferInfo.

Status note: windows-mouse-input.md still lists a planned "drop ConPTY self-hosting / fix direct VT input" plan and a repeat-count fix marked critical. The code already honors repeat counts and the crate extraction is done, so those items are largely IMPLEMENTED; treat the doc's plan section as partly historical.


12. OSC 52 clipboard (client/server)

copy_to_system_clipboard writes OSC 52 to stdout and/or sets arboard via a persistent static clipboard handle — a temporary arboard handle would take selection ownership (clobbering OSC 52) then drop it, leaving the clipboard empty.

In session/daemon mode the server's stdout is detached, so OSC 52 written there vanishes. IMPLEMENTED fix: the server broadcasts a set-clipboard control message; the client regenerates the clipboard locally using both OSC 52 (terminal) and arboard (X11/Wayland/macOS). Open items that remain unresolved: multi-client semantics, OSC 52 payload caps, and whether copy should fully skip the stdout write in session mode.


stdin streaming handles cat big.log | fresh: a background thread spools stdin to a temp file and the buffer grows incrementally. The StdinStream type is pure bookkeeping (active flag, growth recording, finished-thread outcome, completion); the heavy lifting lives on Editor. Included for completeness — it shares the "tail a growing file into a buffer" idea with terminal scrollback but uses no emulator or PTY.


14. Implemented vs planned summary

  • IMPLEMENTED: alacritty-backed emulation; portable-pty spawn + 3-thread model; incremental scrollback streaming with reflow re-anchor; per-buffer TerminalBuffer live/scrollback fold; OSC 7 cwd sniffing; Ctrl+Click links (live + scrollback); alt-screen mouse forwarding; alternate-scroll guard; embedded-program & host titles; fresh-winterm (VT input, corrupt-mouse strip, relay, size); OSC 52 set-clipboard for session mode; authority-routed spawning and reconnect respawn preserving scrollback + mode.
  • PLANNED / historical: parts of windows-mouse-input.md's ConPTY-removal plan; osc52-client-server-analysis.md open questions (multi-client, payload caps, session-mode stdout skip).