code comments changes
This commit is contained in:
@@ -83,7 +83,7 @@ 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
|
||||
# with it, the dark/light report is sent so the app can restyle itself
|
||||
stream.feed("\x1b[?2031h")
|
||||
recorder.written = ""
|
||||
terminal.apply_scheme()
|
||||
|
||||
@@ -24,7 +24,7 @@ def feed(stream, data, char_by_char=False):
|
||||
def run_case(char_by_char):
|
||||
tag = "char-by-char" if char_by_char else "chunked"
|
||||
|
||||
# DA1 answered (Claude Code's untimed sentinel)
|
||||
# DA1 answered; apps can wait on it with no timeout
|
||||
proc, screen, stream = make()
|
||||
feed(stream, "\x1b[c", char_by_char)
|
||||
assert proc.written == "\x1b[?62;22c", (tag, proc.written)
|
||||
@@ -34,7 +34,7 @@ def run_case(char_by_char):
|
||||
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)
|
||||
# XTVERSION then DECRQM 2026, the sync-output probe sequence
|
||||
proc, screen, stream = make()
|
||||
feed(stream, "\x1b[>0q\x1b[c\x1b[?2026$p\x1b[c", char_by_char)
|
||||
assert proc.written == (
|
||||
@@ -54,8 +54,8 @@ def run_case(char_by_char):
|
||||
# and nothing leaked to the screen
|
||||
assert screen.buffer[0][0].data == " ", (tag, screen.buffer[0][0])
|
||||
|
||||
# modifyOtherKeys: the level is tracked (pi enables it and reads shift+enter
|
||||
# no other way), and the sequence must not corrupt SGR state
|
||||
# modifyOtherKeys: the level is tracked, and the sequence must not corrupt
|
||||
# SGR state
|
||||
proc, screen, stream = make()
|
||||
assert screen.modify_other_keys == 0, tag
|
||||
feed(stream, "\x1b[>4;2mhello", char_by_char)
|
||||
@@ -211,8 +211,8 @@ def run_resize_cases():
|
||||
feed(stream, "\x1b[?1049l", False)
|
||||
assert max(screen.buffer[0]) < 8, sorted(screen.buffer[0])
|
||||
|
||||
# a shrink clamps the cursor (pyte does it via restore_cursor); without
|
||||
# the clamp draw() writes to rows above or below the screen
|
||||
# a shrink clamps the cursor; without the clamp draw() writes to rows
|
||||
# above or below the screen
|
||||
proc, screen, stream = make(10, 8)
|
||||
feed(stream, "\r\n".join("line{}".format(i) for i in range(8)), False)
|
||||
feed(stream, "\x1b[1;1H", False) # full screen, cursor at home
|
||||
@@ -239,8 +239,8 @@ def run_resize_cases():
|
||||
|
||||
|
||||
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)."""
|
||||
"""No private CSI may raise: an exception here kills the renderer thread
|
||||
and freezes 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
|
||||
@@ -269,9 +269,9 @@ def run_private_csi_cases():
|
||||
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.
|
||||
The lines point at the blank cell, so one keeping a reverse default paints
|
||||
the columns the app never wrote — vim skips indentation with cursor-forward
|
||||
— as white blocks.
|
||||
"""
|
||||
from vterm.render import segment_buffer_line
|
||||
|
||||
@@ -327,7 +327,7 @@ def run_fast_path_cases():
|
||||
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."""
|
||||
"""The blob through the coroutine parser alone, bypassing the fast path."""
|
||||
proc, screen, stream = make(columns=30, lines=8)
|
||||
import pyte
|
||||
pyte.Stream.feed(stream, blob)
|
||||
@@ -407,7 +407,7 @@ def run_clear_cases():
|
||||
assert len(screen.history) > 0, "history suppressed for too long"
|
||||
|
||||
# ED 3 is app-driven: it clears scrollback and fires the clear callback,
|
||||
# but (as in xterm, unlike pyte) leaves the visible screen alone
|
||||
# but (as in xterm) leaves the visible screen alone
|
||||
cleared = []
|
||||
proc = FakeProcess()
|
||||
screen = VtScreen(10, 4, process=proc, clear_callback=lambda: cleared.append(1))
|
||||
@@ -418,8 +418,8 @@ def run_clear_cases():
|
||||
assert len(screen.history) == 0 and cleared, (len(screen.history), cleared)
|
||||
assert screen.buffer[0][0].data == "a", screen.buffer[0][0]
|
||||
|
||||
# with erase_scrollback off, ED 3 is a no-op: agents repaint with it and
|
||||
# would take the history and the view with them
|
||||
# with erase_scrollback off, ED 3 is a no-op: an app repainting with it
|
||||
# would take the history and the view with it
|
||||
cleared = []
|
||||
proc = FakeProcess()
|
||||
screen = VtScreen(10, 4, process=proc, erase_scrollback=False,
|
||||
|
||||
@@ -54,8 +54,8 @@ for char in string.ascii_letters + string.digits + string.punctuation:
|
||||
assert get_key_code(key="space") == " ", "space must send a space"
|
||||
|
||||
# legacy encodings by default; while the app has modifyOtherKeys on, the keys
|
||||
# whose modified form is ambiguous take the CSI 27 form instead. pi reads a
|
||||
# bare ESC CR as alt+enter and submits on it
|
||||
# whose modified form is ambiguous take the CSI 27 form instead. a bare ESC CR
|
||||
# reads as alt+enter
|
||||
assert get_key_code(key="enter", shift=True) == "\x1b\r"
|
||||
assert get_key_code(key="enter", shift=True, modify_other_keys=2) == "\x1b[27;2;13~"
|
||||
assert get_key_code(key="space", ctrl=True, modify_other_keys=2) == "\x1b[27;5;32~"
|
||||
|
||||
@@ -226,8 +226,8 @@ assert len(selection) == 1 and selection[0].empty(), \
|
||||
print("selection tests passed (user selection survives renders, caret still tracked)")
|
||||
|
||||
|
||||
# an app that hides the cursor (pi hides it for good) still needs the caret:
|
||||
# sublime drops a space keypress in a view that has no caret at all
|
||||
# an app that hides the cursor still needs the caret: sublime drops a space
|
||||
# keypress in a view that has no caret at all
|
||||
selection.clear()
|
||||
stream.feed("\x1b[?25l")
|
||||
focus_cursor(terminal.view, None, terminal)
|
||||
@@ -326,7 +326,7 @@ command.run(None)
|
||||
kept = [(r.begin(), r.end()) for r in terminal.view.sel()]
|
||||
assert any(b != e for b, e in kept), "user selection was dropped: {}".format(kept)
|
||||
|
||||
# pyte's dirty is a repaint hint: rows whose content did not change must not
|
||||
# the dirty set is a repaint hint: rows whose content did not change must not
|
||||
# be rewritten (each rewrite is a cross-process call and an on_modified)
|
||||
rewritten = []
|
||||
_replace = terminal.view.replace
|
||||
@@ -523,8 +523,8 @@ print("view scroll tests passed (a screen or a row per press, clamped to the anc
|
||||
|
||||
|
||||
# shift+enter through the whole keypress path: ESC CR by default, and the CSI 27
|
||||
# form once the app turned modifyOtherKeys on. pi enables it (its kitty probe
|
||||
# gets no flags from us) and reads a bare ESC CR as alt+enter, which submits
|
||||
# form once the app turned modifyOtherKeys on, which it reaches for because a
|
||||
# bare ESC CR reads as alt+enter
|
||||
press(key="enter", shift=True)
|
||||
assert terminal.process.written == "\x1b\r", repr(terminal.process.written)
|
||||
stream.feed("\x1b[>4;2m")
|
||||
|
||||
@@ -22,14 +22,7 @@ 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.
|
||||
"""
|
||||
"""Start `cmd` (default: a login shell) in a fresh view"""
|
||||
window = self.window
|
||||
|
||||
if not cmd:
|
||||
@@ -38,7 +31,7 @@ class VtermOpenCommand(sublime_plugin.WindowCommand):
|
||||
elif isinstance(cmd, str):
|
||||
cmd = [cmd]
|
||||
|
||||
# the app may override the title via OSC 0/2 later
|
||||
# cwd: the first project folder, else the active file's directory, else home
|
||||
if not cwd:
|
||||
folders = window.folders()
|
||||
view = window.active_view()
|
||||
@@ -144,9 +137,7 @@ 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.
|
||||
"""
|
||||
"""Send one key; the name and modifier flags come straight from the keymap"""
|
||||
terminal = Terminal.from_id(self.view.id())
|
||||
if not terminal or not terminal.alive:
|
||||
return
|
||||
@@ -158,13 +149,11 @@ class VtermKeypressCommand(sublime_plugin.TextCommand):
|
||||
terminal.send_key(**kwargs)
|
||||
|
||||
def view_scrolled(self, terminal, key=None, **mods):
|
||||
"""Scroll the view over the scrollback, before vterm_show_cursor, whose
|
||||
scroll would pull the viewport straight back down. PgUp/PgDn move a
|
||||
screen, Up/Down one line. The alt screen has its scrollback folded
|
||||
away, so an app there keeps the key.
|
||||
"""Whether the view consumed the key by scrolling over the scrollback.
|
||||
|
||||
Returns:
|
||||
bool: the view consumed the key; False sends it to the app.
|
||||
Runs before vterm_show_cursor, whose scroll would pull the viewport
|
||||
straight back down. PgUp/PgDn move a screen, Up/Down one line. On the
|
||||
alt screen the scrollback is folded away, so the app keeps the key.
|
||||
"""
|
||||
# the cheap tests first: this runs on every keystroke
|
||||
if key not in SCROLL_KEYS or not mods or terminal.screen.alternate_buffer_mode:
|
||||
@@ -206,10 +195,7 @@ 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.
|
||||
"""
|
||||
"""Send text to the app, wrapped in paste brackets when it enabled ?2004"""
|
||||
terminal = Terminal.from_id(self.view.id())
|
||||
if not terminal:
|
||||
return
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
""" Terminal emulation: pyte screen and a parser that handles 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 re
|
||||
@@ -36,7 +36,7 @@ ANSI_COLORS = [
|
||||
"light_white",
|
||||
]
|
||||
|
||||
# SGR 90-97 / 100-107: the bright colours, which stock pyte does not map
|
||||
# SGR 90-97 / 100-107: the bright (aixterm) colours
|
||||
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)}
|
||||
|
||||
@@ -44,7 +44,7 @@ BG_AIXTERM = {100 + i: ANSI_COLORS[8 + i] for i in range(8)}
|
||||
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.
|
||||
# 1: DECCKM, application cursor keys (arrows send SS3); pyte has no name for it
|
||||
# 1: DECCKM, application cursor keys (arrows send SS3)
|
||||
APP_CURSOR_MODE = 1 << 5
|
||||
# 2026: synchronized output, the app is mid-frame and we should not draw
|
||||
SYNC_MODE = 2026 << 5
|
||||
@@ -78,8 +78,8 @@ def _copy_line(line):
|
||||
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)."""
|
||||
Truecolor is folded into the palette so the scheme rule table stays bounded:
|
||||
each distinct combo costs a rule and a file write."""
|
||||
if r == g == b:
|
||||
if r < 8:
|
||||
return 16
|
||||
@@ -102,18 +102,11 @@ Char = namedtuple(
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
"""Cursor position and the attributes new text inherits"""
|
||||
|
||||
__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
|
||||
@@ -121,18 +114,15 @@ class Cursor:
|
||||
|
||||
|
||||
class VtScreen(pyte.Screen):
|
||||
"""Screen with the terminal features these agents rely on.
|
||||
|
||||
Adds to pyte: the alternate screen, scrollback, synchronized output,
|
||||
"""Screen with alternate buffer, 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
|
||||
"""The blank cell an untouched column holds"""
|
||||
return self._default_char
|
||||
|
||||
def __init__(
|
||||
@@ -148,14 +138,7 @@ class VtScreen(pyte.Screen):
|
||||
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.
|
||||
"""
|
||||
"""Set up the buffers, the caches and the palette facts reported to the app"""
|
||||
self._process = process
|
||||
self._erase_scrollback = erase_scrollback
|
||||
self._clear_callback = clear_callback
|
||||
@@ -178,7 +161,7 @@ class VtScreen(pyte.Screen):
|
||||
super().__init__(columns, lines)
|
||||
|
||||
def reset(self):
|
||||
"""RIS: pyte's reset plus our 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._sgr_cache.clear()
|
||||
super().reset()
|
||||
@@ -194,20 +177,12 @@ class VtScreen(pyte.Screen):
|
||||
self.cursor_style = style
|
||||
|
||||
def set_modify_other_keys(self, resource=0, value=0):
|
||||
"""xterm CSI > 4 ; n m: send modified keys as CSI 27 ; mod ; code ~.
|
||||
|
||||
pi turns this on whenever the kitty probe answers with no flags, and
|
||||
reads shift+enter no other way.
|
||||
"""
|
||||
"""xterm CSI > 4 ; n m: send modified keys as CSI 27 ; mod ; code ~"""
|
||||
if resource == 4:
|
||||
self.modify_other_keys = value
|
||||
|
||||
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.
|
||||
"""
|
||||
"""Resize the screen; on a shrink the rows that no longer fit go to scrollback"""
|
||||
lines = lines or self.lines
|
||||
columns = columns or self.columns
|
||||
|
||||
@@ -239,8 +214,7 @@ class VtScreen(pyte.Screen):
|
||||
line.pop(x, None)
|
||||
|
||||
self.lines, self.columns = lines, columns
|
||||
# pyte clamps via restore_cursor; without it a shrink leaves the cursor
|
||||
# negative or past the new edge. x == columns keeps a pending wrap
|
||||
# a shrink can leave the cursor past the new edge; x == columns keeps a pending wrap
|
||||
self.cursor.x = min(self.cursor.x, columns)
|
||||
self.cursor.y = min(max(self.cursor.y, 0), lines - 1)
|
||||
self.set_margins()
|
||||
@@ -272,9 +246,8 @@ class VtScreen(pyte.Screen):
|
||||
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
|
||||
# the lines still point at the old blank cell, which would paint
|
||||
# untouched columns reversed for good
|
||||
for line in self.buffer.values():
|
||||
line.default = self._default_char
|
||||
|
||||
@@ -340,12 +313,11 @@ class VtScreen(pyte.Screen):
|
||||
return BRACKETED_PASTE_MODE in self.mode
|
||||
|
||||
def draw(self, data):
|
||||
"""Write text at the cursor, wcwidth-aware, marking hard wraps.
|
||||
"""Write text at the cursor, wcwidth-aware, marking soft 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.
|
||||
Double-width cells claim two columns, combining marks merge into the
|
||||
preceding cell as NFC, 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)
|
||||
@@ -359,8 +331,7 @@ class VtScreen(pyte.Screen):
|
||||
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
|
||||
# per-run cache: cells differ only by their data while attrs are unchanged
|
||||
if cursor.attrs is not self._cache_attrs:
|
||||
self._cache_attrs = cursor.attrs
|
||||
self._char_cache = {}
|
||||
@@ -431,10 +402,10 @@ class VtScreen(pyte.Screen):
|
||||
super().index()
|
||||
|
||||
def erase_in_display(self, how=0, *args, **kwargs):
|
||||
"""ED: erase part of the screen, keeping what scrolls away.
|
||||
"""ED: erase part of the screen.
|
||||
|
||||
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.
|
||||
A full clear (or a clear-to-end from home) pushes the 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()
|
||||
@@ -470,8 +441,8 @@ class VtScreen(pyte.Screen):
|
||||
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
|
||||
Covers 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,):
|
||||
@@ -575,14 +546,12 @@ class VtScreen(pyte.Screen):
|
||||
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."""
|
||||
"""Send a query reply to the app: it goes 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."""
|
||||
"""DA1: claim to be a VT220 with ANSI colour"""
|
||||
if kwargs.get("private"):
|
||||
return
|
||||
if mode == 0:
|
||||
@@ -603,7 +572,7 @@ class VtScreen(pyte.Screen):
|
||||
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
|
||||
# color-scheme report
|
||||
self.write_process_input(self.theme_report())
|
||||
|
||||
def theme_report(self):
|
||||
@@ -628,7 +597,7 @@ class VtScreen(pyte.Screen):
|
||||
def report_kitty_keyboard(self):
|
||||
"""Answer the kitty keyboard protocol probe."""
|
||||
# answering at all means "supported"; 0 flags means none are enabled,
|
||||
# which is what steers pi onto its modifyOtherKeys fallback
|
||||
# which steers an app onto its modifyOtherKeys fallback
|
||||
self.write_process_input("\x1b[?0u")
|
||||
|
||||
def handle_osc(self, code, payload, terminator):
|
||||
@@ -656,24 +625,21 @@ class VtScreen(pyte.Screen):
|
||||
# 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
|
||||
# a read ending mid-CSI, held for the next read; bounded so a run of digits
|
||||
# with no final byte 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
|
||||
"""Stream with a parser for `>`/`<`/`=` prefixed CSI, the DECRQM `$p`
|
||||
intermediate and multi-digit OSC codes.
|
||||
|
||||
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.
|
||||
feed() dispatches complete plain CSI and lone control characters directly,
|
||||
rather than resuming the parser coroutine once per character.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Copy the CSI table before adding to it, so pyte's class-level one is untouched."""
|
||||
"""Copy the CSI table before adding to it: the class-level one is shared"""
|
||||
self.csi = dict(self.csi)
|
||||
self.csi["S"] = "scroll_up"
|
||||
self.csi["T"] = "scroll_down"
|
||||
@@ -683,10 +649,10 @@ class VtStream(pyte.Stream):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def attach(self, screen):
|
||||
"""Bind the fast dispatch tables when the listener is known."""
|
||||
"""Bind the fast dispatch tables once 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
|
||||
# built like the parser's dispatchers, so an unknown final byte lands
|
||||
# on listener.debug either way
|
||||
self._fast_csi = defaultdict(
|
||||
lambda: screen.debug,
|
||||
{final: getattr(screen, name) for final, name in self.csi.items()},
|
||||
@@ -696,15 +662,13 @@ class VtStream(pyte.Stream):
|
||||
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.
|
||||
"""Parse data into the screen, 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.
|
||||
A complete unprefixed CSI is dispatched in one call and a lone control
|
||||
character in one lookup; everything else — OSC, charsets,
|
||||
private/prefixed CSI, `:` subparams — falls through to the parser. A
|
||||
read ending mid-CSI is carried into the next one, so nothing dispatches
|
||||
until the sequence is whole.
|
||||
"""
|
||||
# the tail of the previous read ended mid-CSI; complete it first
|
||||
if self._carry:
|
||||
@@ -728,7 +692,7 @@ class VtStream(pyte.Stream):
|
||||
offset = 0
|
||||
while offset < length:
|
||||
if taking_plain_text:
|
||||
# printable run: hand it to draw() whole (pyte's own fast path)
|
||||
# printable run: hand it to draw() whole
|
||||
match = match_text(data, offset)
|
||||
if match:
|
||||
start, offset = match.span()
|
||||
@@ -739,7 +703,6 @@ class VtStream(pyte.Stream):
|
||||
# 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:
|
||||
@@ -750,7 +713,7 @@ class VtStream(pyte.Stream):
|
||||
try:
|
||||
csi[match.group(2)](*params)
|
||||
except TypeError:
|
||||
# same guard as _dispatch_csi: excess params cost one sequence, not the chunk
|
||||
# excess params cost one sequence, not the chunk
|
||||
LOG.debug("malformed CSI %s%s", params, match.group(2))
|
||||
offset = match.end()
|
||||
continue
|
||||
@@ -776,11 +739,9 @@ class VtStream(pyte.Stream):
|
||||
self._taking_plain_text = taking_plain_text
|
||||
|
||||
def _parser_fsm(self):
|
||||
"""Coroutine pyte feeds one character at a time; `yield True` resumes bulk text.
|
||||
"""Parser coroutine, fed 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.
|
||||
Locals are bound up front: this runs per byte of every escape sequence.
|
||||
"""
|
||||
basic = self.basic
|
||||
listener = self.listener
|
||||
@@ -829,7 +790,7 @@ class VtStream(pyte.Stream):
|
||||
elif char == "%":
|
||||
self.select_other_charset((yield None))
|
||||
elif char in "()":
|
||||
# honoured even in UTF-8 mode, which pyte skips: smacs is ESC(0
|
||||
# honoured even in UTF-8 mode: xterm-256color's smacs is ESC(0
|
||||
listener.define_charset((yield None), mode=char)
|
||||
else:
|
||||
escape_dispatch[char]()
|
||||
@@ -873,7 +834,7 @@ class VtStream(pyte.Stream):
|
||||
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
|
||||
# ending at BEL or ST
|
||||
elif char == OSC_C1:
|
||||
code = ""
|
||||
while True:
|
||||
@@ -907,8 +868,8 @@ class VtStream(pyte.Stream):
|
||||
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.
|
||||
An unimplemented private sequence is dropped rather than run in its
|
||||
non-private meaning.
|
||||
"""
|
||||
if intermediate == "$":
|
||||
if final == "p":
|
||||
|
||||
@@ -107,25 +107,18 @@ def _modifier_code(ctrl, alt, shift):
|
||||
|
||||
def get_key_code(key, ctrl=False, alt=False, shift=False, application_mode=False, new_line_mode=False,
|
||||
modify_other_keys=0):
|
||||
"""Escape sequence for a key name and its modifiers.
|
||||
|
||||
Args:
|
||||
key: key name from the keymap, or a single literal character.
|
||||
application_mode: DECCKM is set, so cursor keys take their SS3 form.
|
||||
new_line_mode: LNM is set, so enter sends CR LF.
|
||||
modify_other_keys: the level the app asked for with CSI > 4 ; n m.
|
||||
|
||||
Returns:
|
||||
str: the bytes to write to the pty.
|
||||
"""
|
||||
"""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
|
||||
|
||||
# the keys with an xterm modified form of their own
|
||||
if (ctrl or alt or shift) and key_lo in _MODIFIABLE_CSI:
|
||||
return _MODIFIABLE_CSI[key_lo].format(_modifier_code(ctrl, alt, shift))
|
||||
|
||||
# the ambiguous ones, while the app asked for CSI 27
|
||||
if modify_other_keys and (ctrl or alt or shift) and key_lo in _OTHER_KEYS:
|
||||
return "\x1b[27;{};{}~".format(_modifier_code(ctrl, alt, shift), _OTHER_KEYS[key_lo])
|
||||
|
||||
# ctrl folds to a control byte, alt prefixes ESC, shift is bare text
|
||||
if ctrl:
|
||||
base = key_lo
|
||||
if base in _CTRL_KEY_MAP:
|
||||
|
||||
@@ -13,7 +13,6 @@ class VtermCoreEventListener(sublime_plugin.EventListener):
|
||||
"""
|
||||
|
||||
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 = {}
|
||||
|
||||
@@ -56,7 +55,6 @@ class VtermCoreEventListener(sublime_plugin.EventListener):
|
||||
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
|
||||
@@ -86,7 +84,7 @@ class VtermCoreEventListener(sublime_plugin.EventListener):
|
||||
self._cursor[view.id()] = view.sel()[0].end()
|
||||
|
||||
def on_query_context(self, view, key, operator, operand, match_all):
|
||||
"""Answer the contexts the mousemap needs; keymap contexts are native."""
|
||||
"""Answer the contexts the mousemap needs"""
|
||||
# keymap contexts are native "setting.x"; mousemaps do not evaluate
|
||||
# those the same way, so their flags still need the hook
|
||||
if key not in ("vterm_view", "vterm_view.wheel_captured"):
|
||||
|
||||
@@ -13,16 +13,15 @@ from .terminal import Terminal
|
||||
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
|
||||
# lines per wheel tick, as iTerm2 delivers per notch
|
||||
WHEEL_LINES = 3
|
||||
|
||||
|
||||
def send_scroll(terminal, up, lines, cell=None):
|
||||
"""Report `lines` scroll lines to the app, or fake arrow keys on the alt screen.
|
||||
|
||||
One report per line, never one batched write: pi matches the SGR form
|
||||
against a whole stdin chunk and drops anything longer."""
|
||||
One report per write, never a batch: an app may match the SGR form against
|
||||
a whole stdin chunk and drop anything longer."""
|
||||
if terminal.screen.mouse_reporting_enabled():
|
||||
if cell is None:
|
||||
cell = (1, terminal.screen.lines // 2 + 1)
|
||||
@@ -38,8 +37,8 @@ def send_scroll(terminal, up, lines, cell=None):
|
||||
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."""
|
||||
Clamping pulls a point above or below the screen onto the nearest row: the
|
||||
wheel still belongs to the app there, a click does not."""
|
||||
pt = view.window_to_text((event["x"], event["y"]))
|
||||
row, col = view.rowcol(pt)
|
||||
row -= terminal.offset
|
||||
|
||||
@@ -12,7 +12,7 @@ import sublime
|
||||
import sublime_plugin
|
||||
from wcwidth import wcswidth
|
||||
|
||||
# per-cell call in segment_buffer_line; pyte caches its own wcwidth likewise
|
||||
# per-cell call in segment_buffer_line
|
||||
cell_width = lru_cache(maxsize=4096)(wcswidth)
|
||||
|
||||
from .scheme import scheme_manager
|
||||
@@ -125,8 +125,8 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||
"""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
|
||||
# view row -> (segments, lf) as last rendered; the dirty set is only a
|
||||
# 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 +
|
||||
@@ -199,11 +199,11 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||
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).
|
||||
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."""
|
||||
"""Fold the scrollback away while the app owns the alt screen; the view text is its only copy.
|
||||
|
||||
Re-checked every render: an edit at the fold boundary can silently
|
||||
disturb a fold, and the screen's first row is replaced on every scroll.
|
||||
"""
|
||||
view = self.view
|
||||
alt = terminal.screen.alternate_buffer_mode
|
||||
if not alt and not terminal.applied_alt_screen:
|
||||
@@ -303,17 +303,11 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||
self.update_line(edit, line + offset, buffer_line, columns)
|
||||
|
||||
def update_line(self, edit, line, buffer_line, columns):
|
||||
"""Rewrite one view row from a screen row, unless it is unchanged.
|
||||
|
||||
Args:
|
||||
line: absolute view row.
|
||||
columns: screen width; the last cell's linefeed flag marks a soft
|
||||
wrap, which gets the CONTINUATION sentinel.
|
||||
"""
|
||||
"""Rewrite one view row from a screen row, unless it is unchanged"""
|
||||
view = self.view
|
||||
lf = buffer_line[columns - 1].linefeed
|
||||
segments = list(segment_buffer_line(buffer_line))
|
||||
# pyte's dirty set is a repaint hint, not a diff: most dirty rows come
|
||||
# the 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:
|
||||
@@ -373,16 +367,10 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||
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.
|
||||
Every region key is a separate pass in sublime's redraw, so merging
|
||||
bounds the count at scopes x (scrollback / CHUNK) plus one open chunk.
|
||||
The merge waits for the chunk to close: add_regions replaces a key's
|
||||
whole set, so merging every frame would be quadratic in CHUNK.
|
||||
"""
|
||||
view = self.view
|
||||
rows, chunk = self._open_rows, self._open_chunk
|
||||
@@ -526,8 +514,7 @@ def following(view, terminal):
|
||||
|
||||
def bottom_y(view, terminal):
|
||||
"""Viewport y that pins the bottom of the live screen to the bottom of the view."""
|
||||
# layout_extent is maintained incrementally; text_to_layout(size()) forced
|
||||
# a whole-buffer layout on every call
|
||||
# layout_extent is maintained incrementally, unlike text_to_layout(size())
|
||||
viewport_y = 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)
|
||||
@@ -566,9 +553,7 @@ class VtermShowCursorCommand(sublime_plugin.TextCommand):
|
||||
which has no edit token of its own."""
|
||||
|
||||
def run(self, edit, scroll=True):
|
||||
"""Args:
|
||||
scroll: also follow the output down to the cursor.
|
||||
"""
|
||||
"""Place the caret at the terminal cursor, optionally following the output down"""
|
||||
view = self.view
|
||||
terminal = Terminal.from_id(view.id())
|
||||
if not terminal:
|
||||
|
||||
@@ -116,7 +116,7 @@ class SchemeManager:
|
||||
self._listeners.remove(callback)
|
||||
|
||||
def reload(self):
|
||||
"""Rebuild every rule for a new palette; colours resolve at add time, so they cannot be appended"""
|
||||
"""Rebuild every rule for a new palette, then notify the terminals"""
|
||||
if not self._initialized:
|
||||
# no scheme file to maintain yet; initialize() will read the setting
|
||||
return
|
||||
|
||||
@@ -150,8 +150,7 @@ class Terminal:
|
||||
"""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.
|
||||
little changed, so a terminal the user only watches draws at a lower rate.
|
||||
"""
|
||||
if self.focused:
|
||||
return True
|
||||
@@ -236,8 +235,7 @@ class Terminal:
|
||||
|
||||
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
|
||||
# deque appends/poplefts are atomic, so the reader never blocks behind a render
|
||||
chunks = deque()
|
||||
done = [False]
|
||||
|
||||
@@ -274,8 +272,8 @@ class Terminal:
|
||||
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
|
||||
# the parser resets itself before re-raising; letting this
|
||||
# escape would kill the renderer thread
|
||||
LOG.exception("stream.feed failed, dropping the rest of a chunk")
|
||||
|
||||
def renderer():
|
||||
@@ -322,7 +320,7 @@ class Terminal:
|
||||
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"""
|
||||
"""Make the app repaint by briefly narrowing the pty. Never write ctrl+L instead: apps bind it themselves"""
|
||||
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))
|
||||
@@ -342,12 +340,9 @@ class Terminal:
|
||||
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.
|
||||
"""
|
||||
"""Write text to the pty, chunking anything large"""
|
||||
# escape sequences go through untouched; typed and pasted text has its
|
||||
# line endings canonicalised for the app's LNM setting
|
||||
if normalized:
|
||||
string = string.replace("\r\n", "\n")
|
||||
if mo.LNM in self.screen.mode:
|
||||
@@ -387,8 +382,6 @@ class Terminal:
|
||||
|
||||
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
|
||||
|
||||
@@ -52,10 +52,10 @@ def intermission(period=0.1):
|
||||
|
||||
|
||||
def rev_wcwidth(text, width):
|
||||
"""Return the index into `text` at which the substring reaches `width` cells.
|
||||
"""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.
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user