fix resize scrollback retention and harden remote ssh cmd+d resize regression
This commit is contained in:
parent
9dd19161be
commit
9f18ae7f96
5 changed files with 325 additions and 49 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue