# vterm architecture How the plugin is put together, for someone about to change it. `README.md` is the user-facing overview; this is the developer reference. Code style lives in `CODE.md`. ## What it is A terminal emulator that runs inside an ordinary Sublime Text tab. There is no custom drawing surface: the emulator's screen is written into the view as **real text**, and colours are region overlays. That single decision explains most of what follows. Constraints it works under: - **Sublime Text 4**, build 4149+ (`View.folded_regions`), plugin host **Python 3.8** (`.python-version`). The host is a separate process, so every Sublime API call from plugin code is a cross-process round trip — call *count* is what costs. - **macOS only.** The pty layer is posix and the mousemap is `Default (OSX)`. - Third-party code comes from Package Control libraries declared in `dependencies.json`: `pyte` (emulation), `ptyprocess` (pty), `wcwidth` (character widths). - Editing plugin sources needs a **Sublime restart**: submodules are deliberately not purged on reload, because rebuilding the terminal registry would orphan open tabs. ## Files ### The package | File | Role | |---|---| | `main.py` | Entry point. Imports every command and listener class so Sublime discovers them, and defines `plugin_loaded()`. | | `vterm/terminal.py` | `Terminal`: pty process, emulator ownership, the reader/renderer threads, and everything written back to the app. | | `vterm/emulator.py` | `VtScreen` and `VtStream`: pyte subclasses adding the terminal features these agents need. | | `vterm/render.py` | Screen → view text, colour overlays, caret, folding, trimming. | | `vterm/scheme.py` | `SchemeManager`: generates the colour scheme that defines the scopes the renderer paints with. | | `vterm/themes.py` | The built-in palettes (`default`, `homebrew`, `pro`, `slate`). | | `vterm/commands.py` | The Sublime commands: open, keypress, copy/paste, theme picker, clear, cleanup, close. | | `vterm/listeners.py` | `VtermCoreEventListener`: focus, IME capture, copy/paste rerouting, the mousemap context. | | `vterm/mouse.py` | Wheel and click handling, and the trackpad poller. | | `vterm/keys.py` | Key name + modifiers → escape sequence tables. | | `vterm/utils.py` | `settings()`, the `responsive` throttle, the `intermission` pacer, `rev_wcwidth`. | ### Sublime-facing files at the repo root | File | Role | |---|---| | `vterm.sublime-settings` | Defaults for the eleven plugin settings. | | `vterm.sublime-commands` | Four palette entries: open, open terminal in project folder, clear screen, select theme. | | `vterm.sublime-syntax` | An intentionally empty hidden syntax, so terminal text is never tokenized. | | `Default.sublime-keymap` | 245 generated bindings. **Do not edit by hand** — regenerate with `tools/gen_keymap.py`. | | `Default (OSX).sublime-mousemap` | Two wheel bindings. | | `dependencies.json` | Package Control libraries. | Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-scheme`. ## Classes - **`Terminal`** (`terminal.py`) — one per terminal tab. Owns the pty, the `VtScreen` and `VtStream`, and the two threads. Instances live in the class-level registry `Terminal._terminals`, keyed by **view id**; `Terminal.from_id(view.id())` is how every command and listener finds its terminal, and it is a plain dict lookup, so it is the cheapest possible gate to put at the top of a hook. - **`TerminalPtyProcess`** (`terminal.py`) — `ptyprocess.PtyProcess` that accepts `str`. - **`VtScreen`** (`emulator.py`) — `pyte.Screen` plus the alternate screen, scrollback, synchronized output, mouse/focus/paste modes, cursor styles, query replies, OSC handling. Mutated only by the renderer thread, so it carries no lock. - **`VtStream`** (`emulator.py`) — `pyte.Stream` with a rewritten parser FSM. Stock pyte drops what these agents rely on: `>`/`<`/`=` prefixed CSI, the DECRQM `$p` intermediate, and OSC codes longer than one digit. Its `feed()` regex-matches complete plain CSI and dispatches them directly (params memoized) — pyte resumes the parser coroutine once per character, which dominated escape-heavy floods. OSC, charsets, private/prefixed CSI and `:` subparams still take the coroutine, byte for byte; a read ending mid-CSI is carried into the next one. Parity is enforced by a differential test (`run_fast_path_cases`). - **`Cursor`** (`emulator.py`) — replaces pyte's so the default attribute set can be a `Char` carrying the extra `linefeed` field. - **`SchemeManager`** (`scheme.py`) — module singleton `scheme_manager`. Owns the generated scheme file and the mapping from colour combination to scope name. - **`VtermRenderCommand`** (`render.py`) — Sublime keeps **one command instance per view**, so its `colored_lines`, `_row_cache`, `_key_counter`, the scrollback chunk bookkeeping (`_archived_chunks`, `_open_rows`, `_open_chunk`, `_trimmed`) and `_lastrow` are that view's render state. ### Commands | Command | Class | Reached from | |---|---|---| | `vterm_open` | `VtermOpenCommand` | palette, user keybindings | | `vterm_open_terminal_project_folder` | `VtermOpenTerminalProjectFolderCommand` | palette | | `vterm_initialize_view` | `VtermInitializeViewCommand` | internal | | `vterm_keypress` | `VtermKeypressCommand` | 244 keymap bindings | | `vterm_render` | `VtermRenderCommand` | renderer thread | | `vterm_show_cursor` | `VtermShowCursorCommand` | internal | | `vterm_copy` / `vterm_paste` / `vterm_paste_text` | `commands.py` | `on_text_command`, IME capture | | `vterm_select_theme` | `VtermSelectThemeCommand` | palette | | `vterm_clear_screen` | `VtermClearScreenCommand` | palette, `super+shift+k` | | `vterm_cleanup` | `VtermCleanupCommand` | renderer, on process exit | | `vterm_close` | `VtermCloseCommand` | nothing — available for a user keybinding | | `vterm_clear_undo_stack` / `vterm_noop` | `commands.py` | internal | | `vterm_scroll` / `vterm_mouse_click` | `mouse.py` | mousemap, `drag_select` interception | Two application-wide `EventListener`s: `VtermCoreEventListener` (`listeners.py`) and `VtermMouseEventListener` (`mouse.py`). ## Data flows ### Opening a terminal `vterm_open` → resolve `cmd` (default `$SHELL -l`) and `cwd` (first project folder → active file's directory → `~`) → `scheme_manager.initialize()` → `window.new_file()` → `vterm_initialize_view` stamps the view settings → `Terminal(view)` → `Terminal.start()`: register in `_terminals`, compute the size from the viewport, spawn the pty, build the `VtScreen`/`VtStream`, subscribe to palette changes, start the two threads. All on the UI thread; a failure closes the view again. ### Output: a pty byte becomes a visible character ``` child writes │ ├─ reader thread ────────────────────────────────────────────── │ process.read(8192) blocking read on the pty master │ self._decoder.decode(raw) incremental UTF-8: a sequence split │ across reads must not be corrupted │ chunks.append(text) collections.deque, no lock │ ├─ renderer thread, every 30 ms (RENDER_PERIOD) ─────────────── │ feed_data() popleft up to FEED_LIMIT (256 KiB) │ stream.feed(joined) → VtStream parser → VtScreen.draw │ mutates buffer, fills screen.dirty │ in_sync_frame()? mid ?2026 frame → skip this tick │ is_visible()? hidden tab → draw nothing this tick │ was_resized()? (checked once a second) → resize pty │ is_render_due()? unfocused → at most one frame per │ UNFOCUSED_RENDER_PERIOD │ _need_to_render()? dirty rows, a pending clear, or a │ moved cursor │ view.run_command("vterm_render") │ _sync_wheel_flag() publishes the mousemap gate, and starts the │ trackpad poller while the app owns the wheel │ └─ UI thread ────────────────────────────────────────────────── VtermRenderCommand.run(edit) update_lines() → drain screen.history above `offset`, then each dirty screen row update_line() → segment_buffer_line() → row-cache check → view.replace() + add_regions() sync_alt_fold() follow-scroll gate → trim, focus_cursor()*, request_scroll() sync_cursor_style(), set_name()*, dirty.clear() * focused terminals only ``` **Rendering off focus.** A terminal only touches its view the way the user would while it holds focus. Sublime dispatches view events application-wide, and packages hang global state off them: Vintage's `InputStateTracker.on_selection_modified` resets one module-level `g_input_state` for every view in every window, so a caret this plugin moved at 33 fps erased the half-typed `d` of a `dd` in whatever file the user was editing. Sublime drops a pending key sequence over the same kind of traffic, which took `gg` and `ciw` with it. Three states, from `Terminal.focused` (set by `on_activated` / `on_deactivated`) and `Terminal.is_visible()` (polled once a second, because selecting another tab in an unfocused group raises no event for this view): | state | text | caret, `sel` | tab name | follow-scroll | draw rate | | --- | --- | --- | --- | --- | --- | | focused | yes | yes | yes | yes | 33 fps | | visible, unfocused | yes | no | no | yes | 15 fps | | hidden | no | no | no | no | none | A frame costs a command dispatch and a dozen UI round trips whatever changed — two `rowcol(size())`, `folded_regions()`, `viewport_position()`, the `trim_trailing_spaces` scan, then `layout_extent()` / `text_to_layout()` / `set_viewport_position()` — so the rate a terminal is drawn at is what the editor pays for it, and only a focused one is worth 33 fps. The renderer tick itself stays at `RENDER_PERIOD` in every state: `feed_data()` runs inside it, and throttling that would cap the parser at `FEED_LIMIT` per tick while the reader keeps filling an unbounded `chunks`. Only the drawing is rate-limited. So the emulator is always current and one render on activation catches the view up. An unfocused terminal is left with an empty selection — nothing a render edit can shift, so no selection event of ours escapes — unless the user selected text to copy, which is kept. **The view/offset model.** The view holds real text. Rows `0 .. offset-1` are scrollback; rows `offset .. offset + screen.lines - 1` are the live screen. `terminal.offset` is the view row of screen row 0. When the emulator retires lines into `screen.history`, the renderer drains them, advances `offset` by that many rows, and writes them at their now permanent view rows. **Once a line leaves `screen.history`, its only copy is the view text** — which is why the alternate screen *folds* the scrollback rather than deleting it. A fresh view is given one blank scrollback row before anything else: Sublime lays out nothing above row 0, and the trackpad poller needs that slack to see an upward gesture at all — on the alt screen no line ever retires to provide it. **The row cache.** pyte's `dirty` set is a repaint *hint*, not a diff: scrolling one line marks every row dirty, and a TUI repaints whole frames. `update_line` compares `(segments, lf)` against `_row_cache` and returns early when unchanged, which is what keeps a full-screen repaint from costing hundreds of cross-process calls. **The follow-scroll gate.** `following()` compares the viewport against `terminal.viewport_y`, where the last auto-scroll left it. Above that, the user scrolled up by hand, and trimming, caret placement and scrolling are all skipped so their position survives the next frame. `scroll_view` (the PgUp/PgDn and Up/Down chords) closes the gate the same way, and scrolling down clamps to the anchor `bottom_y` returns, which re-opens it. A one-row scroll lands exactly on the tolerance boundary, so one Up closes the gate and one Down re-opens it. **Pinning waits for rest.** Two pollers put the viewport back on the anchor: the renderer tick, for a viewport dragged into sublime's past-end room on a terminal too idle to draw a frame, and the trackpad poller, when a browsing gesture returns to the anchor. Both compare the viewport against the position they saw one tick earlier and act only when the two agree. macOS keeps scrolling after the fingers lift, and a hard `set_viewport_position` against that momentum used to yank the view back thirty times a second for as long as the glide lasted — a visible shake at the bottom of every scroll. ### How colour is actually drawn `view.add_regions(key, regions, scope)` can only name a **scope**. It cannot take a colour. The colours must already exist in the view's colour scheme. So vterm generates the colour scheme at runtime: 1. `segment_buffer_line` splits a screen row into runs of identical attributes. 2. For each non-default run, `scheme_manager.scope(fg, bg, style)` returns a scope name such as `vterm.red.default.b`. On a **miss** it creates the rule and schedules a write; the hot path is a single lock-free dict read. 3. `colorize_line` calls `add_regions` once per distinct scope in the row, with keys `vterm#N` — monotonic rather than per-row, because `trim_history` renumbers rows and per-row keys would collide. `colored_lines[row]` remembers them so they can be erased. Rows that scroll off the screen are queued by `archive_rows` and merged by `flush_chunk` onto shared `vterm@chunk@scope` keys (one per scope per `CHUNK` rows of scrollback), their per-row keys erased — every key is a separate pass in Sublime's redraw, and a long coloured session used to accumulate ~15k of them. The merge waits for the chunk to close: `add_regions` replaces a key's whole set, so merging every frame re-sent everything the chunk held so far, which is quadratic in `CHUNK` when output arrives a few rows at a time. The open chunk's rows keep their per-row keys until then, so the live count is scopes x chunks plus at most one chunk's worth. Chunk ordinals are view row + `_trimmed`, so `trim_history` renumbering cannot move a row between chunks; trimming settles the open chunk first, then erases whole chunks' keys and compacts the boundary chunk. 4. `_write_file` serializes every rule into `Packages/User/vterm/vterm.hidden-color-scheme`, the path the view's `color_scheme` setting points at. **Each write makes Sublime reload the scheme and restyle every view**, so writes are debounced 250 ms onto the async thread. Details worth knowing before touching `scheme.py`: - Rules are **seeded** at build time for all 16 foregrounds (plain and bold) so the very first frame never references a scope the file lacks. - If a rule's resolved background equals the global background *exactly*, it is nudged by one (`next_color`) — `add_regions` inverts foreground and background when they match. - Colours are resolved when a rule is added, so a palette change cannot append: `_build` rebuilds the whole table. - Truecolor SGR is quantized into the 256 palette (`quantize_256`), which bounds the rule table: every distinct pair would otherwise be a permanent rule and another file write. - `plugin_loaded` writes the file **synchronously**, because a restored tab's `color_scheme` is read during startup. ### The alternate screen While an app owns the alternate buffer, `sync_alt_fold` folds rows `0..offset-1` away, so the view shows the app's screen alone. The scroll range is deliberately left in place: the trackpad poller pins the viewport and needs the slack to read a gesture. The fold is re-checked every frame, not just on transitions, because the screen's first row is rewritten on every region scroll and an edit at the fold boundary can silently break a fold. Entering the alt screen collapses the layout, so that transition also zeroes `terminal.viewport_y`; a stale anchor would hold the follow-scroll gate shut. A fold merely repaired keeps the anchor, or the boundary row apps rewrite on every scroll would thrash the poller. The emulator side (`switch_to_screen`) swaps in a `maxlen=0` history, so a full-screen app never pushes its frames into scrollback. ### Input: a key press reaches the app Almost every key, **including every printable character**, is bound to `vterm_keypress`. That is deliberate: if printables were left to Sublime, an app that does not echo (vim in normal mode) would leave the typed character sitting in the view. ``` keymap binding (context: setting.vterm_view) → VtermKeypressCommand.run(key=…, ctrl=…, alt=…, shift=…) → view_scrolled() with scroll_modifier, PgUp/PgDn scroll the view a page and Up/Down a row; stops here, never on the alt screen → Terminal.send_key() adds application_mode (DECCKM), new_line_mode (LNM) → keys.get_key_code() modified nav keys → CSI 1; enter/tab/backspace/space, modified, while the app asked for modifyOtherKeys → CSI 27;;~ ctrl → control byte; alt → ESC prefix plain → _KEY_MAP / _APP_MODE_KEY_MAP → Terminal.send_string(normalized=False) → pty write ≤4096 bytes inline, larger goes through a queue ``` **IME and composed input** cannot work this way — there is no key event for "the IME committed 日本語"; the text arrives as Sublime's own `insert` command. `on_modified` catches that: it reconstructs what was inserted from the caret positions (not from `args["characters"]`, which reports the typed keys rather than the committed composition), forwards it as an unbracketed paste, and `soft_undo`s any other command that modified the view. Text the emulator did not produce must never remain in the view. **Copy and paste** are rerouted by `on_text_command`: `copy` → `vterm_copy` (which strips the zero-width `CONTINUATION` sentinel so soft-wrapped lines re-join), `paste` → `vterm_paste`, and `undo` → `vterm_noop`. Pastes are wrapped in `ESC[200~` / `ESC[201~` when the app enabled bracketed paste. In the other direction, OSC 52 from the app reaches the system clipboard via a hop to the UI thread. ### Mouse Once per tick, `_sync_wheel_flag` publishes `vterm_view.wheel_captured` = "the app reports the mouse, or owns the alternate screen". The mousemap's binding is gated on it, which produces three cases: 1. **App tracks the mouse** → `vterm_scroll` maps the event to a screen cell and sends three SGR wheel reports, *one per write* — pi matches the SGR form against a whole stdin chunk and drops anything longer. 2. **Alternate screen, no tracking** → three arrow keys per notch (alternate scroll, as kitty/iTerm2/xterm do). This is what scrolls a fullscreen UI before it enables mouse reporting. 3. **Neither** → the binding does not match at all and Sublime scrolls the view over the real scrollback text. **The trackpad** fires none of that: on macOS it never reaches a mousemap binding (sublime_text#1838), and its only trace is the viewport moving. So while the wheel flag is up, `start_trackpad` runs a 24 ms timer chain, and any displacement from the follow anchor is a gesture — whole lines of it go out through the same `send_scroll` as the wheel, the fraction is carried into the next tick, and the viewport is pinned back to the anchor. A displacement of a viewport or more is a scrollbar drag or a page scroll instead: the poller disengages and lets the user browse until they return to the anchor. `_sync_wheel_flag` restarts the chain within a frame or two of it stopping on a hidden tab or a capture flap, and `scroll_to_cursor` yields while the poller owns the pin — apps repaint mid-gesture, and re-pinning there would discard displacement not yet harvested. Clicks: `VtermMouseEventListener` intercepts `drag_select` only when the app tracks the mouse, and only the plain single-button form, so shift-click and add-selection stay native. The first click on an unfocused tab only focuses it — we replaced the command that normally does that — and subsequent clicks send a press/release pair. ### Lifecycle - **Tab closed** → `on_pre_close` → `Terminal.kill()`: unsubscribe, terminate the child, drop the registry entry. - **Process exits** → reader gets `EOFError`, sets `alive = False`; the renderer drains the last output, then posts `vterm_cleanup` to the UI thread. Cleanup latches `vterm_view.finished`, renders a final frame, kills the terminal, and either closes the tab (`auto_close`) or appends `[process exited with code N]`. - **Plugin host restart** → the registry is gone but the view survives with its settings and text. `on_activated` detects a view marked `vterm_view` with no terminal behind it and shows a "disconnected" status: the tab has to be closed and reopened. ## Threading | Thread | Runs | Touches | |---|---|---| | UI | commands, listeners, `Terminal.start`, `scroll_to_cursor`, `_trackpad_tick`, clipboard | Sublime API; small pty writes go out here too | | reader | `process.read`, decode, `chunks.append` | no Sublime API, no screen state | | renderer | `stream.feed`, then `run_command("vterm_render")` | owns the emulator | | paste drain | transient; empties the 4096-byte chunk queue | pty only | | ST async worker | `SchemeManager._flush` | the scheme file | The emulator needs no lock because exactly one thread mutates it. The chunk deque needs none either: `append` and `popleft` are atomic under the GIL. This matters — the renderer used to hold a lock across `stream.feed` **and** the whole render command, so the reader could not drain the pty for the duration of a frame and the child blocked in `write()`. Backpressure now lives in `FEED_LIMIT` instead: at most 256 KiB is parsed per tick, so a huge burst still renders intermediate frames while the reader keeps draining. `SchemeManager` does keep a lock, because `scope()` is called from the renderer while `reload()` and `_flush()` run on other threads. Its read fast path skips the lock; the invariant that makes that safe is that `_names` must never outlive `rules` — `_build` replaces both together. ## Sublime settings **Plugin settings** (`vterm.sublime-settings`, read through the cached `settings()` handle): `auto_close`, `scrollback_history_size`, `honor_erase_scrollback`, `scroll_modifier`, `min_columns`, `max_columns`, `theme`, `ansi_colors`, `terminal`, `parameters`, `debug`. `scroll_modifier` is the modifier that turns PgUp/PgDn and Up/Down into a scroll of the view — a page, a row — instead of a key for the app. `terminal` and `parameters` name the external terminal spawned by *vterm: Open Terminal in Project Folder* (`%CWD%` in `parameters` becomes the project folder). ED 3 never touches the visible screen (as in xterm), and `honor_erase_scrollback` is off, so it does not drop the scrollback either: pi and Claude Code repaint by erasing saved lines, which would take the history with them (`cmd+shift+k` still empties the tab). Changing `theme` fires `SchemeManager.reload`, which rebuilds the palette and restyles open terminals in place. **Stamped on every terminal view** by `vterm_initialize_view`: `vterm_view` (the marker every context and hook keys off), `is_widget`, `color_scheme` (the generated scheme), `syntax` (the empty one), `scroll_past_end`, `scroll_context_lines`, and a list of disables — `gutter`, `highlight_line`, `word_wrap`, `auto_complete`, `draw_white_space`, `draw_indent_guides`, `spell_check`, `translate_tabs_to_spaces`, `command_mode` (which defuses Vintage mode inside a terminal). The view is also set scratch, so closing it never prompts to save. **Written at runtime**: `vterm_view.finished` (latched by cleanup; also disables the Escape binding for a dead tab), `vterm_view.wheel_captured` (the mousemap gate), and `block_caret` / `caret_style` (from DECSCUSR; style 0 is the terminal default, a blinking block). **Contexts.** Keymap bindings use the native `setting.vterm_view`, evaluated in Sublime's own process — a plugin `on_query_context` would cost a round trip on every keystroke in every view. Mousemaps do **not** evaluate `setting.` contexts the same way, so the single wheel context still goes through `on_query_context`, which answers that one key and returns `None` for everything else. --- ## Terminal concepts, briefly For anyone who has not worked with terminal protocols before. The ones vterm implements are marked ✅. **Control bytes** — single characters below 0x20 with an effect rather than a glyph: CR (`\r`) return to column 0, LF (`\n`) next line, BS backspace, HT tab, BEL bell. **Escape sequence** — `ESC` (0x1B) followed by more bytes. Everything below is one. - **CSI** ✅ — Control Sequence Introducer, `ESC [`. The workhorse: `ESC[2J` clear screen, `ESC[10;5H` move the cursor. Ends with a *final byte* that names the operation; numbers in between are its parameters. - **SGR** ✅ — Select Graphic Rendition, `CSI … m`. Sets colour and style: `ESC[1m` bold, `ESC[31m` red foreground, `ESC[38;5;208m` a 256-palette colour, `ESC[38;2;r;g;bm` truecolor, `ESC[0m` reset. This is what TUIs emit most. - **OSC** ✅ — Operating System Command, `ESC ]`, ending at BEL or ST. Carries strings rather than numbers: `OSC 0/2` set the window title, `OSC 10/11` query the default foreground/background, `OSC 52` reads or writes the system clipboard. - **DCS / APC** — Device Control String (`ESC P`) and Application Program Command (`ESC _`). Long payloads: sixel images, kitty graphics. vterm parses their framing and discards the payload, so an image never leaks into the text. - **SS3** ✅ — `ESC O`, a single shifted character. Cursor keys take this form in application mode; F1-F4 use it too. - **ST** — String Terminator, `ESC \`, which ends OSC/DCS/APC payloads (BEL also ends OSC). **Modes** are switches. DEC *private* modes are set with `CSI ? n h` (DECSET) and cleared with `CSI ? n l` (DECRST); **DECRQM** ✅ (`CSI ? n $p`) asks whether one is on, which is how an app discovers what the terminal supports. - **1049** ✅ alternate screen — a second, scrollback-less buffer for full-screen apps, so leaving vim restores your shell exactly as it was. - **2026** ✅ synchronized output — "I am mid-frame, do not draw yet"; what makes a fullscreen UI update without tearing. - **2004** ✅ bracketed paste — pasted text is wrapped in `ESC[200~`/`ESC[201~` so an app can tell it apart from typing and not auto-indent it. - **1000 / 1002 / 1003** ✅ mouse reporting — clicks / clicks with drag / all motion. - **1006** ✅ SGR mouse encoding — the modern wire form, `ESC[ c`) — Device Attributes: what kind of terminal are you, and what version. Claude Code waits on DA1 with no timeout, so failing to answer hangs it. - **DSR / CPR** ✅ (`CSI n`, `CSI 6n`) — Device Status Report / Cursor Position Report: "are you ok" and "where is the cursor". Private `DSR 996` reports whether the palette is light or dark. - **XTVERSION** ✅ (`CSI > q`) — the terminal's name and version. Naming ourselves is what lets apps enable 2026 on a generic `TERM`. - **Kitty keyboard protocol** — a richer key encoding with release events. vterm answers the probe (`CSI ? u`) with zero flags, so apps fall back to something else — for pi that is modifyOtherKeys, which it then enables unprompted. - **modifyOtherKeys** ✅ (`CSI > 4 ; n m`) — xterm's pre-kitty answer to the same problem: keys whose modified form has no legacy encoding are sent as `CSI 27 ; ; ~`. While an app has it on, vterm uses that form for modified enter, tab, backspace and space — Shift+Enter is `CSI 27;2;13~`, since a bare `ESC CR` is indistinguishable from Alt+Enter. Ctrl+letters keep their control bytes, so Ctrl+C stays `\x03`. **Other terms** - **pty** — pseudo-terminal: a kernel device pair. The child thinks its stdin/stdout is a terminal; we hold the master end and read what it writes. - **TIOCSWINSZ / SIGWINCH** ✅ — telling the pty its new size delivers SIGWINCH to the child, which is how a resize reaches the app. - **wcwidth** ✅ — how many columns a character occupies: 1 for ASCII, 2 for CJK and most emoji, 0 for combining marks. Terminals are grids, so this has to agree with the font or the caret drifts. - **IME** — Input Method Editor: the OS layer that turns keystrokes into composed text (pinyin → 中文, dead keys → accents). It commits text without a matching key event, which is why `on_modified` exists. - **TERM / COLORTERM** ✅ — environment variables describing the terminal's capabilities. Children get `TERM=xterm-256color` and `COLORTERM=truecolor`.