commit 430af419c04c3672c08c78ccc27cee0272e77b52 Author: Vaclav Tvrdik Date: Thu Aug 27 14:23:44 2026 +0200 initial import diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1126e18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +vterm/__pycache__ +*.sublime-workspace diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..cc1923a --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.8 diff --git a/Architecture.md b/Architecture.md new file mode 100644 index 0000000..a131f57 --- /dev/null +++ b/Architecture.md @@ -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; + 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[ 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`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d93c4e1 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/CODE.md b/CODE.md new file mode 100644 index 0000000..70e72c7 --- /dev/null +++ b/CODE.md @@ -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: + +* `".: "` 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: `": ; "`, 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. diff --git a/Default (OSX).sublime-mousemap b/Default (OSX).sublime-mousemap new file mode 100644 index 0000000..1e443e4 --- /dev/null +++ b/Default (OSX).sublime-mousemap @@ -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}] + } +] diff --git a/Default.sublime-keymap b/Default.sublime-keymap new file mode 100644 index 0000000..21d80e6 --- /dev/null +++ b/Default.sublime-keymap @@ -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"}]} +] diff --git a/README.md b/README.md new file mode 100644 index 0000000..31b2233 --- /dev/null +++ b/README.md @@ -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`. diff --git a/Users/vaclavt/.pi/agent/skills/brave-search b/Users/vaclavt/.pi/agent/skills/brave-search new file mode 100644 index 0000000..5f24fc3 --- /dev/null +++ b/Users/vaclavt/.pi/agent/skills/brave-search @@ -0,0 +1 @@ +/Users/vaclavt/.pi/agent/skills/pi-skills/brave-search diff --git a/dependencies.json b/dependencies.json new file mode 100644 index 0000000..deabf99 --- /dev/null +++ b/dependencies.json @@ -0,0 +1,9 @@ +{ + "*": { + ">3000": [ + "ptyprocess", + "pyte", + "wcwidth" + ] + } +} diff --git a/main.py b/main.py new file mode 100644 index 0000000..a559ccd --- /dev/null +++ b/main.py @@ -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) diff --git a/tests/_bootstrap.py b/tests/_bootstrap.py new file mode 100644 index 0000000..9060023 --- /dev/null +++ b/tests/_bootstrap.py @@ -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 [] diff --git a/tests/harness_live.py b/tests/harness_live.py new file mode 100644 index 0000000..54cd910 --- /dev/null +++ b/tests/harness_live.py @@ -0,0 +1,89 @@ +""" Live harness: run a real command inside VtScreen/VtStream without Sublime. +Usage: python3 tests/harness_live.py [seconds] [--send | --type ] +--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)) diff --git a/tests/stubs/sublime.py b/tests/stubs/sublime.py new file mode 100644 index 0000000..b5801c1 --- /dev/null +++ b/tests/stubs/sublime.py @@ -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" diff --git a/tests/stubs/sublime_plugin.py b/tests/stubs/sublime_plugin.py new file mode 100644 index 0000000..cad5ad1 --- /dev/null +++ b/tests/stubs/sublime_plugin.py @@ -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 diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..0595bda --- /dev/null +++ b/tests/test_commands.py @@ -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)) diff --git a/tests/test_emulator.py b/tests/test_emulator.py new file mode 100644 index 0000000..92649fc --- /dev/null +++ b/tests/test_emulator.py @@ -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[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() diff --git a/tests/test_imports.py b/tests/test_imports.py new file mode 100644 index 0000000..4ea30cb --- /dev/null +++ b/tests/test_imports.py @@ -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+ +# 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: + # (? 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) diff --git a/tools/_prof_feed.py b/tools/_prof_feed.py new file mode 100644 index 0000000..eda46d1 --- /dev/null +++ b/tools/_prof_feed.py @@ -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()) diff --git a/tools/bench_vs_terminus.py b/tools/bench_vs_terminus.py new file mode 100644 index 0000000..0f0bc71 --- /dev/null +++ b/tools/bench_vs_terminus.py @@ -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() diff --git a/tools/gen_keymap.py b/tools/gen_keymap.py new file mode 100644 index 0000000..b904613 --- /dev/null +++ b/tools/gen_keymap.py @@ -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)) diff --git a/vterm.sublime-commands b/vterm.sublime-commands new file mode 100644 index 0000000..ed5b9af --- /dev/null +++ b/vterm.sublime-commands @@ -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" } +] diff --git a/vterm.sublime-project b/vterm.sublime-project new file mode 100644 index 0000000..a9d0589 --- /dev/null +++ b/vterm.sublime-project @@ -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}", + } + ], +} diff --git a/vterm.sublime-settings b/vterm.sublime-settings new file mode 100644 index 0000000..5d44d38 --- /dev/null +++ b/vterm.sublime-settings @@ -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 +} diff --git a/vterm.sublime-syntax b/vterm.sublime-syntax new file mode 100644 index 0000000..8f311d7 --- /dev/null +++ b/vterm.sublime-syntax @@ -0,0 +1,7 @@ +%YAML 1.2 +--- +name: vterm +hidden: true +scope: text.vterm +contexts: + main: [] diff --git a/vterm/__init__.py b/vterm/__init__.py new file mode 100644 index 0000000..6bebdd5 --- /dev/null +++ b/vterm/__init__.py @@ -0,0 +1 @@ +""" vterm: minimalist terminal for Sublime Text """ diff --git a/vterm/commands.py b/vterm/commands.py new file mode 100644 index 0000000..b9f0e39 --- /dev/null +++ b/vterm/commands.py @@ -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 diff --git a/vterm/emulator.py b/vterm/emulator.py new file mode 100644 index 0000000..77ef5ee --- /dev/null +++ b/vterm/emulator.py @@ -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 (' 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 diff --git a/vterm/listeners.py b/vterm/listeners.py new file mode 100644 index 0000000..f36943f --- /dev/null +++ b/vterm/listeners.py @@ -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) diff --git a/vterm/mouse.py b/vterm/mouse.py new file mode 100644 index 0000000..4fb7251 --- /dev/null +++ b/vterm/mouse.py @@ -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 diff --git a/vterm/render.py b/vterm/render.py new file mode 100644 index 0000000..ef1c914 --- /dev/null +++ b/vterm/render.py @@ -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) diff --git a/vterm/scheme.py b/vterm/scheme.py new file mode 100644 index 0000000..7f594de --- /dev/null +++ b/vterm/scheme.py @@ -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() diff --git a/vterm/terminal.py b/vterm/terminal.py new file mode 100644 index 0000000..28333fa --- /dev/null +++ b/vterm/terminal.py @@ -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) diff --git a/vterm/themes.py b/vterm/themes.py new file mode 100644 index 0000000..9516fcf --- /dev/null +++ b/vterm/themes.py @@ -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", + }, +} diff --git a/vterm/utils.py b/vterm/utils.py new file mode 100644 index 0000000..a47334a --- /dev/null +++ b/vterm/utils.py @@ -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