Files
vterm/README.md
Vaclav Tvrdik 430af419c0 initial import
2026-09-06 10:36:21 +02:00

170 lines
8.5 KiB
Markdown

# vterm
Minimalist terminal for Sublime Text 4, built to run TUI coding agents
(`pi`, `claude`) in a tab. macOS only. No panels, no images,
no build-system integration.
Requires Sublime Text 4 with the Python 3.8 plugin host (build 4149+, for
`View.folded_regions`) and Package Control.
## Install
Symlink this folder into Sublime packages as `vterm`:
```sh
ln -s /Users/vaclavt/devel/vt/vterm "$HOME/Library/Application Support/Sublime Text/Packages/vterm"
```
Dependencies (pyte, ptyprocess, wcwidth) are Package Control libraries declared
in `dependencies.json`. If they are not yet in `Lib/python38` (e.g. Terminus was
never installed), run **Package Control: Satisfy Libraries** and restart Sublime.
Package Control garbage-collects libraries no package requires, so if imports
ever fail after uninstalling another package, re-run *Satisfy Libraries*.
## Use
Command palette:
- **vterm: Open Terminal** — login shell in a tab (cwd = first project folder,
else the active file's directory, else `~`)
- **vterm: Open Terminal in Project Folder** — opens the external terminal from
the settings (kitty by default) in the project folder of the active file
- **vterm: Clear Screen** (`cmd+shift+k`) — empties the tab, its screen and all
scrollback, then asks the app to redraw, so the tab looks freshly started
- **vterm: Select Theme** — pick a colour palette, previewing each as you move
through the list; Esc restores what you had
To launch an agent directly, add your own palette entry or keybinding — `vterm_open`
takes `cmd`, `cwd`, `env` and `title`:
```json
{ "caption": "vterm: pi", "command": "vterm_open", "args": {"cmd": ["pi"], "title": "pi"} }
```
Keys: Shift+Enter sends `ESC CR` (newline in both agents). Copy/paste use the
normal Sublime keys, and pastes are bracketed when the app asks for it. Copying
re-joins soft-wrapped lines. The wheel scrolls the view normally; while the app
tracks the mouse the wheel and left click go to it instead, and on the alternate
screen without tracking each wheel tick sends arrow keys (alternate scroll, as
kitty/iTerm2 do — this is what scrolls Claude Code's fullscreen UI before it
enables mouse reporting). While an app owns the alternate screen the scrollback
above it is folded away, kitty-style, and unfolds when the app leaves it.
The caret follows DECSCUSR, so zsh vi-mode can show a block cursor in normal
mode. The shell must emit the sequence — add to `~/.zshrc`:
```zsh
function zle-keymap-select zle-line-init {
case $KEYMAP in
vicmd) print -n '\e[1 q' ;; # blinking block
*) print -n '\e[5 q' ;; # blinking bar
esac
}
zle -N zle-keymap-select
zle -N zle-line-init
```
## Settings
`vterm.sublime-settings`: `auto_close` (close the tab when the process exits; off
leaves it open with a `[process exited with code N]` line),
`scrollback_history_size`, `min_columns` / `max_columns`,
`terminal` / `parameters` (the external terminal for *vterm: Open Terminal in
Project Folder*; `%CWD%` in `parameters` is replaced with the project folder,
defaulting to kitty), `debug` (verbose logging
to the ST console), plus the colours:
- `theme` — `"default"` (Terminal.app Basic, light), `"homebrew"`, `"pro"`,
`"slate"`, or `null` to follow the active Sublime colour scheme. Set it with
*vterm: Select Theme* or by hand; either way open terminals restyle in place,
no restart needed.
- `ansi_colors` — override individual base colours, e.g. `{"red": "#ff0000"}`,
applied on top of the theme.
A light theme is reported to the running agent (OSC 11, DSR 996), so `pi` and
`claude` switch to their light UI to match. An app subscribed to mode 2031 is also
told unprompted when you change the theme mid-session.
## How it works
For the full picture — class inventory, data flows, threading, and a primer on the
terminal protocols — see `Architecture.md`.
- `vterm/emulator.py` — pyte subclasses. The parser is rewritten because stock
pyte swallows what these agents rely on: `>`/`<`/`=` prefixed CSI, DECRQM `$p`,
multi-digit OSC. It answers DA1 (Claude Code's query sentinel, which has no
timeout) and DA2, CPR, XTVERSION + DECRQM 2026 (this is what unlocks Claude
Code's flicker-free synchronized output on a generic `TERM`), the kitty keyboard
probe, and OSC 10/11 colour queries; OSC 52 goes to the Sublime clipboard.
Combining characters are merged as NFC, because Sublime draws decomposed
sequences badly.
- `vterm/terminal.py` — pty process, reader and renderer threads at ~33 Hz.
Rendering is held back inside a `?2026` frame, and the pty is resized from the
view size (TIOCSWINSZ → SIGWINCH). Reads go through an incremental UTF-8
decoder so a sequence split across chunks is not dropped. The child gets
`TERM=xterm-256color` and `COLORTERM=truecolor`, with `TERM_PROGRAM` stripped.
A forced repaint narrows the pty by one column for 250 ms — never ctrl+L, which
is `/clear` in Claude Code.
- `vterm/render.py` — scrollback and live screen are real view text: dirty rows
are whole-line replaced, colours are `add_regions` overlays, and the Sublime
caret doubles as the terminal cursor (DECTCEM turns it into a block, so an app
drawing its own lands on the same cell; a view with no caret at all loses the
space key and IME text).
- `vterm/scheme.py` + `vterm/themes.py` — colour scheme rules generated on demand
per (fg, bg, style) into `Packages/User/vterm/`, truecolor quantized into the
256 palette to bound the rule table, palette from the selected theme or your
editor scheme.
- `vterm/keys.py` + generated `Default.sublime-keymap` — every key including the
printable characters is bound to `vterm_keypress`, so nothing is ever inserted
into the view: an app that does not echo (vim in normal mode) would otherwise
leave the character behind. `on_modified` in `vterm/listeners.py` remains the
fallback for IME and composed input.
- `vterm/mouse.py` — SGR mouse reporting (wheel, click) when the app enables
tracking; native selection and scrolling otherwise.
Supported: alt screen (1049), synchronized output (2026), bracketed paste (2004),
mouse tracking 1000/1002/1003 + SGR 1006 (wheel and left click are reported; no
drag, no right or middle button), focus events (1004), cursor styles (DECSCUSR —
block/bar and blink/steady via the view's caret settings; underline falls back
to bar, 0 is the default blinking block), scroll regions, autowrap,
50 000 lines of scrollback that ED 3 does not erase (agents repaint with it;
`honor_erase_scrollback` restores the usual meaning),
256-colour and truecolor SGR (truecolor quantized to the 256 palette),
wcwidth-aware wide characters, OSC 0/2 titles,
DEC line drawing (`ESC(0`, so ncurses apps such as mutt or dialog draw real
borders even under a non-UTF-8 locale).
Deliberately absent: the kitty keyboard protocol (legacy encodings are sent
instead), image protocols, Windows, panels.
## Rough edges
- Streaming output is expensive: every scroll marks all rows dirty and each row is
rewritten over the plugin-host IPC boundary. A per-row cache skips rewriting rows
whose content did not change, but a genuinely changing full screen still costs
real CPU. Batching the colour regions across a frame is the known next fix.
- Editing `vterm/*.py` needs a **Sublime restart**. Submodules are deliberately
not purged on reload, because rebuilding the terminal registry silently orphans
open tabs; an orphaned tab shows a "disconnected" status and stops accepting
input, so close it and open a new one.
- CJK and emoji rely on wcwidth agreeing with Sublime's font metrics; where they
disagree the caret can drift.
## Dev
```sh
python3 tests/test_emulator.py # emulator, query responder, clear — chunked and char-by-char
python3 tests/test_imports.py # modules import, keymap parses, command/flag/theme wiring
python3 tests/test_render.py # render layer on a fake view: trimming, selection, colouring
python3 tests/test_commands.py # theme picker, live restyle, generated scheme
python3 tests/harness_live.py CMD SECONDS [--send TEXT] # run a real app in the emulator, dump the screen
python3 tools/gen_keymap.py # regenerate Default.sublime-keymap
python3 tools/bench_vs_terminus.py # render cost per frame, against Terminus on the same input
```
`harness_live.py` is the useful one: it drives a real `pi` or `claude` through the
emulator with no Sublime involved, and prints the queries answered plus the
resulting screen. Tests import pyte/ptyprocess/wcwidth from Sublime's
`Lib/python38` (pure Python, so any Python 3 runs them).
Code style is in `CODE.md`, working agreements in `CLAUDE.md`.