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.
|
truecolor, `ESC[0m` reset. This is what TUIs emit most.
|
||||||
- **OSC** ✅ — Operating System Command, `ESC ]`, ending at BEL or ST. Carries strings
|
- **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
|
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
|
- **DCS / APC** — Device Control String (`ESC P`) and Application Program Command
|
||||||
(`ESC _`). Long payloads: sixel images, kitty graphics. vterm parses their framing and
|
(`ESC _`). Long payloads: sixel images, kitty graphics. vterm parses their framing and
|
||||||
discards the payload, so an image never leaks into the text.
|
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
|
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
|
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
|
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
|
Combining characters are merged as NFC, because Sublime draws decomposed
|
||||||
sequences badly.
|
sequences badly.
|
||||||
- `vterm/terminal.py` — pty process, reader and renderer threads at ~33 Hz.
|
- `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
|
`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
|
emulator with no Sublime involved, and prints the queries answered plus the
|
||||||
resulting screen. Tests import pyte/ptyprocess/wcwidth from Sublime's
|
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`.
|
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
|
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.
|
sublime stubs and the Package Control libraries on the path.
|
||||||
"""
|
"""
|
||||||
|
import glob
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
ST_LIB = os.path.expanduser(
|
# Package Control puts the libraries under the plugin host's own directory
|
||||||
"~/Library/Application Support/Sublime Text/Lib/python38")
|
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:
|
if path not in sys.path:
|
||||||
sys.path.insert(0, 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 proc.written == "\x1b]11;rgb:1a1a/2b2b/3c3c\x1b\\", (tag, proc.written)
|
||||||
assert screen.buffer[0][0].data == "y", tag
|
assert screen.buffer[0][0].data == "y", tag
|
||||||
|
|
||||||
# OSC 52 clipboard (base64 "hi")
|
# OSC 52 (clipboard write) is dropped, not drawn: untrusted output must
|
||||||
got = []
|
# not plant a paste
|
||||||
proc = FakeProcess()
|
proc, screen, stream = make()
|
||||||
screen = VtScreen(80, 24, process=proc, clipboard_callback=got.append)
|
feed(stream, "\x1b]52;c;aGk=\x07z", char_by_char)
|
||||||
stream = VtStream(screen)
|
assert screen.buffer[0][0].data == "z" and proc.written == "", tag
|
||||||
feed(stream, "\x1b]52;c;aGk=\x07", char_by_char)
|
|
||||||
assert got == ["hi"], (tag, got)
|
|
||||||
|
|
||||||
# bracketed paste and sync modes tracked
|
# bracketed paste and sync modes tracked
|
||||||
proc, screen, stream = make()
|
proc, screen, stream = make()
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
""" Terminal emulation: the screen and a parser for prefixed CSI, DECRQM and multi-digit OSC """
|
""" Terminal emulation: the screen and a parser for prefixed CSI, DECRQM and multi-digit OSC """
|
||||||
import base64
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
@@ -119,7 +118,7 @@ class Cursor:
|
|||||||
|
|
||||||
class VtScreen(pyte.Screen):
|
class VtScreen(pyte.Screen):
|
||||||
"""Screen with alternate buffer, scrollback, synchronized output,
|
"""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.
|
Mutated only by the renderer thread, so it carries no lock.
|
||||||
"""
|
"""
|
||||||
@@ -137,7 +136,6 @@ class VtScreen(pyte.Screen):
|
|||||||
history=5000,
|
history=5000,
|
||||||
erase_scrollback=True,
|
erase_scrollback=True,
|
||||||
clear_callback=lambda: None,
|
clear_callback=lambda: None,
|
||||||
clipboard_callback=lambda text: None,
|
|
||||||
fg_color="ffffff",
|
fg_color="ffffff",
|
||||||
bg_color="000000",
|
bg_color="000000",
|
||||||
is_dark=True,
|
is_dark=True,
|
||||||
@@ -146,7 +144,6 @@ class VtScreen(pyte.Screen):
|
|||||||
self._process = process
|
self._process = process
|
||||||
self._erase_scrollback = erase_scrollback
|
self._erase_scrollback = erase_scrollback
|
||||||
self._clear_callback = clear_callback
|
self._clear_callback = clear_callback
|
||||||
self._clipboard_callback = clipboard_callback
|
|
||||||
self.primary_buffer = {}
|
self.primary_buffer = {}
|
||||||
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)
|
||||||
@@ -650,7 +647,7 @@ class VtScreen(pyte.Screen):
|
|||||||
self.write_process_input("\x1b[?0u")
|
self.write_process_input("\x1b[?0u")
|
||||||
|
|
||||||
def handle_osc(self, code, payload, terminator):
|
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):
|
if code in (0, 2):
|
||||||
self.set_title(payload)
|
self.set_title(payload)
|
||||||
elif code == 1:
|
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])
|
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
|
# answer with the terminator asked with: meli only ends an OSC at ST
|
||||||
self.write_process_input("\x1b]{};{}{}".format(code, rgb, terminator))
|
self.write_process_input("\x1b]{};{}{}".format(code, rgb, terminator))
|
||||||
elif code == 52:
|
# other OSC codes are intentionally dropped, 52 (clipboard write) among
|
||||||
parts = payload.split(";", 1)
|
# them: any output, cat'd or remote, could plant a command to paste
|
||||||
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
|
# 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.
|
# max chars parsed per tick, so a giant burst still renders intermediate frames.
|
||||||
FEED_LIMIT = 48 * 1024
|
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
|
# SUPPRESS_HISTORY_AFTER_CLEAR in emulator.py must outlast this
|
||||||
REPAINT_NUDGE_DELAY = 0.25
|
REPAINT_NUDGE_DELAY = 0.25
|
||||||
|
|
||||||
@@ -200,7 +203,6 @@ class Terminal:
|
|||||||
history=self.scrollback_size,
|
history=self.scrollback_size,
|
||||||
erase_scrollback=settings().get("honor_erase_scrollback", False),
|
erase_scrollback=settings().get("honor_erase_scrollback", False),
|
||||||
clear_callback=self._clear_callback,
|
clear_callback=self._clear_callback,
|
||||||
clipboard_callback=self._clipboard_callback,
|
|
||||||
fg_color=self.scheme.foreground.lstrip("#"),
|
fg_color=self.scheme.foreground.lstrip("#"),
|
||||||
bg_color=self.scheme.background.lstrip("#"),
|
bg_color=self.scheme.background.lstrip("#"),
|
||||||
is_dark=self.scheme.is_dark(),
|
is_dark=self.scheme.is_dark(),
|
||||||
@@ -233,7 +235,8 @@ class Terminal:
|
|||||||
self.alive = False
|
self.alive = False
|
||||||
self._wake.set()
|
self._wake.set()
|
||||||
try:
|
try:
|
||||||
self.process.terminate()
|
# force: a child ignoring HUP and INT would otherwise outlive the tab
|
||||||
|
self.process.terminate(force=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
LOG.warning("terminate failed", exc_info=True)
|
LOG.warning("terminate failed", exc_info=True)
|
||||||
vid = self.view.id()
|
vid = self.view.id()
|
||||||
@@ -244,10 +247,6 @@ class Terminal:
|
|||||||
"""Screen asked for a clear; the next render performs it on the UI thread."""
|
"""Screen asked for a clear; the next render performs it on the UI thread."""
|
||||||
self.pending_clear = True
|
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):
|
def _need_to_render(self):
|
||||||
"""Whether anything changed since the last frame: dirty rows, a clear, or the cursor."""
|
"""Whether anything changed since the last frame: dirty rows, a clear, or the cursor."""
|
||||||
screen = self.screen
|
screen = self.screen
|
||||||
@@ -283,6 +282,10 @@ class Terminal:
|
|||||||
break
|
break
|
||||||
chunks.append(self._decoder.decode(raw))
|
chunks.append(self._decoder.decode(raw))
|
||||||
self._wake.set()
|
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():
|
if done[0] or not self.is_hosted():
|
||||||
LOG.debug("[reader] stops")
|
LOG.debug("[reader] stops")
|
||||||
break
|
break
|
||||||
|
|||||||
Reference in New Issue
Block a user