Skip to content

Instantly share code, notes, and snippets.

@tlkahn
Last active October 10, 2026 03:50
Show Gist options
  • Select an option

  • Save tlkahn/e3dbe93e84a9bffb0c8bad416455880e to your computer and use it in GitHub Desktop.

Select an option

Save tlkahn/e3dbe93e84a9bffb0c8bad416455880e to your computer and use it in GitHub Desktop.
pi local patch: keep fullscreen transcript scroll position on layout changes (#10556)
#!/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())
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