11 Commits

Author SHA1 Message Date
Vaclav Tvrdik
76927a85ea Point Package Control install at shared sublime-chanel channel
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 11:29:47 +02:00
Vaclav Tvrdik
e150591763 Add GitHub-based install instructions and Package Control repository.json
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 11:06:35 +02:00
Vaclav Tvrdik
27f1140737 Fix when pi started in vterm does not scroll to the end 2026-09-16 16:50:04 +02:00
Vaclav Tvrdik
d371d35fb3 configurable scrolling speed implemented 2026-09-16 16:14:42 +02:00
Vaclav Tvrdik
119d4124f0 bound the undo stack by clearing it every 100 frames 2026-09-11 23:18:48 +02:00
Vaclav Tvrdik
c8caecdc45 batch plain ascii runs in draw with a C-speed row fill 2026-09-11 23:18:25 +02:00
Vaclav Tvrdik
6e93ae2a5b benchmarking and profiling added 2026-09-11 12:33:57 +02:00
Vaclav Tvrdik
f37da42a74 fix for another vim glitch 2026-09-10 17:33:40 +02:00
Vaclav Tvrdik
ca0d76cf5b fix for vim glitch 2026-09-10 17:28:14 +02:00
Vaclav Tvrdik
01a88f7b19 halve the parse cost of coloured output and keep drawing during bursts 2026-09-10 13:03:34 +02:00
Vaclav Tvrdik
28c9c398a1 wake the renderer on pty output instead of polling at 33 fps 2026-09-10 11:23:09 +02:00
14 changed files with 793 additions and 36 deletions

View File

@@ -123,6 +123,9 @@ child writes
│ chunks.append(text) collections.deque, no lock │ chunks.append(text) collections.deque, no lock
│ │
├─ renderer thread, every 30 ms (RENDER_PERIOD) ─────────────── ├─ renderer thread, every 30 ms (RENDER_PERIOD) ───────────────
│ _wake.wait(IDLE_TICK) idle: park until the reader has output,
│ then SETTLE, so the writes of one app
│ update are drawn as a single frame
│ feed_data() popleft up to FEED_LIMIT (256 KiB) │ feed_data() popleft up to FEED_LIMIT (256 KiB)
│ stream.feed(joined) → VtStream parser → VtScreen.draw │ stream.feed(joined) → VtStream parser → VtScreen.draw
│ mutates buffer, fills screen.dirty │ mutates buffer, fills screen.dirty
@@ -359,9 +362,11 @@ replaces both together.
**Plugin settings** (`vterm.sublime-settings`, read through the cached `settings()` **Plugin settings** (`vterm.sublime-settings`, read through the cached `settings()`
handle): `auto_close`, `scrollback_history_size`, `honor_erase_scrollback`, handle): `auto_close`, `scrollback_history_size`, `honor_erase_scrollback`,
`scroll_modifier`, `min_columns`, `max_columns`, `theme`, `ansi_colors`, `terminal`, `scroll_modifier`, `wheel_lines`, `min_columns`, `max_columns`, `theme`, `ansi_colors`, `terminal`,
`parameters`, `debug`. `scroll_modifier` is the modifier that turns PgUp/PgDn and Up/Down `parameters`, `debug`. `scroll_modifier` is the modifier that turns PgUp/PgDn and Up/Down
into a scroll of the view — a page, a row — instead of a key for the app. `terminal` and into a scroll of the view — a page, a row — instead of a key for the app. `wheel_lines`
is how many scroll lines one wheel notch sends the app (3, as iTerm2 delivers per notch;
a trackpad converted to notches scrolls that much faster, so lower it). `terminal` and
`parameters` name the external terminal spawned by *vterm: Open Terminal in Project `parameters` name the external terminal spawned by *vterm: Open Terminal in Project
Folder* (`%CWD%` in `parameters` becomes the project folder). ED 3 never touches the visible screen (as in Folder* (`%CWD%` in `parameters` becomes the project folder). ED 3 never touches the visible screen (as in
xterm), and `honor_erase_scrollback` is off, so it does not drop the scrollback either: xterm), and `honor_erase_scrollback` is off, so it does not drop the scrollback either:

View File

@@ -9,17 +9,46 @@ Requires Sublime Text 4 with the Python 3.8 plugin host (build 4149+, for
## Install ## Install
Symlink this folder into Sublime packages as `vterm`: ### With Package Control
```sh vterm is distributed through the shared channel at
ln -s /Users/vaclavt/devel/vt/vterm "$HOME/Library/Application Support/Sublime Text/Packages/vterm" [vaclavt/sublime-chanel](https://github.com/vaclavt/sublime-chanel), not the
default one. Open **Preferences → Package Settings → Package Control →
Settings** and put the channel URL in the `channels` array of the right-hand
(User) pane:
```json
{
"channels": [
"https://packagecontrol.io/channel_v3.json",
"https://raw.githubusercontent.com/vaclavt/sublime-chanel/main/channel.json"
]
}
``` ```
Dependencies (pyte, ptyprocess, wcwidth) are Package Control libraries declared Keep `https://packagecontrol.io/channel_v3.json` in the list — dropping it
in `dependencies.json`. If they are not yet in `Lib/python38` (e.g. Terminus was hides every default package. Then run **Package Control: Install Package**
never installed), run **Package Control: Satisfy Libraries** and restart Sublime. from the Command Palette and pick **vterm**. Upgrades arrive through
Package Control garbage-collects libraries no package requires, so if imports **Package Control: Upgrade Package** like any other package.
ever fail after uninstalling another package, re-run *Satisfy Libraries*.
### From source
Clone the repository into the Sublime Text `Packages` directory as `vterm`:
```sh
git clone https://github.com/vaclavt/vterm.git \
"$HOME/Library/Application Support/Sublime Text/Packages/vterm"
```
Use one method or the other, not both: an unpacked `Packages/vterm` directory
overrides the `vterm.sublime-package` that Package Control installs.
Either way, 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 ## Use
@@ -56,7 +85,9 @@ 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). what scrolls Claude Code's fullscreen UI before it enables mouse reporting).
Sublime raises no wheel event at all for the trackpad (sublime_text#1838): set Sublime raises no wheel event at all for the trackpad (sublime_text#1838): set
a utility such as [LinearMouse](https://linearmouse.app/) to discrete scrolling a utility such as [LinearMouse](https://linearmouse.app/) to discrete scrolling
for Sublime Text and it arrives as wheel notches like any other. While an app owns the for Sublime Text and it arrives as wheel notches like any other. A notch sends the
app three scroll lines, as iTerm2 does; `wheel_lines` lowers that when a swipe
scrolls too far. While an app owns the
alternate screen the scrollback above it is folded away, kitty-style, and alternate screen the scrollback above it is folded away, kitty-style, and
unfolds when the app leaves it. unfolds when the app leaves it.
@@ -154,7 +185,8 @@ instead), image protocols, Windows, panels.
- Trackpad scrolling needs a helper on macOS: Sublime never delivers it to a - Trackpad scrolling needs a helper on macOS: Sublime never delivers it to a
plugin (sublime_text#1838), so set a utility such as plugin (sublime_text#1838), so set a utility such as
[LinearMouse](https://linearmouse.app/) to discrete/linear scrolling for [LinearMouse](https://linearmouse.app/) to discrete/linear scrolling for
Sublime Text. It then arrives as wheel notches and scrolls the app. Without Sublime Text. It then arrives as wheel notches and scrolls the app, three
lines a notch unless `wheel_lines` says otherwise. Without
one, the trackpad does nothing in a full-screen app; the mouse wheel, one, the trackpad does nothing in a full-screen app; the mouse wheel,
`shift+pageup/pagedown` and `shift+up/down` work regardless. `shift+pageup/pagedown` and `shift+up/down` work regardless.
@@ -167,6 +199,8 @@ python3 tests/test_render.py # render layer on a fake view: trimming, select
python3 tests/test_commands.py # theme picker, live restyle, generated scheme 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 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/gen_keymap.py # regenerate Default.sublime-keymap
python3 benchmarks/bench_vs_terminus.py # render cost per frame, against Terminus on the same input
python3 benchmarks/profiling.py # feed-path hotspots, per-char micro-benches of draw
``` ```
`harness_live.py` is the useful one: it drives a real `pi` or `claude` through the `harness_live.py` is the useful one: it drives a real `pi` or `claude` through the

View File

@@ -0,0 +1,438 @@
""" 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 benchmarks/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():
from vterm.terminal import FEED_LIMIT
both = os.path.exists(TERMINUS)
if both:
sys.path.insert(0, TERMINUS)
else:
print("Terminus is not installed; reporting vterm alone.\n")
burst = FEED_LIMIT # the most one renderer tick parses 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()

129
benchmarks/profiling.py Normal file
View File

@@ -0,0 +1,129 @@
"""Profile the feed/parse path, then micro-bench the per-char steps of VtScreen.draw.
cProfile ranks the hotspots on the bench workloads; the micro loops put numbers
on the per-char candidates inside the draw path.
Run with: python3 benchmarks/profiling.py
"""
import cProfile
import os
import pstats
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(os.path.dirname(HERE), "tests"))
import _bootstrap # noqa: F401 sets sys.path for the imports below
from bench_vs_terminus import colour_flood, plain_flood, tui_repaint # noqa: E402
from _bootstrap import FakeProcess # noqa: E402
from pyte.screens import wcwidth as pyte_wcwidth # noqa: E402 lru_cache'd
from wcwidth import wcwidth as raw_wcwidth # noqa: E402
from vterm.emulator import VtScreen, VtStream # noqa: E402
# ------------------------------------------------------------ feed hotspots
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)
# ------------------------------------------- per-char micro-benches of draw
data = ("x" * 50 + "\x1b[38;5;100m") * 40000 # ~2M printable chars
def t(label, fn, n=3):
best = 1e9
for _ in range(n):
t0 = time.perf_counter()
fn()
best = min(best, time.perf_counter() - t0)
print("{:<42} {:8.3f} ms".format(label, best * 1000))
def loop_wcwidth_pyte():
for c in data:
w = pyte_wcwidth(c)
def loop_wcwidth_raw():
for c in data:
w = raw_wcwidth(c)
def loop_iter_only():
for c in data:
pass
def loop_min():
x = 0
for c in data:
x = min(x + 1, 80)
def loop_dict_get_set():
d = {}
line = {}
for i, c in enumerate(data):
v = d.get(c)
if v is None:
d[c] = c
line[i % 80] = c
def loop_min_vs_cmp():
x = 0
cols = 80
for c in data:
nx = x + 1
x = cols if nx > cols else nx
def 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())
print("==== per-char micro-benches ====")
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)
if __name__ == "__main__":
main()

21
repository.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "sublime://packagecontrol.io/schemas/repository",
"schema_version": "4.0.0",
"packages": [
{
"name": "vterm",
"details": "https://github.com/vaclavt/vterm",
"description": "Minimalist terminal for Sublime Text 4, built to run TUI coding agents (pi, claude) in a tab",
"author": "vaclavt",
"labels": ["terminal", "shell", "pty"],
"releases": [
{
"sublime_text": ">=4149",
"platforms": ["osx"],
"python_versions": ["3.8"],
"tags": true
}
]
}
]
}

View File

@@ -125,7 +125,9 @@ class FakeView:
return self.viewport return self.viewport
def set_viewport_position(self, xy, animate=True): def set_viewport_position(self, xy, animate=True):
self.viewport = xy # scroll_past_end is off, so sublime clamps to the layout
maxy = max(0, self.layout_extent()[1] - self.viewport_extent()[1])
self.viewport = (xy[0], max(0, min(xy[1], maxy)))
def line_height(self): def line_height(self):
return LINE_HEIGHT return LINE_HEIGHT
@@ -202,6 +204,24 @@ command.trim_history(None, terminal)
assert "row 0" in terminal.view.text and "row 7" in terminal.view.text, \ assert "row 0" in terminal.view.text and "row 7" in terminal.view.text, \
terminal.view.text terminal.view.text
# nor may its blank rows be trimmed: an app that blanks and refills its last
# row (vim's showcmd corner) would resize the view under a pinned viewport
terminal, stream = make_terminal(rows=4)
stream.feed("\x1b[?1049h")
terminal.view = FakeView("top\nrow\n\n")
command = make_command(terminal.view)
before = terminal.view.text
command.trim_trailing_spaces(None, terminal)
assert terminal.view.text == before, \
"alt screen blank tail was trimmed: {!r}".format(terminal.view.text)
# on the primary screen the blank tail below the cursor still goes
terminal, stream = make_terminal(rows=4)
terminal.view = FakeView("top\nrow\n\n")
command = make_command(terminal.view)
command.trim_trailing_spaces(None, terminal)
assert terminal.view.text == "top\nrow", terminal.view.text
print("render tests passed (alt screen keeps its view, cap honoured)") print("render tests passed (alt screen keeps its view, cap honoured)")
@@ -522,6 +542,23 @@ plugin_settings.set("scroll_modifier", "shift")
print("view scroll tests passed (a screen or a row per press, clamped to the anchor, configurable modifier)") print("view scroll tests passed (a screen or a row per press, clamped to the anchor, configurable modifier)")
# an app that repaints its block shorter than the screen (pi on every SIGWINCH)
# leaves the live screen shorter than the viewport once the blank tail is
# trimmed. the anchor must stay where sublime can actually put the viewport, or
# the follow gate closes for good and the output is never followed again
short, _ = make_terminal(rows=8, cols=20)
short.view.viewport_rows = short.screen.lines
# 24 scrollback rows, then a live screen holding 3 rows instead of 8
short.view.text = "\n".join("row {}".format(i) for i in range(27))
short.offset = 24
scroll_to_cursor(short.view, short)
assert short.viewport_y == short.view.viewport_position()[1], \
(short.viewport_y, short.view.viewport_position()[1])
assert following(short.view, short), "follow gate latched shut on a short screen"
print("short screen tests passed (the anchor stays reachable, so the view keeps following)")
# shift+enter through the whole keypress path: ESC CR by default, and the CSI 27 # shift+enter through the whole keypress path: ESC CR by default, and the CSI 27
# form once the app turned modifyOtherKeys on, which it reaches for because a # form once the app turned modifyOtherKeys on, which it reaches for because a
# bare ESC CR reads as alt+enter # bare ESC CR reads as alt+enter

View File

@@ -15,6 +15,10 @@
// null sends the keys to the app // null sends the keys to the app
"scroll_modifier": "shift", "scroll_modifier": "shift",
// scroll lines sent to the app per wheel notch (iTerm2 sends 3).
// A trackpad via LinearMouse makes many notches per swipe; 1 is calmer.
"wheel_lines": 1,
"min_columns": 20, "min_columns": 20,
"max_columns": 500, "max_columns": 500,

View File

@@ -264,6 +264,7 @@ class VtermClearScreenCommand(sublime_plugin.TextCommand):
return return
# the renderer thread owns the screen; it applies this on its next tick # the renderer thread owns the screen; it applies this on its next tick
terminal.pending_clear_screen = True terminal.pending_clear_screen = True
terminal.wake()
self.view.run_command("vterm_clear_undo_stack") self.view.run_command("vterm_clear_undo_stack")
def is_enabled(self): def is_enabled(self):

View File

@@ -93,6 +93,9 @@ def quantize_256(r, g, b):
return 16 + 36 * level(r) + 6 * level(g) + level(b) return 16 + 36 * level(r) + 6 * level(g) + level(b)
# a run of these is width-1 cells only: no wcwidth, no wide/combining cases
PLAIN_RUN = re.compile("[ -~]+")
# linefeed marks an autowrap (soft wrap), so copy can re-join the halves # linefeed marks an autowrap (soft wrap), so copy can re-join the halves
Char = namedtuple( Char = namedtuple(
"Char", "Char",
@@ -147,7 +150,9 @@ class VtScreen(pyte.Screen):
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False) self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
# SGR transitions repeat endlessly in TUI output; memoized per (attrs, params) # SGR transitions repeat endlessly in TUI output; memoized per (attrs, params)
self._sgr_cache = {} self._sgr_cache = {}
# chars are identical while cursor.attrs is unchanged (draw rebuilds per run) # one char -> Char cache per attribute set; attrs recur constantly in
# coloured output, so the caches are kept rather than rebuilt on a change
self._char_caches = {}
self._char_cache = {} self._char_cache = {}
self._cache_attrs = None self._cache_attrs = None
self.history = deque(maxlen=history) self.history = deque(maxlen=history)
@@ -164,6 +169,8 @@ class VtScreen(pyte.Screen):
"""RIS: reset the screen along with the cursor, scrollback and caches""" """RIS: reset the screen along with the cursor, scrollback and caches"""
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False) self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
self._sgr_cache.clear() self._sgr_cache.clear()
self._char_caches.clear()
self._cache_attrs = None
super().reset() super().reset()
self.cursor = Cursor(0, 0) self.cursor = Cursor(0, 0)
self.cursor_style = 0 self.cursor_style = 0
@@ -331,11 +338,48 @@ class VtScreen(pyte.Screen):
cursor = self.cursor cursor = self.cursor
buffer = self.buffer buffer = self.buffer
columns = self.columns columns = self.columns
# per-run cache: cells differ only by their data while attrs are unchanged attrs = cursor.attrs
if cursor.attrs is not self._cache_attrs: # cells differ only by their data within one attribute set; the cache is
self._cache_attrs = cursor.attrs # picked per set, not discarded, or colour changes would thrash it
self._char_cache = {} if attrs is not self._cache_attrs:
self._cache_attrs = attrs
cache = self._char_caches.get(attrs)
if cache is None:
if len(self._char_caches) > 256:
self._char_caches.clear()
cache = self._char_caches[attrs] = {}
self._char_cache = cache
cache = self._char_cache cache = self._char_cache
if autowrap and not insert_mode and PLAIN_RUN.fullmatch(data):
# every cell is width 1, so wraps are pure arithmetic: fill whole
# rows with C-speed dict.update instead of looping per char
for c in set(data):
if c not in cache:
cache[c] = attrs._replace(data=c)
get = cache.__getitem__
dirty = self.dirty
pos = 0
length = len(data)
while pos < length:
x = cursor.x
if x == columns:
line = buffer[cursor.y]
last = line[columns - 1]
line[columns - 1] = last._replace(linefeed=True)
dirty.add(cursor.y)
self.carriage_return()
self.linefeed()
x = 0
line = buffer[cursor.y]
take = min(columns - x, length - pos)
end = pos + take
line.update(zip(range(x, x + take), map(get, data[pos:end])))
dirty.add(cursor.y)
cursor.x = x + take
pos = end
return
line = buffer[cursor.y] line = buffer[cursor.y]
for char in data: for char in data:
@@ -356,12 +400,12 @@ class VtScreen(pyte.Screen):
self.insert_characters(char_width) self.insert_characters(char_width)
cell = cache.get(char) cell = cache.get(char)
if cell is None: if cell is None:
cell = cache[char] = cursor.attrs._replace(data=char) cell = cache[char] = attrs._replace(data=char)
line[cursor.x] = cell line[cursor.x] = cell
if char_width == 2 and cursor.x + 1 < columns: if char_width == 2 and cursor.x + 1 < columns:
filler = cache.get("") filler = cache.get("")
if filler is None: if filler is None:
filler = cache[""] = cursor.attrs._replace(data="") filler = cache[""] = attrs._replace(data="")
line[cursor.x + 1] = filler line[cursor.x + 1] = filler
# a combining mark belongs to the cell before it, which may be the # a combining mark belongs to the cell before it, which may be the
# last column of the previous row # last column of the previous row
@@ -391,9 +435,10 @@ class VtScreen(pyte.Screen):
break break
if char_width > 0: if char_width > 0:
self.cursor.x = min(self.cursor.x + char_width, self.columns) x = cursor.x + char_width
cursor.x = x if x < columns else columns
self.dirty.add(self.cursor.y) self.dirty.add(cursor.y)
def index(self): def index(self):
"""Line feed; at the bottom of the primary screen the top row goes to scrollback.""" """Line feed; at the bottom of the primary screen the top row goes to scrollback."""

View File

@@ -105,8 +105,7 @@ def _modifier_code(ctrl, alt, shift):
return 1 + (1 if shift else 0) + (2 if alt else 0) + (4 if ctrl else 0) return 1 + (1 if shift else 0) + (2 if alt else 0) + (4 if ctrl else 0)
def get_key_code(key, ctrl=False, alt=False, shift=False, application_mode=False, new_line_mode=False, def get_key_code(key, ctrl=False, alt=False, shift=False, application_mode=False, new_line_mode=False, modify_other_keys=0):
modify_other_keys=0):
"""Escape sequence for a key name and its modifiers, honouring the app's active modes""" """Escape sequence for a key name and its modifiers, honouring the app's active modes"""
key_lo = key.lower() if len(key) > 1 else key key_lo = key.lower() if len(key) > 1 else key

View File

@@ -27,6 +27,7 @@ class VtermCoreEventListener(sublime_plugin.EventListener):
# background — via the renderer tick: a render here would race the # background — via the renderer tick: a render here would race the
# renderer thread, which owns the emulator # renderer thread, which owns the emulator
terminal.pending_render = True terminal.pending_render = True
terminal.wake()
view.run_command("vterm_show_cursor", {"scroll": False}) view.run_command("vterm_show_cursor", {"scroll": False})
elif view.settings().get("vterm_view") and not view.settings().get("vterm_view.finished"): elif view.settings().get("vterm_view") and not view.settings().get("vterm_view.finished"):
# the plugin host restarted; this tab's terminal is unreachable # the plugin host restarted; this tab's terminal is unreachable

View File

@@ -8,14 +8,12 @@ notches and take the path below.
import sublime_plugin import sublime_plugin
from .terminal import Terminal from .terminal import Terminal
from .utils import settings
# SGR button numbers for the wheel; 64 is the "scroll" bit plus direction # SGR button numbers for the wheel; 64 is the "scroll" bit plus direction
WHEEL_UP = 64 WHEEL_UP = 64
WHEEL_DOWN = 65 WHEEL_DOWN = 65
# lines per wheel tick, as iTerm2 delivers per notch
WHEEL_LINES = 3
def send_scroll(terminal, up, lines, cell=None): def send_scroll(terminal, up, lines, cell=None):
"""Report `lines` scroll lines to the app, or fake arrow keys on the alt screen. """Report `lines` scroll lines to the app, or fake arrow keys on the alt screen.
@@ -105,7 +103,8 @@ class VtermScrollCommand(sublime_plugin.TextCommand):
cell = None cell = None
if terminal.screen.mouse_reporting_enabled() and event: if terminal.screen.mouse_reporting_enabled() and event:
cell = _event_to_cell(self.view, terminal, event, clamp=True) cell = _event_to_cell(self.view, terminal, event, clamp=True)
send_scroll(terminal, up, WHEEL_LINES, cell) lines = max(0, int(settings().get("wheel_lines", 3)))
send_scroll(terminal, up, lines, cell)
def want_event(self): def want_event(self):
"""Ask sublime for the wheel coordinates.""" """Ask sublime for the wheel coordinates."""

View File

@@ -410,9 +410,16 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
Without this the view keeps a full screen of blank rows below a short Without this the view keeps a full screen of blank rows below a short
prompt, and the scrollbar reflects them. prompt, and the scrollbar reflects them.
Both are skipped on the alternate screen, where the app owns every row:
one that blanks and refills - vim's showcmd corner, on every keypress -
would resize the view under a viewport pinned to its bottom, and the
whole screen shifts by the part of a row the viewport leaves over.
""" """
view = self.view view = self.view
screen = terminal.screen screen = terminal.screen
if screen.alternate_buffer_mode:
return
cursor = screen.cursor cursor = screen.cursor
cursor_row = terminal.offset + cursor.y cursor_row = terminal.offset + cursor.y
lastrow = view.rowcol(view.size())[0] lastrow = view.rowcol(view.size())[0]
@@ -513,11 +520,10 @@ def following(view, terminal):
def bottom_y(view, terminal): def bottom_y(view, terminal):
"""Viewport y that pins the bottom of the live screen to the bottom of the view.""" """Viewport y that pins the bottom of the view to the bottom of the viewport."""
# layout_extent is maintained incrementally, unlike text_to_layout(size()) # layout_extent is maintained incrementally, unlike text_to_layout(size())
viewport_y = view.layout_extent()[1] - view.viewport_extent()[1] return max(0, view.layout_extent()[1] - view.viewport_extent()[1])
offset_y = view.text_to_layout(view.text_point(terminal.offset, 0))[1]
return max(offset_y, viewport_y)
def scroll_to_cursor(view, terminal): def scroll_to_cursor(view, terminal):

View File

@@ -24,12 +24,24 @@ RENDER_PERIOD = 0.03
# draw rate for a visible terminal the user is not typing in, ~15 fps # draw rate for a visible terminal the user is not typing in, ~15 fps
UNFOCUSED_RENDER_PERIOD = 0.075 UNFOCUSED_RENDER_PERIOD = 0.075
# max chars parsed per tick, so a giant burst still renders intermediate frames # max chars parsed per tick, so a giant burst still renders intermediate frames.
FEED_LIMIT = 256 * 1024 FEED_LIMIT = 48 * 1024
# SUPPRESS_HISTORY_AFTER_CLEAR in emulator.py must outlast this # SUPPRESS_HISTORY_AFTER_CLEAR in emulator.py must outlast this
REPAINT_NUDGE_DELAY = 0.25 REPAINT_NUDGE_DELAY = 0.25
# extra wait once a tick finds nothing pending, on top of RENDER_PERIOD.
IDLE_TICK = 0.2
# pause before the first frame after an idle park, so the writes of one app
# update land together: vim splits every keypress into two writes a fraction of
# a millisecond apart, and drawing between them flashes its showcmd echo
SETTLE = 0.005
# frames between undo-stack clears: every render edit lands on the view's undo
# stack, which only focus changes clear otherwise, and undo is swallowed anyway
UNDO_CLEAR_FRAMES = 100
def view_size(view, default=None): def view_size(view, default=None):
"""Terminal (rows, columns) that fit the view's viewport.""" """Terminal (rows, columns) that fit the view's viewport."""
@@ -116,6 +128,12 @@ class Terminal:
self.pending_render = False self.pending_render = False
# a UTF-8 sequence split across read chunks must not be dropped # a UTF-8 sequence split across read chunks must not be dropped
self._decoder = codecs.getincrementaldecoder("utf-8")("replace") self._decoder = codecs.getincrementaldecoder("utf-8")("replace")
# set by the reader (and the UI thread) to end the renderer's idle park
self._wake = threading.Event()
def wake(self):
"""Cut short the renderer's idle wait, for a flag set from another thread"""
self._wake.set()
@classmethod @classmethod
def from_id(cls, vid): def from_id(cls, vid):
@@ -203,6 +221,7 @@ class Terminal:
"""Terminate the child and drop this terminal from the registry.""" """Terminate the child and drop this terminal from the registry."""
LOG.debug("[kill] terminating process") LOG.debug("[kill] terminating process")
self.alive = False self.alive = False
self._wake.set()
scheme_manager.remove_listener(self.apply_scheme) scheme_manager.remove_listener(self.apply_scheme)
try: try:
self.process.terminate() self.process.terminate()
@@ -254,11 +273,13 @@ class Terminal:
except EOFError: except EOFError:
break break
chunks.append(self._decoder.decode(raw)) chunks.append(self._decoder.decode(raw))
self._wake.set()
if done[0] or not self.is_hosted(): if done[0] or not self.is_hosted():
LOG.debug("[reader] stops") LOG.debug("[reader] stops")
break break
self.alive = False self.alive = False
done[0] = True done[0] = True
self._wake.set()
def feed_data(): def feed_data():
"""Parse up to FEED_LIMIT of pending output into the screen.""" """Parse up to FEED_LIMIT of pending output into the screen."""
@@ -278,11 +299,13 @@ class Terminal:
def renderer(): def renderer():
"""Own the emulator: parse, then ask sublime to redraw, once per tick.""" """Own the emulator: parse, then ask sublime to redraw, once per tick."""
frames = 0
while True: while True:
# a hidden tab is not drawn at all: the screen keeps the state # 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 # and one render on unhide catches the view up.
# full rate either way, or the parser would fall behind the pty
visible = self.is_visible() visible = self.is_visible()
self._wake.clear()
with intermission(period=RENDER_PERIOD): with intermission(period=RENDER_PERIOD):
feed_data() feed_data()
if self.screen.in_sync_frame(): if self.screen.in_sync_frame():
@@ -295,11 +318,26 @@ class Terminal:
# due first: _need_to_render consumes the cursor move it reports # due first: _need_to_render consumes the cursor move it reports
if self.is_render_due() and self._need_to_render(): if self.is_render_due() and self._need_to_render():
self.view.run_command("vterm_render") self.view.run_command("vterm_render")
frames += 1
if frames >= UNDO_CLEAR_FRAMES:
frames = 0
self.view.run_command("vterm_clear_undo_stack")
self._sync_wheel_flag() self._sync_wheel_flag()
if done[0] or not self.is_hosted(): if done[0] or not self.is_hosted():
LOG.debug("[renderer] stops") LOG.debug("[renderer] stops")
break break
# nothing left to do: park until woken, instead of spinning at
# RENDER_PERIOD. A sync frame or a teardown skipped this by
# leaving the block above, and keeps its own cadence.
# dirty rows only count while visible: nothing clears them for a
# hidden tab, and on_activated wakes us with pending_render
if not (
chunks or self.pending_render or self.pending_clear or self.pending_clear_screen or (visible and self.screen.dirty)
):
if self._wake.wait(IDLE_TICK):
time.sleep(SETTLE)
while chunks: while chunks:
feed_data() feed_data()
done[0] = True done[0] = True