initial import

This commit is contained in:
Vaclav Tvrdik
2026-08-27 14:23:44 +02:00
committed by Vaclav Tvrdik
commit 430af419c0
38 changed files with 6175 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
__pycache__/
vterm/__pycache__
*.sublime-workspace

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.8

434
Architecture.md Normal file
View File

@@ -0,0 +1,434 @@
# 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. |
| `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 ten 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` 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")
│
└─ 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.
**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.** `terminal.viewport_y` records where the last auto-scroll left
the viewport. If the current viewport is *above* that, the user scrolled up by hand, and
trimming, caret placement and scrolling are all skipped so their position survives the
next frame.
### 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 and
sets `scroll_past_end` to false, leaving the view with no scroll range — the app's own
scrolling is the only scrolling. 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. 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=…)
→ Terminal.send_key() adds application_mode (DECCKM), new_line_mode (LNM)
→ keys.get_key_code() modified nav keys → CSI 1;<mod><final>
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.
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`, 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`, `min_columns`,
`max_columns`, `theme`, `ansi_colors`, `debug`. `honor_erase_scrollback` is off, so ED 3
blanks the screen without dropping the scrollback: 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),
`scroll_past_end` (toggled by the alt-screen fold), 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[<button;col;rowM`, without
the 223-column limit of the original.
- **1004** ✅ focus reporting — `ESC[I` on focus, `ESC[O` on blur.
- **DECCKM** ✅ — application cursor keys: arrows switch from `CSI A` to `SS3 A`.
- **DECAWM** ✅ — autowrap at the right margin.
- **DECSCNM** ✅ — reverse video for the whole screen.
- **DECSCUSR** ✅ (`CSI n SP q`) — caret shape: block, underline or bar, blinking or not.
- **DECSTBM** ✅ (`CSI t;b r`) — set the scrolling region to a band of rows.
- **DECKPAM** — application keypad mode, the numeric keypad's equivalent of DECCKM.
vterm ignores it.
- **LNM** ✅ — line feed/new line mode; decides whether Enter sends CR or CR LF.
**Queries** — the app asks, the terminal answers *on the input stream*, as if typed:
- **DA1 / DA2** ✅ (`CSI c`, `CSI > 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 with "unsupported", so apps fall back to the legacy encodings it does send.
**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`.

37
CLAUDE.md Normal file
View File

@@ -0,0 +1,37 @@
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
## 3. Surgical Changes
**Touch only what you must.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
## 4. Writing Style, Code Style and Rules
When writing something intended for human consumption, (comment, commit message, reply to prompt) use as few words as possible. Pick every word meticulously to reduce the volume to a strict minimum. Be down to the point. Less is more.
Coding rules can be found in CODE.md file

177
CODE.md Normal file
View File

@@ -0,0 +1,177 @@
# Code Style and Rules
## Python
### Logging
Use module-level loggers:
```python
import logging
LOG = logging.getLogger(__name__)
```
Use structured and parameterized logging:
```python
LOG.info("Export started: export_type=%s", export_type)
```
Guidelines:
* Log important execution flow and state changes
* Log errors with enough context for debugging
* Use parameterized logging (`%s`) instead of f-strings
* Do not log secrets, passwords, tokens, personal data, or large payloads
* Use appropriate log levels:
* `debug` — detailed diagnostic information
* `info` — normal application flow
* `warning` — unexpected but recoverable situations
* `error` — failed operations
* `exception` — errors with stack traces
An `except` block that swallows a failure and continues must preserve the stack, with
`LOG.exception(...)` or `LOG.warning(..., exc_info=True)`. Logging only `str(exc)` loses
which failure mode fired and where.
Message form:
* `"<stage>.<step>: <what>"` for progress — `LOG.info("asset_render.symbols: generating symbol=%s", name)`
* `key=%s` pairs once a message carries more than one value; a single value may read as prose
* bracketed markers for lifecycle transitions — `[start]`, `[run]`, `[done]`, `[loading]`
`mmgen/cli.py` (pipeline start) is the reference for a multi-value message.
For function tracing, log only meaningful arguments and results when useful for debugging:
```python
LOG.debug("Creating export: design size=%s", len(design))
result = exporter.run()
LOG.debug("Export completed: items=%s", len(result))
```
### Doc strings
**Module doc strings.** The first line is a concise summary of what the module is for:
```python
""" Simple minislot images generation """
```
Further paragraphs are allowed, and encouraged, only to record *why* the module exists or a
non-obvious constraint it encodes — a failure observed in practice, an external format
requirement, a decision a future reader would otherwise undo. Naming config keys, context
keys or sibling modules is fine when that is the constraint being explained. Do not restate
the module's API. See `mmgen/asset_design/framing.py` for a justified long one.
**Function and method doc strings** use this format:
```python
def update_columns_by_filter_cond(
table: Base,
filter_cond: Any,
columns: dict,
session: Optional[Session] = None,
) -> list[Base]:
"""
Update specified columns of all rows in the database matching a given filter condition.
Args:
table: The database table to update.
filter_cond: SQLAlchemy filter condition to select rows.
columns: A dictionary containing column names as keys and new values.
session: The database session to use.
Returns:
list[Base]: A list of refreshed ORM instances reflecting the updates.
Raises:
RuntimeError: If the table is not mapped.
"""
```
* Types belong in the signature, not in `Args:` — a doc string type is a second source of
truth that drifts, and pyright already checks the real one
* `Returns:` leads with the type, because there is no parameter name to carry it
* `Raises:` documents the exceptions a caller is expected to handle
* Omit `Args:`/`Returns:` on a private helper whose signature is self-evident; a one-line
summary is enough
* A `Step.run()` override needs no doc string when the class doc string says what the step
does — the contract is documented once on the base class in `mmgen/pipeline.py`
### Type hints
- when optional type use Optional. Eg. do not use "dict | None", use "Optional[dict]"
- annotate every parameter and return type; parameterize containers (`dict[str, int]`, not
bare `dict`) where the element types are known
- no suppression comments (`# type: ignore`, `# pyright: ignore`) — solve the typing
problem instead
### Code comments
Use comments only when they add non-obvious context.
Guidelines:
* Prefer clear naming and small functions over comments
* Comment:
* business logic
* important decisions
* workarounds or edge cases
* logical sections in long functions
* Keep comments short and explain **why**, not **what**
* Update comments when code changes
* Avoid comments that restate the code
* Don't add and don't remove comments to a block of code if you did not create it or modify it.
Form:
* A comment sits on its own line above the code it describes — never trailing
* No `# ---- section ----` banners
* A comment above a constant explains what the *value* means; it must not paraphrase the
identifier
### Errors and failures
* No custom exception classes
* `RuntimeError` for operational failures — missing context prerequisites, missing files,
unknown names, unsupported configs
* `ValueError` for value and domain validation — pydantic validators, pure helpers
* Guard-clause-then-raise at the top of `run()`, naming the stage that should have run first:
```python
section = ctx.design.get("symbols_section")
if section is None:
raise RuntimeError("symbols_section not in context; run the game_design stage first")
```
* Let failures propagate. `Pipeline.run` does not catch, so a failing step aborts the run
* Catch narrowly, and only to continue with a degraded result. Re-raise wrapped with
`from exc` (see `mmgen/shapes/registry.py`)
* Error message form: `"<context>: <problem>; <what to do>"`, listing the valid options where
there is a closed set. f-strings are correct here, unlike in logging — the message is built
once, at the point of failure
* Validate cheaply before expensive work, so a typo fails before any paid LLM call
(`prompts/registry.validate()`, `shapes/registry.validate()` run at stage head)
### Imports and paths
* Absolute imports only. No relative imports, no star imports
* Three groups, blank-line separated, alphabetized within group: stdlib → third-party →
`mmgen.*`
* `__init__.py` holds a doc string only — no re-exports. Import from the concrete module
* `pathlib` only, never `os.path`. Join with `/`, prefer `Path.read_text(...)`, and always
pass `encoding="utf-8"` explicitly
### Tests
There is no unit-test suite. Correctness is checked by running the pipeline end to end.
If you add tests, put them in `tests/` with pytest and add pytest to the `dev` dependency
group.
### Tooling
`black` (line-length 140) is the formatter and `pyright` is the type checker. No linter is
configured. See the *Dev tools* section of `README.md` for the commands.

View File

@@ -0,0 +1,14 @@
[
{
"button": "scroll_up",
"command": "vterm_scroll",
"args": {"up": true},
"context": [{"key": "vterm_view.wheel_captured", "operand": true}]
},
{
"button": "scroll_down",
"command": "vterm_scroll",
"args": {"up": false},
"context": [{"key": "vterm_view.wheel_captured", "operand": true}]
}
]

248
Default.sublime-keymap Normal file
View File

@@ -0,0 +1,248 @@
[
// generated by tools/gen_keymap.py - do not edit by hand
{"keys": ["a"], "command": "vterm_keypress", "args": {"key": "a"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["b"], "command": "vterm_keypress", "args": {"key": "b"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["c"], "command": "vterm_keypress", "args": {"key": "c"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["d"], "command": "vterm_keypress", "args": {"key": "d"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["e"], "command": "vterm_keypress", "args": {"key": "e"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["f"], "command": "vterm_keypress", "args": {"key": "f"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["g"], "command": "vterm_keypress", "args": {"key": "g"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["h"], "command": "vterm_keypress", "args": {"key": "h"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["i"], "command": "vterm_keypress", "args": {"key": "i"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["j"], "command": "vterm_keypress", "args": {"key": "j"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["k"], "command": "vterm_keypress", "args": {"key": "k"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["l"], "command": "vterm_keypress", "args": {"key": "l"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["m"], "command": "vterm_keypress", "args": {"key": "m"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["n"], "command": "vterm_keypress", "args": {"key": "n"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["o"], "command": "vterm_keypress", "args": {"key": "o"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["p"], "command": "vterm_keypress", "args": {"key": "p"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["q"], "command": "vterm_keypress", "args": {"key": "q"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["r"], "command": "vterm_keypress", "args": {"key": "r"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["s"], "command": "vterm_keypress", "args": {"key": "s"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["t"], "command": "vterm_keypress", "args": {"key": "t"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["u"], "command": "vterm_keypress", "args": {"key": "u"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["v"], "command": "vterm_keypress", "args": {"key": "v"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["w"], "command": "vterm_keypress", "args": {"key": "w"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["x"], "command": "vterm_keypress", "args": {"key": "x"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["y"], "command": "vterm_keypress", "args": {"key": "y"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["z"], "command": "vterm_keypress", "args": {"key": "z"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["A"], "command": "vterm_keypress", "args": {"key": "A"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["B"], "command": "vterm_keypress", "args": {"key": "B"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["C"], "command": "vterm_keypress", "args": {"key": "C"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["D"], "command": "vterm_keypress", "args": {"key": "D"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["E"], "command": "vterm_keypress", "args": {"key": "E"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["F"], "command": "vterm_keypress", "args": {"key": "F"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["G"], "command": "vterm_keypress", "args": {"key": "G"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["H"], "command": "vterm_keypress", "args": {"key": "H"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["I"], "command": "vterm_keypress", "args": {"key": "I"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["J"], "command": "vterm_keypress", "args": {"key": "J"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["K"], "command": "vterm_keypress", "args": {"key": "K"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["L"], "command": "vterm_keypress", "args": {"key": "L"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["M"], "command": "vterm_keypress", "args": {"key": "M"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["N"], "command": "vterm_keypress", "args": {"key": "N"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["O"], "command": "vterm_keypress", "args": {"key": "O"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["P"], "command": "vterm_keypress", "args": {"key": "P"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["Q"], "command": "vterm_keypress", "args": {"key": "Q"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["R"], "command": "vterm_keypress", "args": {"key": "R"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["S"], "command": "vterm_keypress", "args": {"key": "S"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["T"], "command": "vterm_keypress", "args": {"key": "T"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["U"], "command": "vterm_keypress", "args": {"key": "U"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["V"], "command": "vterm_keypress", "args": {"key": "V"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["W"], "command": "vterm_keypress", "args": {"key": "W"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["X"], "command": "vterm_keypress", "args": {"key": "X"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["Y"], "command": "vterm_keypress", "args": {"key": "Y"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["Z"], "command": "vterm_keypress", "args": {"key": "Z"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["0"], "command": "vterm_keypress", "args": {"key": "0"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["1"], "command": "vterm_keypress", "args": {"key": "1"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["2"], "command": "vterm_keypress", "args": {"key": "2"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["3"], "command": "vterm_keypress", "args": {"key": "3"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["4"], "command": "vterm_keypress", "args": {"key": "4"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["5"], "command": "vterm_keypress", "args": {"key": "5"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["6"], "command": "vterm_keypress", "args": {"key": "6"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["7"], "command": "vterm_keypress", "args": {"key": "7"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["8"], "command": "vterm_keypress", "args": {"key": "8"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["9"], "command": "vterm_keypress", "args": {"key": "9"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["!"], "command": "vterm_keypress", "args": {"key": "!"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["\""], "command": "vterm_keypress", "args": {"key": "\""}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["#"], "command": "vterm_keypress", "args": {"key": "#"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["$"], "command": "vterm_keypress", "args": {"key": "$"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["%"], "command": "vterm_keypress", "args": {"key": "%"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["&"], "command": "vterm_keypress", "args": {"key": "&"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["'"], "command": "vterm_keypress", "args": {"key": "'"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["("], "command": "vterm_keypress", "args": {"key": "("}, "context": [{"key": "setting.vterm_view"}]},
{"keys": [")"], "command": "vterm_keypress", "args": {"key": ")"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["*"], "command": "vterm_keypress", "args": {"key": "*"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["+"], "command": "vterm_keypress", "args": {"key": "+"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": [","], "command": "vterm_keypress", "args": {"key": ","}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["-"], "command": "vterm_keypress", "args": {"key": "-"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["."], "command": "vterm_keypress", "args": {"key": "."}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["/"], "command": "vterm_keypress", "args": {"key": "/"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": [":"], "command": "vterm_keypress", "args": {"key": ":"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": [";"], "command": "vterm_keypress", "args": {"key": ";"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["<"], "command": "vterm_keypress", "args": {"key": "<"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["="], "command": "vterm_keypress", "args": {"key": "="}, "context": [{"key": "setting.vterm_view"}]},
{"keys": [">"], "command": "vterm_keypress", "args": {"key": ">"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["?"], "command": "vterm_keypress", "args": {"key": "?"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["@"], "command": "vterm_keypress", "args": {"key": "@"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["["], "command": "vterm_keypress", "args": {"key": "["}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["\\"], "command": "vterm_keypress", "args": {"key": "\\"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["]"], "command": "vterm_keypress", "args": {"key": "]"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["^"], "command": "vterm_keypress", "args": {"key": "^"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["_"], "command": "vterm_keypress", "args": {"key": "_"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["`"], "command": "vterm_keypress", "args": {"key": "`"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["{"], "command": "vterm_keypress", "args": {"key": "{"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["|"], "command": "vterm_keypress", "args": {"key": "|"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["}"], "command": "vterm_keypress", "args": {"key": "}"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["~"], "command": "vterm_keypress", "args": {"key": "~"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["space"], "command": "vterm_keypress", "args": {"key": "space"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["enter"], "command": "vterm_keypress", "args": {"key": "enter"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["tab"], "command": "vterm_keypress", "args": {"key": "tab"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["backspace"], "command": "vterm_keypress", "args": {"key": "backspace"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["up"], "command": "vterm_keypress", "args": {"key": "up"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["down"], "command": "vterm_keypress", "args": {"key": "down"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["right"], "command": "vterm_keypress", "args": {"key": "right"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["left"], "command": "vterm_keypress", "args": {"key": "left"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["home"], "command": "vterm_keypress", "args": {"key": "home"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["end"], "command": "vterm_keypress", "args": {"key": "end"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["delete"], "command": "vterm_keypress", "args": {"key": "delete"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["pageup"], "command": "vterm_keypress", "args": {"key": "pageup"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["pagedown"], "command": "vterm_keypress", "args": {"key": "pagedown"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["escape"], "command": "vterm_keypress", "args": {"key": "escape"}, "context": [{"key": "setting.vterm_view"}, {"key": "setting.vterm_view.finished", "operator": "not_equal", "operand": true}, {"key": "overlay_visible", "operator": "equal", "operand": false}, {"key": "popup_visible", "operator": "equal", "operand": false}]},
{"keys": ["f1"], "command": "vterm_keypress", "args": {"key": "f1"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["f2"], "command": "vterm_keypress", "args": {"key": "f2"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["f3"], "command": "vterm_keypress", "args": {"key": "f3"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["f4"], "command": "vterm_keypress", "args": {"key": "f4"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["f5"], "command": "vterm_keypress", "args": {"key": "f5"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["f6"], "command": "vterm_keypress", "args": {"key": "f6"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["f7"], "command": "vterm_keypress", "args": {"key": "f7"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["f8"], "command": "vterm_keypress", "args": {"key": "f8"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["f9"], "command": "vterm_keypress", "args": {"key": "f9"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["f10"], "command": "vterm_keypress", "args": {"key": "f10"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["f11"], "command": "vterm_keypress", "args": {"key": "f11"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["f12"], "command": "vterm_keypress", "args": {"key": "f12"}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+up"], "command": "vterm_keypress", "args": {"key": "up", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+down"], "command": "vterm_keypress", "args": {"key": "down", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+right"], "command": "vterm_keypress", "args": {"key": "right", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+left"], "command": "vterm_keypress", "args": {"key": "left", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+home"], "command": "vterm_keypress", "args": {"key": "home", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+end"], "command": "vterm_keypress", "args": {"key": "end", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+delete"], "command": "vterm_keypress", "args": {"key": "delete", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+pageup"], "command": "vterm_keypress", "args": {"key": "pageup", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+pagedown"], "command": "vterm_keypress", "args": {"key": "pagedown", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+up"], "command": "vterm_keypress", "args": {"key": "up", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+down"], "command": "vterm_keypress", "args": {"key": "down", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+right"], "command": "vterm_keypress", "args": {"key": "right", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+left"], "command": "vterm_keypress", "args": {"key": "left", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+home"], "command": "vterm_keypress", "args": {"key": "home", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+end"], "command": "vterm_keypress", "args": {"key": "end", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+delete"], "command": "vterm_keypress", "args": {"key": "delete", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+pageup"], "command": "vterm_keypress", "args": {"key": "pageup", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+pagedown"], "command": "vterm_keypress", "args": {"key": "pagedown", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["shift+up"], "command": "vterm_keypress", "args": {"key": "up", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["shift+down"], "command": "vterm_keypress", "args": {"key": "down", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["shift+right"], "command": "vterm_keypress", "args": {"key": "right", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["shift+left"], "command": "vterm_keypress", "args": {"key": "left", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["shift+home"], "command": "vterm_keypress", "args": {"key": "home", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["shift+end"], "command": "vterm_keypress", "args": {"key": "end", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["shift+delete"], "command": "vterm_keypress", "args": {"key": "delete", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["shift+pageup"], "command": "vterm_keypress", "args": {"key": "pageup", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["shift+pagedown"], "command": "vterm_keypress", "args": {"key": "pagedown", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+shift+up"], "command": "vterm_keypress", "args": {"key": "up", "ctrl": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+shift+down"], "command": "vterm_keypress", "args": {"key": "down", "ctrl": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+shift+right"], "command": "vterm_keypress", "args": {"key": "right", "ctrl": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+shift+left"], "command": "vterm_keypress", "args": {"key": "left", "ctrl": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+shift+home"], "command": "vterm_keypress", "args": {"key": "home", "ctrl": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+shift+end"], "command": "vterm_keypress", "args": {"key": "end", "ctrl": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+shift+delete"], "command": "vterm_keypress", "args": {"key": "delete", "ctrl": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+shift+pageup"], "command": "vterm_keypress", "args": {"key": "pageup", "ctrl": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+shift+pagedown"], "command": "vterm_keypress", "args": {"key": "pagedown", "ctrl": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+shift+up"], "command": "vterm_keypress", "args": {"key": "up", "alt": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+shift+down"], "command": "vterm_keypress", "args": {"key": "down", "alt": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+shift+right"], "command": "vterm_keypress", "args": {"key": "right", "alt": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+shift+left"], "command": "vterm_keypress", "args": {"key": "left", "alt": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+shift+home"], "command": "vterm_keypress", "args": {"key": "home", "alt": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+shift+end"], "command": "vterm_keypress", "args": {"key": "end", "alt": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+shift+delete"], "command": "vterm_keypress", "args": {"key": "delete", "alt": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+shift+pageup"], "command": "vterm_keypress", "args": {"key": "pageup", "alt": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+shift+pagedown"], "command": "vterm_keypress", "args": {"key": "pagedown", "alt": true, "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+alt+up"], "command": "vterm_keypress", "args": {"key": "up", "ctrl": true, "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+alt+down"], "command": "vterm_keypress", "args": {"key": "down", "ctrl": true, "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+alt+right"], "command": "vterm_keypress", "args": {"key": "right", "ctrl": true, "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+alt+left"], "command": "vterm_keypress", "args": {"key": "left", "ctrl": true, "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+alt+home"], "command": "vterm_keypress", "args": {"key": "home", "ctrl": true, "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+alt+end"], "command": "vterm_keypress", "args": {"key": "end", "ctrl": true, "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+alt+delete"], "command": "vterm_keypress", "args": {"key": "delete", "ctrl": true, "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+alt+pageup"], "command": "vterm_keypress", "args": {"key": "pageup", "ctrl": true, "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+alt+pagedown"], "command": "vterm_keypress", "args": {"key": "pagedown", "ctrl": true, "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+enter"], "command": "vterm_keypress", "args": {"key": "enter", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+enter"], "command": "vterm_keypress", "args": {"key": "enter", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["shift+enter"], "command": "vterm_keypress", "args": {"key": "enter", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+backspace"], "command": "vterm_keypress", "args": {"key": "backspace", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+backspace"], "command": "vterm_keypress", "args": {"key": "backspace", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["shift+backspace"], "command": "vterm_keypress", "args": {"key": "backspace", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+tab"], "command": "vterm_keypress", "args": {"key": "tab", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+tab"], "command": "vterm_keypress", "args": {"key": "tab", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["shift+tab"], "command": "vterm_keypress", "args": {"key": "tab", "shift": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+a"], "command": "vterm_keypress", "args": {"key": "a", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+a"], "command": "vterm_keypress", "args": {"key": "a", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+b"], "command": "vterm_keypress", "args": {"key": "b", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+b"], "command": "vterm_keypress", "args": {"key": "b", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+c"], "command": "vterm_keypress", "args": {"key": "c", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+c"], "command": "vterm_keypress", "args": {"key": "c", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+d"], "command": "vterm_keypress", "args": {"key": "d", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+d"], "command": "vterm_keypress", "args": {"key": "d", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+e"], "command": "vterm_keypress", "args": {"key": "e", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+e"], "command": "vterm_keypress", "args": {"key": "e", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+f"], "command": "vterm_keypress", "args": {"key": "f", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+f"], "command": "vterm_keypress", "args": {"key": "f", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+g"], "command": "vterm_keypress", "args": {"key": "g", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+g"], "command": "vterm_keypress", "args": {"key": "g", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+h"], "command": "vterm_keypress", "args": {"key": "h", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+h"], "command": "vterm_keypress", "args": {"key": "h", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+i"], "command": "vterm_keypress", "args": {"key": "i", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+i"], "command": "vterm_keypress", "args": {"key": "i", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+j"], "command": "vterm_keypress", "args": {"key": "j", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+j"], "command": "vterm_keypress", "args": {"key": "j", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+k"], "command": "vterm_keypress", "args": {"key": "k", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+k"], "command": "vterm_keypress", "args": {"key": "k", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+l"], "command": "vterm_keypress", "args": {"key": "l", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+l"], "command": "vterm_keypress", "args": {"key": "l", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+m"], "command": "vterm_keypress", "args": {"key": "m", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+m"], "command": "vterm_keypress", "args": {"key": "m", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+n"], "command": "vterm_keypress", "args": {"key": "n", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+n"], "command": "vterm_keypress", "args": {"key": "n", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+o"], "command": "vterm_keypress", "args": {"key": "o", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+o"], "command": "vterm_keypress", "args": {"key": "o", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+p"], "command": "vterm_keypress", "args": {"key": "p", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+p"], "command": "vterm_keypress", "args": {"key": "p", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+q"], "command": "vterm_keypress", "args": {"key": "q", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+q"], "command": "vterm_keypress", "args": {"key": "q", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+r"], "command": "vterm_keypress", "args": {"key": "r", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+r"], "command": "vterm_keypress", "args": {"key": "r", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+s"], "command": "vterm_keypress", "args": {"key": "s", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+s"], "command": "vterm_keypress", "args": {"key": "s", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+t"], "command": "vterm_keypress", "args": {"key": "t", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+t"], "command": "vterm_keypress", "args": {"key": "t", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+u"], "command": "vterm_keypress", "args": {"key": "u", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+u"], "command": "vterm_keypress", "args": {"key": "u", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+v"], "command": "vterm_keypress", "args": {"key": "v", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+v"], "command": "vterm_keypress", "args": {"key": "v", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+w"], "command": "vterm_keypress", "args": {"key": "w", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+w"], "command": "vterm_keypress", "args": {"key": "w", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+x"], "command": "vterm_keypress", "args": {"key": "x", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+x"], "command": "vterm_keypress", "args": {"key": "x", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+y"], "command": "vterm_keypress", "args": {"key": "y", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+y"], "command": "vterm_keypress", "args": {"key": "y", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+z"], "command": "vterm_keypress", "args": {"key": "z", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["alt+z"], "command": "vterm_keypress", "args": {"key": "z", "alt": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+`"], "command": "vterm_keypress", "args": {"key": "`", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+["], "command": "vterm_keypress", "args": {"key": "[", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+]"], "command": "vterm_keypress", "args": {"key": "]", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+\\"], "command": "vterm_keypress", "args": {"key": "\\", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+space"], "command": "vterm_keypress", "args": {"key": "space", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+_"], "command": "vterm_keypress", "args": {"key": "_", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+^"], "command": "vterm_keypress", "args": {"key": "^", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+/"], "command": "vterm_keypress", "args": {"key": "/", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["ctrl+@"], "command": "vterm_keypress", "args": {"key": "@", "ctrl": true}, "context": [{"key": "setting.vterm_view"}]},
{"keys": ["super+shift+k"], "command": "vterm_clear_screen", "context": [{"key": "setting.vterm_view"}]}
]

169
README.md Normal file
View File

@@ -0,0 +1,169 @@
# 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`.

View File

@@ -0,0 +1 @@
/Users/vaclavt/.pi/agent/skills/pi-skills/brave-search

9
dependencies.json Normal file
View File

@@ -0,0 +1,9 @@
{
"*": {
">3000": [
"ptyprocess",
"pyte",
"wcwidth"
]
}
}

45
main.py Normal file
View File

@@ -0,0 +1,45 @@
""" vterm plugin entry point
Submodules are deliberately not purged on reload: rebuilding the terminal
registry would orphan open terminal tabs.
"""
import logging
# imported for sublime's command discovery, not called here
from .vterm.commands import (
VtermClearScreenCommand,
VtermClearUndoStackCommand,
VtermCleanupCommand,
VtermCloseCommand,
VtermCopyCommand,
VtermInitializeViewCommand,
VtermKeypressCommand,
VtermNoopCommand,
VtermOpenCommand,
VtermOpenTerminalProjectFolderCommand,
VtermPasteCommand,
VtermPasteTextCommand,
VtermSelectThemeCommand,
)
from .vterm.listeners import VtermCoreEventListener
from .vterm.mouse import (
VtermMouseClickCommand,
VtermMouseEventListener,
VtermScrollCommand,
)
from .vterm.scheme import scheme_manager
from .vterm.utils import settings
from .vterm.render import (
VtermRenderCommand,
VtermShowCursorCommand,
)
def plugin_loaded():
"""Write the colour scheme and set the log level, once per plugin host."""
# synchronous: a restored tab's color_scheme is read during startup
scheme_manager.ensure_file()
level = logging.DEBUG if settings().get("debug", False) else logging.INFO
logging.basicConfig()
logging.getLogger("vterm").setLevel(level)

33
tests/_bootstrap.py Normal file
View File

@@ -0,0 +1,33 @@
""" sys.path setup and fakes shared by the test scripts
They run as plain scripts (see README), so each imports this first to get the
sublime stubs and the Package Control libraries on the path.
"""
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ST_LIB = os.path.expanduser(
"~/Library/Application Support/Sublime Text/Lib/python38")
for path in (ROOT, ST_LIB, os.path.join(ROOT, "tests", "stubs")):
if path not in sys.path:
sys.path.insert(0, path)
class FakeProcess:
"""Captures what the emulator writes to the pty, one entry per write."""
def __init__(self):
self.writes = []
def write(self, data):
self.writes.append(data)
@property
def written(self):
return "".join(self.writes)
@written.setter
def written(self, value):
self.writes = [value] if value else []

89
tests/harness_live.py Normal file
View File

@@ -0,0 +1,89 @@
""" Live harness: run a real command inside VtScreen/VtStream without Sublime.
Usage: python3 tests/harness_live.py <cmd> [seconds] [--send <text> | --type <text>]
--send writes the text in one go; --type writes a character per write, as typing does. """
import codecs
import os
import sys
import threading
import time
import _bootstrap # sets sys.path for the imports below
from vterm.emulator import VtScreen, VtStream
from vterm.terminal import TerminalPtyProcess, agent_env
cmd = sys.argv[1:2] or ["/bin/zsh"]
seconds = float(sys.argv[2]) if len(sys.argv) > 2 else 3.0
send = None
if "--send" in sys.argv:
send = sys.argv[sys.argv.index("--send") + 1]
typed = None
if "--type" in sys.argv:
typed = sys.argv[sys.argv.index("--type") + 1]
# the same process and environment the plugin spawns
proc = TerminalPtyProcess.spawn(
cmd, cwd=os.getcwd(), env=agent_env(), dimensions=(30, 100))
replies = []
class Screen(VtScreen):
"""Records the query replies the emulator writes back to the app."""
def write_process_input(self, data):
replies.append(data)
super().write_process_input(data)
screen = Screen(100, 30, process=proc)
stream = VtStream(screen)
decoder = codecs.getincrementaldecoder("utf-8")("replace")
lock = threading.Lock()
stop = [False]
def reader():
while not stop[0]:
try:
raw = proc.read(4096)
except EOFError:
break
with lock:
stream.feed(decoder.decode(raw))
t = threading.Thread(target=reader, daemon=True)
t.start()
if send is not None:
time.sleep(2)
proc.write(send)
if typed is not None:
time.sleep(2)
# one write per character: the app frames its input per read, so a whole
# string in one write is not the same test as typing it
for char in typed:
proc.write(char)
time.sleep(0.04)
time.sleep(seconds)
stop[0] = True
try:
proc.terminate(force=True)
except Exception:
pass
print("=== queries answered ===")
for r in replies:
print(repr(r))
print("=== screen (non-empty rows) ===")
for y in range(screen.lines):
row = "".join(screen.buffer[y][x].data for x in range(screen.columns)).rstrip()
if row:
print("{:2}| {}".format(y, row))
print("=== modes: {} title: {!r} alt: {}".format(
sorted(m >> 5 for m in screen.mode if m >= 32), screen.title,
screen.alternate_buffer_mode))

89
tests/stubs/sublime.py Normal file
View File

@@ -0,0 +1,89 @@
""" Minimal sublime API stub for offline tests """
class Region:
def __init__(self, a, b=None):
self.a = a
self.b = a if b is None else b
def begin(self):
return min(self.a, self.b)
def end(self):
return max(self.a, self.b)
def empty(self):
return self.a == self.b
OP_EQUAL = 0
OP_NOT_EQUAL = 1
_settings = {}
_clipboard = [""]
_timeouts = []
class Settings(dict):
def get(self, key, default=None):
return dict.get(self, key, default)
def set(self, key, value):
self[key] = value
def erase(self, key):
self.pop(key, None)
def has(self, key):
return key in self
def add_on_change(self, tag, callback):
pass
def clear_on_change(self, tag):
pass
def load_settings(name):
return _settings.setdefault(name, Settings())
_saved = []
def save_settings(name):
_saved.append(name)
def set_timeout(fn, delay=0):
_timeouts.append(fn)
def set_timeout_async(fn, delay=0):
_timeouts.append(fn)
def run_timeouts():
while _timeouts:
_timeouts.pop(0)()
def set_clipboard(text):
_clipboard[0] = text
def get_clipboard():
return _clipboard[0]
def packages_path():
import tempfile
return tempfile.gettempdir()
def expand_variables(value, variables):
return value
def version():
return "4200"

View File

@@ -0,0 +1,15 @@
""" Minimal sublime_plugin stub """
class TextCommand:
def __init__(self, view=None):
self.view = view
class WindowCommand:
def __init__(self, window=None):
self.window = window
class EventListener:
pass

126
tests/test_commands.py Normal file
View File

@@ -0,0 +1,126 @@
""" Theme picker flows: preview, cancel, confirm.
Run with: python3 tests/test_commands.py """
import _bootstrap # sets sys.path for the imports below
from _bootstrap import FakeProcess
import sublime
from vterm.commands import VtermSelectThemeCommand
from vterm.utils import SETTINGS_FILE
from vterm.themes import THEMES
settings = sublime.load_settings(SETTINGS_FILE)
panel = {}
class FakeWindow:
def show_quick_panel(self, items, on_done, selected_index=-1, on_highlight=None):
panel.update(items=items, on_done=on_done,
selected_index=selected_index, on_highlight=on_highlight)
def open_picker(current):
settings.set("theme", current)
command = VtermSelectThemeCommand(FakeWindow())
command.run()
return command
# every theme is offered, with "no theme" first
command = open_picker(None)
assert [i[0] for i in panel["items"]] == ["No theme"] + sorted(THEMES), panel["items"]
assert panel["selected_index"] == 0
# the configured theme starts highlighted
command = open_picker("pro")
assert panel["selected_index"] == command.choices.index("pro")
# highlighting previews but must not persist
saves = len(sublime._saved)
panel["on_highlight"](command.choices.index("slate"))
assert settings.get("theme") == "slate", settings.get("theme")
assert len(sublime._saved) == saves, "preview must not save settings"
# cancelling restores the value from before the panel opened
panel["on_done"](-1)
assert settings.get("theme") == "pro", settings.get("theme")
# confirming persists
command = open_picker("pro")
panel["on_done"](command.choices.index("homebrew"))
assert settings.get("theme") == "homebrew", settings.get("theme")
assert sublime._saved[-1] == SETTINGS_FILE
# "no theme" persists as null rather than a name
command = open_picker("slate")
panel["on_done"](0)
assert settings.get("theme") is None, settings.get("theme")
print("theme picker tests passed ({} themes offered)".format(len(THEMES)))
# a palette change must reach terminals that are already running: the screen
# answers OSC 10/11 and DSR 996 from these values
from vterm.emulator import VtScreen, VtStream
from vterm.scheme import scheme_manager
from vterm.terminal import Terminal
recorder = FakeProcess()
# real __init__ (view unused here); process/screen stand in for start()
terminal = Terminal(None)
terminal.screen = VtScreen(20, 3, process=recorder, fg_color="000000",
bg_color="ffffff", is_dark=False)
terminal.process = recorder
stream = VtStream(terminal.screen)
scheme_manager.foreground = "#eeeeee"
scheme_manager.background = "#111111"
terminal.apply_scheme()
assert terminal.screen.default_bg_color == "111111", terminal.screen.default_bg_color
assert terminal.screen.theme_is_dark is True
# without mode 2031 the app is not interrupted
assert recorder.written == "", repr(recorder.written)
# with it, the dark/light report is sent so the agent can restyle itself
stream.feed("\x1b[?2031h")
recorder.written = ""
terminal.apply_scheme()
assert recorder.written == "\x1b[?997;1n", repr(recorder.written)
# and the emulator answers a colour query with the new palette
recorder.written = ""
stream.feed("\x1b]11;?\x07")
assert recorder.written == "\x1b]11;rgb:1111/1111/1111\x07", repr(recorder.written)
print("live theme tests passed (running screen adopts palette, app notified)")
# the scheme file must exist before any restored tab's color_scheme is loaded at
# startup, and building twice must not duplicate the seeded rules
import os
from vterm.scheme import SCHEME_FILE_NAME
path = os.path.join(scheme_manager._directory(), SCHEME_FILE_NAME)
if os.path.exists(path):
os.remove(path)
scheme_manager.ensure_file()
assert os.path.exists(path), "ensure_file did not write the scheme"
assert not scheme_manager._initialized, \
"ensure_file must leave the palette open to refinement by initialize()"
# a second call is a no-op: same file, untouched
stamp = os.path.getmtime(path)
scheme_manager.ensure_file()
assert os.path.getmtime(path) == stamp, "ensure_file rewrote an existing scheme"
# _build is idempotent, which is what makes the ensure_file + initialize pair safe
with scheme_manager.lock:
scheme_manager._build()
first = len(scheme_manager.rules)
scheme_manager._build()
assert len(scheme_manager.rules) == first, \
"rules duplicated: {} -> {}".format(first, len(scheme_manager.rules))
print("scheme file tests passed ({} seeded rules, idempotent)".format(first))

420
tests/test_emulator.py Normal file
View File

@@ -0,0 +1,420 @@
""" Offline emulator tests: queries answered, modes tracked, parsing survives
chunk boundaries. Run with: python3 tests/test_emulator.py """
import _bootstrap # sets sys.path for the imports below
from _bootstrap import FakeProcess
from vterm.emulator import VtScreen, VtStream
def make(columns=80, lines=24):
proc = FakeProcess()
screen = VtScreen(columns, lines, process=proc)
stream = VtStream(screen)
return proc, screen, stream
def feed(stream, data, char_by_char=False):
if char_by_char:
for c in data:
stream.feed(c)
else:
stream.feed(data)
def run_case(char_by_char):
tag = "char-by-char" if char_by_char else "chunked"
# DA1 answered (Claude Code's untimed sentinel)
proc, screen, stream = make()
feed(stream, "\x1b[c", char_by_char)
assert proc.written == "\x1b[?62;22c", (tag, proc.written)
# CPR, plain and private form
proc, screen, stream = make()
feed(stream, "\x1b[5;10H\x1b[6n\x1b[?6n", char_by_char)
assert proc.written == "\x1b[5;10R\x1b[?5;10R", (tag, proc.written)
# XTVERSION then DECRQM 2026 (the Claude Code probe sequence)
proc, screen, stream = make()
feed(stream, "\x1b[>0q\x1b[c\x1b[?2026$p\x1b[c", char_by_char)
assert proc.written == (
"\x1bP>|vterm 0.1\x1b\\" "\x1b[?62;22c"
"\x1b[?2026;2$y" "\x1b[?62;22c"), (tag, proc.written)
# DECRQM for DECAWM (7) and DECTCEM (25): both default-on, and the
# wire-number list must answer them as set, not as unrecognized
proc, screen, stream = make()
feed(stream, "\x1b[?7$p\x1b[?25$p\x1b[?2026$p", char_by_char)
assert proc.written == "\x1b[?7;1$y\x1b[?25;1$y\x1b[?2026;2$y", (tag, proc.written)
# kitty probe: push ignored, query answered 0, DA1 fence answered
proc, screen, stream = make()
feed(stream, "\x1b[>7u\x1b[?u\x1b[c\x1b[<u", char_by_char)
assert proc.written == "\x1b[?0u\x1b[?62;22c", (tag, proc.written)
# and nothing leaked to the screen
assert screen.buffer[0][0].data == " ", (tag, screen.buffer[0][0])
# modifyOtherKeys must not corrupt SGR state
proc, screen, stream = make()
feed(stream, "\x1b[>4;2mhello", char_by_char)
assert screen.buffer[0][0].data == "h" and not screen.buffer[0][0].underscore, tag
# OSC 11 color query answered; OSC 0 sets title; OSC 133 dropped
proc, screen, stream = make()
screen.default_bg_color = "1a2b3c"
feed(stream, "\x1b]0;mytitle\x07\x1b]11;?\x07\x1b]133;A\x07x", char_by_char)
assert screen.title == "mytitle", (tag, screen.title)
assert proc.written == "\x1b]11;rgb:1a1a/2b2b/3c3c\x07", (tag, proc.written)
assert screen.buffer[0][0].data == "x", tag
# OSC with ST terminator; a query asked with ST is answered with ST (meli)
proc, screen, stream = make()
screen.default_bg_color = "1a2b3c"
feed(stream, "\x1b]2;st-title\x1b\\\x1b]11;?\x1b\\y", char_by_char)
assert screen.title == "st-title", (tag, screen.title)
assert proc.written == "\x1b]11;rgb:1a1a/2b2b/3c3c\x1b\\", (tag, proc.written)
assert screen.buffer[0][0].data == "y", tag
# OSC 52 clipboard (base64 "hi")
got = []
proc = FakeProcess()
screen = VtScreen(80, 24, process=proc, clipboard_callback=got.append)
stream = VtStream(screen)
feed(stream, "\x1b]52;c;aGk=\x07", char_by_char)
assert got == ["hi"], (tag, got)
# bracketed paste and sync modes tracked
proc, screen, stream = make()
feed(stream, "\x1b[?2004h\x1b[?2026h", char_by_char)
assert screen.bracketed_paste_enabled(), tag
assert screen.in_sync_frame(), tag
feed(stream, "\x1b[?2026l\x1b[?2004l", char_by_char)
assert not screen.in_sync_frame(), tag
assert not screen.bracketed_paste_enabled(), tag
# mouse modes
proc, screen, stream = make()
feed(stream, "\x1b[?1000h\x1b[?1002h\x1b[?1004h\x1b[?1006h", char_by_char)
assert screen.mouse_reporting_enabled(), tag
assert screen.focus_tracking_enabled(), tag
# alt screen switch and restore
proc, screen, stream = make()
feed(stream, "primary\x1b[?1049h\x1b[2Jalt\x1b[?1049l", char_by_char)
row0 = "".join(screen.buffer[0][i].data for i in range(7))
assert row0 == "primary", (tag, row0)
assert not screen.alternate_buffer_mode, tag
# truecolor SGR is quantized into the 256 palette; 256-color as hex; bold tracked
proc, screen, stream = make()
feed(stream, "\x1b[1;38;2;255;0;10mX\x1b[0m\x1b[38;5;196mY", char_by_char)
assert screen.buffer[0][0].fg == "ff0000" and screen.buffer[0][0].bold, tag
assert screen.buffer[0][1].fg == "ff0000", (tag, screen.buffer[0][1].fg)
# SGR 5/25 (blink): ignored, not a crash — Char has no blink field
proc, screen, stream = make()
feed(stream, "\x1b[5;31mX\x1b[25mY", char_by_char)
assert screen.buffer[0][0].fg == "red", (tag, screen.buffer[0][0])
assert screen.buffer[0][1].fg == "red" and screen.buffer[0][1].data == "Y", tag
# DECSTBM + SU/SD don't crash and scroll within margins
proc, screen, stream = make(10, 6)
feed(stream, "\x1b[2;5r\x1b[2S\x1b[1T\x1b[r", char_by_char)
assert screen.margins is None, tag
# DECSCUSR ignored, CSI t ignored, ?996n answered
proc, screen, stream = make()
feed(stream, "\x1b[2 q\x1b[16t\x1b[?996n", char_by_char)
assert proc.written == "\x1b[?997;1n", (tag, proc.written)
# scrollback: lines pushed to history when scrolling at bottom
proc, screen, stream = make(10, 3)
feed(stream, "a\r\nb\r\nc\r\nd", char_by_char)
assert len(screen.history) == 1, (tag, len(screen.history))
assert screen.history[0][0].data == "a", tag
# ED 3 clears history and fires callback
cleared = []
proc = FakeProcess()
screen = VtScreen(10, 3, process=proc, clear_callback=lambda: cleared.append(1))
stream = VtStream(screen)
feed(stream, "a\r\nb\r\nc\r\nd\x1b[3J", char_by_char)
assert len(screen.history) == 0 and cleared, tag
# DCS/APC payloads are consumed, not drawn
proc, screen, stream = make()
feed(stream, "\x1bP1$rpayload\x1b\\Z\x1b_Gapc\x1b\\W", char_by_char)
row0 = "".join(screen.buffer[0][i].data for i in range(2))
assert row0 == "ZW", (tag, row0)
# DEC special graphics: xterm-256color's smacs is ESC(0, so ncurses apps
# (mutt, dialog, less) draw borders with it when the locale is not UTF-8
proc, screen, stream = make(20, 3)
feed(stream, "\x1b(0lqqqk\x1b(Bplain", char_by_char)
row = "".join(screen.buffer[0][x].data for x in range(10))
assert row == "\u250c\u2500\u2500\u2500\u2510plain", (tag, repr(row))
# G1 designation plus SO/SI stays ignored in UTF-8 mode, so a stray 0x0e in
# decoded text cannot silently switch the charset
proc, screen, stream = make(20, 3)
feed(stream, "\x1b)0\x0eq\x0f", char_by_char)
assert screen.buffer[0][0].data == "q", (tag, screen.buffer[0][0].data)
# multi-byte text is unaffected by a graphics designation
proc, screen, stream = make(20, 3)
feed(stream, "\x1b(0\u00e9\u2500", char_by_char)
row = "".join(screen.buffer[0][x].data for x in range(2))
assert row == "\u00e9\u2500", (tag, repr(row))
# shift+enter esc-cr passthrough sanity: ESC CR fed back in draws nothing odd
proc, screen, stream = make()
feed(stream, "\x1b[?25l\x1b[?25h", char_by_char)
assert not screen.cursor.hidden, tag
# DECSCUSR tracked; missing parameter means 0; reset restores the default
proc, screen, stream = make()
feed(stream, "\x1b[2 q", char_by_char)
assert screen.cursor_style == 2, (tag, screen.cursor_style)
feed(stream, "\x1b[ q", char_by_char)
assert screen.cursor_style == 0, (tag, screen.cursor_style)
feed(stream, "\x1b[6 q", char_by_char)
screen.reset()
assert screen.cursor_style == 0, (tag, screen.cursor_style)
# nothing leaked to the screen or the pty
assert screen.buffer[0][0].data == " " and proc.written == "", tag
print("all emulator tests passed ({})".format(tag))
def run_resize_cases():
"""Shrinking must drop cells past the new width, in both buffers: the
segmenter walks a line to its highest key, so leftovers keep rendering."""
proc, screen, stream = make(20, 3)
feed(stream, "ABCDEFGHIJKLMNOPQRST", False)
screen.resize(3, 10)
assert max(screen.buffer[0]) < 10, sorted(screen.buffer[0])
# the buffer parked by the alt screen is trimmed too, or switching back
# brings the stale cells with it
proc, screen, stream = make(20, 3)
feed(stream, "PRIMARYPRIMARYPRIMAR", False)
feed(stream, "\x1b[?1049h", False)
screen.resize(3, 8)
feed(stream, "\x1b[?1049l", False)
assert max(screen.buffer[0]) < 8, sorted(screen.buffer[0])
# mode 2031 is how a live theme change reaches the app
proc, screen, stream = make()
assert not screen.theme_notify_enabled()
feed(stream, "\x1b[?2031h", False)
assert screen.theme_notify_enabled()
print("all resize tests passed")
def run_private_csi_cases():
"""No private CSI may raise: an exception here used to kill the renderer
thread and freeze the tab. aerc probes XTSMGRAPHICS (ESC[?2;1;0S)."""
for final in "ABCDEFGHJKLMPSTXZbcdfghlmnqrstu@":
proc, screen, stream = make()
stream.feed("\x1b[?2;1;0" + final) # multi-parameter private CSI
stream.feed("\x1b[?1" + final) # single-parameter
stream.feed("\x1b[?" + final) # bare
# the ones we do support still work
proc, screen, stream = make()
feed(stream, "\x1b[?1000h\x1b[?1002h\x1b[?1006h", False)
assert screen.mouse_reporting_enabled(), "private h must still set modes"
feed(stream, "\x1b[?1000l\x1b[?1002l", False)
assert not screen.mouse_reporting_enabled(), "private l must still reset modes"
proc, screen, stream = make()
feed(stream, "\x1b[5;10H\x1b[?6n", False)
assert proc.written == "\x1b[?5;10R", proc.written
# ...and an unsupported one is ignored rather than answered or dispatched
proc, screen, stream = make()
feed(stream, "\x1b[?2;1;0S", False)
assert proc.written == "", "XTSMGRAPHICS must be ignored, not answered"
print("all private CSI tests passed")
def run_decscnm_cases():
"""A DECSCNM flash (vim's visual bell) must not outlive itself.
pyte points every line at the blank cell from inside set/reset_mode, so a
line kept a reverse default and painted the columns the app never wrote —
vim skips indentation with cursor-forward — as white blocks.
"""
from vterm.render import segment_buffer_line
# a gutter, then the indent skipped with CSI 8C, then the text: vim's shape
paint = "\x1b[1;1H\x1b[93m 47 \x1b[m\x1b[8C\x1b[96mreturn\x1b[m default"
for order in (paint + "\x1b[?5h\x1b[?5l", "\x1b[?5h\x1b[?5l" + paint):
proc, screen, stream = make(columns=60, lines=5)
feed(stream, order, False)
assert not screen.default_char.reverse, "the flash is over"
assert not screen.buffer[0].default.reverse, "line kept the flash's blank cell"
assert not screen.buffer[0][6].reverse, "untouched column left reverse"
painted = [s for s in segment_buffer_line(screen.buffer[0])
if not s[0].strip() and s[3] == "reverse_default"]
assert not painted, ("whitespace painted reverse", painted)
# while the flash is on, the untouched columns invert too
proc, screen, stream = make(columns=60, lines=5)
feed(stream, paint + "\x1b[?5h", False)
assert screen.buffer[0].default.reverse and screen.buffer[0][6].reverse, \
"DECSCNM must invert the whole screen, not only the written columns"
print("all DECSCNM tests passed")
# everything the two paths must agree on: text, SGR in each form, empty and
# clamped params, cursor moves, private modes, prefixed CSI, DECRQM, OSC,
# scroll regions, controls inside a CSI, a colon subparameter, wide chars
GAUNTLET = (
"plain text\r\n"
"\x1b[1;31mbold red\x1b[m normal \x1b[38;5;190mpalette\x1b[0m\r\n"
"\x1b[38;2;10;200;30mtruecolor\x1b[49m\x1b[m\r\n"
"\x1b[H\x1b[2Kover\twritten\x1b[3;5Hmoved\x1b[999999C clamp\x1b[m\r\n"
"\x1b[?25l\x1b[?1049h\x1b[?25h\x1b[?1049l"
"\x1b[>q\x1b[?2026$p\x1b[5n\x1b[6n"
"\x1b]0;a title\x07\x1b]11;?\x07"
"\x1b[2;5r\x1b[Sscrolled\x1b[T\x1b[r"
"\x1b[38:5:100mcolon\x1b[m\r\n"
"\x1b[2\x073m bel mid-CSI \x1b[3\x18X can cancels\r\n"
"wide: 漢字 \x1b[7m漢\x1b[m\r\n"
"\x1b[m\x1b[;5m\x1b[5;m\x1b[25m params\r\n"
)
def run_fast_path_cases():
"""The feed() fast path must be indistinguishable from the parser."""
def state(screen):
rows = [
tuple(screen.buffer[y][x] for x in range(screen.columns))
for y in range(screen.lines)
]
c = screen.cursor
return (rows, c.x, c.y, c.attrs, set(screen.mode), screen.title)
def parser_only(blob):
"""The blob through the coroutine parser alone: pyte's original feed."""
proc, screen, stream = make(columns=30, lines=8)
import pyte
pyte.Stream.feed(stream, blob)
return proc, screen
ref_proc, ref_screen = parser_only(GAUNTLET)
reference = state(ref_screen)
# chunk sizes that split every sequence somewhere; 1 exercises pure carry
for size in (1, 2, 3, 7, 64, len(GAUNTLET)):
proc, screen, stream = make(columns=30, lines=8)
for i in range(0, len(GAUNTLET), size):
stream.feed(GAUNTLET[i : i + size])
assert state(screen) == reference, "fast path diverged at chunk size {}".format(size)
assert proc.written == ref_proc.written, (size, proc.written, ref_proc.written)
# a param run past the carry bound still parses, via the parser
proc, screen, stream = make()
stream.feed("\x1b[" + "1;" * 40)
stream.feed("31mX")
assert screen.buffer[0][0].fg == "red", screen.buffer[0][0]
# a carried partial does not linger: nothing dispatches until it completes
proc, screen, stream = make()
stream.feed("A\x1b[3")
assert screen.buffer[0][1].data == " ", "partial CSI must not be drawn"
stream.feed("1mB")
assert screen.buffer[0][1].fg == "red" and screen.buffer[0][1].data == "B"
print("all fast-path tests passed")
def run_malformed_csi_cases():
"""A malformed CSI must cost one sequence, not the chunk.
Excess params used to TypeError out of the dispatch; feed_data caught it
and dropped up to 256K of output. Triggered by cat'ing a binary file or a
corrupted stream. The SGR range guards the blink class the same way.
"""
for char_by_char in (False, True): # fast path / parser
for final in (chr(c) for c in range(0x40, 0x7F)):
for params in ("2;3", "0;0;0"):
proc, screen, stream = make(columns=20, lines=6)
feed(stream, "ab\x1b[{}{}cd".format(params, final), char_by_char)
row = "".join(screen.buffer[y][x].data for y in range(6) for x in range(20))
assert "c" in row and "d" in row, \
("output after a malformed CSI was lost", params, final, char_by_char)
for n in range(121):
proc, screen, stream = make(columns=20, lines=6)
feed(stream, "a\x1b[{}mb".format(n), char_by_char)
assert screen.buffer[0][1].data == "b", ("SGR {} ate the chunk".format(n), char_by_char)
print("all malformed-CSI tests passed")
def run_clear_cases():
"""Screen wipe and post-clear history suppression (vterm_clear_screen)."""
proc, screen, stream = make(10, 4)
feed(stream, "aaa\r\nbbb\r\nccc\r\nddd\r\neee", False)
assert len(screen.history) > 0
assert screen.buffer[0][0].data != " "
screen.wipe()
rows = ["".join(screen.buffer[y][x].data for x in range(screen.columns)).strip()
for y in range(screen.lines)]
assert rows == ["", "", "", ""], rows
assert (screen.cursor.x, screen.cursor.y) == (0, 0)
assert screen.dirty == set(range(4)), screen.dirty
# the app's repaint right after a clear must not refill scrollback
screen.history.clear()
feed(stream, "one\r\ntwo\r\nthree\r\nfour\r\nfive", False)
assert len(screen.history) == 0, len(screen.history)
# ... but scrollback works again once the window expires
screen._suppress_history_until = 0
feed(stream, "\r\nsix\r\nseven", False)
assert len(screen.history) > 0, "history suppressed for too long"
# ED 3 is app-driven: it clears scrollback and fires the clear callback,
# and (as in pyte/Terminus) also blanks the screen. Both agents pair it
# with 2J and repaint, so the render side must not wipe or nudge again.
cleared = []
proc = FakeProcess()
screen = VtScreen(10, 4, process=proc, clear_callback=lambda: cleared.append(1))
stream = VtStream(screen)
feed(stream, "gone\r\nalso-gone\r\nmore\r\nlines\r\nx", False)
assert len(screen.history) > 0
feed(stream, "\x1b[3J", False)
assert len(screen.history) == 0 and cleared, (len(screen.history), cleared)
# with erase_scrollback off, ED 3 still blanks the screen but the history
# and the view survive: agents repaint with it and would take both
cleared = []
proc = FakeProcess()
screen = VtScreen(10, 4, process=proc, erase_scrollback=False,
clear_callback=lambda: cleared.append(1))
stream = VtStream(screen)
feed(stream, "kept\r\nalso-kept\r\nmore\r\nlines\r\nx", False)
kept = len(screen.history)
assert kept > 0
del cleared[:] # the screen's own reset() fired one at construction
feed(stream, "\x1b[3J", False)
assert len(screen.history) == kept and not cleared, (len(screen.history), cleared)
assert screen.buffer[0][0].data == " ", screen.buffer[0][0]
print("all clear-screen tests passed")
run_case(char_by_char=False)
run_case(char_by_char=True)
run_clear_cases()
run_resize_cases()
run_private_csi_cases()
run_decscnm_cases()
run_fast_path_cases()
run_malformed_csi_cases()

125
tests/test_imports.py Normal file
View File

@@ -0,0 +1,125 @@
""" Smoke test: every plugin module imports cleanly against the sublime stubs.
Run with: python3 tests/test_imports.py """
import _bootstrap # sets sys.path for the imports below
ROOT = _bootstrap.ROOT
import vterm.utils
import vterm.keys
import vterm.emulator
import vterm.scheme
import vterm.terminal
import vterm.render
import vterm.commands
import vterm.listeners
import vterm.mouse
# keymap and mousemap must parse the way sublime parses them: JSON with
# // line comments
import importlib
import json
import os
import re
import sys
import types
def load_sublime_json(name):
with open(os.path.join(ROOT, name), encoding="utf-8") as f:
return json.loads(re.sub(r"^\s*//.*$", "", f.read(), flags=re.M))
keymap = load_sublime_json("Default.sublime-keymap")
assert len(keymap) > 100
# every printable must be bound, or sublime inserts it into the view and an app
# that does not echo (vim in normal mode) leaves the character behind. Each
# binding's key must also round-trip to that same character: the shift+<letter>
# form silently yields lowercase, which is why uppercase is bound literally.
import string # noqa
from vterm.keys import get_key_code
bound = {}
for entry in keymap:
combo = entry["keys"][0]
if len(combo) == 1 and entry["command"] == "vterm_keypress":
assert combo not in bound, "{} bound twice".format(combo)
bound[combo] = entry["args"]
for char in string.ascii_letters + string.digits + string.punctuation:
assert char in bound, "printable {!r} is not bound".format(char)
produced = get_key_code(**bound[char])
assert produced == char, "{!r} would send {!r}".format(char, produced)
assert get_key_code(key="space") == " ", "space must send a space"
load_sublime_json("Default (OSX).sublime-mousemap")
# scheme manager produces scopes and rules
import vterm.scheme as scheme_module
from vterm.scheme import scheme_manager
scope = scheme_manager.scope("ff0000", "default", "b")
assert scope == "vterm.ff0000.default.b", scope
# every command referenced by the palette and the keymap must exist, and the
# flags commands set must be the ones the renderer reads (a rename here is
# otherwise only caught at runtime)
import vterm.commands as commands
import vterm.render as render
import vterm.terminal as terminal_module
from vterm.terminal import Terminal
def command_class_name(name):
return "".join(part.title() for part in name.split("_")) + "Command"
for entry in load_sublime_json("vterm.sublime-commands") + keymap:
cls = command_class_name(entry["command"])
assert hasattr(commands, cls) or hasattr(render, cls), \
"{} -> missing {}".format(entry["command"], cls)
# every pending_* name mentioned in the modules must be a real Terminal
# attribute, so a renamed flag cannot leave a writer pointing at nothing
flags = {f for f in vars(Terminal(None)) if f.startswith("pending_")}
assert flags == {"pending_clear", "pending_clear_screen"}, flags
for module in (commands, render, terminal_module):
with open(module.__file__, encoding="utf-8") as f:
# (?<!_) skips the private _pending_to_send_string write-queue flag
for name in set(re.findall(r"(?<!_)pending_[a-z_]+", f.read())):
assert name in flags, "{}: {} is not a Terminal attribute".format(
os.path.basename(module.__file__), name)
# a theme is data, so a typo'd colour name would only show up as a silently
# unstyled scope at runtime
from vterm.emulator import ANSI_COLORS
from vterm.themes import THEMES
for name, theme in THEMES.items():
missing = (set(ANSI_COLORS) | {"foreground", "background"}) - set(theme)
assert not missing, "theme {}: missing {}".format(name, sorted(missing))
assert not set(theme) - (set(ANSI_COLORS) | {"foreground", "background"}), name
for key, value in theme.items():
assert re.fullmatch(r"#[0-9a-f]{6}", value), \
"theme {}: {} = {!r}".format(name, key, value)
configured = load_sublime_json("vterm.sublime-settings").get("theme")
assert configured is None or configured in THEMES, configured
# plugin_loaded must create the colour scheme: sublime loads a restored tab's
# color_scheme during startup, before any terminal opens
# import main.py the way sublime does - as a module of the package directory -
# so its relative imports and plugin_loaded body are actually executed
pkg = types.ModuleType("vterm")
pkg.__path__ = [ROOT]
sys.modules["vterm"] = pkg
main = importlib.import_module("vterm.main")
scheme_file = os.path.join(main.scheme_manager._directory(), scheme_module.SCHEME_FILE_NAME)
if os.path.exists(scheme_file):
os.remove(scheme_file)
main.plugin_loaded()
assert os.path.exists(scheme_file), \
"plugin_loaded must leave the colour scheme on disk"
print("import smoke test passed ({} keymap bindings, {} pending flags, "
"{} themes)".format(len(keymap), len(flags), len(THEMES)))

509
tests/test_render.py Normal file
View File

@@ -0,0 +1,509 @@
""" Render-layer tests against a fake view.
The live harness exercises the emulator only, so regressions in render.py used
to reach Sublime unnoticed. Run with: python3 tests/test_render.py
"""
import _bootstrap # sets sys.path for the imports below
from _bootstrap import FakeProcess
import sublime
from vterm.emulator import VtScreen, VtStream
from vterm.mouse import VtermMouseClickCommand, VtermScrollCommand
from vterm.render import (
CHUNK,
VtermRenderCommand,
focus_cursor,
)
from vterm.terminal import Terminal
class FakeSelection(list):
def clear(self):
del self[:]
def add(self, region):
self.append(region)
class FakeView:
"""Enough of the view API for the trim paths, with real line arithmetic."""
def __init__(self, text=""):
self.text = text
self.selection = FakeSelection()
self.regions = {}
self.commands = []
self.folds = []
self._name = ""
def size(self):
return len(self.text)
def rowcol(self, point):
head = self.text[:point]
row = head.count("\n")
return (row, point - (head.rfind("\n") + 1))
def text_point(self, row, col):
lines = self.text.split("\n")
start = sum(len(l) + 1 for l in lines[:row])
return start + min(col, len(lines[row]) if row < len(lines) else 0)
def line(self, point):
start = self.text.rfind("\n", 0, point) + 1
end = self.text.find("\n", point)
return sublime.Region(start, len(self.text) if end == -1 else end)
def full_line(self, point):
region = self.line(point)
return sublime.Region(region.begin(),
min(region.end() + 1, len(self.text)))
def lines(self, region):
out = []
point = region.begin()
while point <= region.end():
line = self.line(point)
out.append(line)
point = line.end() + 1
return out
def substr(self, region):
return self.text[region.begin():region.end()]
def erase(self, edit, region):
self.text = self.text[:region.begin()] + self.text[region.end():]
def replace(self, edit, region, value):
self.text = self.text[:region.begin()] + value + self.text[region.end():]
# sublime maps a caret inside the replaced span onto the new text,
# which is where the artefact selection came from
for i, sel in enumerate(list(self.selection)):
if sel.empty() and region.begin() <= sel.begin() <= region.end():
self.selection[i] = sublime.Region(region.begin(),
region.begin() + len(value))
def insert(self, edit, point, value):
self.text = self.text[:point] + value + self.text[point:]
return len(value)
def fold(self, region):
self.folds.append((region.begin(), region.end()))
return True
def unfold(self, region):
unfolded = self.folds
self.folds = []
return unfolded
def folded_regions(self):
return [sublime.Region(b, e) for b, e in self.folds]
def erase_regions(self, key):
self.regions.pop(key, None)
def add_regions(self, key, regions, scope):
self.regions[key] = (scope, [(r.begin(), r.end()) for r in regions])
def get_regions(self, key):
_, pairs = self.regions.get(key, ("", []))
return [sublime.Region(a, b) for a, b in pairs]
def settings(self):
return sublime.load_settings("fake")
def viewport_position(self):
return (0, 0)
def line_height(self):
return 20
def name(self):
return self._name
def set_name(self, name):
self._name = name
def run_command(self, name, args=None):
self.commands.append(name)
def sel(self):
return self.selection
def make_terminal(rows=10, cols=20, scrollback=None):
# real __init__ for the plain attributes; process/screen stand in for start()
terminal = Terminal(FakeView())
if scrollback is not None:
terminal.scrollback_size = scrollback
terminal.process = FakeProcess()
terminal.screen = VtScreen(cols, rows, process=terminal.process)
return terminal, VtStream(terminal.screen)
def make_command(view):
# the real __init__, so the fake cannot drift from the command's attributes
return VtermRenderCommand(view)
# an app on the alternate screen must not have its view trimmed away: the
# emulator's history deque is maxlen=0 there, so it cannot be the row cap
terminal, stream = make_terminal(rows=10)
stream.feed("\x1b[?1049h")
assert terminal.screen.alternate_buffer_mode
assert terminal.screen.history.maxlen == 0, "alt screen zeroes the deque"
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(10)))
command = make_command(terminal.view)
before = terminal.view.text
command.trim_history(None, terminal)
assert terminal.view.text == before, \
"alt screen view was trimmed: {!r}".format(terminal.view.text)
# the configured cap still applies on the primary screen
terminal, stream = make_terminal(rows=5, scrollback=20)
terminal.offset = 40
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(40)))
command = make_command(terminal.view)
command.trim_history(None, terminal)
rows_left = terminal.view.text.count("\n") + 1
assert rows_left <= 20, rows_left
assert "row 39" in terminal.view.text, "newest rows must survive"
assert "row 0" not in terminal.view.text, "oldest rows must be dropped"
# below the cap nothing is dropped
terminal, stream = make_terminal(rows=5, scrollback=5000)
terminal.offset = 3
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(8)))
command = make_command(terminal.view)
command.trim_history(None, terminal)
assert "row 0" in terminal.view.text and "row 7" in terminal.view.text, \
terminal.view.text
print("render tests passed (alt screen keeps its view, cap honoured)")
# a selection the user made must survive app output: focus_cursor runs on every
# frame, and clearing it there made click-drag selection impossible to hold
terminal, stream = make_terminal(rows=5, cols=20)
terminal.view = FakeView("hello world\nsecond line\n")
stream.feed("x")
selection = terminal.view.sel()
selection.add(sublime.Region(0, 5)) # user selected "hello"
focus_cursor(terminal.view, None, terminal)
assert len(selection) == 1 and selection[0].begin() == 0 and selection[0].end() == 5, \
"user selection was clobbered by a render"
# an empty selection (a plain click, or none at all) still gets the caret placed
# at the terminal cursor
selection.clear()
focus_cursor(terminal.view, None, terminal)
assert len(selection) == 1 and selection[0].empty(), \
"caret must be placed when nothing is selected"
print("selection tests passed (user selection survives renders, caret still tracked)")
# an app that hides the cursor (pi hides it for good) still needs the caret:
# sublime drops a space keypress in a view that has no caret at all
selection.clear()
stream.feed("\x1b[?25l")
focus_cursor(terminal.view, None, terminal)
assert len(selection) == 1 and selection[0].empty(), \
"caret must survive DECTCEM, or the space key stops working"
print("cursor visibility tests passed (hidden cursor keeps the caret)")
# colorize_line positions regions by arithmetic from the line start now, and
# batches the runs that share a scope into one add_regions call
terminal, stream = make_terminal(rows=3, cols=20)
terminal.view = FakeView()
command = make_command(terminal.view)
stream.feed("\x1b[31mRED\x1b[0m..\x1b[31mred\x1b[0m..\x1b[32mgreen")
command.update_lines(None, terminal)
painted = terminal.view.regions
scopes = sorted(scope for scope, _ in painted.values())
assert scopes == ["vterm.green.default", "vterm.red.default"], scopes
red = next(spans for scope, spans in painted.values() if scope.endswith("red.default"))
assert red == [(0, 3), (5, 8)], "two red runs must share one key: {}".format(red)
green = next(spans for scope, spans in painted.values() if scope.endswith("green.default"))
assert green == [(10, 15)], green
assert terminal.view.text.startswith("RED..red..green"), repr(terminal.view.text)
# the default-on-default run between them gets no region at all
assert len(painted) == 2, painted
# keys are unique per view and recorded so decolorize_line can erase them
assert sorted(terminal.view.regions) == sorted(command.colored_lines[0]), \
"colored_lines must track exactly the keys that were added"
command.decolorize_line(0)
assert terminal.view.regions == {}, "decolorize must erase every key it added"
print("colorize tests passed (arithmetic offsets, scope batching, key bookkeeping)")
# rows that leave the screen have their colours merged onto shared per-(scope,
# chunk) keys once the chunk closes; per-row keys would otherwise accumulate for
# the whole session, and merging every frame would re-send the whole chunk
terminal, stream = make_terminal(rows=3, cols=20)
terminal.view = FakeView()
command = make_command(terminal.view)
for i in range(300):
stream.feed("\x1b[31mred{}\x1b[0m\r\n".format(i))
command.update_lines(None, terminal)
terminal.screen.dirty.clear()
regions = terminal.view.regions
chunk = "vterm@0@vterm.red.default"
assert chunk in regions, sorted(regions)
_, spans = regions[chunk]
assert len(spans) == CHUNK, "the closed chunk merges its rows: {}".format(len(spans))
assert "vterm@1@vterm.red.default" not in regions, "an open chunk must not be sent yet"
row_keys = [k for k in regions if k.startswith("vterm#")]
assert len(row_keys) == 300 - CHUNK, \
"merged rows drop their per-row keys, the open chunk keeps them: {}".format(len(row_keys))
# trimming settles the open chunk, then erases the keys of chunks above the cut
terminal, stream = make_terminal(rows=3, scrollback=30)
terminal.view = FakeView()
command = make_command(terminal.view)
stream.feed("".join("\x1b[31mr{}\x1b[0m\r\n".format(i) for i in range(300)))
command.update_lines(None, terminal)
terminal.screen.dirty.clear()
assert set(command._archived_chunks) == {0}, command._archived_chunks
command.trim_history(None, terminal)
assert "vterm@0@vterm.red.default" not in terminal.view.regions, "trimmed chunk key left behind"
assert 0 not in command._archived_chunks
assert "vterm@1@vterm.red.default" in terminal.view.regions, "boundary chunk must survive"
print("scrollback archive tests passed (chunk keys bounded, trim erases whole chunks)")
# a fresh terminal must not be left with the prompt selected: our own replace
# expands the caret across the inserted text, and sublime paints that with the
# selection colour (its default is pure blue)
terminal, stream = make_terminal(rows=3, cols=20)
terminal.view = FakeView()
terminal.view.sel().add(sublime.Region(0, 0)) # the caret of a new view
Terminal._terminals[id(terminal)] = terminal
terminal.view.id = lambda: id(terminal)
terminal.default_title = "zsh"
command = make_command(terminal.view)
stream.feed("[vterm] %")
command.run(None)
assert all(r.empty() for r in terminal.view.sel()), \
"prompt was left selected: {}".format([(r.begin(), r.end()) for r in terminal.view.sel()])
# but a selection the user already had is still respected
terminal.view.sel().clear()
terminal.view.sel().add(sublime.Region(2, 8))
stream.feed("\x1b[2Kmore output")
command.run(None)
kept = [(r.begin(), r.end()) for r in terminal.view.sel()]
assert any(b != e for b, e in kept), "user selection was dropped: {}".format(kept)
# pyte's dirty is a repaint hint: rows whose content did not change must not
# be rewritten (each rewrite is a cross-process call and an on_modified)
rewritten = []
_replace = terminal.view.replace
terminal.view.replace = lambda edit, region, value: (rewritten.append(value), _replace(edit, region, value))[1]
terminal.screen.dirty.update(range(terminal.screen.lines))
command.run(None)
terminal.view.replace = _replace
# blank tail rows still churn via trim_trailing_spaces; content rows must not
assert all(text == "" for text in rewritten), "unchanged rows were rewritten: {}".format(rewritten)
print("render diff tests passed (unchanged dirty rows skipped)")
# the generated scheme must define a selection colour, or sublime uses blue
from vterm.scheme import blend, scheme_manager
scheme_manager.background = "#303841"
scheme_manager.foreground = "#d8dee9"
assert blend(scheme_manager.background, scheme_manager.foreground, 0.3) == "#626a73"
print("selection artefact tests passed (fresh prompt unselected, user selection kept)")
# DECSCUSR drives the caret shape through view settings; before any app asks,
# and for 0, the caret is the terminal default: a blinking block
terminal, stream = make_terminal(rows=3, cols=20)
command = make_command(terminal.view)
settings = terminal.view.settings()
command.sync_cursor_style(terminal)
assert settings.get("block_caret") is True, settings.get("block_caret")
assert settings.get("caret_style") == "blink", settings.get("caret_style")
stream.feed("\x1b[2 q")
command.sync_cursor_style(terminal)
assert settings.get("block_caret") is True, settings.get("block_caret")
assert settings.get("caret_style") == "solid", settings.get("caret_style")
stream.feed("\x1b[5 q")
command.sync_cursor_style(terminal)
assert settings.get("block_caret") is False, settings.get("block_caret")
assert settings.get("caret_style") == "blink", settings.get("caret_style")
stream.feed("\x1b[0 q")
command.sync_cursor_style(terminal)
assert settings.get("block_caret") is True, settings.get("block_caret")
assert settings.get("caret_style") == "blink", settings.get("caret_style")
# a hidden cursor outranks DECSCUSR: the caret stays for typing, as a block on
# the cell the app draws its own cursor over
stream.feed("\x1b[5 q\x1b[?25l")
command.sync_cursor_style(terminal)
assert settings.get("block_caret") is True, settings.get("block_caret")
assert settings.get("caret_style") == "solid", settings.get("caret_style")
stream.feed("\x1b[?25h")
command.sync_cursor_style(terminal)
assert settings.get("block_caret") is False, settings.get("block_caret")
assert settings.get("caret_style") == "blink", settings.get("caret_style")
print("cursor style tests passed (DECSCUSR maps to caret settings, 0 is the default block)")
# wheel over an alt-screen app that does not track the mouse must fall back to
# arrow keys (alternate scroll); with tracking on, SGR reports as before
terminal, stream = make_terminal(rows=5, cols=20)
Terminal._terminals[id(terminal)] = terminal
terminal.view.id = lambda: id(terminal)
scroll = VtermScrollCommand.__new__(VtermScrollCommand)
scroll.view = terminal.view
stream.feed("\x1b[?1049h")
scroll.run(None, up=True)
assert terminal.process.written == "\x1b[A" * 3, repr(terminal.process.written)
# application cursor mode switches the arrow encoding
terminal.process.written = ""
stream.feed("\x1b[?1h")
scroll.run(None, up=False)
assert terminal.process.written == "\x1bOB" * 3, repr(terminal.process.written)
# with mouse tracking on, the wheel is reported, not translated: one report per
# line, aimed mid-screen when there is no event to place it
terminal.process.written = ""
stream.feed("\x1b[?1002h\x1b[?1006h")
scroll.run(None, up=True)
assert terminal.process.writes == ["\x1b[<64;1;3M"] * 3, terminal.process.writes
# on the primary screen without tracking the command stays inert
terminal.process.written = ""
stream.feed("\x1b[?1002l\x1b[?1006l\x1b[?1049l")
scroll.run(None, up=True)
assert terminal.process.written == "", repr(terminal.process.written)
print("alternate scroll tests passed (3 lines per tick: arrows on alt screen, SGR when tracking, inert otherwise)")
# a click in an unfocused group must focus the tab instead of reporting: we
# replaced drag_select, which is what sublime focuses the group from
class FakeWindow:
def __init__(self, active):
self.active = active
def active_view(self):
return self.active
def focus_view(self, view):
self.active = view
terminal, stream = make_terminal(rows=5, cols=20)
Terminal._terminals[id(terminal)] = terminal
terminal.view.id = lambda: id(terminal)
terminal.view.window_to_text = lambda xy: 0
window = FakeWindow(active=object())
terminal.view.window = lambda: window
stream.feed("\x1b[?1002h\x1b[?1006h")
click = VtermMouseClickCommand.__new__(VtermMouseClickCommand)
click.view = terminal.view
event = {"x": 0, "y": 0, "button": 1}
click.run(None, event)
assert window.active_view() is terminal.view, "click did not focus the tab"
assert terminal.process.written == "", repr(terminal.process.written)
# once focused, the same click reports as before
click.run(None, event)
assert terminal.process.written == "\x1b[<0;1;1M\x1b[<0;1;1m", repr(terminal.process.written)
print("click focus tests passed (first click focuses only, then reports)")
# entering the alt screen folds the scrollback away (kitty hides it); leaving
# unfolds it, and the fold resets the follow anchor so the gate re-engages.
# the fold ends one character before the screen's first row, out of reach of
# the per-frame replaces of that row
terminal, stream = make_terminal(rows=5, cols=20)
terminal.view = FakeView("old1\nold2\nold3\nr0\nr1\nr2\nr3\nr4\n")
terminal.offset = 3
terminal.viewport_y = 400
command = make_command(terminal.view)
fold_end = terminal.view.text_point(3, 0) - 1
terminal.view.settings().set("scroll_past_end", True)
stream.feed("\x1b[?1049h")
command.sync_alt_fold(terminal)
assert terminal.view.folds == [(0, fold_end)], terminal.view.folds
assert terminal.viewport_y == 0, terminal.viewport_y
assert terminal.view.settings().get("scroll_past_end") is False, "alt screen must not be scrollable"
# a second render leaves the fold alone
command.sync_alt_fold(terminal)
assert terminal.view.folds == [(0, fold_end)], terminal.view.folds
# a disturbed fold is re-asserted on the next render
terminal.view.folds = [(0, fold_end - 2)]
command.sync_alt_fold(terminal)
assert terminal.view.folds == [(0, fold_end)], terminal.view.folds
stream.feed("\x1b[?1049l")
command.sync_alt_fold(terminal)
assert terminal.view.folds == [], terminal.view.folds
assert terminal.view.settings().get("scroll_past_end") is True, "primary screen scrolls past end again"
# without scrollback there is nothing to fold, but the scroll lock still applies
terminal, stream = make_terminal(rows=5, cols=20)
terminal.view = FakeView("r0\nr1\nr2\nr3\nr4\n")
command = make_command(terminal.view)
stream.feed("\x1b[?1049h")
command.sync_alt_fold(terminal)
assert terminal.view.folds == [], terminal.view.folds
assert terminal.view.settings().get("scroll_past_end") is False, "alt screen must not be scrollable"
print("alt fold tests passed (scrollback folded on entry, restored on exit)")
# a row whose first cells were never written: they read back as default cells
# (StaticDefaultDict), so they form their own uncoloured run and the coloured
# run that follows keeps the right column
from pyte.screens import StaticDefaultDict
from vterm.emulator import Char
from vterm.render import segment_buffer_line
line = StaticDefaultDict(Char(" "))
line[3] = Char("X", "red", "default")
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
assert runs == [(" ", 0, 3, "default", "default", ""),
("X", 3, 4, "red", "default", "")], runs
# wide characters consume two columns but one character of text
line = StaticDefaultDict(Char(" "))
line[0] = Char("漢", "red", "default")
line[1] = Char("", "red", "default")
line[2] = Char("x", "blue", "default")
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
assert runs == [("漢", 0, 1, "red", "default", ""), ("x", 1, 2, "blue", "default", "")], runs
# reverse video swaps the pair, and default becomes reverse_default
line = StaticDefaultDict(Char(" "))
line[0] = Char("r", "red", "default", False, False, False, False, True)
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
assert runs == [("r", 0, 1, "reverse_default", "red", "")], runs
print("segmenter tests passed (leading gap, wide char, reverse)")

69
tools/_micro_draw.py Normal file
View File

@@ -0,0 +1,69 @@
"""Micro-bench the per-char steps of VtScreen.draw."""
import os
import time
import sys
ST_LIB = os.path.expanduser("~/Library/Application Support/Sublime Text/Lib/python38")
sys.path.insert(0, ST_LIB)
sys.path.insert(0, "tests/stubs")
from pyte.screens import wcwidth as pyte_wcwidth # lru_cache'd
from wcwidth import wcwidth as raw_wcwidth
from functools import lru_cache
data = ("x" * 50 + "\x1b[38;5;100m") * 40000 # ~1.2M printable chars
def t(label, fn, n=3):
best = 1e9
for _ in range(n):
t0 = time.perf_counter()
fn()
best = min(best, time.perf_counter() - t0)
print("{:<42} {:8.3f} ms".format(label, best * 1000))
def loop_wcwidth_pyte():
for c in data:
w = pyte_wcwidth(c)
def loop_wcwidth_raw():
for c in data:
w = raw_wcwidth(c)
def loop_iter_only():
for c in data:
pass
def loop_min():
x = 0
for c in data:
x = min(x + 1, 80)
def loop_dict_get_set():
d = {}
line = {}
for i, c in enumerate(data):
v = d.get(c)
if v is None:
d[c] = c
line[i % 80] = c
def loop_min_vs_cmp():
x = 0
cols = 80
for c in data:
nx = x + 1
x = cols if nx > cols else nx
t("iter only", loop_iter_only)
t("wcwidth (pyte lru_cache)", loop_wcwidth_pyte)
t("wcwidth (raw)", loop_wcwidth_raw)
t("min(x+w, cols) per char", loop_min)
t("min -> branch per char", loop_min_vs_cmp)
t("dict get/miss/set per char", loop_dict_get_set)

50
tools/_prof_feed.py Normal file
View File

@@ -0,0 +1,50 @@
"""One-shot profile of the feed/parse path, to rank hotspots."""
import cProfile
import pstats
import sys
import time
sys.path.insert(0, "tools")
sys.path.insert(0, ".")
from tools.bench_vs_terminus import colour_flood, plain_flood, tui_repaint, _bootstrap # noqa
from vterm.emulator import VtScreen, VtStream
from tests._bootstrap import FakeProcess # noqa
def build():
screen = VtScreen(100, 40, process=FakeProcess(), history=10000)
return VtStream(screen)
def run(name, data):
stream = build()
t0 = time.perf_counter()
for i in range(0, len(data), 32768):
stream.feed(data[i:i + 32768])
dt = (time.perf_counter() - t0) * 1000
print("{:<20} {:.1f} ms".format(name, dt))
def profiled(name, data):
stream = build()
pr = cProfile.Profile()
pr.enable()
for i in range(0, len(data), 32768):
stream.feed(data[i:i + 32768])
pr.disable()
s = pstats.Stats(pr)
print("\n==== {} ====".format(name))
s.sort_stats("cumulative").print_stats(18)
if __name__ == "__main__":
floods = {
"colour": colour_flood(),
"plain": plain_flood(),
"tui": tui_repaint(),
}
for n, d in floods.items():
run(n, d)
profiled("colour", colour_flood())
profiled("tui", tui_repaint())

436
tools/bench_vs_terminus.py Normal file
View File

@@ -0,0 +1,436 @@
""" Render-cost comparison between vterm and Terminus
Both plugins turn pty output into view text plus add_regions overlays, so what
they cost the editor is the number of Sublime calls a frame makes -- the plugin
host is a separate process (see Architecture.md). This drives both render
commands against a fake view that counts every call, on the same input, with
each plugin's own render gate deciding when a frame happens.
Terminus is read straight out of its .sublime-package by zipimport; it is
skipped when not installed. Parse times are real, call counts stand in for the
cross-process cost, which a fake view cannot show.
Run with: python3 tools/bench_vs_terminus.py
"""
import collections
import os
import sys
import time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "tests"))
import _bootstrap # noqa: F401 sets sys.path for the imports below
TERMINUS = os.path.expanduser(
"~/Library/Application Support/Sublime Text/Installed Packages/Terminus.sublime-package")
import sublime
ROWS, COLUMNS = 40, 100
SCROLLBACK = 10000
class FakeSelection(list):
def clear(self):
del self[:]
def add(self, region):
self.append(region)
class CountingView:
"""A view that answers with real line arithmetic and tallies every call."""
_ids = [0]
def __init__(self, counter):
CountingView._ids[0] += 1
self._id = CountingView._ids[0]
self.c = counter
self.text = ""
self.selection = FakeSelection()
self.regions = {}
self.folds = []
self._name = ""
self._settings = sublime.Settings()
# so a plugin that renders through a second command has it counted too
self.commands = {}
def _t(self, name):
self.c[name] += 1
def id(self):
return self._id
def size(self):
self._t("size")
return len(self.text)
def rowcol(self, point):
self._t("rowcol")
head = self.text[:point]
return (head.count("\n"), point - (head.rfind("\n") + 1))
def text_point(self, row, col):
self._t("text_point")
lines = self.text.split("\n")
start = sum(len(l) + 1 for l in lines[:row])
return start + min(col, len(lines[row]) if row < len(lines) else 0)
def _line(self, point):
start = self.text.rfind("\n", 0, point) + 1
end = self.text.find("\n", point)
return sublime.Region(start, len(self.text) if end == -1 else end)
def line(self, point):
self._t("line")
return self._line(point)
def full_line(self, point):
self._t("full_line")
region = self._line(point)
return sublime.Region(region.begin(), min(region.end() + 1, len(self.text)))
def lines(self, region):
self._t("lines")
out = []
point = region.begin()
while point <= region.end():
line = self._line(point)
out.append(line)
point = line.end() + 1
return out
def substr(self, region):
self._t("substr")
return self.text[region.begin():region.end()]
def erase(self, edit, region):
self._t("erase")
self.text = self.text[:region.begin()] + self.text[region.end():]
def replace(self, edit, region, value):
self._t("replace")
self.text = self.text[:region.begin()] + value + self.text[region.end():]
def insert(self, edit, point, value):
self._t("insert")
self.text = self.text[:point] + value + self.text[point:]
return len(value)
def fold(self, region):
self._t("fold")
self.folds.append((region.begin(), region.end()))
return True
def unfold(self, region):
self._t("unfold")
folded, self.folds = self.folds, []
return folded
def folded_regions(self):
self._t("folded_regions")
return [sublime.Region(a, b) for a, b in self.folds]
def add_regions(self, key, regions, scope="", *args, **kwargs):
self._t("add_regions")
# what crosses the process boundary is the regions, not just the call
self.c["regions.sent"] += len(regions)
self.c["regions.sent.merged" if "@" in key else "regions.sent.row"] += len(regions)
self.regions[key] = (scope, [(r.begin(), r.end()) for r in regions])
def get_regions(self, key):
self._t("get_regions")
_, spans = self.regions.get(key, ("", []))
self.c["regions.read"] += len(spans)
return [sublime.Region(a, b) for a, b in spans]
def erase_regions(self, key):
self._t("erase_regions")
self.regions.pop(key, None)
def settings(self):
self._t("settings")
return CountingSettings(self._settings, self.c)
def viewport_position(self):
self._t("viewport_position")
return (0, 0)
def viewport_extent(self):
self._t("viewport_extent")
return (800, ROWS * 20)
def layout_extent(self):
self._t("layout_extent")
return (800, len(self.text))
def text_to_layout(self, point):
self._t("text_to_layout")
return (0, point)
def set_viewport_position(self, position, animate=True):
self._t("set_viewport_position")
def line_height(self):
self._t("line_height")
return 20
def em_width(self):
self._t("em_width")
return 8
def name(self):
self._t("name")
return self._name
def set_name(self, name):
self._t("set_name")
self._name = name
def window(self):
self._t("window")
return object()
def sel(self):
self._t("sel")
return self.selection
def run_command(self, name, args=None):
self._t("run_command:" + name)
command = self.commands.get(name)
if command:
command.run(None, **(args or {}))
class CountingSettings:
"""Settings reads and writes are round trips too, and Terminus does one per run."""
def __init__(self, settings, counter):
self._settings = settings
self._c = counter
def get(self, key, default=None):
self._c["settings.get"] += 1
return self._settings.get(key, default)
def set(self, key, value):
self._c["settings.set"] += 1
self._settings[key] = value
def erase(self, key):
self._c["settings.erase"] += 1
self._settings.pop(key, None)
def has(self, key):
return key in self._settings
def add_on_change(self, *args):
pass
def clear_on_change(self, *args):
pass
class FakeProcess:
def __init__(self):
self.writes = []
def write(self, data):
self.writes.append(data)
# ------------------------------------------------------------------ workloads
def colour_flood(lines=2000, runs=6, palette=200):
"""Coloured output: `runs` SGR changes per line, drawn from `palette` colours."""
width = COLUMNS // runs
out = []
for i in range(lines):
parts = []
for r in range(runs):
parts.append("\x1b[38;5;{}m".format(16 + (i * runs + r) % palette))
parts.append(str((i + r) % 10) * width)
parts.append("\x1b[0m\r\n")
out.append("".join(parts))
return "".join(out)
def plain_flood(lines=2000):
return "".join("{:04d} ".format(i) + "x" * (COLUMNS - 5) + "\r\n" for i in range(lines))
def tui_repaint(frames=60):
"""A full-screen coloured app redrawing itself; one cell differs per frame."""
out = []
for f in range(frames):
parts = ["\x1b[H"]
for row in range(ROWS):
for r in range(4):
parts.append("\x1b[38;5;{}m".format(16 + (row * 4 + r) % 200))
parts.append(str((row + r) % 10) * (COLUMNS // 4))
parts.append("\x1b[0m")
if row < ROWS - 1:
parts.append("\r\n")
parts.append("\x1b[1;1H\x1b[38;5;99m{:04d}\x1b[0m".format(f))
out.append("".join(parts))
return "".join(out)
# ------------------------------------------------------------------- drivers
def drive(feed, need_to_render, render, data, tick):
"""Feed `data` in tick-sized reads, rendering whenever the plugin asks to."""
parse = 0.0
frames = 0
for i in range(0, len(data), tick):
start = time.perf_counter()
feed(data[i:i + tick])
parse += time.perf_counter() - start
if need_to_render():
render()
frames += 1
return parse, frames
def run_vterm(data, tick):
from vterm.emulator import VtScreen, VtStream
from vterm.render import VtermRenderCommand
from vterm.terminal import Terminal
counter = collections.Counter()
view = CountingView(counter)
terminal = Terminal(view)
terminal.scrollback_size = SCROLLBACK
terminal.process = FakeProcess()
terminal.screen = VtScreen(COLUMNS, ROWS, process=terminal.process, history=SCROLLBACK)
Terminal._terminals[view.id()] = terminal
stream = VtStream(terminal.screen)
command = VtermRenderCommand(view)
def render():
counter["run_command:vterm_render"] += 1
command.run(None)
try:
parse, frames = drive(stream.feed, terminal._need_to_render, render, data, tick)
finally:
del Terminal._terminals[view.id()]
return counter, parse, frames, len(view.regions)
def run_terminus(data, tick):
from terminus.ptty import TerminalScreen, TerminalStream
from terminus.render import TerminusRenderCommand, TerminusShowCursorCommand
from terminus.terminal import Terminal
counter = collections.Counter()
view = CountingView(counter)
terminal = Terminal(view)
terminal.detached = False
terminal.offset = 0
terminal.title = None
terminal.default_title = "Terminus"
terminal.screen = TerminalScreen(
COLUMNS, ROWS, process=FakeProcess(), clear_callback=lambda: None,
reset_callback=lambda: None, history=SCROLLBACK)
Terminal._terminals[view.id()] = terminal
stream = TerminalStream(terminal.screen)
command = TerminusRenderCommand(view)
command.scrollback_history_size = SCROLLBACK
view.commands["terminus_show_cursor"] = TerminusShowCursorCommand(view)
def render():
counter["run_command:terminus_render"] += 1
command.run(None)
# terminus clears dirty in its render loop, not in the command
terminal.screen.dirty.clear()
try:
parse, frames = drive(stream.feed, terminal._need_to_render, render, data, tick)
finally:
del Terminal._terminals[view.id()]
return counter, parse, frames, len(view.regions)
# -------------------------------------------------------------------- report
def measure(runner, data, tick):
counter, parse, frames, keys = runner(data, tick)
calls = sum(v for k, v in counter.items() if not k.startswith("regions."))
return {
"parse": parse * 1000,
"frames": frames,
"calls": calls,
"per_frame": calls / max(frames, 1),
"sent": counter["regions.sent"],
"sent_row": counter["regions.sent.row"],
"sent_merged": counter["regions.sent.merged"],
"read": counter["regions.read"],
"keys": keys,
"counter": counter,
}
ROW_FORMAT = " {:<26}{:>14}{:>14}{:>10}"
def report(title, data, tick, both):
print("=" * 66)
print("{}\n {:.0f} KiB fed in {} B reads".format(title, len(data) / 1024, tick))
print("=" * 66)
ours = measure(run_vterm, data, tick)
theirs = measure(run_terminus, data, tick) if both else None
def line(label, key, fmt="{:.0f}"):
mine = fmt.format(ours[key])
if not theirs:
print(ROW_FORMAT.format(label, mine, "-", "-"))
return
other = fmt.format(theirs[key])
ratio = theirs[key] / ours[key] if ours[key] else 0
print(ROW_FORMAT.format(label, mine, other, "{:.1f}x".format(ratio)))
print(ROW_FORMAT.format("", "vterm", "Terminus", "ratio"))
line("parse ms", "parse", "{:.1f}")
line("frames", "frames")
line("sublime calls", "calls")
line("calls / frame", "per_frame", "{:.1f}")
line("regions sent", "sent")
line(" of them merges", "sent_merged")
line("regions read back", "read")
line("live region keys", "keys")
top = sorted(((v, k) for k, v in ours["counter"].items() if not k.startswith("regions.")),
reverse=True)[:6]
print(" vterm top: " + ", ".join("{}={}".format(k, v) for v, k in top))
if theirs:
top = sorted(((v, k) for k, v in theirs["counter"].items() if not k.startswith("regions.")),
reverse=True)[:6]
print(" Terminus top: " + ", ".join("{}={}".format(k, v) for v, k in top))
print()
def main():
both = os.path.exists(TERMINUS)
if both:
sys.path.insert(0, TERMINUS)
else:
print("Terminus is not installed; reporting vterm alone.\n")
burst = 32 * 1024 # what a 30 ms tick collects during a flood
trickle = 512 # a few rows a frame, as a build log arrives
report("A. colour flood, 6 runs/line, 200 colours",
colour_flood(), burst, both)
report("B. colour flood, 6 runs/line, 8 colours",
colour_flood(palette=8), burst, both)
report("C. colour trickle, a few rows a frame",
colour_flood(lines=600), trickle, both)
report("D. plain flood, no colour",
plain_flood(), burst, both)
report("E. TUI repaint, 60 full-screen frames, one cell changes",
tui_repaint(), burst, both)
if __name__ == "__main__":
main()

87
tools/gen_keymap.py Normal file
View File

@@ -0,0 +1,87 @@
""" Generate Default.sublime-keymap """
import json
import pathlib
import string
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from vterm.keys import _MODIFIABLE_CSI
# "setting.x" is evaluated natively by sublime; a plugin on_query_context would
# cost an IPC round trip on every keystroke in every view
CTX = [{"key": "setting.vterm_view"}]
ESCAPE_CTX = [
{"key": "setting.vterm_view"},
{"key": "setting.vterm_view.finished", "operator": "not_equal", "operand": True},
{"key": "overlay_visible", "operator": "equal", "operand": False},
{"key": "popup_visible", "operator": "equal", "operand": False},
]
# derived from keys.py so a key added there cannot silently lack a binding
NAV_KEYS = list(_MODIFIABLE_CSI)
MODS = ["ctrl", "alt", "shift", "ctrl+shift", "alt+shift", "ctrl+alt"]
def binding(keys, key, context=None, **flags):
args = {"key": key}
args.update(flags)
return {
"keys": [keys],
"command": "vterm_keypress",
"args": args,
"context": context or CTX,
}
def mod_flags(mod):
return {m: True for m in mod.split("+")}
entries = []
# printable characters: bound like any other key so nothing is ever inserted into
# the view. Uppercase must be a literal ("A", not shift+a): get_key_code returns
# lowercase for the shift+ form. on_modified still covers IME and composed input.
for c in string.ascii_lowercase + string.ascii_uppercase + string.digits + string.punctuation:
entries.append(binding(c, c))
entries.append(binding("space", "space"))
for key in ["enter", "tab", "backspace"] + NAV_KEYS:
entries.append(binding(key, key))
entries.append(binding("escape", "escape", context=ESCAPE_CTX))
for i in range(1, 13):
entries.append(binding("f{}".format(i), "f{}".format(i)))
for mod in MODS:
for key in NAV_KEYS:
entries.append(binding("{}+{}".format(mod, key), key, **mod_flags(mod)))
for key in ["enter", "backspace", "tab"]:
for mod in ["ctrl", "alt", "shift"]:
entries.append(binding("{}+{}".format(mod, key), key, **mod_flags(mod)))
for c in "abcdefghijklmnopqrstuvwxyz":
entries.append(binding("ctrl+{}".format(c), c, ctrl=True))
entries.append(binding("alt+{}".format(c), c, alt=True))
for c in ["`", "[", "]", "\\", "space", "_", "^", "/", "@"]:
entries.append(binding("ctrl+{}".format(c if c != " " else "space"), c, ctrl=True))
# plugin-command bindings (everything above sends keys to the pty)
entries.append({
"keys": ["super+shift+k"],
"command": "vterm_clear_screen",
"context": CTX,
})
# one binding per line: indented JSON turns 150 bindings into 2000+ lines
lines = ["[", " // generated by tools/gen_keymap.py - do not edit by hand"]
lines += [" " + json.dumps(e, separators=(", ", ": ")) + "," for e in entries]
lines[-1] = lines[-1].rstrip(",")
lines.append("]")
path = pathlib.Path(__file__).parent.parent / "Default.sublime-keymap"
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
print("wrote {} bindings to {}".format(len(entries), path))

6
vterm.sublime-commands Normal file
View File

@@ -0,0 +1,6 @@
[
{ "caption": "vterm: Open Terminal", "command": "vterm_open" },
{ "caption": "vterm: Open Terminal in Project Folder", "command": "vterm_open_terminal_project_folder" },
{ "caption": "vterm: Clear Screen", "command": "vterm_clear_screen" },
{ "caption": "vterm: Select Theme", "command": "vterm_select_theme" }
]

29
vterm.sublime-project Normal file
View File

@@ -0,0 +1,29 @@
{
"folders":
[
{
"path": "./",
}
],
"settings": {
"python_interpreter": "${project_path}/.venv/bin/python3",
},
"build_systems":
[
{
"file_regex": "^[ ]*File \"(...*?)\", line ([0-9]*)",
"name": "venv Python file",
"selector": "source.python",
"shell_cmd": "source ${project_path}/.venv/bin/activate && ${project_path}/.venv/bin/python3 -u \"$file\""
},
],
"debugger_configurations": [
{
"name": "Run file",
"type": "python",
"request": "launch",
"program": "${file}",
}
],
}

34
vterm.sublime-settings Normal file
View File

@@ -0,0 +1,34 @@
{
// close the tab automatically when the process exits
"auto_close": true,
// number of scrollback lines kept in the view
"scrollback_history_size": 50000,
// ED 3 (erase saved lines) drops the scrollback, as a terminal should.
// Agents repaint with it and take your history with them, so it is off;
// cmd+shift+k is then the only way to empty the tab.
"honor_erase_scrollback": false,
"min_columns": 20,
"max_columns": 500,
// colour palette: "default", "homebrew", "pro", "slate", or null to
// follow the active Sublime colour scheme. Applies to open tabs too.
"theme": null,
// override any of the 16 ANSI colors, e.g. {"red": "#ff0000"}
// applied on top of the theme
"ansi_colors": {},
// External terminal for "vterm: Open Terminal in Project Folder".
// %CWD% in parameters is replaced with the project folder.
"terminal": "/opt/homebrew/bin/kitty",
"parameters": ["-d", "%CWD%"],
// Apple Terminal instead of kitty:
// "terminal": "/usr/bin/open",
// "parameters": ["-a", "Terminal", "%CWD%"],
"debug": false
}

7
vterm.sublime-syntax Normal file
View File

@@ -0,0 +1,7 @@
%YAML 1.2
---
name: vterm
hidden: true
scope: text.vterm
contexts:
main: []

1
vterm/__init__.py Normal file
View File

@@ -0,0 +1 @@
""" vterm: minimalist terminal for Sublime Text """

296
vterm/commands.py Normal file
View File

@@ -0,0 +1,296 @@
""" User-facing commands and the terminal view setup """
import logging
import os
import subprocess
import sublime
import sublime_plugin
from .render import CONTINUATION
from .scheme import SCHEME_PATH, scheme_manager
from .terminal import Terminal
from .themes import THEMES
from .utils import SETTINGS_FILE, settings
LOG = logging.getLogger(__name__)
class VtermOpenCommand(sublime_plugin.WindowCommand):
"""Open a terminal in a new tab. `vterm_open`, the only entry point users bind."""
def run(self, cmd=None, cwd=None, env=None, title=None):
"""Start `cmd` (default: a login shell) in a fresh view.
Args:
cwd: defaults to the first project folder, else the active file's
directory, else the home directory.
env: extra environment on top of the child defaults.
title: tab name; the program name unless the app sets its own.
"""
window = self.window
if not cmd:
shell = os.environ.get("SHELL", "/bin/zsh")
cmd = [shell, "-l"]
elif isinstance(cmd, str):
cmd = [cmd]
# the app may override the title via OSC 0/2 later
if not cwd:
folders = window.folders()
view = window.active_view()
if folders:
cwd = folders[0]
elif view and view.file_name():
cwd = os.path.dirname(view.file_name())
else:
cwd = os.path.expanduser("~")
cwd = sublime.expand_variables(cwd, window.extract_variables())
scheme_manager.initialize(window.active_view())
view = window.new_file()
view.run_command("vterm_initialize_view")
terminal = Terminal(view)
try:
terminal.start(cmd, cwd=cwd, env=env, default_title=title or os.path.basename(cmd[0]))
except Exception:
LOG.exception("failed to start terminal: cmd=%s", cmd)
view.close()
return
window.focus_view(view)
class VtermOpenTerminalProjectFolderCommand(sublime_plugin.WindowCommand):
"""Open the external terminal in the project folder of the active file."""
def run(self):
"""Resolve the folder, substitute %CWD%, spawn the configured terminal."""
view = self.window.active_view()
if view and view.file_name():
path = view.file_name()
folders = [f for f in self.window.folders() if path.find(f + os.sep) == 0]
cwd = folders[0] if folders else os.path.dirname(path)
elif self.window.folders():
cwd = self.window.folders()[0]
else:
return
s = settings()
terminal = s.get("terminal")
if not terminal:
sublime.error_message('vterm: set "terminal" in vterm.sublime-settings')
return
parameters = [p.replace("%CWD%", cwd) for p in s.get("parameters", [])]
try:
subprocess.Popen([terminal] + parameters, cwd=cwd)
except OSError:
LOG.exception("failed to start external terminal: terminal=%s", terminal)
sublime.error_message(f"vterm: could not start {terminal}")
def is_visible(self):
"""Hide the palette entry when there is no project folder."""
return bool(self.window.folders())
class VtermInitializeViewCommand(sublime_plugin.TextCommand):
"""Stamps a fresh view with the settings that make it a terminal."""
def run(self, _):
"""Apply the view settings; the pty starts only after this."""
view = self.view
view_settings = view.settings()
view_settings.set("vterm_view", True)
view.set_scratch(True)
view.set_read_only(False)
view_settings.set("is_widget", True)
view_settings.set("gutter", False)
view_settings.set("highlight_line", False)
view_settings.set("auto_complete_commit_on_tab", False)
view_settings.set("draw_centered", False)
view_settings.set("word_wrap", False)
view_settings.set("auto_complete", False)
view_settings.set("draw_white_space", "none")
view_settings.set("draw_unicode_white_space", False)
view_settings.set("draw_indent_guides", False)
view_settings.set("scroll_past_end", True)
view_settings.set("scroll_context_lines", 0)
view_settings.set("color_scheme", SCHEME_PATH)
view_settings.set("syntax", "Packages/vterm/vterm.sublime-syntax")
view_settings.set("spell_check", False)
view_settings.set("detect_indentation", False)
view_settings.set("translate_tabs_to_spaces", False)
view_settings.set("command_mode", False)
class VtermKeypressCommand(sublime_plugin.TextCommand):
"""Sends one key to the app. Every key binding in the generated keymap lands here."""
def run(self, _, **kwargs):
"""Args:
kwargs: key name plus modifier flags, straight from the keymap.
"""
terminal = Terminal.from_id(self.view.id())
if not terminal or not terminal.alive:
return
# drop any selection so the caret follows the terminal cursor
self.view.sel().clear()
self.view.run_command("vterm_show_cursor")
terminal.send_key(**kwargs)
class VtermCopyCommand(sublime_plugin.TextCommand):
"""Copies the selection, re-joining rows the emulator soft-wrapped."""
def run(self, edit):
"""Strip the soft-wrap sentinels, then copy as usual."""
view = self.view
if not view.settings().get("vterm_view"):
return
text = "\n".join(view.substr(s) for s in view.sel())
# re-join soft-wrapped lines
text = text.replace(CONTINUATION + "\n", "")
text = text.replace(CONTINUATION, "")
sublime.set_clipboard(text)
class VtermPasteCommand(sublime_plugin.TextCommand):
"""Pastes the clipboard into the app; replaces sublime's own paste."""
def run(self, edit):
"""Hand the clipboard to vterm_paste_text."""
self.view.run_command("vterm_paste_text", {"text": sublime.get_clipboard()})
class VtermPasteTextCommand(sublime_plugin.TextCommand):
"""Sends arbitrary text to the app, bracketed when it asked for that."""
def run(self, edit, text, bracketed=True):
"""Args:
bracketed: wrap in paste brackets, if the app enabled ?2004. IME
text comes through unbracketed — it was typed, not pasted.
"""
terminal = Terminal.from_id(self.view.id())
if not terminal:
return
bracketed = bracketed and terminal.screen.bracketed_paste_enabled()
if bracketed:
terminal.send_key(key="bracketed_paste_start")
self.view.run_command("vterm_show_cursor")
terminal.send_string(text)
if bracketed:
terminal.send_key(key="bracketed_paste_end")
class VtermSelectThemeCommand(sublime_plugin.WindowCommand):
"""Pick a colour palette, previewing each as it is highlighted"""
def run(self):
"""Show the palette picker, starting on the configured theme."""
self.settings = settings()
self.original = self.settings.get("theme")
self.choices = [None] + sorted(THEMES)
items = [["No theme", "follow the Sublime color scheme"]]
for name in self.choices[1:]:
theme = THEMES[name]
items.append([name, f"{theme["foreground"]} on {theme["background"]}"])
current = self.choices.index(self.original) if self.original in self.choices else 0
self.window.show_quick_panel(items, self._done, selected_index=current, on_highlight=self._preview)
def _preview(self, index):
"""Apply a palette while it is highlighted, without saving it."""
self.settings.set("theme", self.choices[index])
def _done(self, index):
"""Persist the choice, or restore the original if the panel was cancelled."""
if index == -1:
self.settings.set("theme", self.original)
return
self.settings.set("theme", self.choices[index])
sublime.save_settings(SETTINGS_FILE)
class VtermClearScreenCommand(sublime_plugin.TextCommand):
"""Clear the view, the screen and all scrollback, then let the app redraw"""
def run(self, edit):
"""Flag the clear; the renderer applies it and asks the app to redraw."""
terminal = Terminal.from_id(self.view.id())
if not terminal:
return
# the renderer thread owns the screen; it applies this on its next tick
terminal.pending_clear_screen = True
self.view.run_command("vterm_clear_undo_stack")
def is_enabled(self):
"""Greys the palette entry out for views that are not terminals."""
return Terminal.from_id(self.view.id()) is not None
class VtermCleanupCommand(sublime_plugin.TextCommand):
"""Finishes a terminal whose process exited: last frame, then close or annotate."""
def run(self, edit):
"""Render the last frame, then close the tab or leave the exit code behind."""
view = self.view
terminal = Terminal.from_id(view.id())
if not terminal:
return
# latched: the flag also disables the escape binding for a dead tab
if view.settings().get("vterm_view.finished"):
return
view.settings().set("vterm_view.finished", True)
view.run_command("vterm_render")
terminal.kill()
if settings().get("auto_close", True):
view.close()
return
# reaps the child, which sets exitstatus
terminal.process.isalive()
exitstatus = terminal.process.exitstatus
if exitstatus is not None:
view.run_command("append", {"characters": f"\n[process exited with code {exitstatus}]"})
view.sel().clear()
class VtermCloseCommand(sublime_plugin.TextCommand):
"""Kills the process and closes the tab. Registered but bound nowhere; for user keybindings."""
def run(self, _):
"""Kill the process, then close the view."""
view = self.view
if not view.settings().get("vterm_view"):
return
terminal = Terminal.from_id(view.id())
if terminal:
terminal.kill()
view.close()
class VtermClearUndoStackCommand(sublime_plugin.TextCommand):
"""Drops the undo history, so rendered output can never be undone into view."""
def run(self, _):
"""Deferred: clearing during a command would fight the edit in progress."""
sublime.set_timeout(self.view.clear_undo_stack)
class VtermNoopCommand(sublime_plugin.TextCommand):
"""Swallows a sublime command that must not reach a terminal view."""
def run(self, _):
"""Deliberately does nothing."""
pass

929
vterm/emulator.py Normal file
View File

@@ -0,0 +1,929 @@
""" Terminal emulation: pyte screen and a parser that handles prefixed CSI, DECRQM and multi-digit OSC """
import base64
import logging
import re
import time
import unicodedata
from collections import defaultdict, deque, namedtuple
import pyte
from pyte import control as ctrl
from pyte import graphics as g
from pyte import modes as mo
from pyte.charsets import LAT1_MAP
from pyte.screens import Margins, StaticDefaultDict, wcwidth
from wcwidth import wcswidth
LOG = logging.getLogger(__name__)
# pyte's colour names, in SGR 30-37 / 90-97 order; the index is the 256-palette entry
ANSI_COLORS = [
"black",
"red",
"green",
"brown",
"blue",
"magenta",
"cyan",
"white",
"light_black",
"light_red",
"light_green",
"light_brown",
"light_blue",
"light_magenta",
"light_cyan",
"light_white",
]
# SGR 90-97 / 100-107: the bright colours, which stock pyte does not map
FG_AIXTERM = {90 + i: ANSI_COLORS[8 + i] for i in range(8)}
BG_AIXTERM = {100 + i: ANSI_COLORS[8 + i] for i in range(8)}
# DECRQM answers, as wire numbers (pyte stores modes shifted << 5)
REPORTABLE_MODES = (mo.DECAWM >> 5, mo.DECTCEM >> 5, 1000, 1002, 1003, 1004, 1006, 1049, 2004, 2026)
# DEC private modes, pre-shifted into pyte's internal representation.
# 2026: synchronized output, the app is mid-frame and we should not draw
SYNC_MODE = 2026 << 5
# 1049: alternate screen, a full-screen app owns the viewport
ALT_SCREEN_MODE = 1049 << 5
# 2004: wrap pasted text in ESC[200~ / ESC[201~ so the app can tell it from typing
BRACKETED_PASTE_MODE = 2004 << 5
# 1004: report focus gain/loss as ESC[I / ESC[O
FOCUS_MODE = 1004 << 5
# 1000 click only, 1002 click plus drag, 1003 any motion
MOUSE_MODES = (1000 << 5, 1002 << 5, 1003 << 5)
# 1006: SGR mouse encoding, the only wire form we emit
MOUSE_SGR_MODE = 1006 << 5
# 2031: the app wants to be told when the colour scheme changes
THEME_NOTIFY_MODE = 2031 << 5
# how long a ?2026 frame may suppress rendering before we draw anyway
SYNC_FRAME_TIMEOUT = 0.15
# must outlast Terminal.REPAINT_NUDGE_DELAY, which triggers the repaint
SUPPRESS_HISTORY_AFTER_CLEAR = 0.8
def _copy_line(line):
"""copy.copy on a dict subclass takes the pickle path (~10x); rebuild instead"""
new = StaticDefaultDict(line.default)
dict.update(new, line)
return new
def quantize_256(r, g, b):
"""Nearest xterm-256 index: grays to the ramp, colours to the 6x6x6 cube.
Truecolor is folded into the 256 palette so the colour-scheme rule table
stays bounded (each distinct combo becomes a scheme rule and a file write)."""
if r == g == b:
if r < 8:
return 16
if r > 248:
return 231
return 232 + (r - 8) // 10
def level(c):
return 0 if c < 48 else (1 if c < 115 else (c - 35) // 40)
return 16 + 36 * level(r) + 6 * level(g) + level(b)
# linefeed marks a hard wrap, so copy can re-join soft-wrapped lines
Char = namedtuple(
"Char",
["data", "fg", "bg", "bold", "italics", "underscore", "strikethrough", "reverse", "linefeed"],
defaults=("default", "default", False, False, False, False, False, False),
)
class Cursor:
"""Cursor position and pending attributes.
Replaces pyte's own so a Char carrying the extra `linefeed` field can be
the default attribute set.
"""
__slots__ = ("x", "y", "attrs", "hidden")
def __init__(self, x, y, attrs=Char(" ")):
"""Args:
attrs: the Char whose styling new text inherits.
"""
self.x = x
self.y = y
self.attrs = attrs
self.hidden = False
class VtScreen(pyte.Screen):
"""Screen with the terminal features these agents rely on.
Adds to pyte: the alternate screen, scrollback, synchronized output,
mouse/focus/paste modes, cursor styles, query replies and OSC handling.
Mutated only by the renderer thread, so it carries no lock.
"""
@property
def default_char(self):
"""The blank cell an untouched column holds."""
# pyte's base property cannot be shadowed by an instance attribute;
# the value is maintained on reset and DECSCNM flips
return self._default_char
def __init__(
self,
columns,
lines,
process,
history=5000,
erase_scrollback=True,
clear_callback=lambda: None,
clipboard_callback=lambda text: None,
fg_color="ffffff",
bg_color="000000",
is_dark=True,
):
"""Args:
process: written to directly when the app queries the terminal.
history: scrollback depth, in lines.
erase_scrollback: whether ED 3 drops the scrollback, as a terminal should.
clear_callback: the app cleared the screen; the view follows.
clipboard_callback: an OSC 52 payload for the system clipboard.
fg_color, bg_color, is_dark: palette facts reported back to the app.
"""
self._process = process
self._erase_scrollback = erase_scrollback
self._clear_callback = clear_callback
self._clipboard_callback = clipboard_callback
self.primary_buffer = {}
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
# SGR transitions repeat endlessly in TUI output; memoized per (attrs, params)
self._sgr_cache = {}
# chars are identical while cursor.attrs is unchanged (draw rebuilds per run)
self._char_cache = {}
self._cache_attrs = None
self.history = deque(maxlen=history)
self.alternate_buffer_mode = False
self._sync_frame_start = 0
self._suppress_history_until = 0
# rgb hex (no '#'), answered to OSC 10/11 colour queries
self.default_fg_color = fg_color
self.default_bg_color = bg_color
self.theme_is_dark = is_dark
super().__init__(columns, lines)
def reset(self):
"""RIS: pyte's reset plus our cursor, scrollback and caches."""
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
self._sgr_cache.clear()
super().reset()
self.cursor = Cursor(0, 0)
self.cursor_style = 0
self.history.clear()
self._clear_callback()
def set_cursor_style(self, style):
"""Record the caret shape the app asked for; render maps it onto sublime."""
# DECSCUSR: 0 default, 1/2 block, 3/4 underline, 5/6 bar; odd = blinking
self.cursor_style = style
def resize(self, lines=None, columns=None):
"""Resize the screen, pushing scrolled-off lines into scrollback.
Unlike pyte's, shrinking preserves content: rows that no longer fit go
to history rather than being dropped.
"""
lines = lines or self.lines
columns = columns or self.columns
if lines == self.lines and columns == self.columns:
return
self.dirty.update(range(lines))
# shrinking: anything below the last non-empty row is free to lose,
# the rest scrolls into history
line_diff = self.lines - lines
if line_diff > 0:
bottom = self.first_non_empty_line_from_bottom()
num_empty_lines = self.lines - 1 - bottom
if line_diff > num_empty_lines:
line_diff = line_diff - num_empty_lines
self.push_lines_into_history(line_diff)
self.scroll_up(line_diff)
self.cursor.y -= line_diff
if columns < self.columns:
# leftovers past the new width would keep rendering
buffers = [self.buffer]
if self.primary_buffer.get("buffer") is not None:
buffers.append(self.primary_buffer["buffer"])
for buffer in buffers:
for line in buffer.values():
for x in range(columns, self.columns):
line.pop(x, None)
self.lines, self.columns = lines, columns
self.set_margins()
self.tabstops = set(range(8, self.columns, 8))
def set_mode(self, *modes, **kwargs):
"""Track DECSET, entering the alternate screen and opening ?2026 frames."""
super().set_mode(*modes, **kwargs)
self._sync_default_char()
if kwargs.get("private") and 2026 in modes:
self._sync_frame_start = time.time()
if ALT_SCREEN_MODE in self.mode and not self.alternate_buffer_mode:
self.alternate_buffer_mode = True
self.save_cursor()
self.switch_to_screen(alt=True)
def reset_mode(self, *modes, **kwargs):
"""Track DECRST, leaving the alternate screen and restoring the cursor."""
super().reset_mode(*modes, **kwargs)
self._sync_default_char()
if ALT_SCREEN_MODE not in self.mode and self.alternate_buffer_mode:
self.alternate_buffer_mode = False
self.switch_to_screen(alt=False)
self.restore_cursor()
def _sync_default_char(self):
"""Refresh the blank cell after a DECSCNM (reverse video) flip."""
reverse = mo.DECSCNM in self.mode
if reverse != self._default_char.reverse:
self._default_char = self._default_char._replace(reverse=reverse)
self._sgr_cache.clear()
# pyte points every line at the old blank cell from inside
# set/reset_mode, before this runs; a stale one paints the columns
# the app never wrote reverse for good
for line in self.buffer.values():
line.default = self._default_char
def switch_to_screen(self, alt=False):
"""Swap buffer, scrollback and cursor between the primary and alternate screens.
The alternate screen gets a maxlen=0 history: a full-screen app must not
push its frames into scrollback.
"""
if alt:
self.primary_buffer["buffer"] = self.buffer
self.primary_buffer["history"] = self.history
self.primary_buffer["cursor"] = self.cursor
self.buffer = defaultdict(lambda: StaticDefaultDict(self.default_char))
self.history = deque(maxlen=0)
self.cursor = Cursor(0, 0)
else:
self.buffer = self.primary_buffer["buffer"]
self.history = self.primary_buffer["history"]
self.cursor = self.primary_buffer["cursor"]
self.dirty.update(range(self.lines))
def wipe(self):
"""Blank the screen and home the cursor"""
for y in range(self.lines):
self.buffer[y].clear()
self.cursor.x = 0
self.cursor.y = 0
self.dirty.update(range(self.lines))
self._suppress_history_until = time.time() + SUPPRESS_HISTORY_AFTER_CLEAR
def in_sync_frame(self):
"""True while an app is mid ?2026 frame, so rendering should wait.
Bounded by SYNC_FRAME_TIMEOUT: an app that never closes its frame
must not freeze the view.
"""
return SYNC_MODE in self.mode and time.time() - self._sync_frame_start <= SYNC_FRAME_TIMEOUT
def mouse_reporting_enabled(self):
"""True when the app asked for mouse reports we can actually encode."""
return MOUSE_SGR_MODE in self.mode and any(m in self.mode for m in MOUSE_MODES)
def cursor_hidden(self):
"""True while the app has DECTCEM off (CSI ?25l).
Read from the mode set, not cursor.hidden: switch_to_screen builds a
fresh Cursor, which would flash the caret on every alt-screen entry.
"""
return mo.DECTCEM not in self.mode
def theme_notify_enabled(self):
"""True when the app subscribed to colour scheme changes (?2031)."""
return THEME_NOTIFY_MODE in self.mode
def focus_tracking_enabled(self):
"""True when the app wants focus reports (?1004)."""
return FOCUS_MODE in self.mode
def bracketed_paste_enabled(self):
"""True when pastes should be bracketed (?2004)."""
return BRACKETED_PASTE_MODE in self.mode
def draw(self, data):
"""Write text at the cursor, wcwidth-aware, marking hard wraps.
Differs from pyte: double-width cells claim two columns, combining marks
are merged into the preceding cell as NFC (sublime renders decomposed
sequences badly), and an autowrap sets `linefeed` on the last cell so
copy can tell a soft wrap from a real newline.
"""
if self.charset:
data = data.translate(self.g1_charset)
elif self.g0_charset is not LAT1_MAP:
data = data.translate(self.g0_charset)
autowrap = mo.DECAWM in self.mode
insert_mode = mo.IRM in self.mode
# hoisted: this loop runs per printable character of every burst
cursor = self.cursor
buffer = self.buffer
columns = self.columns
# cells only differ by their data while attrs are unchanged, so a
# per-run cache turns the namedtuple rebuild into a dict lookup
if cursor.attrs is not self._cache_attrs:
self._cache_attrs = cursor.attrs
self._char_cache = {}
cache = self._char_cache
line = buffer[cursor.y]
for char in data:
char_width = wcwidth(char)
if (cursor.x == columns and char_width >= 1) or (cursor.x == columns - 1 and char_width >= 2):
if autowrap:
last = line[columns - 1]
line[columns - 1] = last._replace(linefeed=True)
self.dirty.add(cursor.y)
self.carriage_return()
self.linefeed()
line = buffer[cursor.y]
elif char_width > 0:
cursor.x -= char_width
if char_width == 1:
if insert_mode:
self.insert_characters(char_width)
cell = cache.get(char)
if cell is None:
cell = cache[char] = cursor.attrs._replace(data=char)
line[cursor.x] = cell
elif char_width == 2:
if insert_mode:
self.insert_characters(char_width)
cell = cache.get(char)
if cell is None:
cell = cache[char] = cursor.attrs._replace(data=char)
line[cursor.x] = cell
if cursor.x + 1 < columns:
filler = cache.get("")
if filler is None:
filler = cache[""] = cursor.attrs._replace(data="")
line[cursor.x + 1] = filler
# a combining mark belongs to the cell before it, which may be the
# last column of the previous row
elif char_width == 0 and unicodedata.combining(char):
# sublime renders decomposed combining chars poorly; merge as NFC
pos = None
last = None
for row, col in [(self.cursor.y, self.cursor.x), (self.cursor.y - 1, self.columns)]:
if row < 0:
continue
row_line = self.buffer[row]
if col >= 2:
last = row_line[col - 2]
if wcswidth(last.data) >= 2:
pos = (row, col - 2)
break
if col >= 1:
last = row_line[col - 1]
pos = (row, col - 1)
break
if pos and last is not None:
normalized = unicodedata.normalize("NFC", last.data + char)
self.buffer[pos[0]][pos[1]] = last._replace(data=normalized)
self.dirty.add(pos[0])
else:
break
if char_width > 0:
self.cursor.x = min(self.cursor.x + char_width, self.columns)
self.dirty.add(self.cursor.y)
def index(self):
"""Line feed; at the bottom of the primary screen the top row goes to scrollback."""
if not self.alternate_buffer_mode and self.cursor.y == self.lines - 1 and self.margins is None:
self.push_lines_into_history(1)
super().index()
def erase_in_display(self, how=0, *args, **kwargs):
"""ED: erase part of the screen, keeping what scrolls away.
Unlike pyte's, a full clear (or a clear-to-end from home) pushes the
current screen into scrollback first, so `clear` does not lose history.
"""
if not self.alternate_buffer_mode and (how == 2 or (how == 0 and self.cursor.x == 0 and self.cursor.y == 0)):
self.push_lines_into_history()
if how == 0:
interval = range(self.cursor.y + 1, self.lines)
elif how == 1:
interval = range(self.cursor.y)
elif how == 2 or how == 3:
interval = range(self.lines)
else:
return
self.dirty.update(interval)
for y in interval:
line = self.buffer[y]
for x in list(line):
if x < self.columns:
line[x] = self.cursor.attrs
else:
line.pop(x, None)
if how == 0 or how == 1:
self.erase_in_line(how)
if how == 3 and self._erase_scrollback:
self.history.clear()
self._clear_callback()
def select_graphic_rendition(self, *attrs, **kwargs):
"""Apply an SGR sequence to the cursor attributes.
Adds to pyte: bright (aixterm) colours, and truecolor folded into the
256 palette. Transitions are memoized — TUIs cycle through a handful of
sequences thousands of times per frame.
"""
if not attrs or attrs == (0,):
self.cursor.attrs = self._default_char
return
cache_key = (self.cursor.attrs, attrs)
cached = self._sgr_cache.get(cache_key)
if cached is not None:
self.cursor.attrs = cached
return
# index walk rather than pop(): 38/48 consume their own parameters
replace = {}
i = 0
end = len(attrs)
while i < end:
attr = attrs[i]
i += 1
if attr == 0:
replace.update(self._default_char._asdict())
elif attr in g.FG_ANSI:
replace["fg"] = g.FG_ANSI[attr]
elif attr in g.BG_ANSI:
replace["bg"] = g.BG_ANSI[attr]
elif attr in g.TEXT:
attr = g.TEXT[attr]
if attr[1:] != "blink":
replace[attr[1:]] = attr.startswith("+")
elif attr in FG_AIXTERM:
replace.update(fg=FG_AIXTERM[attr])
elif attr in BG_AIXTERM:
replace.update(bg=BG_AIXTERM[attr])
elif attr in (g.FG_256, g.BG_256):
key = "fg" if attr == g.FG_256 else "bg"
try:
n = attrs[i]
i += 1
if n == 5:
m = attrs[i]
i += 1
if m < 16:
replace[key] = ANSI_COLORS[m]
else:
replace[key] = g.FG_BG_256[m]
elif n == 2:
m = quantize_256(attrs[i], attrs[i + 1], attrs[i + 2])
i += 3
replace[key] = ANSI_COLORS[m] if m < 16 else g.FG_BG_256[m]
except IndexError:
break
result = self.cursor.attrs._replace(**replace)
if len(self._sgr_cache) > 1024:
self._sgr_cache.clear()
self._sgr_cache[cache_key] = result
self.cursor.attrs = result
def scroll_up(self, n=None):
"""CSI S: scroll the margin region up, blanking the rows it exposes."""
n = n or 1
top, bottom = self.margins or Margins(0, self.lines - 1)
for y in range(top, bottom + 1):
if y + n > bottom:
self.buffer[y].clear()
else:
self.buffer[y] = _copy_line(self.buffer[y + n])
self.dirty.update(range(self.lines))
def scroll_down(self, n=None):
"""CSI T: scroll the margin region down, blanking the rows it exposes."""
n = n or 1
top, bottom = self.margins or Margins(0, self.lines - 1)
for y in reversed(range(top, bottom + 1)):
if y - n < top:
self.buffer[y].clear()
else:
self.buffer[y] = _copy_line(self.buffer[y - n])
self.dirty.update(range(self.lines))
def first_non_empty_line_from_bottom(self):
"""Index of the lowest row holding text, or -1 when the screen is blank."""
for nz_line in reversed(range(self.lines)):
# .get: indexing the defaultdict would materialize every empty row
line = self.buffer.get(nz_line)
if line and any(c.data and not c.data.isspace() for c in line.values()):
return nz_line
return -1
def push_lines_into_history(self, count=None):
"""Copy the top `count` rows into scrollback; all content rows by default.
Suppressed on the alternate screen, and briefly after a wipe so the
forced repaint does not land in scrollback twice.
"""
if self.alternate_buffer_mode:
return
if time.time() < self._suppress_history_until:
return
if count is None:
count = self.first_non_empty_line_from_bottom() + 1
self.history.extend(_copy_line(self.buffer[y]) for y in range(count))
# replies go to the pty, never to the screen
def write_process_input(self, data):
"""Send a reply to the app. Query answers go to the pty, never to the screen."""
self._process.write(data)
def report_device_attributes(self, mode=0, **kwargs):
"""DA1: claim to be a VT220 with ANSI colour. Claude Code waits on this with no timeout."""
if kwargs.get("private"):
return
if mode == 0:
self.write_process_input("\x1b[?62;22c")
def report_secondary_device_attributes(self, mode=0):
"""DA2: terminal type and version, as a VT220 would answer."""
self.write_process_input("\x1b[>1;10;0c")
def report_device_status(self, mode=0, **kwargs):
"""DSR: 5 device ok, 6 cursor position, private 996 the light/dark scheme."""
private = kwargs.get("private", False)
if mode == 5:
self.write_process_input("\x1b[0n")
elif mode == 6:
x = min(self.cursor.x, self.columns - 1) + 1
y = self.cursor.y + 1
marker = "?" if private else ""
self.write_process_input("\x1b[{}{};{}R".format(marker, y, x))
elif mode == 996 and private:
# color-scheme report, pi asks with a 2s timeout
self.write_process_input("\x1b[?997;{}n".format(1 if self.theme_is_dark else 2))
def report_xtversion(self):
"""XTVERSION: naming ourselves is what unlocks ?2026 for apps on a generic TERM."""
self.write_process_input("\x1bP>|vterm 0.1\x1b\\")
def report_mode_status(self, mode, private):
"""DECRQM: answer 1 set, 2 reset, 0 for modes we do not implement."""
if not private:
self.write_process_input("\x1b[{};0$y".format(mode))
return
if mode in REPORTABLE_MODES:
value = 1 if (mode << 5) in self.mode else 2
else:
value = 0
self.write_process_input("\x1b[?{};{}$y".format(mode, value))
def report_kitty_keyboard(self):
"""Answer the kitty keyboard protocol probe."""
# 0 = unsupported, so apps fall back to legacy key encodings
self.write_process_input("\x1b[?0u")
def handle_osc(self, code, payload, terminator):
"""Dispatch an OSC: 0/2 title, 1 icon, 10/11 colour query, 52 clipboard."""
if code in (0, 2):
self.set_title(payload)
elif code == 1:
self.set_icon_name(payload)
elif code in (10, 11):
if payload == "?":
color = self.default_fg_color if code == 10 else self.default_bg_color
rgb = "rgb:{0}{0}/{1}{1}/{2}{2}".format(color[0:2], color[2:4], color[4:6])
# answer with the terminator asked with: meli only ends an OSC at ST
self.write_process_input("\x1b]{};{}{}".format(code, rgb, terminator))
elif code == 52:
parts = payload.split(";", 1)
if len(parts) == 2 and parts[1] and parts[1] != "?":
try:
self._clipboard_callback(base64.b64decode(parts[1]).decode("utf-8", "replace"))
except ValueError:
LOG.warning("osc52: undecodable payload", exc_info=True)
# other OSC codes are intentionally dropped
# a complete plain CSI: params then a final byte. No private marker, prefix or
# intermediate — those forms, and sequences split across reads, take the parser
CSI_COMPLETE = re.compile("(?:\x1b\\[|\x9b)([0-9;]*)([@-~])")
# a read ending mid-CSI; held for the next read, as the parser would hold state.
# bounded so a run of digits with no final cannot buffer forever
CSI_PARTIAL = re.compile("(?:\x1b\\[?|\x9b)[0-9;]{0,32}$")
class VtStream(pyte.Stream):
"""pyte.Stream with a parser for prefixed CSI, DECRQM and multi-digit OSC
Stock pyte drops what these agents rely on: `>`/`<`/`=` prefixed CSI, the
DECRQM `$p` intermediate, and OSC codes longer than one digit.
feed() dispatches complete plain CSI and lone control characters directly:
pyte resumes the parser coroutine once per character, which was ~90% of the
cost of an escape-heavy flood.
"""
def __init__(self, *args, **kwargs):
"""Copy the CSI table before adding to it, so pyte's class-level one is untouched."""
self.csi = dict(self.csi)
self.csi["S"] = "scroll_up"
self.csi["T"] = "scroll_down"
self._carry = ""
# param-string -> parsed tuple; SGR strings repeat across a whole frame
self._params_cache = {}
super().__init__(*args, **kwargs)
def attach(self, screen):
"""Bind the fast dispatch tables when the listener is known."""
super().attach(screen)
# same construction as the parser's create_dispatcher, so an unknown
# final lands on listener.debug either way
self._fast_csi = defaultdict(
lambda: screen.debug,
{final: getattr(screen, name) for final, name in self.csi.items()},
)
# SI/SO always take the parser: it skips them under utf8, and utf8 can
# be toggled at runtime
self._fast_basic = {char: getattr(screen, name) for char, name in self.basic.items() if char not in (ctrl.SI, ctrl.SO)}
def feed(self, data):
"""pyte's feed with a ground-state fast path.
Plain text runs keep pyte's regex path. A complete unprefixed CSI is
matched whole and dispatched in one call, and basic controls (CR, LF,
tab...) in one lookup, instead of one coroutine resume per character.
Everything else — OSC, charsets, private/prefixed CSI, `:` subparams —
falls through to the parser, byte for byte as before. A read ending
mid-CSI is carried into the next read; the parser would hold it as
state, so nothing is dispatched either way until the sequence is whole.
"""
# the tail of the previous read ended mid-CSI; complete it first
if self._carry:
data = self._carry + data
self._carry = ""
# locals for the loop, which runs per text run / escape sequence
send = self._send_to_parser
draw = self.listener.draw
match_text = self._text_pattern.match
match_csi = CSI_COMPLETE.match
match_partial = CSI_PARTIAL.match
csi = self._fast_csi
basic = self._fast_basic
cache = self._params_cache
# True: the parser is at ground state, the fast paths may act.
# False: it is mid-sequence and owns every byte until it yields True
taking_plain_text = self._taking_plain_text
length = len(data)
offset = 0
while offset < length:
if taking_plain_text:
# printable run: hand it to draw() whole (pyte's own fast path)
match = match_text(data, offset)
if match:
start, offset = match.span()
draw(data[start:offset])
continue
char = data[offset]
if char == "\x1b" or char == "\x9b":
# complete plain CSI: dispatch here, skipping the parser
match = match_csi(data, offset)
if match:
# params memo: SGR strings repeat across a whole frame
raw = match.group(1)
params = cache.get(raw)
if params is None:
params = tuple(min(int(p), 9999) if p else 0 for p in raw.split(";"))
if len(cache) > 4096:
cache.clear()
cache[raw] = params
try:
csi[match.group(2)](*params)
except TypeError:
# same guard as _dispatch_csi: excess params cost one sequence, not the chunk
LOG.debug("malformed CSI %s%s", params, match.group(2))
offset = match.end()
continue
# CSI cut off by the end of the read: hold it for the next
if match_partial(data, offset):
self._carry = data[offset:]
break
# some other escape (OSC, charset, private CSI...):
# fall through to the parser
elif char in basic:
# lone control character (CR, LF, tab, BEL...)
basic[char]()
offset += 1
continue
# this byte is the parser's; the else branch below sends it
taking_plain_text = False
else:
# parser mid-sequence: one byte at a time until it returns
# True, saying it is back at ground state
taking_plain_text = send(data[offset : offset + 1])
offset += 1
self._taking_plain_text = taking_plain_text
def _parser_fsm(self):
"""Coroutine pyte feeds one character at a time; `yield True` resumes bulk text.
A full copy of pyte's FSM rather than a hook, because the additions are
inside its inner loops. Locals are bound up front: this runs per byte of
every escape sequence.
"""
basic = self.basic
listener = self.listener
draw = listener.draw
debug = listener.debug
ESC, CSI_C1 = ctrl.ESC, ctrl.CSI_C1
OSC_C1 = ctrl.OSC_C1
NUL_OR_DEL = ctrl.NUL + ctrl.DEL
CAN_OR_SUB = ctrl.CAN + ctrl.SUB
ALLOWED_IN_CSI = "".join([ctrl.BEL, ctrl.BS, ctrl.HT, ctrl.LF, ctrl.VT, ctrl.FF, ctrl.CR])
OSC_TERMINATORS = set([ctrl.ST_C0, ctrl.ST_C1, ctrl.BEL])
MAX_OSC = 65536
# unknown sequences fall through to listener.debug rather than raising
def create_dispatcher(mapping):
return defaultdict(lambda: debug, dict((event, getattr(listener, attr)) for event, attr in mapping.items()))
basic_dispatch = create_dispatcher(basic)
sharp_dispatch = create_dispatcher(self.sharp)
escape_dispatch = create_dispatcher(self.escape)
csi_dispatch = create_dispatcher(self.csi)
while True:
char = yield True
# ESC introducer: fold the 7-bit forms onto their C1 equivalents
if char == ESC:
char = yield None
if char == "[":
char = CSI_C1
elif char == "]":
char = OSC_C1
elif char in "PX^_":
# consume the payload so it never reaches the screen
prev = ""
while True:
c = yield None
if c == ctrl.ST_C1 or (prev == ESC and c == "\\"):
break
prev = c
continue
else:
if char == "#":
sharp_dispatch[(yield None)]()
elif char == "%":
self.select_other_charset((yield None))
elif char in "()":
# honoured even in UTF-8 mode, which pyte skips: smacs is ESC(0
listener.define_charset((yield None), mode=char)
else:
escape_dispatch[char]()
continue
if char in basic:
if (char == ctrl.SI or char == ctrl.SO) and self.use_utf8:
continue
basic_dispatch[char]()
# CSI: collect private/prefix/intermediate markers and the numeric
# parameters until a final byte arrives
elif char == CSI_C1:
params = []
current = ""
private = False
prefix = ""
intermediate = ""
while True:
char = yield None
if char == "?":
private = True
elif char in "><=":
prefix = char
elif char in " $":
intermediate = char
elif char in ALLOWED_IN_CSI:
basic_dispatch[char]()
elif char in CAN_OR_SUB:
draw(char)
break
elif char.isdigit():
current += char
elif char == ":":
# SGR subparameters, which we do not support
pass
elif char == ";":
params.append(min(int(current or 0), 9999))
current = ""
else:
params.append(min(int(current or 0), 9999))
self._dispatch_csi(csi_dispatch, listener, char, params, private, prefix, intermediate)
break
# OSC: a code of up to 8 digits, then a payload up to MAX_OSC,
# ending at BEL or ST. pyte only accepts a single-digit code
elif char == OSC_C1:
code = ""
while True:
char = yield None
if char.isdigit() and len(code) < 8:
code += char
else:
break
payload = ""
if char == ";" or (char not in OSC_TERMINATORS and char != ESC):
while True:
char = yield None
if char == ESC:
char += yield None
if char in OSC_TERMINATORS:
break
if len(payload) < MAX_OSC:
payload += char
elif char == ESC:
# take the '\' of ST, so char holds the whole terminator
char += yield None
try:
listener.handle_osc(int(code or 0), payload, char)
except Exception:
LOG.warning("osc handler failed: code=%s", code, exc_info=True)
elif char not in NUL_OR_DEL:
draw(char)
def _dispatch_csi(self, csi_dispatch, listener, final, params, private, prefix, intermediate):
"""Route a CSI by its intermediate, prefix and private marker.
The forms pyte has no table for are handled here; a private sequence we
do not implement is dropped rather than run in its non-private meaning.
"""
if intermediate == "$":
if final == "p":
listener.report_mode_status(params[0] if params else 0, private)
return
if intermediate == " ":
if final == "q":
listener.set_cursor_style(params[0] if params else 0)
return
if prefix == ">":
if final == "q":
listener.report_xtversion()
elif final == "c":
listener.report_secondary_device_attributes(*params)
# '>m' modifyOtherKeys, '>u' kitty push: ignore
return
if prefix:
# kitty pop ('<u'), DECKPAM-style ('='): ignore
return
if private:
if final == "u":
listener.report_kitty_keyboard()
elif final in ("h", "l"):
csi_dispatch[final](*params, private=True)
elif final in ("J", "K", "n", "c"):
csi_dispatch[final](params[0] if params else 0, private=True)
else:
# only these handlers understand the private form; passing it to
# the rest raises TypeError. aerc probes XTSMGRAPHICS (?S)
LOG.debug("ignoring private CSI ?%s%s", params, final)
return
try:
csi_dispatch[final](*params)
except TypeError:
LOG.debug("malformed CSI %s%s", params, final)

148
vterm/keys.py Normal file
View File

@@ -0,0 +1,148 @@
""" Key-name to escape-sequence tables
The keymap binds key *names*; get_key_code turns a name plus its modifier flags
into the bytes an app expects. Legacy xterm encodings only — the kitty keyboard
protocol is deliberately not implemented.
"""
# unmodified keys, and the paste brackets, in their default (non-application) form
_KEY_MAP = {
"enter": "\r",
"backspace": "\x7f",
"tab": "\t",
"space": " ",
"escape": "\x1b",
"up": "\x1b[A",
"down": "\x1b[B",
"right": "\x1b[C",
"left": "\x1b[D",
"home": "\x1b[H",
"end": "\x1b[F",
"pageup": "\x1b[5~",
"pagedown": "\x1b[6~",
"delete": "\x1b[3~",
"f1": "\x1bOP",
"f2": "\x1bOQ",
"f3": "\x1bOR",
"f4": "\x1bOS",
"f5": "\x1b[15~",
"f6": "\x1b[17~",
"f7": "\x1b[18~",
"f8": "\x1b[19~",
"f9": "\x1b[20~",
"f10": "\x1b[21~",
"f11": "\x1b[23~",
"f12": "\x1b[24~",
"bracketed_paste_start": "\x1b[200~",
"bracketed_paste_end": "\x1b[201~",
}
# DECCKM: cursor keys switch from CSI to SS3 while an app asks for application mode
_APP_MODE_KEY_MAP = {
"up": "\x1bOA",
"down": "\x1bOB",
"right": "\x1bOC",
"left": "\x1bOD",
"home": "\x1bOH",
"end": "\x1bOF",
}
# xterm modified keys: CSI 1;<mod><letter> / CSI <n>;<mod>~
_MODIFIABLE_CSI = {
"up": "\x1b[1;{}A",
"down": "\x1b[1;{}B",
"right": "\x1b[1;{}C",
"left": "\x1b[1;{}D",
"home": "\x1b[1;{}H",
"end": "\x1b[1;{}F",
"delete": "\x1b[3;{}~",
"pageup": "\x1b[5;{}~",
"pagedown": "\x1b[6;{}~",
}
# ctrl+<key> for the keys whose control byte is not chr(letter - 'a' + 1)
_CTRL_KEY_MAP = {
"@": "\x00",
"`": "\x00",
"2": "\x00",
"[": "\x1b",
"{": "\x1b",
"3": "\x1b",
"\\": "\x1c",
"|": "\x1c",
"4": "\x1c",
"]": "\x1d",
"}": "\x1d",
"5": "\x1d",
"^": "\x1e",
"~": "\x1e",
"6": "\x1e",
"_": "\x1f",
"/": "\x1f",
"7": "\x1f",
"?": "\x7f",
"8": "\x7f",
"space": "\x00",
"enter": "\n",
"tab": "\t",
"backspace": "\x08",
}
# the two shifted keys with an encoding of their own; shift is bare text elsewhere
_SHIFT_KEY_MAP = {
"tab": "\x1b[Z",
"enter": "\x1b\r",
}
def _modifier_code(ctrl, alt, shift):
"""xterm modifier parameter: 1 plus a bitmask of shift/alt/ctrl."""
return 1 + (1 if shift else 0) + (2 if alt else 0) + (4 if ctrl else 0)
def get_key_code(key, ctrl=False, alt=False, shift=False, application_mode=False, new_line_mode=False):
"""Escape sequence for a key name and its modifiers.
Args:
key: key name from the keymap, or a single literal character.
application_mode: DECCKM is set, so cursor keys take their SS3 form.
new_line_mode: LNM is set, so enter sends CR LF.
Returns:
str: the bytes to write to the pty.
"""
key_lo = key.lower() if len(key) > 1 else key
if (ctrl or alt or shift) and key_lo in _MODIFIABLE_CSI:
return _MODIFIABLE_CSI[key_lo].format(_modifier_code(ctrl, alt, shift))
if ctrl:
base = key_lo
if base in _CTRL_KEY_MAP:
code = _CTRL_KEY_MAP[base]
elif len(base) == 1 and "a" <= base <= "z":
code = chr(ord(base) - ord("a") + 1)
else:
code = _plain(key, application_mode, new_line_mode)
return ("\x1b" + code) if alt else code
if alt:
return "\x1b" + _plain(key if len(key) == 1 else key_lo, application_mode, new_line_mode)
if shift:
if key_lo in _SHIFT_KEY_MAP:
return _SHIFT_KEY_MAP[key_lo]
return _KEY_MAP.get(key, key)
return _plain(key, application_mode, new_line_mode)
def _plain(key, application_mode=False, new_line_mode=False):
"""Unmodified encoding, honouring DECCKM and LNM; unknown names pass through as text."""
if application_mode and key in _APP_MODE_KEY_MAP:
return _APP_MODE_KEY_MAP[key]
if new_line_mode and key == "enter":
return "\r\n"
if key in _KEY_MAP:
return _KEY_MAP[key]
return key

109
vterm/listeners.py Normal file
View File

@@ -0,0 +1,109 @@
""" Event listeners: printable/IME input capture, command rerouting,
keymap context, focus reporting, lifecycle """
import sublime
import sublime_plugin
from .terminal import Terminal
class VtermCoreEventListener(sublime_plugin.EventListener):
"""View events a terminal has to answer: focus, input capture, teardown.
Application-wide, so every hook runs for ordinary files too and has to bail
out cheaply.
"""
def __init__(self):
"""Track carets per view, for the insert-detection in on_modified."""
# view id -> last caret position, to work out what sublime just inserted
self._cursor = {}
def on_activated(self, view):
"""Report focus; warn if the plugin host restarted and left the tab orphaned."""
terminal = Terminal.from_id(view.id())
if terminal:
terminal.focused = True
terminal.send_focus(True)
view.run_command("vterm_clear_undo_stack")
# catch up on the frames, title and caret skipped while in the background
view.run_command("vterm_render")
view.run_command("vterm_show_cursor", {"scroll": False})
elif view.settings().get("vterm_view") and not view.settings().get("vterm_view.finished"):
# the plugin host restarted; this tab's terminal is unreachable
view.set_status("vterm", "vterm: disconnected — close this tab and open a new one")
def on_deactivated(self, view):
"""Report focus loss, and leave the view without a caret while it is in the background."""
terminal = Terminal.from_id(view.id())
if terminal:
terminal.focused = False
terminal.send_focus(False)
if all(region.empty() for region in view.sel()):
view.sel().clear()
def on_pre_close(self, view):
"""Kill the process when its tab closes."""
terminal = Terminal.from_id(view.id())
if terminal:
terminal.kill()
self._cursor.pop(view.id(), None)
def on_modified(self, view):
"""Forward text sublime inserted, and undo anything else that edits the view.
The keymap covers printable keys, but IME and composed input never
raise a key event — it arrives as an `insert` command. Text the emulator
did not produce must never stay in the view.
"""
# printables and IME text arrive as sublime inserts, not keypresses
terminal = Terminal.from_id(view.id())
if not terminal:
return
command, args, _ = view.command_history(0)
if command.startswith("vterm"):
return
if not terminal.alive:
return
if command == "insert" and "characters" in args and len(view.sel()) == 1 and view.sel()[0].empty():
chars = args["characters"]
current_cursor = view.sel()[0].end()
prev = self._cursor.get(view.id(), 0)
region = sublime.Region(max(current_cursor - len(chars), prev), current_cursor)
text = view.substr(region)
self._cursor[view.id()] = current_cursor
view.run_command("vterm_paste_text", {"text": text, "bracketed": False})
elif command:
view.run_command("soft_undo")
def on_selection_modified(self, view):
"""Track the caret, so on_modified can tell which text was just inserted."""
terminal = Terminal.from_id(view.id())
if not terminal or not terminal.alive:
return
if len(view.sel()) != 1 or not view.sel()[0].empty():
return
self._cursor[view.id()] = view.sel()[0].end()
def on_query_context(self, view, key, operator, operand, match_all):
"""Answer the one context the mousemap needs; keymap contexts are native."""
# keymap contexts are native "setting.x"; mousemaps do not evaluate
# those the same way, so the wheel flag still needs the hook
if key != "vterm_view.wheel_captured":
return None
value = view.settings().get(key, False)
if operator == sublime.OP_EQUAL:
return value == operand
elif operator == sublime.OP_NOT_EQUAL:
return value != operand
return None
def on_text_command(self, view, name, args):
"""Reroute copy/paste to the pty and swallow undo, in terminal views."""
if not view.settings().get("vterm_view"):
return
if name == "copy":
return ("vterm_copy", None)
elif name in ("paste", "paste_and_indent"):
return ("vterm_paste", None)
elif name == "undo":
return ("vterm_noop", None)

99
vterm/mouse.py Normal file
View File

@@ -0,0 +1,99 @@
""" SGR mouse reporting while the app tracks the mouse; otherwise sublime keeps drag_select """
import sublime_plugin
from .terminal import Terminal
# SGR button numbers for the wheel; 64 is the "scroll" bit plus direction
WHEEL_UP = 64
WHEEL_DOWN = 65
# lines per wheel tick, as iTerm2 delivers per notch; pi scrolls exactly one
# line per report and has no setting of its own
WHEEL_LINES = 3
def _event_to_cell(view, terminal, event, clamp=False):
"""Map a mouse event to 1-based (col, row) on the live screen, or None.
`clamp` pulls a point above or below the screen onto the nearest row: the
wheel still belongs to the app, while a click there does not."""
pt = view.window_to_text((event["x"], event["y"]))
row, col = view.rowcol(pt)
row -= terminal.offset
screen = terminal.screen
if row < 0 or row >= screen.lines:
if not clamp:
return None
row = min(max(row, 0), screen.lines - 1)
return (min(col, screen.columns - 1) + 1, row + 1)
class VtermMouseEventListener(sublime_plugin.EventListener):
"""Hands button-1 clicks to the app, but only while it tracks the mouse."""
def on_text_command(self, view, name, args):
"""Replace drag_select with a mouse report; other click variants stay native."""
terminal = Terminal.from_id(view.id())
if not terminal:
return
if not terminal.screen.mouse_reporting_enabled():
# leave selection, caret and scrolling to sublime
return
if name == "drag_select" and args and "event" in args and args.get("event", {}).get("button") == 1 and len(args) == 1:
return ("vterm_mouse_click", {"event": args["event"]})
class VtermMouseClickCommand(sublime_plugin.TextCommand):
"""Reports a click to the app, or focuses the tab if it was not active."""
def run(self, edit, event):
"""Focus the tab on the first click, report press and release after that."""
terminal = Terminal.from_id(self.view.id())
if not terminal:
return
window = self.view.window()
if window and window.active_view() != self.view:
# we replaced drag_select, which is what focuses the group; this
# click only focuses, as clicking an unfocused terminal window does
window.focus_view(self.view)
return
cell = _event_to_cell(self.view, terminal, event)
if cell is None:
return
terminal.send_mouse(0, cell[0], cell[1], pressed=True)
terminal.send_mouse(0, cell[0], cell[1], pressed=False)
def want_event(self):
"""Ask sublime for the click coordinates."""
return True
class VtermScrollCommand(sublime_plugin.TextCommand):
"""Bound to scroll wheel in the mousemap while the app tracks the mouse or is on the alt screen."""
def run(self, edit, event=None, up=False):
"""Report the wheel to the app, or fake arrow keys on the alt screen.
Only bound while the app owns the wheel; otherwise sublime scrolls the
view over the real scrollback text and this never runs.
"""
terminal = Terminal.from_id(self.view.id())
if not terminal:
return
if terminal.screen.mouse_reporting_enabled():
cell = _event_to_cell(self.view, terminal, event, clamp=True) if event else None
if cell is None:
cell = (1, terminal.screen.lines // 2 + 1)
# one report per line, never one batched write: pi matches the SGR
# form against a whole stdin chunk and drops anything longer
for _ in range(WHEEL_LINES):
terminal.send_mouse(WHEEL_UP if up else WHEEL_DOWN, cell[0], cell[1], pressed=True)
elif terminal.screen.alternate_buffer_mode:
# alternate scroll: an alt-screen app that does not track the mouse
# gets arrow keys per wheel tick, as kitty/iTerm2/xterm do
for _ in range(WHEEL_LINES):
terminal.send_key(key="up" if up else "down")
def want_event(self):
"""Ask sublime for the wheel coordinates."""
return True

547
vterm/render.py Normal file
View File

@@ -0,0 +1,547 @@
""" Rendering the emulator screen into the Sublime view
The view holds real text: scrollback above `terminal.offset`, the live screen
window from `offset` on. Dirty rows are whole-line replaced; colors are
add_regions overlays with scopes served by the dynamic scheme. The ST caret
doubles as the terminal cursor.
"""
import math
from functools import lru_cache
import sublime
import sublime_plugin
from wcwidth import wcswidth
# per-cell call in segment_buffer_line; pyte caches its own wcwidth likewise
cell_width = lru_cache(maxsize=4096)(wcswidth)
from .scheme import scheme_manager
from .terminal import Terminal
from .utils import rev_wcwidth
# zero-width sentinel appended to soft-wrapped lines so copy can re-join them
CONTINUATION = "\u200b\u200c\u200b"
# scrollback colour overlays are merged onto one key per (scope, chunk of rows):
# the key count stays bounded and each merge re-sends at most a chunk's regions
CHUNK = 256
def chunk_key(chunk, scope):
"""Region key of one scrollback chunk's overlays for one scope."""
return "vterm@{}@{}".format(chunk, scope)
def reverse_fg_bg(fg, bg):
"""Swap the colours of a reverse-video run, keeping "default" resolvable."""
fg, bg = bg, fg
if fg == "default":
fg = "reverse_default"
if bg == "default":
bg = "reverse_default"
return fg, bg
# style flags in the order Char stores them, as the compact scope suffix
STYLE_FLAGS = (("bold", "b"), ("italics", "i"), ("underscore", "u"))
# the Char fields that decide whether a run continues
ATTRS = slice(1, 8)
def segment_buffer_line(buffer_line):
"""Yield (text, start, end, fg, bg, style) runs of a screen row.
A run continues while the cell attributes are unchanged, so a row becomes a
handful of spans rather than one entry per cell.
"""
if not buffer_line:
return
last_index = max(buffer_line) + 1
run = []
start = 0
column = 0
attrs = None
skip_continuation = False
for i in range(last_index):
if skip_continuation:
skip_continuation = False
continue
char = buffer_line[i]
# a wide char owns the next column too, which holds only a filler cell
skip_continuation = cell_width(char.data) >= 2
if attrs is None:
column = i
attrs = char[ATTRS]
elif char[ATTRS] != attrs:
yield _run(run, start, column, attrs)
run = []
start = column
attrs = char[ATTRS]
run.append(char.data)
column += 1
if attrs is not None:
yield _run(run, start, column, attrs)
def _run(run, start, end, attrs):
"""Pack one segment: its text, span, resolved colours and style suffix."""
fg, bg = attrs[0], attrs[1]
# attrs[6] is Char.reverse
if attrs[6]:
fg, bg = reverse_fg_bg(fg, bg)
style = "".join(flag for i, (_, flag) in enumerate(STYLE_FLAGS, start=2) if attrs[i])
return "".join(run), start, end, fg, bg, style
def ensure_position(view, edit, row, col=0):
"""Grow the view so (row, col) exists"""
lastrow = view.rowcol(view.size())[0]
if lastrow < row:
view.insert(edit, view.size(), "\n" * (row - lastrow))
line_region = view.line(view.text_point(row, 0))
lastcol = view.rowcol(line_region.end())[1]
if lastcol < col:
view.insert(edit, line_region.end(), " " * (col - lastcol))
class VtermRenderCommand(sublime_plugin.TextCommand):
"""Draws the screen into the view. Sublime keeps one instance per view, so
the caches below are that view's render state."""
def __init__(self, *args, **kwargs):
"""Set up this view's render caches."""
super().__init__(*args, **kwargs)
self.colored_lines = {}
# view row -> (segments, lf) as last rendered; pyte's dirty means
# "repaint hint", so most dirty rows are identical frame to frame
self._row_cache = {}
self._key_counter = 0
# chunk ordinal -> scopes it holds a key for; ordinals are view row +
# rows trimmed so far, so they survive trim_history renumbering
self._archived_chunks = {}
# rows waiting for their chunk to close, and the chunk they belong to
self._open_rows = []
self._open_chunk = None
self._trimmed = 0
self._lastrow = 0
self._applied_cursor_style = None
def next_key(self):
"""A region key unique to this view; per-row keys would collide after trim_history renumbers rows"""
self._key_counter += 1
return "vterm#{}".format(self._key_counter)
def ensure_row(self, edit, row):
"""Row-existence check for the hot path, tracking the last row in python"""
if self._lastrow < row:
self.view.insert(edit, self.view.size(), "\n" * (row - self._lastrow))
self._lastrow = row
def run(self, edit):
"""Draw one frame: dirty rows, fold, caret, trimming, title."""
view = self.view
terminal = Terminal.from_id(view.id())
if not terminal:
return
screen = terminal.screen
# the caret and the tab name are the user's; an unfocused terminal leaves
# both alone so no view event of ours reaches the view they are typing in
focused = terminal.focused
# our own replace expands the caret over the text it inserts
had_selection = any(not region.empty() for region in view.sel())
if terminal.pending_clear:
terminal.pending_clear = False
self.clear_view(edit, terminal)
if terminal.pending_clear_screen:
terminal.pending_clear_screen = False
self.clear_view(edit, terminal, wipe_screen=True)
self.update_lines(edit, terminal)
self.sync_alt_fold(terminal)
if focused and not had_selection:
# drop the artefact; focus_cursor re-places the caret
view.sel().clear()
# follow-scroll gate: viewport_y is where the last auto-scroll left the
# view, so a smaller current position means the user scrolled up. Then
# trimming, the caret and the scroll are all skipped, keeping their place
if terminal.viewport_y < view.viewport_position()[1] + view.line_height():
self.trim_trailing_spaces(edit, terminal)
self.trim_history(edit, terminal)
if focused:
# direct call: a nested run_command opens a second edit/undo group
focus_cursor(view, edit, terminal)
request_scroll(view, terminal)
self.sync_cursor_style(terminal)
if focused:
title = screen.title or terminal.default_title
if view.name() != title:
view.set_name(title)
# this command is the only writer of screen.dirty, so it owns clearing it
screen.dirty.clear()
def sync_alt_fold(self, terminal):
"""Hide the scrollback while the app owns the alt screen: fold it away
(the view text is its only copy) and drop past-end scrolling so the
view has no scroll range. The fold is re-checked every render — the
screen's first row is replaced on every region scroll, and an edit at
the fold boundary can silently disturb a fold."""
view = self.view
alt = terminal.screen.alternate_buffer_mode
if not alt and not terminal.applied_alt_screen:
return
if alt != terminal.applied_alt_screen:
terminal.applied_alt_screen = alt
view.settings().set("scroll_past_end", not alt)
end = view.text_point(terminal.offset, 0) - 1 if alt and terminal.offset else 0
want = [(0, end)] if end > 0 else []
if [(r.begin(), r.end()) for r in view.folded_regions()] == want:
return
view.unfold(sublime.Region(0, view.size()))
if want and view.fold(sublime.Region(0, end)):
# a fold collapses the layout; a stale viewport anchor would stop
# the follow-scroll gate from re-engaging
terminal.viewport_y = 0
def sync_cursor_style(self, terminal):
"""Map DECSCUSR onto the view's caret settings, on transitions only."""
screen = terminal.screen
# 0 is the terminal default, which DEC and xterm define as a blinking block
style = screen.cursor_style or 1
hidden = screen.cursor_hidden()
if (style, hidden) == self._applied_cursor_style:
return
self._applied_cursor_style = (style, hidden)
settings = self.view.settings()
if hidden:
# the app draws its own cursor, but the caret has to stay for
# typing to work: a solid block sits on the same cell rather than
# blinking next to it
settings.set("block_caret", True)
settings.set("caret_style", "solid")
elif 1 <= style <= 6:
# ST has no underline caret: 3/4 fall back to bar
settings.set("block_caret", style <= 2)
settings.set("caret_style", "blink" if style % 2 else "solid")
else:
# unknown values: back to the user's caret preferences
settings.erase("block_caret")
settings.erase("caret_style")
def clear_view(self, edit, terminal, wipe_screen=False):
"""Empty the view and scrollback; `wipe_screen` also blanks the screen and asks the app to redraw"""
view = self.view
screen = terminal.screen
view.replace(edit, sublime.Region(0, view.size()), "")
for line in list(self.colored_lines):
self.decolorize_line(line)
for chunk, scopes in self._archived_chunks.items():
for scope in scopes:
view.erase_regions(chunk_key(chunk, scope))
self._archived_chunks.clear()
self._open_rows = []
self._open_chunk = None
self._trimmed = 0
self._row_cache.clear()
terminal.offset = 0
screen.history.clear()
if wipe_screen:
screen.wipe()
terminal.nudge_repaint()
screen.dirty.update(range(screen.lines))
terminal.viewport_y = 0
def update_lines(self, edit, terminal):
"""Write the scrollback that just arrived, then every dirty screen row.
Lines the emulator retired go in above `terminal.offset`, which advances
by that many rows; from there down is the live screen.
"""
screen = terminal.screen
columns = screen.columns
dirty_lines = sorted(screen.dirty)
# valid for this method only; the trim paths erase rows
self._lastrow = self.view.rowcol(self.view.size())[0]
if dirty_lines:
# history pops newest-first, so it fills the rows below `offset` backwards
history = screen.history
popped = len(history)
terminal.offset += popped
offset = terminal.offset
for line in range(popped):
buffer_line = history.pop()
lf = buffer_line[columns - 1].linefeed
self.update_line(edit, offset - line - 1, buffer_line, lf)
# after the loop: a pop edits an earlier point than the one before
# it, so spans measured mid-loop would be stale
self.archive_rows(range(offset - popped, offset))
for line in dirty_lines:
buffer_line = screen.buffer[line]
lf = buffer_line[columns - 1].linefeed
self.update_line(edit, line + offset, buffer_line, lf)
def update_line(self, edit, line, buffer_line, lf):
"""Rewrite one view row from a screen row, unless it is unchanged.
Args:
line: absolute view row.
lf: the row ended in a real newline, not a soft wrap.
"""
view = self.view
segments = list(segment_buffer_line(buffer_line))
# pyte's dirty set is a repaint hint, not a diff: most dirty rows come
# back identical, and rewriting one costs an edit plus its region churn
rendered = (segments, lf)
if self._row_cache.get(line) == rendered:
return
self._row_cache[line] = rendered
by_scope = {}
needed = 0
for _, start, end, fg, bg, style in segments:
if fg == "default" and bg == "default" and not style:
continue
needed = max(needed, end)
by_scope.setdefault(scheme_manager.scope(fg, bg, style), []).append((start, end))
text = "".join(s[0] for s in segments)
if lf:
text += CONTINUATION
text = text.rstrip()
if len(text) < needed:
# pad only to the last coloured run, in the same edit as the text
text += " " * (needed - len(text))
self.ensure_row(edit, line)
line_region = view.line(view.text_point(line, 0))
self.decolorize_line(line)
view.replace(edit, line_region, text)
# replacing a line cannot move its start
self.colorize_line(line, line_region.begin(), by_scope)
def colorize_line(self, line, line_start, by_scope):
"""Lay the colour overlays for one row, one add_regions per scope."""
view = self.view
keys = self.colored_lines.setdefault(line, []) if by_scope else None
for scope, spans in by_scope.items():
key = self.next_key()
view.add_regions(key, [sublime.Region(line_start + a, line_start + b) for a, b in spans], scope)
keys.append(key)
def decolorize_line(self, line):
"""Drop a row's colour overlays before it is rewritten or erased."""
if line in self.colored_lines:
for key in self.colored_lines[line]:
self.view.erase_regions(key)
del self.colored_lines[line]
def archive_rows(self, rows):
"""Queue rows that left the screen for their chunk's merge."""
for row in rows:
if row not in self.colored_lines:
continue
chunk = (row + self._trimmed) // CHUNK
if chunk != self._open_chunk:
self.flush_chunk()
self._open_chunk = chunk
self._open_rows.append(row)
def flush_chunk(self):
"""Merge the queued rows onto shared per-(scope, chunk) keys.
A scrollback row is never repainted, so its per-row keys used to live
until trimmed: a long coloured session held ~15k region keys, and every
key is a separate pass in sublime's redraw. Merging bounds the count at
scopes x (scrollback / CHUNK).
The merge waits for the chunk to close, because add_regions replaces a
key's whole set: merging every frame re-sent everything accumulated so
far, which is quadratic in CHUNK when output arrives a few rows a frame.
Until then the rows keep their per-row keys, so the count is bounded by
scopes x chunks plus one chunk's worth.
"""
view = self.view
rows, chunk = self._open_rows, self._open_chunk
self._open_rows = []
if not rows:
return
grouped = {}
stale = []
for row in rows:
keys = self.colored_lines.pop(row, None)
if not keys:
continue
stale += keys
# the segments that were painted, straight from the row cache
segments = self._row_cache[row][0]
start = view.line(view.text_point(row, 0)).begin()
for _, a, b, fg, bg, style in segments:
if fg == "default" and bg == "default" and not style:
continue
scope = scheme_manager.scope(fg, bg, style)
grouped.setdefault(scope, []).append(sublime.Region(start + a, start + b))
scopes = self._archived_chunks.setdefault(chunk, set())
for scope, regions in grouped.items():
key = chunk_key(chunk, scope)
if scope in scopes:
# a trim flushed this chunk mid-way; keep what it already holds
regions = view.get_regions(key) + regions
view.add_regions(key, regions, scope)
scopes.add(scope)
for key in stale:
view.erase_regions(key)
def trim_trailing_spaces(self, edit, terminal):
"""Erase the blank tail below the cursor, and padding on the cursor row.
Without this the view keeps a full screen of blank rows below a short
prompt, and the scrollbar reflects them.
"""
view = self.view
screen = terminal.screen
cursor = screen.cursor
cursor_row = terminal.offset + cursor.y
lastrow = view.rowcol(view.size())[0]
row = lastrow
while row > cursor_row:
line_region = view.line(view.text_point(row, 0))
text = view.substr(line_region)
if len(text.strip()) == 0 and (row not in self.colored_lines or not self.colored_lines[row]):
row = row - 1
else:
break
if row < lastrow:
# one erase for the whole blank tail; the row cache entries stay,
# so a still-blank row is not recreated and re-erased every tick
view.erase(edit, sublime.Region(view.text_point(row + 1, 0) - 1, view.size()))
if row == cursor_row:
line_region = view.line(view.text_point(row, 0))
text = view.substr(line_region)
trailing_region = sublime.Region(line_region.begin() + rev_wcwidth(text, cursor.x) + 1, line_region.end())
if not trailing_region.empty() and len(view.substr(trailing_region).strip()) == 0:
view.erase(edit, trailing_region)
self._row_cache.pop(row, None)
def trim_history(self, edit, terminal):
"""Hold the view to scrollback_size rows, and drop rows below the screen."""
view = self.view
screen = terminal.screen
lastrow = view.rowcol(view.size())[0]
n = terminal.scrollback_size
if lastrow + 1 > n:
# queued rows are about to be renumbered or erased: merge them first
self.flush_chunk()
m = max(lastrow + 1 - n, math.ceil(n / 10))
for line in range(m):
self.decolorize_line(line)
self.colored_lines = {k - m: v for (k, v) in self.colored_lines.items()}
self._row_cache = {k - m: v for (k, v) in self._row_cache.items() if k >= m}
top_region = sublime.Region(0, view.full_line(view.text_point(m - 1, 0)).end())
view.erase(edit, top_region)
terminal.offset -= m
lastrow -= m
# chunks wholly above the cut go; the boundary chunk drops the
# regions the erase collapsed to empty
self._trimmed += m
cut = self._trimmed // CHUNK
for chunk in [c for c in self._archived_chunks if c < cut]:
for scope in self._archived_chunks.pop(chunk):
view.erase_regions(chunk_key(chunk, scope))
for scope in self._archived_chunks.get(cut, ()):
key = chunk_key(cut, scope)
view.add_regions(key, [r for r in view.get_regions(key) if not r.empty()], scope)
if lastrow > terminal.offset + screen.lines:
tail_region = sublime.Region(view.text_point(terminal.offset + screen.lines, 0), view.size())
for line in view.lines(tail_region):
row = view.rowcol(line.begin())[0]
self.decolorize_line(row)
self._row_cache.pop(row, None)
view.erase(edit, tail_region)
def focus_cursor(view, edit, terminal):
"""Put the caret where the terminal cursor is, keeping any user selection.
The caret is placed even when the app hides the cursor (CSI ?25l): sublime
drops a plain space keypress in a view that has no caret, and IME text has
no insertion point. sync_cursor_style turns it into a block there, so it
lands on the cell the app draws its own cursor over, not beside it.
"""
sel = view.sel()
# keep a user selection; typing collapses it (VtermKeypressCommand)
if any(not region.empty() for region in sel):
return
sel.clear()
screen = terminal.screen
cursor = screen.cursor
offset = terminal.offset
ensure_position(view, edit, cursor.y + offset)
line_region = view.line(view.text_point(cursor.y + offset, 0))
text = view.substr(line_region)
col = rev_wcwidth(text, cursor.x) + 1
ensure_position(view, edit, cursor.y + offset, col)
pt = view.text_point(cursor.y + offset, col)
sel.add(sublime.Region(pt, pt))
def scroll_to_cursor(view, terminal):
"""Follow the output: pin the viewport to the bottom of the live screen."""
terminal._scroll_pending = False
# layout_extent is maintained incrementally; text_to_layout(size()) forced
# a whole-buffer layout on every call
viewport_y = view.layout_extent()[1] - view.viewport_extent()[1]
offset_y = view.text_to_layout(view.text_point(terminal.offset, 0))[1]
y = max(offset_y, viewport_y)
terminal.viewport_y = y
view.set_viewport_position((0, y), False)
def request_scroll(view, terminal):
"""Coalesce follow-scrolls: at most one queued callback per terminal."""
if not terminal._scroll_pending:
terminal._scroll_pending = True
sublime.set_timeout(lambda: scroll_to_cursor(view, terminal))
class VtermShowCursorCommand(sublime_plugin.TextCommand):
"""Places the caret at the terminal cursor. For callers outside a render,
which has no edit token of its own."""
def run(self, edit, scroll=True):
"""Args:
scroll: also follow the output down to the cursor.
"""
view = self.view
terminal = Terminal.from_id(view.id())
if not terminal:
return
focus_cursor(view, edit, terminal)
if scroll:
request_scroll(view, terminal)

268
vterm/scheme.py Normal file
View File

@@ -0,0 +1,268 @@
""" Colour scheme generated on demand: a region can only name a scope, and a scope's colours must already exist in the scheme """
import json
import logging
import os
import threading
import sublime
from .emulator import ANSI_COLORS
from .utils import settings
from .themes import THEMES
LOG = logging.getLogger(__name__)
# written under Packages/User so it survives a package reinstall
SCHEME_DIR_NAME = "User/vterm"
# "hidden-" keeps the generated scheme out of the scheme picker
SCHEME_FILE_NAME = "vterm.hidden-color-scheme"
# the value terminal views get as their color_scheme setting
SCHEME_PATH = "Packages/{}/{}".format(SCHEME_DIR_NAME, SCHEME_FILE_NAME)
# each write makes sublime reload the scheme and restyle every view
WRITE_DEBOUNCE_MS = 250
# palette used when no theme is selected, overridable via vterm.sublime-settings
DEFAULT_ANSI = {
"black": "#000000",
"red": "#cd3131",
"green": "#0dbc79",
"brown": "#e5e510",
"blue": "#2472c8",
"magenta": "#bc3fbc",
"cyan": "#11a8cd",
"white": "#e5e5e5",
"light_black": "#666666",
"light_red": "#f14c4c",
"light_green": "#23d18b",
"light_brown": "#f5f543",
"light_blue": "#3b8eea",
"light_magenta": "#d670d6",
"light_cyan": "#29b8db",
"light_white": "#ffffff",
}
def next_color(color_text):
"""Nudge a #rrggbb colour by one; add_regions inverts fg/bg when they match exactly"""
hex_value = int(color_text[5:7], 16)
if hex_value == 255:
return color_text[:5] + "fe"
else:
return color_text[:5] + "{:02x}".format(hex_value + 1)
def blend(base, other, ratio):
"""Mix two #rrggbb colours"""
channels = []
for i in (1, 3, 5):
a = int(base[i : i + 2], 16)
b = int(other[i : i + 2], 16)
channels.append(round(a + (b - a) * ratio))
return "#{:02x}{:02x}{:02x}".format(*channels)
def _is_dark(color_text):
"""True when a #rrggbb colour is dark by perceived luminance."""
r, g, b = (int(color_text[i : i + 2], 16) for i in (1, 3, 5))
return 0.299 * r + 0.587 * g + 0.114 * b < 128
class SchemeManager:
"""Owns the generated scheme file and the set of known scopes."""
def __init__(self):
"""Start on the built-in palette; initialize() picks up the real one."""
self.lock = threading.Lock()
self.rules = {}
# key -> scope name; the render thread reads it without the lock, so it
# must never outlive self.rules (cleared together in _build)
self._names = {}
self.foreground = "#ffffff"
self.background = "#000000"
self._editor_fg = "#ffffff"
self._editor_bg = "#000000"
self.ansi = dict(DEFAULT_ANSI)
self._write_pending = False
self._initialized = False
self._dir = None
self._host_view = None
self._listeners = []
def initialize(self, view):
"""Pick up the palette, seed the rules, write the file. Main thread only"""
if self._initialized:
return
self._initialized = True
self._host_view = view
settings().add_on_change("vterm.theme", self.reload)
self._build()
def ensure_file(self):
"""Write the scheme if missing: a restored tab's color_scheme is loaded during startup"""
if os.path.exists(os.path.join(self._directory(), SCHEME_FILE_NAME)):
return
with self.lock:
self._build()
def add_listener(self, callback):
"""Called after every palette rebuild"""
self._listeners.append(callback)
def remove_listener(self, callback):
"""Drop a rebuild subscriber; terminals unsubscribe when they die."""
if callback in self._listeners:
self._listeners.remove(callback)
def reload(self):
"""Rebuild every rule for a new palette; colours resolve at add time, so they cannot be appended"""
if not self._initialized:
# no scheme file to maintain yet; initialize() will read the setting
return
with self.lock:
self._build()
for callback in list(self._listeners):
try:
callback()
except Exception:
LOG.warning("scheme listener failed", exc_info=True)
def _build(self):
"""Resolve the palette and regenerate every rule, then write the file.
Rules cannot be appended after a palette change: each one stores resolved
colours, so they all have to be rebuilt. Caller holds the lock.
"""
self._names = {}
self.rules = {}
self.ansi = dict(DEFAULT_ANSI)
theme = THEMES.get(settings().get("theme"))
if theme:
self.foreground = theme["foreground"]
self.background = theme["background"]
self.ansi.update({name: theme[name] for name in ANSI_COLORS if name in theme})
else:
# captured values outlive the view they came from
if self._host_view is not None and self._host_view.is_valid():
style = self._host_view.style()
self._editor_fg = style.get("foreground", self._editor_fg)
self._editor_bg = style.get("background", self._editor_bg)
self.foreground = self._editor_fg
self.background = self._editor_bg
# user overrides sit on top of whichever palette was chosen
user_ansi = settings().get("ansi_colors", {})
for name in ANSI_COLORS:
if name in user_ansi:
self.ansi[name] = user_ansi[name]
# seeded so the first frame does not reference scopes the file lacks
for fg in ANSI_COLORS:
self._add_rule(fg, "default", "")
self._add_rule(fg, "default", "b")
self._add_rule("default", "default", "b")
self._add_rule("reverse_default", "reverse_default", "")
self._write_file()
def is_dark(self):
"""Whether the active palette is dark; reported to the app via OSC 11 and DSR 996."""
return _is_dark(self.background)
def scope(self, fg, bg, style):
"""Scope for a colour combination, creating its rule if new. Thread-safe"""
key = (fg, bg, style)
# hot path: one GIL-atomic dict read, no lock, no formatting
name = self._names.get(key)
if name is not None:
return name
with self.lock:
if key not in self.rules:
self._add_rule(fg, bg, style)
if not self._write_pending:
self._write_pending = True
# a warm-up burst coalesces into one write, off the UI thread
sublime.set_timeout_async(self._flush, WRITE_DEBOUNCE_MS)
name = self._scope_name(fg, bg, style)
self._names[key] = name
return name
def _scope_name(self, fg, bg, style):
"""Scope for a colour combination, e.g. vterm.red.default.b"""
if style:
return "vterm.{}.{}.{}".format(fg, bg, style)
return "vterm.{}.{}".format(fg, bg)
def _resolve(self, color, for_bg):
"""Turn a pyte colour name into #rrggbb against the active palette."""
if color == "default":
return self.background if for_bg else self.foreground
if color == "reverse_default":
return self.foreground if for_bg else self.background
if color in self.ansi:
return self.ansi[color]
return "#" + color
def _add_rule(self, fg, bg, style):
"""Add the scheme rule for one colour combination. Caller holds the lock."""
fg_color = self._resolve(fg, for_bg=False)
bg_color = self._resolve(bg, for_bg=True)
if bg_color.lower() == self.background.lower():
bg_color = next_color(bg_color)
rule = {
"scope": self._scope_name(fg, bg, style),
"foreground": fg_color,
"background": bg_color,
}
font_style = []
if "b" in style:
font_style.append("bold")
if "i" in style:
font_style.append("italic")
if "u" in style:
font_style.append("underline")
if font_style:
rule["font_style"] = " ".join(font_style)
self.rules[(fg, bg, style)] = rule
def _directory(self):
"""Path of the generated scheme's directory, created on first use."""
if self._dir is None:
self._dir = os.path.join(sublime.packages_path(), *SCHEME_DIR_NAME.split("/"))
os.makedirs(self._dir, exist_ok=True)
return self._dir
def _flush(self):
"""Debounced write, on sublime's async thread."""
with self.lock:
self._write_pending = False
self._write_file()
def _write_file(self):
"""Serialize globals and every rule to the scheme file. Caller holds the lock."""
scheme = {
"name": "vterm",
"globals": {
"background": self.background,
"foreground": self.foreground,
"caret": self.foreground,
"block_caret": self.foreground,
"gutter": self.background,
"selection": blend(self.background, self.foreground, 0.3),
"selection_foreground": self.foreground,
"selection_border_width": "0",
"inactive_selection": blend(self.background, self.foreground, 0.15),
},
"rules": list(self.rules.values()),
}
path = os.path.join(self._directory(), SCHEME_FILE_NAME)
with open(path, "w", encoding="utf-8") as f:
# compact: sublime reparses the whole file on every write
json.dump(scheme, f, separators=(",", ":"))
LOG.debug("scheme written: rules=%s", len(self.rules))
scheme_manager = SchemeManager()

391
vterm/terminal.py Normal file
View File

@@ -0,0 +1,391 @@
""" Terminal lifecycle: pty process, emulator state, reader/renderer threads """
import codecs
import logging
import os
import threading
import time
from collections import deque
from queue import Empty, Queue
import sublime
from ptyprocess import PtyProcess
from .emulator import VtScreen, VtStream
from .keys import get_key_code
from .scheme import scheme_manager
from .utils import intermission, responsive, settings
LOG = logging.getLogger(__name__)
# renderer tick, ~33 fps
RENDER_PERIOD = 0.03
# draw rate for a visible terminal the user is not typing in, ~15 fps
UNFOCUSED_RENDER_PERIOD = 0.075
# max chars parsed per tick, so a giant burst still renders intermediate frames
FEED_LIMIT = 256 * 1024
# SUPPRESS_HISTORY_AFTER_CLEAR in emulator.py must outlast this
REPAINT_NUDGE_DELAY = 0.25
def view_size(view, default=None):
"""Terminal (rows, columns) that fit the view's viewport."""
pixel_width, pixel_height = view.viewport_extent()
pixel_per_line = view.line_height()
pixel_per_char = view.em_width()
if pixel_per_line == 0 or pixel_per_char == 0:
return default or (1, 1)
# -3 columns: scrollbar and padding fudge
nb_columns = max(int(pixel_width / pixel_per_char) - 3, 1)
nb_rows = max(int(pixel_height / pixel_per_line), 1)
if nb_columns == 1 and default:
return default
nb_columns = min(
max(nb_columns, settings().get("min_columns", 20)),
settings().get("max_columns", 500),
)
return (nb_rows, nb_columns)
def agent_env(overrides=None):
"""Environment for a vterm child process"""
env = os.environ.copy()
env["TERM"] = "xterm-256color"
env["COLORTERM"] = "truecolor"
env.pop("TERM_PROGRAM", None)
env.pop("TERM_PROGRAM_VERSION", None)
env.setdefault("LANG", "en_US.UTF-8")
env.update(overrides or {})
return env
class TerminalPtyProcess(PtyProcess):
"""PtyProcess that takes str; ptyprocess itself only writes bytes."""
def write(self, s):
"""Encode text and write it to the pty."""
b = s.encode("utf-8", "backslashreplace")
return super().write(b)
class Terminal:
"""One terminal: its pty, emulator and the threads that keep the view current.
Instances live in the class-level registry keyed by view id, which is why
the plugin never purges its submodules on reload — rebuilding the registry
would orphan every open tab.
"""
_terminals = {}
def __init__(self, view):
"""State only; the pty and the threads come up in start()."""
self.view = view
# process.isalive() costs two waitpid syscalls; UI hooks read this instead
self.alive = True
self.default_title = ""
self.offset = 0
self.viewport_y = 0
self.scrollback_size = 5000
self._hosted = True
self._hosted_checked = 0
self.focused = True
self._visible = True
self._visible_checked = 0
self._rendered = 0
self._cached_cursor = (0, 0, False, 0)
self.applied_alt_screen = False
self._strings = Queue()
self._pending_to_send_string = False
self._wheel_captured = False
self._scroll_pending = False
self.pending_clear = False
self.pending_clear_screen = False
# a UTF-8 sequence split across read chunks must not be dropped
self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
@classmethod
def from_id(cls, vid):
"""The terminal behind a view id, or None once it is gone."""
return cls._terminals.get(vid)
def is_hosted(self):
"""Window lookup, throttled per terminal (a shared @responsive closure would rate-limit all of them)"""
now = time.time()
if now - self._hosted_checked > 1:
self._hosted_checked = now
self._hosted = self.view.window() is not None
return self._hosted
def is_visible(self):
"""Whether this tab is the front one in its group, throttled like is_hosted.
Selecting another tab in a group that does not hold focus raises no
event for this view, so the flag has to be polled.
"""
if self.focused:
return True
now = time.time()
if now - self._visible_checked > 1:
self._visible_checked = now
window = self.view.window()
group = window.get_view_index(self.view)[0] if window else -1
self._visible = group >= 0 and window.active_view_in_group(group) == self.view
return self._visible
def is_render_due(self):
"""Whether an unfocused terminal may draw again.
A frame costs a command dispatch and a dozen UI round trips however
little changed, so a terminal the user is only watching gets them at a
lower rate than one they are typing in.
"""
if self.focused:
return True
now = time.time()
if now - self._rendered < UNFOCUSED_RENDER_PERIOD:
return False
self._rendered = now
return True
def start(self, cmd, cwd=None, env=None, default_title=None):
"""Spawn the pty sized to the view, build the emulator, start the threads."""
view = self.view
Terminal._terminals[view.id()] = self
self.default_title = default_title
size = view_size(view, default=(40, 80))
LOG.debug("[start] cmd=%s cwd=%s size=%s", cmd, cwd, size)
self.process = TerminalPtyProcess.spawn(cmd, cwd=cwd, env=agent_env(env), dimensions=size)
# one source for the deque and the view row cap (alt screen zeroes the deque)
self.scrollback_size = settings().get("scrollback_history_size", 50000)
self.screen = VtScreen(
size[1],
size[0],
process=self.process,
history=self.scrollback_size,
erase_scrollback=settings().get("honor_erase_scrollback", False),
clear_callback=self._clear_callback,
clipboard_callback=self._clipboard_callback,
fg_color=scheme_manager.foreground.lstrip("#"),
bg_color=scheme_manager.background.lstrip("#"),
is_dark=scheme_manager.is_dark(),
)
self.stream = VtStream(self.screen)
scheme_manager.add_listener(self.apply_scheme)
self._start_rendering()
def apply_scheme(self):
"""Adopt a new palette; mode 2031 subscribers are told so they can restyle"""
# the screen answers OSC 10/11 and DSR 996 from these values
screen = self.screen
screen.default_fg_color = scheme_manager.foreground.lstrip("#")
screen.default_bg_color = scheme_manager.background.lstrip("#")
screen.theme_is_dark = scheme_manager.is_dark()
if screen.theme_notify_enabled():
self.send_string("\x1b[?997;{}n".format(1 if screen.theme_is_dark else 2), normalized=False)
def kill(self):
"""Terminate the child and drop this terminal from the registry."""
LOG.debug("[kill] terminating process")
self.alive = False
scheme_manager.remove_listener(self.apply_scheme)
try:
self.process.terminate()
except Exception:
LOG.warning("terminate failed", exc_info=True)
vid = self.view.id()
if vid in Terminal._terminals:
del Terminal._terminals[vid]
def _clear_callback(self):
"""Screen asked for a clear; the next render performs it on the UI thread."""
self.pending_clear = True
def _clipboard_callback(self, text):
"""OSC 52 payload; the clipboard may only be touched on the UI thread."""
sublime.set_timeout(lambda: sublime.set_clipboard(text))
def _need_to_render(self):
"""Whether anything changed since the last frame: dirty rows, a clear, or the cursor."""
screen = self.screen
if self.pending_clear or self.pending_clear_screen:
# a pending clear must render even when the app is idle
return True
cursor = (screen.cursor.x, screen.cursor.y, screen.cursor_hidden(), screen.cursor_style)
if screen.dirty or cursor != self._cached_cursor:
self._cached_cursor = cursor
return True
return False
def _start_rendering(self):
"""Start the reader and renderer threads that drive this terminal."""
# deque appends/poplefts are atomic, so the reader never blocks behind
# a render; a lock here used to backpressure the pty for whole frames
chunks = deque()
done = [False]
@responsive(period=1, default=False)
def was_resized():
"""Whether the viewport no longer matches the screen. Costs sublime API calls, so once a second."""
size = view_size(self.view)
return self.screen.lines != size[0] or self.screen.columns != size[1]
def reader():
"""Drain the pty into `chunks` until EOF or the view goes away."""
while True:
try:
# ptyprocess buffers 8K; smaller reads double the syscalls
raw = self.process.read(8192)
except EOFError:
break
chunks.append(self._decoder.decode(raw))
if done[0] or not self.is_hosted():
LOG.debug("[reader] stops")
break
self.alive = False
done[0] = True
def feed_data():
"""Parse up to FEED_LIMIT of pending output into the screen."""
parts = []
size = 0
while chunks and size < FEED_LIMIT:
part = chunks.popleft()
parts.append(part)
size += len(part)
if parts:
try:
self.stream.feed("".join(parts))
except Exception:
# pyte resets its parser before re-raising, so the terminal
# can carry on; letting this escape would kill the thread
LOG.exception("stream.feed failed, dropping the rest of a chunk")
def renderer():
"""Own the emulator: parse, then ask sublime to redraw, once per tick."""
while True:
# a hidden tab is not drawn at all: the screen keeps the state
# and one render on unhide catches the view up. The tick stays at
# full rate either way, or the parser would fall behind the pty
visible = self.is_visible()
with intermission(period=RENDER_PERIOD):
feed_data()
if self.screen.in_sync_frame():
# mid ?2026 frame: wait for the closing sequence
continue
if visible:
if was_resized():
self.handle_resize()
self.view.run_command("vterm_show_cursor")
# due first: _need_to_render consumes the cursor move it reports
if self.is_render_due() and self._need_to_render():
self.view.run_command("vterm_render")
self._sync_wheel_flag()
if done[0] or not self.is_hosted():
LOG.debug("[renderer] stops")
break
while chunks:
feed_data()
done[0] = True
sublime.set_timeout(lambda: self.view.run_command("vterm_cleanup"))
threading.Thread(target=reader, daemon=True).start()
threading.Thread(target=renderer, daemon=True).start()
def handle_resize(self):
"""Match the pty and the screen to the view, which signals SIGWINCH to the app."""
size = view_size(self.view)
LOG.debug("[resize] %sx%s -> %sx%s", self.screen.lines, self.screen.columns, size[0], size[1])
try:
self.process.setwinsize(*size)
self.screen.resize(*size)
except OSError:
# pty already gone; cleanup follows via the reader thread
LOG.warning("resize failed", exc_info=True)
def nudge_repaint(self):
"""Make the app repaint by briefly narrowing the pty. Never write ctrl+L instead: it is /clear in Claude Code"""
rows, columns = self.screen.lines, self.screen.columns
self._set_winsize(rows, max(columns - 1, 1))
sublime.set_timeout(lambda: self._set_winsize(rows, columns), int(REPAINT_NUDGE_DELAY * 1000))
def _set_winsize(self, rows, columns):
"""Resize the pty alone, leaving the screen as it is."""
try:
self.process.setwinsize(rows, columns)
except OSError:
LOG.warning("setwinsize failed", exc_info=True)
def send_key(self, **kwargs):
"""Encode a key name plus modifiers for the app's current modes and send it."""
kwargs["application_mode"] = (1 << 5) in self.screen.mode
kwargs["new_line_mode"] = 20 in self.screen.mode
self.send_string(get_key_code(**kwargs), normalized=False)
def send_string(self, string, normalized=True):
"""Write text to the pty, chunking anything large.
Args:
normalized: canonicalise line endings for the app's LNM setting;
off for escape sequences, which must go through untouched.
"""
if normalized:
string = string.replace("\r\n", "\n")
if 20 in self.screen.mode:
string = string.replace("\n", "\r\n")
else:
string = string.replace("\n", "\r")
# chunked so a big paste cannot overrun the pty buffer; the blocking
# pty write is the backpressure, no pacing sleep needed
no_queue = not self._pending_to_send_string
if no_queue and len(string) <= 4096:
self.process.write(string)
else:
for i in range(0, len(string), 4096):
self._strings.put(string[i : i + 4096])
if no_queue:
self._pending_to_send_string = True
threading.Thread(target=self._process_send_string, daemon=True).start()
def _process_send_string(self):
"""Drain the queued paste chunks; the blocking pty write paces them."""
while True:
try:
# linger so a burst of sends does not respawn the thread
string = self._strings.get(timeout=0.2)
self.process.write(string)
except Empty:
self._pending_to_send_string = False
return
def _sync_wheel_flag(self):
"""Publish whether the app owns the wheel, for the mousemap context."""
# the mousemap routes the wheel to the app while it reports the mouse
# or owns the alt screen; VtermScrollCommand picks the encoding
captured = self.screen.mouse_reporting_enabled() or self.screen.alternate_buffer_mode
if captured != self._wheel_captured:
self._wheel_captured = captured
self.view.settings().set("vterm_view.wheel_captured", captured)
def send_focus(self, focused):
"""Report focus gain or loss, when the app asked for it (?1004)."""
if self.screen.focus_tracking_enabled():
self.send_string("\x1b[I" if focused else "\x1b[O", normalized=False)
def send_mouse(self, button, col, row, pressed):
"""Emit an SGR mouse report (1-based col/row)."""
if not self.screen.mouse_reporting_enabled():
return
self.send_string("\x1b[<{};{};{}{}".format(button, col, row, "M" if pressed else "m"), normalized=False)

53
vterm/themes.py Normal file
View File

@@ -0,0 +1,53 @@
""" Terminal colour palettes
The macOS Terminal.app classics, in the colour naming scheme.py already uses
(the names come from pyte). Selected with the `theme` setting; without one, the
terminal follows the active Sublime colour scheme instead.
"""
# the 16 ANSI colours every Terminal.app theme below shares; only fg/bg differ
_TERMINAL_APP_ANSI = {
"black": "#000000",
"red": "#990000",
"green": "#00a600",
"brown": "#999900",
"blue": "#0000b2",
"magenta": "#b200b2",
"cyan": "#00a6b2",
"white": "#bfbfbf",
"light_black": "#666666",
"light_red": "#e50000",
"light_green": "#00d900",
"light_brown": "#e5e500",
"light_blue": "#0000ff",
"light_magenta": "#e500e5",
"light_cyan": "#00e5e5",
"light_white": "#e5e5e5",
}
# selectable palettes, by the name the "theme" setting takes
THEMES = {
"default": dict(_TERMINAL_APP_ANSI, foreground="#000000", background="#ffffff"),
"homebrew": dict(_TERMINAL_APP_ANSI, foreground="#00ff00", background="#000000"),
"pro": dict(_TERMINAL_APP_ANSI, foreground="#f2f2f2", background="#000000", blue="#2009db"),
"slate": {
"foreground": "#35b1d2",
"background": "#222222",
"black": "#222222",
"red": "#e2a8bf",
"green": "#81d778",
"brown": "#c4c9c0",
"blue": "#264b49",
"magenta": "#a481d3",
"cyan": "#15ab9c",
"white": "#02c5e0",
"light_black": "#ffffff",
"light_red": "#ffcdd9",
"light_green": "#beffa8",
"light_brown": "#d0ccca",
"light_blue": "#7ab0d2",
"light_magenta": "#c5a7d9",
"light_cyan": "#8cdfe0",
"light_white": "#e0e0e0",
},
}

72
vterm/utils.py Normal file
View File

@@ -0,0 +1,72 @@
""" Small helpers shared across vterm modules """
import time
from contextlib import contextmanager
from functools import wraps
import sublime
from pyte.screens import wcwidth
# the plugin's own settings file, loaded once by settings()
SETTINGS_FILE = "vterm.sublime-settings"
_settings = None
def settings():
"""Cached settings handle; a sublime Settings object stays live"""
global _settings
if _settings is None:
_settings = sublime.load_settings(SETTINGS_FILE)
return _settings
def responsive(period=0.1, default=True):
"""Rate-limit a function; return `default` between invocations."""
def wrapper(f):
# a list so the inner closure can mutate it without `nonlocal`
t = [0]
@wraps(f)
def _(*args, **kwargs):
now = time.time()
if now - t[0] > period:
t[0] = now
return f(*args, **kwargs)
else:
return default
return _
return wrapper
@contextmanager
def intermission(period=0.1):
"""Sleep out the remainder of `period` seconds after the body ran."""
startt = time.time()
yield
deltat = time.time() - startt
if deltat < period:
time.sleep(period - deltat)
def rev_wcwidth(text, width):
"""Return the index into `text` at which the substring reaches `width` cells.
The inverse of wcswidth: maps a terminal column back onto a character
index, so a wide char or a tab does not put the caret in the wrong place.
"""
if width == 0:
return -1
w = 0
i = -1
for i, c in enumerate(text):
w += wcwidth(c)
if w >= width:
break
if w >= width:
return i
else:
return i + width - w