#!/usr/bin/env python3 """Verify the pane settles to the right size after a layout change. The bug this guards is old and kept coming back in new clothes: opening the side panel, the sidebar or zen mode changes the terminal's width, and if the refit runs before the browser has finished laying that out it measures the *old* box. The pane comes back scaled wrong, or the right size locally while tmux is still painting the other one, and the mismatch shows as dead space, a stray scrollbar, or a screen of dots. It used to be papered over with `setTimeout(settle, 80)`. This asserts the thing the timeout was guessing at, so the guess is not needed: - one toggle produces exactly one settle, on a frame, not a timer; - after it, the terminal's columns match the tmux window's, both ways round; - `refresh` reaches tmux and repaints only the browser that asked. ~/.cache/clique-visual/bin/python tools/redraw_check.py Its own home, port and tmux socket. 0 on pass, 1 on fail. """ from __future__ import annotations import os import shutil import subprocess import sys import time import urllib.error import urllib.request from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from clique import tmux as tmux_mod from clique.auth import COOKIE_NAME, Auth PASSWORD = "redraw-check" # noqa: S105 — a throwaway panel on loopback PORT = 3303 BASE = f"http://127.0.0.1:{PORT}" HOME = Path("/tmp/clique-redraw-check-home") SOCKET = "clique-redraw-check" WORK = Path("/tmp/clique-redraw-check-work") # Count the settles instead of trusting a screenshot. One toggle is one # settle: the old pair fired twice, once too early to be right. COUNT_SETTLES = """() => { window.__settles = 0; const real = window.settlePane; window.settlePane = function () { window.__settles += 1; return real.apply(this, arguments); }; }""" def _catalogue() -> None: """The packaged CLIs plus a boxed stand-in. Claude, Grok and Gemini draw their own prompt box, and that is the shape the pane zoom exists for. This is that shape without opening a paid CLI inside a check. """ body = (ROOT / "clique" / "config" / "clis.toml").read_text(encoding="utf-8") body += ( "\n[cli.boxed]\n" 'label = "Boxed"\n' f"command = {sys.executable!r}\n" f"args = [{str(ROOT / 'tools' / 'fake_boxed_cli.py')!r}]\n" 'color = "#6b7280"\n' "own_input = true\n" ) (HOME / "clis.toml").write_text(body, encoding="utf-8") def _panel() -> subprocess.Popen: shutil.rmtree(HOME, ignore_errors=True) HOME.mkdir(parents=True) _catalogue() env = dict(os.environ, CLIQUE_HOME=str(HOME), CLIQUE_TMUX_SOCKET=SOCKET) subprocess.run( [sys.executable, "-m", "clique", "password"], input=f"{PASSWORD}\n{PASSWORD}\n", text=True, env=env, cwd=str(ROOT), capture_output=True, ) proc = subprocess.Popen( [ sys.executable, "-m", "clique", "--host", "127.0.0.1", "--port", str(PORT), "--state", str(HOME / "state.json"), ], env=env, cwd=str(ROOT), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) for _ in range(80): try: urllib.request.urlopen(BASE + "/healthz", timeout=2).read() return proc except (urllib.error.URLError, OSError): time.sleep(0.25) raise SystemExit(f"the check's own panel never came up on {PORT}") def main() -> int: from playwright.sync_api import sync_playwright shutil.rmtree(WORK, ignore_errors=True) WORK.mkdir(parents=True) Path("/tmp/clique-redraw").mkdir(exist_ok=True) proc = _panel() res: dict[str, object] = {} try: auth = Auth(PASSWORD, HOME / "secret") with sync_playwright() as play: browser = play.chromium.launch() ctx = browser.new_context(viewport={"width": 1280, "height": 860}) ctx.add_cookies( [{"name": COOKIE_NAME, "value": auth.issue(), "domain": "127.0.0.1", "path": "/"}] ) page = ctx.new_page() errs: list[str] = [] page.on("pageerror", lambda e: errs.append(str(e))) page.goto(BASE, wait_until="domcontentloaded") page.wait_for_timeout(700) sid = page.evaluate( """async (cwd) => { const r = await fetch('/api/sessions', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({cli:'shell', cwd, name:'redraw'})}); const id = (await r.json()).id; await refresh(); await openSession(id); return id; }""", str(WORK), ) page.wait_for_timeout(1500) # The tmux session name, which is not the CLIque id and is not # sent to the browser. One session was made here, so this is it. mux = next( s.mux for s in tmux_mod.list_sessions(SOCKET, prefix=tmux_mod.PREFIX) if not s.mux.startswith(tmux_mod.VIEW_PREFIX) ) def cols() -> int: return page.evaluate( "(id) => { const e = terms.get(id); return e && e.term ? e.term.cols : 0; }", sid, ) def rows() -> int: return page.evaluate( "(id) => { const e = terms.get(id); return e && e.term ? e.term.rows : 0; }", sid, ) def tmux_rows() -> int: out = tmux_mod._run( ["list-windows", "-t", f"={mux}", "-F", "#{window_height}"], SOCKET ) return int(out.split()[0]) if out.split() else 0 def tmux_cols() -> int: # list-windows, not display-message: the latter wants a client # and there is none on the session itself, only on the view. out = tmux_mod._run( ["list-windows", "-t", f"={mux}", "-F", "#{window_width}"], SOCKET ) return int(out.split()[0]) if out.split() else 0 wide, tall = cols(), rows() res["terminal_opened"] = wide > 40 and tall > 10 and bool(mux) res["tmux_matches_at_rest"] = (tmux_cols(), tmux_rows()) == (wide, tall) # Taller rows and wider letters fit fewer of each, and tmux has to # be told: a pane that draws 30 rows while tmux paints 40 is the # row-too-many bug from 0.67.12 under another name. base_cols, base_rows = wide, tall page.evaluate("() => saveSettings({ term_line_height: 150, term_letter_spacing: 2 })") page.wait_for_timeout(600) spaced_cols, spaced_rows = cols(), rows() res["line_height_fits_fewer_rows"] = 0 < spaced_rows < base_rows res["letter_spacing_fits_fewer_cols"] = 0 < spaced_cols < base_cols res["tmux_matches_spaced_type"] = (tmux_cols(), tmux_rows()) == (spaced_cols, spaced_rows) page.screenshot(path="/tmp/clique-redraw/spaced-type.png") page.evaluate("() => saveSettings({ term_line_height: 100, term_letter_spacing: 0 })") page.wait_for_timeout(600) res["type_reset_restores_size"] = (cols(), rows()) == (base_cols, base_rows) # One toggle, one settle — and the columns it lands on are the ones # tmux is painting, not the ones the pane had before the panel. page.evaluate(COUNT_SETTLES) page.evaluate("() => openPanel('notes')") page.wait_for_timeout(250) narrow, narrow_rows = cols(), rows() res["panel_narrows_pane"] = narrow < wide res["tmux_matches_with_panel"] = (tmux_cols(), tmux_rows()) == (narrow, narrow_rows) # The pane keeps its full height when the panel takes width off it. res["panel_keeps_pane_height"] = narrow_rows == tall page.screenshot(path="/tmp/clique-redraw/panel-open.png") res["one_settle_per_toggle"] = page.evaluate("() => window.__settles") == 1 # A settle is a frame, not a timer. Two frames is well inside the # 80ms this replaces, so a short wait has to be enough on its own. page.evaluate("() => { window.__settles = 0; closePanel(); }") page.wait_for_timeout(60) res["settles_within_two_frames"] = cols() == wide and tmux_cols() == wide # Toggling twice in a frame collapses to one settle rather than # queueing a second that fits against a box already gone. page.evaluate("() => { window.__settles = 0; openPanel('notes'); closePanel(); }") page.wait_for_timeout(250) res["rapid_toggle_settles_once"] = page.evaluate("() => window.__settles") == 1 res["rapid_toggle_ends_wide"] = cols() == wide and tmux_cols() == wide # A boxed CLI keeps its columns and zooms the picture instead of # refitting, because narrowing the grid is what stacks its prompt. # Zooming to fit the width shrinks the cells, so the pane has to # take the rows that frees up or it sits in the top of its box with # a dead band underneath — the bug this whole section exists for. bid = page.evaluate( """async (cwd) => { const r = await fetch('/api/sessions', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({cli:'boxed', cwd, name:'boxed'})}); const id = (await r.json()).id; await refresh(); await openSession(id); return id; }""", str(WORK), ) page.wait_for_timeout(1800) res["boxed_is_boxed"] = page.evaluate("(id) => sessionOwnsInput(id)", bid) def boxed_fill() -> float: """How much of the pane's height the terminal actually covers.""" return page.evaluate( """(id) => { const e = terms.get(id); if (!e || !e.term || !e.term.element) return 0; const painted = e.term.element.getBoundingClientRect().height; return e.el.clientHeight ? painted / e.el.clientHeight : 0; }""", bid, ) res["boxed_fills_height_at_rest"] = boxed_fill() > 0.9 # The label that explains an odd-looking pane. Quiet when the pane # is at its own fit, which is almost always. def size_note() -> str: return page.evaluate( "() => { const e = document.querySelector('#sessionline .sl-size');" " return e ? e.textContent : ''; }" ) res["no_size_note_at_rest"] = size_note() == "" page.evaluate("() => openPanel('notes')") page.wait_for_timeout(400) zoomed = page.evaluate( """(id) => { const e = terms.get(id); return (e.term.element.style.transform || '').includes('scale'); }""", bid, ) res["boxed_zooms_for_panel"] = zoomed note = size_note() res["size_note_explains_the_zoom"] = "scaled to fit" in note and "x" in note if not res["size_note_explains_the_zoom"]: print(f" size note read {note!r}") fill = boxed_fill() res["boxed_fills_height_with_panel"] = fill > 0.9 spill = page.evaluate( """(id) => { const e = terms.get(id); const term = e.term.element.getBoundingClientRect(); const box = e.el.getBoundingClientRect(); return {right: term.right - box.right, bottom: term.bottom - box.bottom}; }""", bid, ) res["zoomed_pane_stays_in_its_box"] = spill["right"] <= 1 and spill["bottom"] <= 1 # xterm lays its viewport out from getBoundingClientRect, which # reports scaled pixels. Resizing under a transform sized the # viewport to the shrunken measurement while the screen kept its # real width, and the scrollbar ended up stranded in the middle of # the pane with terminal text on both sides of it. edges = page.evaluate( """(id) => { const el = terms.get(id).term.element; const vp = el.querySelector('.xterm-viewport'); const sc = el.querySelector('.xterm-screen'); if (!vp || !sc) return null; return Math.round(sc.getBoundingClientRect().right - vp.getBoundingClientRect().right); }""", bid, ) res["scrollbar_sits_at_the_pane_edge"] = edges is not None and abs(edges) <= 2 if not res["scrollbar_sits_at_the_pane_edge"]: print(f" screen overhangs the scrollbar by {edges}px") # The question the overlap answered wrongly: just inside the # panel's left edge, is the panel what the pointer would hit? res["panel_is_not_covered_by_the_pane"] = page.evaluate( """() => { const panel = document.querySelector('#panelBody') || document.querySelector('#sidepanel'); if (!panel) return false; const r = panel.getBoundingClientRect(); const hits = [8, r.height / 2, r.height - 8].map((dy) => { const el = document.elementFromPoint(r.left + 6, r.top + dy); return Boolean(el && !el.closest('#termwrap')); }); return hits.every(Boolean); }""" ) if not res["zoomed_pane_stays_in_its_box"]: print(f" zoomed pane spills {spill} px past its box") page.screenshot(path="/tmp/clique-redraw/boxed-panel-open.png") page.evaluate("() => closePanel()") page.wait_for_timeout(400) res["boxed_fills_height_again"] = boxed_fill() > 0.9 res["size_note_goes_quiet_again"] = size_note() == "" if fill <= 0.9: print(f" boxed pane covered {fill:.0%} of its box with the panel open") # Two browsers, one session, different window sizes. Only one of # them owns the shared tmux window, and the other's grid changes # under it without its box moving. Nothing recomputed the zoom on # that path, so the loser drew at the scale that suited the grid it # used to have: a half-size picture in a full-width box, with # xterm's scrollbar floating where the picture stopped. second = ctx.new_page() second.goto(BASE, wait_until="domcontentloaded") second.wait_for_timeout(700) second.set_viewport_size({"width": 760, "height": 620}) second.evaluate("async (id) => { await refresh(); await openSession(id); }", bid) second.wait_for_timeout(2000) page.bring_to_front() page.wait_for_timeout(1500) def underfill(pg): """Fraction of the pane's width the picture actually covers.""" return pg.evaluate( """(id) => { const e = terms.get(id); if (!e || !e.term || !e.term.element) return 0; const w = e.term.element.getBoundingClientRect().width; return e.el.clientWidth ? w / e.el.clientWidth : 0; }""", bid, ) wide_fill, small_fill = underfill(page), underfill(second) res["both_panes_fill_their_width"] = wide_fill > 0.9 and small_fill > 0.9 # And the smaller one says why it looks the way it does, rather # than leaving the person in front of it to guess. READ = ( "() => { const e = document.querySelector('#sessionline .sl-size');" " return [e ? e.textContent : ''," " (state.clients ?? 0) + '/' + openTabs.length," " (() => { const t = terms.get(activeId);" " if (!t) return 'none';" " const w = t.fit.proposeDimensions() || {};" " return t.term.cols + 'x' + t.term.rows + ' want ' + w.cols + 'x' + w.rows; })()]; }" ) # The strip refreshes on the 3s poll, and a busy box can be late # with one. Wait for the condition rather than for a number of # milliseconds, or this is a check that fails under load. first_note = second_note = "" for _ in range(16): first_note = page.evaluate(READ)[0] second_note = second.evaluate(READ)[0] if "another window" in first_note or "another window" in second_note: break page.wait_for_timeout(700) told = [n for n in (first_note, second_note) if n.strip()] res["a_browser_is_told_another_owns_the_size"] = any( "another window" in n for n in told ) print(f" strips read: {first_note!r} / {second_note!r}") if not res["both_panes_fill_their_width"]: print(f" widths covered: first {wide_fill:.0%}, second {small_fill:.0%}") second.screenshot(path="/tmp/clique-redraw/second-browser.png") second.close() page.wait_for_timeout(500) # The repaint reaches tmux, finds this browser's own client, and # finds nothing on the session itself — so it cannot hit anyone else. views = [s.mux for s in tmux_mod.list_sessions(SOCKET, prefix=tmux_mod.VIEW_PREFIX)] # One view per open terminal, never one on the session itself. res["viewer_exists"] = len(views) == 2 if views: clients = tmux_mod._run( ["list-clients", "-t", f"={views[0]}", "-F", "#{client_tty}"], SOCKET ).split() res["viewer_has_one_client"] = len(clients) == 1 # tmux 3.4 errors rather than printing nothing when a target # session has no clients, so both shapes mean "none". try: own = tmux_mod._run( ["list-clients", "-t", f"={mux}", "-F", "#{client_tty}"], SOCKET ).strip() except tmux_mod.TmuxError: own = "" res["session_has_no_client"] = not own tmux_mod.refresh_client(views[0], SOCKET) res["refresh_client_survives"] = tmux_mod.exists(mux, SOCKET) # And the socket takes the message the browser actually sends. page.evaluate("() => repaintPane()") page.wait_for_timeout(300) res["repaint_keeps_socket"] = page.evaluate( "(id) => { const e = terms.get(id); return Boolean(e && e.ws && e.ws.readyState === 1); }", sid, ) res["errors"] = errs[:6] browser.close() finally: proc.terminate() subprocess.run( ["tmux", "-L", SOCKET, "kill-server"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) shutil.rmtree(HOME, ignore_errors=True) shutil.rmtree(WORK, ignore_errors=True) ok = all(v for k, v in res.items() if k != "errors") and not res.get("errors") for key, value in res.items(): good = (not value) if key == "errors" else bool(value) print(f" {'ok ' if good else 'FAIL'} {key}: {value}") print("redraw_check:", "PASS" if ok else "FAIL") return 0 if ok else 1 if __name__ == "__main__": sys.exit(main())