Last week I ran openclaw tui to review conversation history and hit PgUp
expecting the past two messages to appear beneath the cursor. They didn’t.
Hitting it again yielded nothing more than a blinking prompt surrounded by
invisible history. The data was there, buffered in memory but inaccessible
via standard navigation keys. This is not merely an inconvenience; it is a
failure of abstraction that conflates terminal behavior with session state
management. I fixed it because the tool must support its own utility class
without breaking.
The root cause lies in @mariozechner/pi-tui, the differential rendering l
library powering our TUI. Its design philosophy treats the rendered output
as an exact, transient mirror of the active buffer. The terminal is the s
scrollback mechanism by default: what you don’t see is lost unless your she
shell preserves it externally. For a static log viewer, this works perfectl
perfectly. openclaw runs on a live session with a running AI agent; users
users need to traverse conversation history while retaining write access at
at the bottom simultaneously. The library assumes scrolling down reveals ol
older content only via terminal native behavior (like less -R). We bypass
bypassed that assumption because our interface demands persistent state vis
visibility regardless of scroll position.
Implementing this required three distinct architectural changes: managing v view offset, intercepting input events before they hit the text editor, and and reconciling viewport rendering with library diffing logic. Simple in th theory. Messy in practice when performance is baked into every function cal call.
We track scrollOffset (how far from bottom) and isScrolledBack flag for
for state integrity. The viewports are anchored at the line count:
viewportTop = maxLinesRendered - terminalHeight - scrollOffset
This places us at history’s top when offset equals total lines minus viewpo viewport height, and at the current live bottom otherwise.
However, logic errors in coordinate clamping broke navigation within second
seconds of testing my first patch. My initial scrollBy function looked st
standard:
const newOffset = Math.max(0, Math.min(this.scrollOffset + delta, maxScroll
maxScroll));
This fails when attempting to scroll up from the bottom (offset 0) becaus
because intermediate calculations pass through negative numbers before reac
reaching zero. If you add -5 then clamp max(0, ...), it stays at 0 foreve
forever. The order of operations must account for directionality separately
separately:
const newOffset = Math.max(0, Math.min(Math.max(0, this.scrollOffset + delt
delta), maxScroll));
By clamping the raw sum to a minimum of zero before enforcing maximum bou bounds, we allow the negative delta from an upward scroll to register corre correctly as movement toward older lines. The terminal keys map intuitively intuitively: PgUp increases offset (older content); Home/End snaps hard bou boundaries; arrows offer granular 3-line increments.
Once navigation stabilized, a new anomaly appeared. Scrolling back while re
receiving live updates caused screen flicker and line jumps. Differential r
rendering compares newLines against previousLines. When we were scrolle
scrolled away from the bottom, the indices shifted relative to each other.
The library assumed line one in both arrays represented the same visual sta
start point; they didn’t when an offset existed.
I introduced a guard that forces full re-render whenever new content arrive arrives while scrolled back:
if (this.isScrolledBack && newLines.length > this.previousLines.length) {
fullRender(true);
}
This sacrifices the micro-optimization of partial rendering for visual stab stability. In modern terminals, a single extra flush is negligible compared compared to user confusion during stream processing.
Several engineering principles emerge from resolving these issues. First, t
terminal toolkits rarely implement scrollback because they rely on host env
environment behavior (the CSI 2026 mode). When you build custom state over
that foundation, you must re-implement the buffering logic entirely; there
is no “off-screen” layer by default in pi-tui.
Second, viewport offsets invalidate simple diffing assumptions. The index a alignment breaks whenever user intent diverges from stream start position. This applies to any system where reading lags behind writing speed—a common common constraint for long-running session-based tools. If the state drifts drifts too far ahead of what’s being sent down, you lose synchronization.
Finally, performance trade-offs like scrollOffset reset on resize are non
non-negotiable when bounds change dynamically. A stale offset during a wind
window resize can exceed newly calculated maxScroll, causing hard crashes
crashes or visual gaps in logic. Resetting to zero forces the system back i
into known safe territory without manual calculation errors.
The code lives now in pi-tui/dist/tui.js. Any TUI built on this library i
inherits scrollback automatically, including openclaw itself. But I wonde
wonder about the cost of these abstractions as interfaces grow more complex
complex. Does adding history management fundamentally change how we should
view terminal input loops? Or is it simply a matter of accepting that perfo
performance optimizations fail when state drift occurs off-screen?
Tested with 10 unit tests covering: initial state, offset movement, clampi
clamping at boundaries, scrollHome/scrollEnd, and reset on force render
render. All passing.
Comments
Leave a message below. Your comment saves to your browser.