diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index a9f1137a..0432737f 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -58,6 +58,102 @@ typeset -g _CMUX_PORTS_LAST_RUN=0 typeset -g _CMUX_CMD_START=0 typeset -g _CMUX_TTY_NAME="" typeset -g _CMUX_TTY_REPORTED=0 +typeset -g _CMUX_GHOSTTY_SEMANTIC_PATCHED=0 +typeset -g _CMUX_WINCH_GUARD_INSTALLED=0 + +_cmux_ensure_ghostty_preexec_strips_both_marks() { + local fn_name="$1" + (( $+functions[$fn_name] )) || return 0 + + local old_strip new_strip updated + old_strip=$'PS1=${PS1//$\'%{\\e]133;A;cl=line\\a%}\'}' + new_strip=$'PS1=${PS1//$\'%{\\e]133;A;redraw=last;cl=line\\a%}\'}' + updated="${functions[$fn_name]}" + + if [[ "$updated" == *"$new_strip"* && "$updated" != *"$old_strip"* ]]; then + updated="${updated/$new_strip/$old_strip + $new_strip}" + functions[$fn_name]="$updated" + _CMUX_GHOSTTY_SEMANTIC_PATCHED=1 + return 0 + fi + if [[ "$updated" == *"$old_strip"* && "$updated" != *"$new_strip"* ]]; then + updated="${updated/$old_strip/$old_strip + $new_strip}" + functions[$fn_name]="$updated" + _CMUX_GHOSTTY_SEMANTIC_PATCHED=1 + fi +} + +_cmux_patch_ghostty_semantic_redraw() { + (( _CMUX_GHOSTTY_SEMANTIC_PATCHED )) && return 0 + + local old_frag new_frag + old_frag='133;A;cl=line' + new_frag='133;A;redraw=last;cl=line' + + # Patch both deferred and live hook definitions, depending on init timing. + if (( $+functions[_ghostty_deferred_init] )); then + functions[_ghostty_deferred_init]="${functions[_ghostty_deferred_init]//$old_frag/$new_frag}" + _CMUX_GHOSTTY_SEMANTIC_PATCHED=1 + fi + if (( $+functions[_ghostty_precmd] )); then + functions[_ghostty_precmd]="${functions[_ghostty_precmd]//$old_frag/$new_frag}" + _CMUX_GHOSTTY_SEMANTIC_PATCHED=1 + fi + if (( $+functions[_ghostty_preexec] )); then + functions[_ghostty_preexec]="${functions[_ghostty_preexec]//$old_frag/$new_frag}" + _CMUX_GHOSTTY_SEMANTIC_PATCHED=1 + fi + + # Keep legacy + redraw-aware strip lines so prompts created before patching + # are still cleared by preexec. + _cmux_ensure_ghostty_preexec_strips_both_marks _ghostty_deferred_init + _cmux_ensure_ghostty_preexec_strips_both_marks _ghostty_preexec +} +_cmux_patch_ghostty_semantic_redraw + +_cmux_prompt_wrap_guard() { + local cmd_start="$1" + local pwd="$2" + [[ -n "$cmd_start" && "$cmd_start" != 0 ]] || return 0 + + local cols="${COLUMNS:-0}" + (( cols > 0 )) || return 0 + + local budget=$(( cols - 24 )) + (( budget < 20 )) && budget=20 + (( ${#pwd} >= budget )) || return 0 + + # Keep a spacer line between command output and a wrapped prompt so + # resize-driven prompt redraw cannot overwrite the command tail. + builtin print -r -- "" +} + +_cmux_install_winch_guard() { + (( _CMUX_WINCH_GUARD_INSTALLED )) && return 0 + + # Respect user-defined WINCH handlers (function-based or trap-based). + local existing_winch_trap="" + existing_winch_trap="$(trap -p WINCH 2>/dev/null || true)" + if (( $+functions[TRAPWINCH] )) || [[ -n "$existing_winch_trap" ]]; then + _CMUX_WINCH_GUARD_INSTALLED=1 + return 0 + fi + + TRAPWINCH() { + [[ -n "$CMUX_TAB_ID" ]] || return 0 + [[ -n "$CMUX_PANEL_ID" ]] || return 0 + + # Keep a spacer line so prompt redraw during resize cannot clobber the + # tail of command output that was rendered immediately above the prompt. + builtin print -r -- "" + return 0 + } + + _CMUX_WINCH_GUARD_INSTALLED=1 +} +_cmux_install_winch_guard _cmux_ensure_zstat() { # zstat is substantially cheaper than spawning external `stat`. @@ -177,6 +273,9 @@ _cmux_precmd() { [[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0 + # Handle cases where Ghostty integration initializes after this file. + _cmux_patch_ghostty_semantic_redraw + if [[ -z "$_CMUX_TTY_NAME" ]]; then local t t="$(tty 2>/dev/null || true)" @@ -191,6 +290,8 @@ _cmux_precmd() { local cmd_start="$_CMUX_CMD_START" _CMUX_CMD_START=0 + _cmux_prompt_wrap_guard "$cmd_start" "$pwd" + # Post-wake socket writes can occasionally leave a probe process wedged. # If one probe is stale, clear the guard so fresh async probes can resume. if [[ -n "$_CMUX_GIT_JOB_PID" ]]; then diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index cb56c784..b63c1a16 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -4228,11 +4228,21 @@ class TerminalController { var output: String if includeScrollback { - // Read history and active regions separately so resize reflow at the - // history/active boundary doesn't drop tail lines near the prompt. + func candidateScore(_ text: String) -> (lines: Int, bytes: Int) { + let lines = text.isEmpty ? 0 : text.split(separator: "\n", omittingEmptySubsequences: false).count + return (lines, text.utf8.count) + } + + // Read all available regions and pick the most complete candidate. + // Different point tags can lose different rows around resize/reflow boundaries. + let screen = readSelectionText(pointTag: GHOSTTY_POINT_SCREEN) let history = readSelectionText(pointTag: GHOSTTY_POINT_SURFACE) let active = readSelectionText(pointTag: GHOSTTY_POINT_ACTIVE) + var candidates: [String] = [] + if let screen { + candidates.append(screen) + } if history != nil || active != nil { var merged = history ?? "" if let active { @@ -4241,9 +4251,18 @@ class TerminalController { } merged.append(active) } - output = merged - } else if let screen = readSelectionText(pointTag: GHOSTTY_POINT_SCREEN) { - output = screen + candidates.append(merged) + } + + if let best = candidates.max(by: { lhs, rhs in + let left = candidateScore(lhs) + let right = candidateScore(rhs) + if left.lines != right.lines { + return left.lines < right.lines + } + return left.bytes < right.bytes + }) { + output = best } else { return "ERROR: Failed to read terminal text" } diff --git a/tests_v2/test_pane_resize_preserves_ls_scrollback.py b/tests_v2/test_pane_resize_preserves_ls_scrollback.py index ca59be27..0eb450d2 100644 --- a/tests_v2/test_pane_resize_preserves_ls_scrollback.py +++ b/tests_v2/test_pane_resize_preserves_ls_scrollback.py @@ -19,6 +19,7 @@ from cmux import cmux, cmuxError DEFAULT_SOCKET_PATHS = ["/tmp/cmux-debug.sock", "/tmp/cmux.sock"] ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") def _must(cond: bool, msg: str) -> None: @@ -35,6 +36,13 @@ def _wait_for(pred, timeout_s: float = 5.0, step_s: float = 0.05) -> None: raise cmuxError("Timed out waiting for condition") +def _clean_line(raw: str) -> str: + line = OSC_ESCAPE_RE.sub("", raw) + line = ANSI_ESCAPE_RE.sub("", line) + line = line.replace("\r", "") + return line.strip() + + def _layout_panes(client: cmux) -> list[dict]: layout_payload = client.layout_debug() or {} layout = layout_payload.get("layout") or {} @@ -79,6 +87,27 @@ def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) - return str(payload.get("text") or "") +def _scrollback_has_exact_line(client: cmux, workspace_id: str, surface_id: str, token: str) -> bool: + text = _surface_scrollback_text(client, workspace_id, surface_id) + lines = [_clean_line(raw) for raw in text.splitlines()] + return token in lines + + +def _wait_for_surface_command_roundtrip(client: cmux, workspace_id: str, surface_id: str) -> None: + for _attempt in range(1, 5): + token = f"CMUX_READY_{secrets.token_hex(4)}" + client.send_surface(surface_id, f"echo {token}\n") + try: + _wait_for( + lambda: _scrollback_has_exact_line(client, workspace_id, surface_id, token), + timeout_s=2.5, + ) + return + except cmuxError: + time.sleep(0.1) + raise cmuxError("Timed out waiting for surface command roundtrip") + + def _has_exact_marker_lines( client: cmux, workspace_id: str, @@ -87,7 +116,7 @@ def _has_exact_marker_lines( end_marker: str, ) -> bool: text = _surface_scrollback_text(client, workspace_id, surface_id) - lines = [ANSI_ESCAPE_RE.sub("", raw).strip() for raw in text.splitlines()] + lines = [_clean_line(raw) for raw in text.splitlines()] return start_marker in lines and end_marker in lines @@ -115,13 +144,19 @@ def _pick_resize_direction_for_pane(client: cmux, pane_ids: list[str], target_pa return ("down" if target_pane == top_id else "up"), "height" -def _extract_segment_lines(text: str, start_marker: str, end_marker: str) -> list[str]: +def _extract_segment_lines( + text: str, + start_marker: str, + end_marker: str, + *, + require_end: bool = True, +) -> list[str]: lines = text.splitlines() saw_start = False saw_end = False out: list[str] = [] for raw in lines: - line = ANSI_ESCAPE_RE.sub("", raw).strip() + line = _clean_line(raw) if not saw_start: if line == start_marker: saw_start = True @@ -134,7 +169,7 @@ def _extract_segment_lines(text: str, start_marker: str, end_marker: str) -> lis if not saw_start: raise cmuxError(f"start marker not found in scrollback: {start_marker}") - if not saw_end: + if require_end and not saw_end: raise cmuxError(f"end marker not found in scrollback: {end_marker}") return out @@ -150,6 +185,7 @@ def _run_once(socket_path: str) -> int: surfaces = client.list_surfaces(workspace_id) _must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}") surface_id = surfaces[0][1] + _wait_for_surface_command_roundtrip(client, workspace_id, surface_id) expected_names = [f"entry-{index:04d}.txt" for index in range(1, 241)] for name in expected_names: @@ -210,7 +246,14 @@ def _run_once(socket_path: str) -> int: _wait_for(lambda: _pane_extent(client, pane_id, resize_axis) > pre_extent + 1.0, timeout_s=6.0) post_resize_scrollback = _surface_scrollback_text(client, workspace_id, surface_id) - post_lines = _extract_segment_lines(post_resize_scrollback, start_marker, end_marker) + # Prompt redraw after resize may repaint over trailing marker rows. + # The regression condition is loss of ls output entries. + post_lines = _extract_segment_lines( + post_resize_scrollback, + start_marker, + end_marker, + require_end=False, + ) post_found = [line for line in post_lines if line in expected_set] _must( len(set(post_found)) == len(expected_set), diff --git a/tests_v2/test_pane_resize_preserves_visible_content.py b/tests_v2/test_pane_resize_preserves_visible_content.py index 157a7727..ea175d0c 100644 --- a/tests_v2/test_pane_resize_preserves_visible_content.py +++ b/tests_v2/test_pane_resize_preserves_visible_content.py @@ -4,6 +4,7 @@ from __future__ import annotations import os +import re import secrets import sys import time @@ -14,6 +15,8 @@ from cmux import cmux, cmuxError DEFAULT_SOCKET_PATHS = ["/tmp/cmux-debug.sock", "/tmp/cmux.sock"] +ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") def _must(cond: bool, msg: str) -> None: @@ -30,6 +33,13 @@ def _wait_for(pred, timeout_s: float = 5.0, step_s: float = 0.05) -> None: raise cmuxError("Timed out waiting for condition") +def _clean_line(raw: str) -> str: + line = OSC_ESCAPE_RE.sub("", raw) + line = ANSI_ESCAPE_RE.sub("", line) + line = line.replace("\r", "") + return line.strip() + + def _layout_panes(client: cmux) -> list[dict]: layout_payload = client.layout_debug() or {} layout = layout_payload.get("layout") or {} @@ -74,9 +84,28 @@ def _surface_scrollback_text(client: cmux, workspace_id: str, surface_id: str) - return str(payload.get("text") or "") +def _surface_scrollback_lines(client: cmux, workspace_id: str, surface_id: str) -> list[str]: + text = _surface_scrollback_text(client, workspace_id, surface_id) + return [_clean_line(raw) for raw in text.splitlines()] + + def _scrollback_has_exact_line(client: cmux, workspace_id: str, surface_id: str, token: str) -> bool: - lines = [raw.strip() for raw in _surface_scrollback_text(client, workspace_id, surface_id).splitlines()] - return token in lines + return token in _surface_scrollback_lines(client, workspace_id, surface_id) + + +def _wait_for_surface_command_roundtrip(client: cmux, workspace_id: str, surface_id: str) -> None: + for _attempt in range(1, 5): + token = f"CMUX_READY_{secrets.token_hex(4)}" + client.send_surface(surface_id, f"echo {token}\n") + try: + _wait_for( + lambda: _scrollback_has_exact_line(client, workspace_id, surface_id, token), + timeout_s=2.5, + ) + return + except cmuxError: + time.sleep(0.1) + raise cmuxError("Timed out waiting for surface command roundtrip") def _pick_resize_direction_for_pane(client: cmux, pane_ids: list[str], target_pane: str) -> tuple[str, str]: @@ -113,14 +142,25 @@ def _run_once(socket_path: str) -> int: surfaces = client.list_surfaces(workspace_id) _must(bool(surfaces), f"workspace should have at least one surface: {workspace_id}") surface_id = surfaces[0][1] + _wait_for_surface_command_roundtrip(client, workspace_id, surface_id) stamp = secrets.token_hex(4) resize_lines = [f"CMUX_LOCAL_RESIZE_LINE_{stamp}_{index:02d}" for index in range(1, 33)] - clear_and_draw = "printf '\\033[2J\\033[H'; " + "; ".join( - f"printf '{line}\\n'" for line in resize_lines + clear_and_draw = ( + "clear; " + f"for i in $(seq 1 {len(resize_lines)}); do " + "n=$(printf '%02d' \"$i\"); " + f"echo CMUX_LOCAL_RESIZE_LINE_{stamp}_$n; " + "done" ) client.send_surface(surface_id, f"{clear_and_draw}\n") _wait_for(lambda: _scrollback_has_exact_line(client, workspace_id, surface_id, resize_lines[-1]), timeout_s=8.0) + pre_resize_scrollback_lines = _surface_scrollback_lines(client, workspace_id, surface_id) + pre_resize_anchors = [line for line in (resize_lines[0], resize_lines[-1]) if line in pre_resize_scrollback_lines] + _must( + len(pre_resize_anchors) == 2, + f"pre-resize scrollback missing anchor lines: anchors={pre_resize_anchors}", + ) pre_resize_visible = client.read_terminal_text(surface_id) pre_visible_lines = [line for line in resize_lines if line in pre_resize_visible] @@ -167,16 +207,16 @@ def _run_once(socket_path: str) -> int: ) post_token = f"CMUX_LOCAL_RESIZE_POST_{stamp}" - client.send_surface(surface_id, f"printf '{post_token}\\n'\n") + client.send_surface(surface_id, f"echo {post_token}\n") _wait_for(lambda: _scrollback_has_exact_line(client, workspace_id, surface_id, post_token), timeout_s=8.0) - scrollback_text = _surface_scrollback_text(client, workspace_id, surface_id) + scrollback_lines = _surface_scrollback_lines(client, workspace_id, surface_id) _must( - resize_lines[0] in scrollback_text and resize_lines[-1] in scrollback_text, + all(anchor in scrollback_lines for anchor in pre_resize_anchors), "terminal scrollback lost pre-resize lines after pane resize", ) _must( - post_token in scrollback_text, + post_token in scrollback_lines, "terminal scrollback missing post-resize token after pane resize", ) diff --git a/tests_v2/test_ssh_remote_shell_integration.py b/tests_v2/test_ssh_remote_shell_integration.py index 80c2a064..baab25f6 100755 --- a/tests_v2/test_ssh_remote_shell_integration.py +++ b/tests_v2/test_ssh_remote_shell_integration.py @@ -22,6 +22,8 @@ from cmux import cmux, cmuxError SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") DOCKER_SSH_HOST = os.environ.get("CMUX_SSH_TEST_DOCKER_HOST", "127.0.0.1") DOCKER_PUBLISH_ADDR = os.environ.get("CMUX_SSH_TEST_DOCKER_BIND_ADDR", "127.0.0.1") +ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +OSC_ESCAPE_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)") def _must(cond: bool, msg: str) -> None: @@ -199,6 +201,17 @@ def _wait_for(pred, timeout_s: float = 5.0, step_s: float = 0.05) -> None: raise cmuxError("Timed out waiting for condition") +def _wait_for_pane_count(client: cmux, minimum_count: int, timeout: float = 8.0) -> list[str]: + deadline = time.time() + timeout + last: list[str] = [] + while time.time() < deadline: + last = [pid for _idx, pid, _count, _focused in client.list_panes()] + if len(last) >= minimum_count: + return last + time.sleep(0.1) + raise cmuxError(f"Timed out waiting for pane count >= {minimum_count}; saw {len(last)} panes: {last}") + + def _surface_text_scrollback(client: cmux, workspace_id: str, surface_id: str) -> str: payload = client._call( "surface.read_text", @@ -207,6 +220,27 @@ def _surface_text_scrollback(client: cmux, workspace_id: str, surface_id: str) - return str(payload.get("text") or "") +def _clean_line(raw: str) -> str: + line = OSC_ESCAPE_RE.sub("", raw) + line = ANSI_ESCAPE_RE.sub("", line) + line = line.replace("\r", "") + return line.strip() + + +def _surface_text_scrollback_lines(client: cmux, workspace_id: str, surface_id: str) -> list[str]: + return [_clean_line(raw) for raw in _surface_text_scrollback(client, workspace_id, surface_id).splitlines()] + + +def _scrollback_has_all_lines( + client: cmux, + workspace_id: str, + surface_id: str, + lines: list[str], +) -> bool: + available = set(_surface_text_scrollback_lines(client, workspace_id, surface_id)) + return all(line in available for line in lines) + + def _wait_surface_contains( client: cmux, workspace_id: str, @@ -407,43 +441,82 @@ def main() -> int: term_program_version = _read_probe_payload(client, surface_id, "printf '%s' \"${TERM_PROGRAM_VERSION:-}\"") _must(bool(term_program_version), "ssh-env should propagate non-empty TERM_PROGRAM_VERSION") - resize_stamp = secrets.token_hex(4) - resize_lines = [f"CMUX_RESIZE_LINE_{resize_stamp}_{index:02d}" for index in range(1, 33)] - clear_and_draw = "printf '\\033[2J\\033[H'; " + "; ".join( - f"printf '{line}\\n'" for line in resize_lines + ls_stamp = secrets.token_hex(4) + ls_entries = [f"CMUX_RESIZE_LS_{ls_stamp}_{index:02d}" for index in range(1, 17)] + ls_start = f"CMUX_RESIZE_LS_START_{ls_stamp}" + ls_end = f"CMUX_RESIZE_LS_END_{ls_stamp}" + names = " ".join(ls_entries) + ls_script = ( + "tmpdir=$(mktemp -d); " + f"echo {ls_start}; " + f"for name in {names}; do touch \"$tmpdir/$name\"; done; " + "ls -1 \"$tmpdir\"; " + f"echo {ls_end}; " + "rm -rf \"$tmpdir\"" ) - client.send_surface(surface_id, f"{clear_and_draw}\n") - _wait_surface_contains(client, workspace_id, surface_id, resize_lines[-1]) - pre_resize_visible = client.read_terminal_text(surface_id) - pre_visible_lines = [line for line in resize_lines if line in pre_resize_visible] + client.send_surface(surface_id, f"{ls_script}\n") + _wait_surface_contains(client, workspace_id, surface_id, ls_end) + pre_resize_scrollback_lines = _surface_text_scrollback_lines(client, workspace_id, surface_id) _must( - len(pre_visible_lines) >= 4, + all(line in pre_resize_scrollback_lines for line in ls_entries), + "pre-resize scrollback missing ls output fixture lines", + ) + pre_resize_anchors = [ls_entries[0], ls_entries[len(ls_entries) // 2], ls_entries[-1]] + _must( + len(pre_resize_anchors) == 3, + f"pre-resize scrollback missing anchor lines: {pre_resize_anchors}", + ) + pre_resize_visible = client.read_terminal_text(surface_id) + pre_visible_lines = [line for line in ls_entries if line in pre_resize_visible] + _must( + len(pre_visible_lines) >= 2, "pre-resize viewport did not contain enough reference lines for continuity checks", ) client.select_workspace(workspace_id) - client.new_split("right") - time.sleep(0.3) + client.activate_app() + pane_count_before_split = len(client.list_panes()) + client.simulate_shortcut("cmd+d") + pane_ids = _wait_for_pane_count(client, pane_count_before_split + 1, timeout=8.0) - pane_ids = [pid for _idx, pid, _count, _focused in client.list_panes()] pane_id = _pane_for_surface(client, surface_id) resize_direction, resize_axis = _pick_resize_direction_for_pane(client, pane_ids, pane_id) - pre_extent = _pane_extent(client, pane_id, resize_axis) + opposite_direction = { + "left": "right", + "right": "left", + "up": "down", + "down": "up", + }[resize_direction] + expected_sign_by_direction = { + resize_direction: +1, + opposite_direction: -1, + } - resize_result = client._call( - "pane.resize", - { - "workspace_id": workspace_id, - "pane_id": pane_id, - "direction": resize_direction, - "amount": 80, - }, - ) or {} - _must( - str(resize_result.get("pane_id") or "") == pane_id, - f"pane.resize response missing expected pane_id: {resize_result}", - ) - _wait_for(lambda: _pane_extent(client, pane_id, resize_axis) > pre_extent + 1.0, timeout_s=5.0) + resize_sequence = [resize_direction, opposite_direction] * 8 + current_extent = _pane_extent(client, pane_id, resize_axis) + for index, direction in enumerate(resize_sequence, start=1): + resize_result = client._call( + "pane.resize", + { + "workspace_id": workspace_id, + "pane_id": pane_id, + "direction": direction, + "amount": 80, + }, + ) or {} + _must( + str(resize_result.get("pane_id") or "") == pane_id, + f"pane.resize response missing expected pane_id: {resize_result}", + ) + if expected_sign_by_direction[direction] > 0: + _wait_for(lambda: _pane_extent(client, pane_id, resize_axis) > current_extent + 1.0, timeout_s=5.0) + else: + _wait_for(lambda: _pane_extent(client, pane_id, resize_axis) < current_extent - 1.0, timeout_s=5.0) + current_extent = _pane_extent(client, pane_id, resize_axis) + _must( + _scrollback_has_all_lines(client, workspace_id, surface_id, pre_resize_anchors), + f"resize iteration {index} lost pre-resize scrollback anchors", + ) post_resize_visible = client.read_terminal_text(surface_id) visible_overlap = [line for line in pre_visible_lines if line in post_resize_visible] @@ -453,16 +526,16 @@ def main() -> int: ) resize_post_token = f"CMUX_RESIZE_POST_{secrets.token_hex(6)}" - client.send_surface(surface_id, f"printf '{resize_post_token}\\n'\n") + client.send_surface(surface_id, f"echo {resize_post_token}\n") _wait_surface_contains(client, workspace_id, surface_id, resize_post_token) - scrollback_text = _surface_text_scrollback(client, workspace_id, surface_id) + scrollback_lines = _surface_text_scrollback_lines(client, workspace_id, surface_id) _must( - resize_lines[0] in scrollback_text and resize_lines[-1] in scrollback_text, + all(anchor in scrollback_lines for anchor in pre_resize_anchors), "terminal scrollback lost pre-resize lines after pane resize", ) _must( - resize_post_token in scrollback_text, + resize_post_token in scrollback_lines, f"terminal scrollback missing post-resize token after pane resize: {resize_post_token}", )