fix resize scrollback retention and harden remote ssh cmd+d resize regression

This commit is contained in:
Lawrence Chen 2026-03-01 04:41:11 -08:00
parent 9dd19161be
commit 9f18ae7f96
5 changed files with 325 additions and 49 deletions

View file

@ -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

View file

@ -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"
}

View file

@ -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),

View file

@ -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",
)

View file

@ -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}",
)