Drop OSC 52 clipboard writes, force-kill on close, bound the reader, find the ST libs for tests
- OSC 52 is ignored: any output, cat'd or remote, could plant a command on the clipboard to paste - kill(): terminate(force=True), so a child ignoring HUP and INT does not outlive its tab with the pty open - reader pauses once 64 unparsed reads wait, so a large dump blocks the child at the pty instead of being buffered whole in memory - tests/_bootstrap.py: add every Lib/python3* directory; Package Control puts the libraries under the active plugin host's own directory
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user