Last active
October 10, 2026 03:50
-
-
Save tlkahn/e3dbe93e84a9bffb0c8bad416455880e to your computer and use it in GitHub Desktop.
pi local patch: keep fullscreen transcript scroll position on layout changes (#10556)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """Re-apply the ScrollView follow-clamp fix to an installed pi. | |
| Upstream rejected the fix (issue #10556), so this keeps the local patch in place | |
| across `pi update`. | |
| The fix removes `ScrollView.updateLayout`'s geometry-based re-enable of | |
| follow-end. Before, a non-following view that happened to sit at the new content | |
| end after any layout change (a tool card above the viewport collapsing, the dock | |
| shrinking when the streaming status clears) silently resumed following, so the | |
| next streamed line dragged it to the bottom. Follow now resumes only through an | |
| explicit scroll (ctrl+end, the jump-to-end indicator, a scrollbar jump, or | |
| scrolling past the end). | |
| It is conservative. It only replaces one exact block per file, and it upgrades | |
| both the pristine upstream block and the earlier partial 10556 guard. When | |
| future code does not match, it skips (and says so) instead of guessing, so pi | |
| keeps working unpatched rather than getting corrupted. Each write goes to a temp | |
| file, is checked with `node --check`, and only then replaces the target | |
| atomically. | |
| It is idempotent and cheap to run on every pi invocation: an already-patched | |
| file is detected, and a version stamp short circuits the scan until the installed | |
| pi changes. | |
| Patches, when present: | |
| <node_modules>/@earendil-works/pi-tui/dist/components/scroll-view.js | |
| <node_modules>/@earendil-works/pi-coding-agent/dist/bundle/chunks/*.js | |
| Usage: pi-scroll-follow-patch [--force] [node_modules_root] | |
| --force ignore the version stamp and rescan | |
| """ | |
| import glob | |
| import json | |
| import os | |
| import shutil | |
| import subprocess | |
| import sys | |
| import tempfile | |
| # A positive marker that only exists once the block has been stripped. It spans | |
| # the clamp and the following `hideTransientScrollbar` guard, so it is absent in | |
| # both the pristine and the earlier partial-patch shape. | |
| MARKER_SRC = ( | |
| "this.currentScrollTop = Math.max(0, Math.min(this.currentScrollTop, maxScrollTop));\n" | |
| " if (this.contentHeight <= this.currentViewportHeight)" | |
| ) | |
| MARKER_BUNDLE = ( | |
| "Math.min(this.currentScrollTop,maxScrollTop))," | |
| "this.contentHeight<=this.currentViewportHeight&&this.hideTransientScrollbar()" | |
| ) | |
| # Pristine upstream 1.0.4 shape. | |
| SRC_OLD_PRISTINE = """ const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight); | |
| if (this.followingEnd) | |
| this.currentScrollTop = maxScrollTop; | |
| else | |
| this.currentScrollTop = Math.max(0, Math.min(this.currentScrollTop, maxScrollTop)); | |
| if (this.currentScrollTop < maxScrollTop) | |
| this.followSuppressedAtEnd = false; | |
| if (this.followEnd && this.currentScrollTop === maxScrollTop && !this.followSuppressedAtEnd) { | |
| this.followingEnd = true; | |
| }""" | |
| # Earlier partial 10556 guard (this patcher's previous output). | |
| SRC_OLD_FIX1 = """ const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight); | |
| const previousScrollTop = this.currentScrollTop; | |
| if (this.followingEnd) | |
| this.currentScrollTop = maxScrollTop; | |
| else | |
| this.currentScrollTop = Math.max(0, Math.min(this.currentScrollTop, maxScrollTop)); | |
| if (this.currentScrollTop < maxScrollTop) | |
| this.followSuppressedAtEnd = false; | |
| else if (!this.followingEnd && this.currentScrollTop < previousScrollTop) | |
| this.followSuppressedAtEnd = true; | |
| if (this.followEnd && this.currentScrollTop === maxScrollTop && !this.followSuppressedAtEnd) { | |
| this.followingEnd = true; | |
| }""" | |
| SRC_NEW = """ const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight); | |
| if (this.followingEnd) | |
| this.currentScrollTop = maxScrollTop; | |
| else | |
| this.currentScrollTop = Math.max(0, Math.min(this.currentScrollTop, maxScrollTop));""" | |
| BUNDLE_OLD_PRISTINE = ( | |
| "let maxScrollTop=Math.max(0,this.contentHeight-this.currentViewportHeight);" | |
| "this.followingEnd?this.currentScrollTop=maxScrollTop:" | |
| "this.currentScrollTop=Math.max(0,Math.min(this.currentScrollTop,maxScrollTop))," | |
| "this.currentScrollTop<maxScrollTop&&(this.followSuppressedAtEnd=!1)," | |
| "this.followEnd&&this.currentScrollTop===maxScrollTop&&!this.followSuppressedAtEnd&&(this.followingEnd=!0)" | |
| ) | |
| BUNDLE_OLD_FIX1 = ( | |
| "let maxScrollTop=Math.max(0,this.contentHeight-this.currentViewportHeight)," | |
| "previousScrollTop=this.currentScrollTop;" | |
| "this.followingEnd?this.currentScrollTop=maxScrollTop:" | |
| "this.currentScrollTop=Math.max(0,Math.min(this.currentScrollTop,maxScrollTop))," | |
| "this.currentScrollTop<maxScrollTop?this.followSuppressedAtEnd=!1:" | |
| "!this.followingEnd&&this.currentScrollTop<previousScrollTop&&(this.followSuppressedAtEnd=!0)," | |
| "this.followEnd&&this.currentScrollTop===maxScrollTop&&!this.followSuppressedAtEnd&&(this.followingEnd=!0)" | |
| ) | |
| BUNDLE_NEW = ( | |
| "let maxScrollTop=Math.max(0,this.contentHeight-this.currentViewportHeight);" | |
| "this.followingEnd?this.currentScrollTop=maxScrollTop:" | |
| "this.currentScrollTop=Math.max(0,Math.min(this.currentScrollTop,maxScrollTop))" | |
| ) | |
| def find_node_modules_root(): | |
| """Resolve the node_modules that holds the running pi, or fall back to bun global.""" | |
| binary = shutil.which("pi") | |
| if binary: | |
| current = os.path.dirname(os.path.realpath(binary)) | |
| while current and current != "/": | |
| candidate = os.path.join(current, "@earendil-works", "pi-coding-agent") | |
| if os.path.isdir(candidate) and os.path.basename(current) == "node_modules": | |
| return current | |
| current = os.path.dirname(current) | |
| default = os.path.expanduser("~/.bun/install/global/node_modules") | |
| return default if os.path.isdir(default) else None | |
| def cache_dir(): | |
| base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache") | |
| return os.path.join(base, "pi-scroll-follow-patch") | |
| def version_key(root): | |
| """Identify the installed pi so the scan only runs after a change.""" | |
| parts = [] | |
| for package in ("pi-tui", "pi-coding-agent"): | |
| manifest = os.path.join(root, "@earendil-works", package, "package.json") | |
| try: | |
| with open(manifest, encoding="utf-8") as handle: | |
| parts.append(str(json.load(handle).get("version", "?"))) | |
| except (OSError, ValueError): | |
| parts.append("?") | |
| target = os.path.join(root, "@earendil-works", "pi-tui", "dist", "components", "scroll-view.js") | |
| try: | |
| stat = os.stat(target) | |
| parts.append(f"{stat.st_size}:{int(stat.st_mtime)}") | |
| except OSError: | |
| parts.append("?") | |
| return "|".join(parts) | |
| def validate_js(path): | |
| """True when node accepts the file, or when node is unavailable. | |
| The temp file carries an .mjs suffix so node parses ESM early errors (for | |
| example a duplicate `const`). `node --check` skips those for an ambiguous | |
| .js file, which the patched files are. | |
| """ | |
| try: | |
| result = subprocess.run(["node", "--check", path], capture_output=True) | |
| except FileNotFoundError: | |
| return True, "" | |
| if result.returncode == 0: | |
| return True, "" | |
| return False, result.stderr.decode("utf-8", "replace").strip() | |
| def write_atomic(path, text): | |
| """Write via a temp file, validate, and rename; leave the target untouched on failure.""" | |
| directory = os.path.dirname(path) | |
| handle, tmp = tempfile.mkstemp(prefix=".pi-scroll-patch-", suffix=".mjs", dir=directory) | |
| try: | |
| with os.fdopen(handle, "w", encoding="utf-8") as stream: | |
| stream.write(text) | |
| ok, error = validate_js(tmp) | |
| if not ok: | |
| os.unlink(tmp) | |
| return False, error | |
| os.replace(tmp, path) | |
| return True, "" | |
| except BaseException: | |
| try: | |
| os.unlink(tmp) | |
| except OSError: | |
| pass | |
| raise | |
| def patch_file(path, olds, new, marker, label): | |
| try: | |
| with open(path, encoding="utf-8") as handle: | |
| text = handle.read() | |
| except OSError as error: | |
| print(f"pi-scroll-follow-patch: cannot read {path}: {error}", file=sys.stderr) | |
| return False | |
| if marker in text: | |
| return False | |
| for old in olds: | |
| if old not in text: | |
| continue | |
| ok, error = write_atomic(path, text.replace(old, new, 1)) | |
| if not ok: | |
| print( | |
| f"pi-scroll-follow-patch: patched {path} failed node --check, left unchanged\n{error}", | |
| file=sys.stderr, | |
| ) | |
| return False | |
| print(f"pi-scroll-follow-patch: patched {path}") | |
| return True | |
| if label == "bundle" and "followSuppressedAtEnd" not in text: | |
| return False # Not the scroll view chunk, nothing to do. | |
| print( | |
| f"pi-scroll-follow-patch: pattern not found in {path}; upstream code changed, patch skipped", | |
| file=sys.stderr, | |
| ) | |
| return False | |
| def main(): | |
| force = "--force" in sys.argv[1:] | |
| positional = [arg for arg in sys.argv[1:] if not arg.startswith("--")] | |
| root = positional[0] if positional else find_node_modules_root() | |
| if not root: | |
| print("pi-scroll-follow-patch: could not locate node_modules", file=sys.stderr) | |
| return 0 | |
| key = version_key(root) | |
| stamp = os.path.join(cache_dir(), "stamp") | |
| if not force: | |
| try: | |
| with open(stamp, encoding="utf-8") as handle: | |
| if handle.read().strip() == key: | |
| return 0 | |
| except OSError: | |
| pass | |
| patch_file( | |
| os.path.join(root, "@earendil-works", "pi-tui", "dist", "components", "scroll-view.js"), | |
| (SRC_OLD_PRISTINE, SRC_OLD_FIX1), | |
| SRC_NEW, | |
| MARKER_SRC, | |
| "src", | |
| ) | |
| chunk_glob = os.path.join(root, "@earendil-works", "pi-coding-agent", "dist", "bundle", "chunks", "*.js") | |
| for chunk in sorted(glob.glob(chunk_glob)): | |
| patch_file(chunk, (BUNDLE_OLD_PRISTINE, BUNDLE_OLD_FIX1), BUNDLE_NEW, MARKER_BUNDLE, "bundle") | |
| try: | |
| os.makedirs(cache_dir(), exist_ok=True) | |
| with open(stamp, "w", encoding="utf-8") as handle: | |
| handle.write(key) | |
| except OSError as error: | |
| print(f"pi-scroll-follow-patch: could not write stamp: {error}", file=sys.stderr) | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| function pi --description 'Run pi, re-applying the ScrollView follow-clamp patch after updates' | |
| pi-scroll-follow-patch | |
| command pi $argv | |
| end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment