Skip to content

⚡️ Reuse the layout work REPL typing and focus did not change (#881) - #889

Merged
taras merged 2 commits into
mainfrom
agent/issue-881-pr4
Oct 10, 2026
Merged

taras merged 2 commits into
mainfrom
agent/issue-881-pr4

Conversation

@taras

@taras taras commented Oct 10, 2026

Copy link
Copy Markdown
Owner

Why

Typing in the REPL does far more layout work than a keystroke needs, and the
work grows with the length of the selected entry rather than with what
changed. Measured on the production commit path, one keystroke beside a
222-line selected entry cost 2,247 engine measurements — for 52 terminal
bytes.

Follow-up to #881's accepted parity implementation. Stacked on #887.

What changes

Before: every dispatched action — including every keystroke — built a view and
ran the whole frame preparation before reducing, and read one field of it.
Source fitting was recomputed from scratch on every frame. A focus move
painted the entire frame twice.

After: an interaction that changes nothing a measurement decides reuses the
geometry it already has.

Selected reading Before, per keystroke After
12-line entry 127 measurements 5
222-line entry 2,247 measurements 5

Five is the fixed cost a complete frame always pays. It no longer grows with
the entry.

How it works

keystroke → tree.dispatch → readsGeometry? → reduce → commitReplFrame → prepareFrame → prepareReading
                                 │                                                          │
                            (false: no frame measured)                        (inputs unchanged: reuse)

Three independent changes:

  1. readsGeometry, exported beside reduceRepl, says which actions need
    the frame's admission. Only the four scrolls do; the reducer reads
    admission in exactly four branches. act() prepares only for those, and
    skips the build() and the screen.size() read for everything else.
  2. prepareReading splits into geometry and decoration. fitGeometry
    performs every engine render and answers where each line is cut and how
    wide each piece is; decorate builds rows from the lines as they stand
    now. A ReplFitStore owned by runReplProgram's scope holds the last
    geometry with the inputs it came from.
  3. Focus settles before the paint. A traversal has already moved focus in
    the tree, so the loop takes it before composing instead of drawing the old
    control and correcting it.

Review guide

Start with: packages/cli/src/repl/fitting.ts — the geometry/decoration
split and createFitStore.

Then review:

  1. readsGeometry in application.ts, and the gate at its call site in act()
  2. The store threaded through prepareFrame / commitReplFrame
  3. The focus settle in act() and the first-frame paint
  4. The regressions, especially RS2 and RS3

Look carefully at:

  • Invalidation: text, depth, room and terminal size each invalidate
    independently, and inputs are compared rather than digested.
  • Decoration runs every frame on a hit, so live lifecycle, head state,
    selection and inspected stay fresh without a Journal append.

What must stay true

  • Measure → admit → reconcile → draw stays authoritative when geometry
    changes
    — commitReplFrame is untouched; the store invalidates on size
    and room. Checked by TL3 and the resize journeys.
  • Session readings stay coherent — session.reading() is still called
    every frame. This caches presentation work only, never the stale-reading
    shortcut rejected in Navigate recorded History positions with a measured REPL rail (#881, 3/3) #887's review.
  • Freedom owns focus — the move is still the tree's; the loop stops
    lagging it. Checked by RS1.

How to verify it

  • RS2 proves a complete frame after a keystroke costs the same for a
    12-line and a 222-line reading, and fails if production stops threading the
    store.
  • RS3 proves typing, erasing, Tab and Backtab each read the terminal size
    exactly four times through the real loop, and fails if the gate is bypassed
    at its call site.
  • S5 proves a 222-line reading is measured once and not again, and that
    depth, room, terminal size and one line's text each invalidate on their own
    — each compared against a freshly measured fit.
  • EU2 proves no non-scroll action's answer depends on the admission, over
    all 24 action kinds.
deno task test packages/cli/tests/repl-source-reading.test.ts \
  packages/cli/tests/repl-presentation-style.test.ts \
  packages/cli/tests/repl-layout-admission.test.ts \
  packages/cli/tests/repl-entries.test.ts packages/cli/tests/repl-forms.test.ts
deno task test packages/cli/tests/repl-journey.test.ts \
  packages/cli/tests/repl-agent-journey.test.ts packages/cli/tests/repl-terminal.test.ts

→ 52 passed (218 steps) and 56 passed (146 steps).

Seven mutation controls, each required to break the test it names, are in
.reviewer/issue-881/pr4-evidence/.controls.py.

Scope

Included

  • The action gate, the fitting split and store, and the focus settle.
  • Regressions for each, and a counting renderer in the test fixtures.

Intentionally unchanged

  • The History rail's own measurement — it is one batched render and did not
    appear in the remaining cost.
  • The reading tree, which readingOf(view) still rebuilds every frame. That
    is where live phases come from, so retaining it is a different and riskier
    invalidation problem.
  • No persistence, Journal, dependency, public API or drawer changes.

New abstractions

  • ReplFitStore and ReplRowCut exist because the geometry has to be held
    somewhere with the inputs it was computed from, and because separating the
    measured half from the live half is what makes it reusable at all.
  • Each new abstraction has multiple concrete uses or a clear justification.
  • No speculative functionality is included.

Risks and limitations

  • A keystroke beside a 222-line entry still costs ~1.1 ms of pure JS: the
    reading tree is rebuilt and decorated per frame. No engine calls, but it
    still grows with entry length.
  • J2's drag is armed on an absolute count of terminal size reads, and this
    change removes one per keystroke. Its constant moved from 4 to 3, confirmed
    against the instrumented call sequence — too-small-check → drawnAt → measurement → revalidation — rather than against the test going green.
    Every assertion in that test is unchanged.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

@taras
taras force-pushed the agent/issue-881-pr4 branch from 6f5f150 to db4896f Compare October 10, 2026 09:16
@taras
taras force-pushed the agent/issue-881-pr4 branch from db4896f to f3024b6 Compare October 10, 2026 09:55
@taras
taras added this pull request to stack #888 October 10, 2026 10:05
@taras
taras force-pushed the agent/issue-881-pr4 branch from f3024b6 to 4da6190 Compare October 10, 2026 10:07
@taras
taras force-pushed the agent/issue-881-pr4 branch from 4da6190 to 6749fa8 Compare October 10, 2026 10:08
Base automatically changed from agent/issue-881-pr3 to main October 10, 2026 10:19
@taras
taras force-pushed the agent/issue-881-pr4 branch from 6749fa8 to 22b9d2e Compare October 10, 2026 10:19
@taras
taras marked this pull request as ready for review October 10, 2026 10:27
@taras
taras merged commit e61d6ce into main Oct 10, 2026
43 of 44 checks passed
@taras
taras deleted the agent/issue-881-pr4 branch October 10, 2026 10:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant