diff --git a/Architecture.md b/Architecture.md index ce5452c..b5edc34 100644 --- a/Architecture.md +++ b/Architecture.md @@ -100,7 +100,8 @@ are marked ✅. 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. + foreground/background, `OSC 52` reads or writes the system clipboard (vterm + ignores it: untrusted output must not plant a paste). - **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. diff --git a/README.md b/README.md index bfe2ff7..db2596e 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,8 @@ terminal protocols — see `Architecture.md`. 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. + probe, and OSC 10/11 colour queries; OSC 52 (clipboard write) is ignored, + since any output could plant a command to paste. Combining characters are merged as NFC, because Sublime draws decomposed sequences badly. - `vterm/terminal.py` — pty process, reader and renderer threads at ~33 Hz. @@ -209,6 +210,6 @@ python3 benchmarks/profiling.py # feed-path hotspots, per-char micro-b `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). +`Lib/python3*` (pure Python, so any Python 3 runs them). Code style is in `CODE.md`, working agreements in `CLAUDE.md`. diff --git a/tests/_bootstrap.py b/tests/_bootstrap.py index 9060023..f5dfd1c 100644 --- a/tests/_bootstrap.py +++ b/tests/_bootstrap.py @@ -3,14 +3,16 @@ 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 glob 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") +# Package Control puts the libraries under the plugin host's own directory +ST_LIBS = sorted(glob.glob(os.path.expanduser( + "~/Library/Application Support/Sublime Text/Lib/python3*"))) -for path in (ROOT, ST_LIB, os.path.join(ROOT, "tests", "stubs")): +for path in [ROOT] + ST_LIBS + [os.path.join(ROOT, "tests", "stubs")]: if path not in sys.path: sys.path.insert(0, path) diff --git a/tests/test_emulator.py b/tests/test_emulator.py index 25fab39..85b0cf4 100644 --- a/tests/test_emulator.py +++ b/tests/test_emulator.py @@ -83,13 +83,11 @@ def run_case(char_by_char): 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) + # OSC 52 (clipboard write) is dropped, not drawn: untrusted output must + # not plant a paste + proc, screen, stream = make() + feed(stream, "\x1b]52;c;aGk=\x07z", char_by_char) + assert screen.buffer[0][0].data == "z" and proc.written == "", tag # bracketed paste and sync modes tracked proc, screen, stream = make() diff --git a/vterm/emulator.py b/vterm/emulator.py index f53f3c7..8048722 100644 --- a/vterm/emulator.py +++ b/vterm/emulator.py @@ -1,5 +1,4 @@ """ Terminal emulation: the screen and a parser for prefixed CSI, DECRQM and multi-digit OSC """ -import base64 import logging import re import time @@ -119,7 +118,7 @@ class Cursor: class VtScreen(pyte.Screen): """Screen with alternate buffer, scrollback, synchronized output, - mouse/focus/paste modes, cursor styles, query replies and OSC handling. + mouse/focus/paste modes, cursor styles, query replies and OSC titles/colours. Mutated only by the renderer thread, so it carries no lock. """ @@ -137,7 +136,6 @@ class VtScreen(pyte.Screen): history=5000, erase_scrollback=True, clear_callback=lambda: None, - clipboard_callback=lambda text: None, fg_color="ffffff", bg_color="000000", is_dark=True, @@ -146,7 +144,6 @@ class VtScreen(pyte.Screen): 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) @@ -650,7 +647,7 @@ class VtScreen(pyte.Screen): 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.""" + """Dispatch an OSC: 0/2 title, 1 icon, 10/11 colour query.""" if code in (0, 2): self.set_title(payload) elif code == 1: @@ -661,14 +658,8 @@ class VtScreen(pyte.Screen): 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 + # other OSC codes are intentionally dropped, 52 (clipboard write) among + # them: any output, cat'd or remote, could plant a command to paste # a complete plain CSI: params then a final byte. No private marker, prefix or diff --git a/vterm/terminal.py b/vterm/terminal.py index 561ea08..c1b0311 100644 --- a/vterm/terminal.py +++ b/vterm/terminal.py @@ -26,6 +26,9 @@ UNFOCUSED_RENDER_PERIOD = 0.075 # max chars parsed per tick, so a giant burst still renders intermediate frames. FEED_LIMIT = 48 * 1024 +# unparsed 8K reads the reader may hold before it pauses: ~10 ticks of parsing +PENDING_CHUNKS = 64 + # SUPPRESS_HISTORY_AFTER_CLEAR in emulator.py must outlast this REPAINT_NUDGE_DELAY = 0.25 @@ -200,7 +203,6 @@ class Terminal: history=self.scrollback_size, erase_scrollback=settings().get("honor_erase_scrollback", False), clear_callback=self._clear_callback, - clipboard_callback=self._clipboard_callback, fg_color=self.scheme.foreground.lstrip("#"), bg_color=self.scheme.background.lstrip("#"), is_dark=self.scheme.is_dark(), @@ -233,7 +235,8 @@ class Terminal: self.alive = False self._wake.set() try: - self.process.terminate() + # force: a child ignoring HUP and INT would otherwise outlive the tab + self.process.terminate(force=True) except Exception: LOG.warning("terminate failed", exc_info=True) vid = self.view.id() @@ -244,10 +247,6 @@ class Terminal: """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 @@ -283,6 +282,10 @@ class Terminal: break chunks.append(self._decoder.decode(raw)) self._wake.set() + # backpressure: once this much waits unparsed, stop reading so + # the pty fills and the child blocks, as under a real terminal + while len(chunks) > PENDING_CHUNKS and not done[0] and self.is_hosted(): + time.sleep(RENDER_PERIOD) if done[0] or not self.is_hosted(): LOG.debug("[reader] stops") break