From 2a8fffb9c60fda700103238c004e7b8fa9c3378f Mon Sep 17 00:00:00 2001 From: Vaclav Tvrdik Date: Thu, 10 Sep 2026 10:07:12 +0200 Subject: [PATCH] code comments changes --- tests/test_commands.py | 2 +- tests/test_emulator.py | 30 ++++----- tests/test_imports.py | 4 +- tests/test_render.py | 10 +-- vterm/commands.py | 30 +++------ vterm/emulator.py | 147 +++++++++++++++-------------------------- vterm/keys.py | 15 ++--- vterm/listeners.py | 4 +- vterm/mouse.py | 11 ++- vterm/render.py | 47 +++++-------- vterm/scheme.py | 2 +- vterm/terminal.py | 23 +++---- vterm/utils.py | 6 +- 13 files changed, 123 insertions(+), 208 deletions(-) diff --git a/tests/test_commands.py b/tests/test_commands.py index 896e183..bbcacd3 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -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() diff --git a/tests/test_emulator.py b/tests/test_emulator.py index 863077b..840775a 100644 --- a/tests/test_emulator.py +++ b/tests/test_emulator.py @@ -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, diff --git a/tests/test_imports.py b/tests/test_imports.py index f7d3188..79a6089 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -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~" diff --git a/tests/test_render.py b/tests/test_render.py index 7518d55..f4fc66f 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -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") diff --git a/vterm/commands.py b/vterm/commands.py index b86ed1e..17eba9f 100644 --- a/vterm/commands.py +++ b/vterm/commands.py @@ -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 diff --git a/vterm/emulator.py b/vterm/emulator.py index 26c4300..c21669a 100644 --- a/vterm/emulator.py +++ b/vterm/emulator.py @@ -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": diff --git a/vterm/keys.py b/vterm/keys.py index c5c3c02..db8a681 100644 --- a/vterm/keys.py +++ b/vterm/keys.py @@ -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: diff --git a/vterm/listeners.py b/vterm/listeners.py index f2ce832..4287620 100644 --- a/vterm/listeners.py +++ b/vterm/listeners.py @@ -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"): diff --git a/vterm/mouse.py b/vterm/mouse.py index c5ca964..1290cfa 100644 --- a/vterm/mouse.py +++ b/vterm/mouse.py @@ -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 diff --git a/vterm/render.py b/vterm/render.py index ace85a9..b549b58 100644 --- a/vterm/render.py +++ b/vterm/render.py @@ -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: diff --git a/vterm/scheme.py b/vterm/scheme.py index 7f594de..be89213 100644 --- a/vterm/scheme.py +++ b/vterm/scheme.py @@ -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 diff --git a/vterm/terminal.py b/vterm/terminal.py index b0d05bb..c80c9f5 100644 --- a/vterm/terminal.py +++ b/vterm/terminal.py @@ -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 diff --git a/vterm/utils.py b/vterm/utils.py index a47334a..155dbe5 100644 --- a/vterm/utils.py +++ b/vterm/utils.py @@ -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