Skip to content

Keep long REPL output readable - #990

Merged
taras merged 1 commit into
mainfrom
agent/repl-render-capacity-opus-20261025
Oct 25, 2026
Merged

taras merged 1 commit into
mainfrom
agent/repl-render-capacity-opus-20261025

Conversation

@taras

@taras taras commented Oct 25, 2026

Copy link
Copy Markdown
Owner

Why

Long REPL output crashed the screen. An entry whose recorded output held a large
document — the repository's own architecture.md, read and emitted by a Sidekick
entry — could not be drawn, and reopening the retained history that held it
failed the same way. The command exited rather than showing the reading.

What changes

Long REPL output stays readable. A reading is still measured through the layout
engine and still kept whole; what changed is that a measuring render now stays
inside the engine's text budget as well as its element budget.

Before:

ReplRenderError: the layout does not fit the measuring engine even after
rebuilding it: TEXT_MEASUREMENT_CAPACITY_EXCEEDED: Clay has run out of space in
it's internal text measurement cache.
    at Object.measure (packages/cli/src/repl/renderer.ts:208)
    at batchWidths (packages/cli/src/repl/fitting.ts:363)
    at widths (packages/cli/src/repl/fitting.ts:328)
    at fitLine (packages/cli/src/repl/fitting.ts:682)
    at fitGeometry (packages/cli/src/repl/fitting.ts:475)

The pane exited with status 1 before any frame was drawn, so the output was
unreachable and so was the entry beside it.

After: the reading prepares and draws. Every logical line is recoverable from
the rows it was cut into, character for character, and no row is wider than the
region it was cut for.

How it works

The engine has two budgets per render, and only one of them was bounded.
WIDTHS_PER_RENDER = 1024 bounded how many elements one measuring render
carries. The second budget is text: every space-separated part of every text in
a render is kept in a cache the engine refuses from, and that cache holds 16,384
of them however few elements carry them. Measured on a freshly built engine, one
text of 16,384 parts, 1,366 texts of twelve, and 820 texts of twenty all refuse,
while 16,383 parts go through either way.

This is the one refusal the renderer's recovery cannot absorb. measure()
rebuilds the measuring instance and asks again, but a replacement instance is
given the same cache, so a render that overran it overruns the replacement too —
which is why the error reads even after rebuilding it. The recovery itself is
working: preparing all 8,951 lines of architecture.md rebuilds the instance
seven times and succeeds each time.

Two call sites overran the text budget:

  1. One line, which no batch size bounds. fitLine measured the whole line
    to decide whether it fits in one row. A retained entry's recorded output
    holds lines far past the budget — the journal behind the reported failure
    holds one of 90,294 parts — so the first measurement of that single line
    refused. This is the reported trace.
  2. A batch inside the element budget. The pass that measures every finished
    row at once sent 1,024 rows per render. Prose rows carry more than sixteen
    parts each, so it asked for more parts than the cache holds while asking for
    fewer elements than the element budget allowed.
one reading → per-line candidate, bounded to what the region could hold
            → batches bounded by elements AND by text parts
            → engine answers every width → rows, complete

Both now bound the request rather than the document:

  • WORDS_PER_RENDER and wordsIn() count what a text costs the engine, the way
    the engine counts it. widths() closes a batch on either budget, and a text
    that outgrows the budget alone still goes in a render of its own so the
    refusal names that text rather than the loop never advancing.
  • windowOf(text, room) is the prefix of a line the region could still hold:
    room + 2 parts. Every part after the first is preceded by a space, so
    room + 2 of them are already wider than room columns, and text does not
    narrow when more of it is appended — so that prefix settles the question the
    whole line was asked. It ends at a space, which is a grapheme boundary of its
    own. fitLine asks the engine about that prefix, and cutAt searches its
    grapheme boundaries.

Review guide

Start with: packages/cli/tests/repl-source-reading.test.ts, row S7

Then review:

  1. WORDS_PER_RENDER and wordsIn() — the budget and its unit
  2. widths() — the batch now closes on either budget and always advances
  3. windowOf(), and its two callers fitLine and cutAt
  4. architecture.md — the settled width-measurement invariant now states both
    budgets

Look carefully at:

  • The inner batching loop advances on every iteration: to > from is what lets
    an over-budget text through alone instead of looping on it.
  • A bounded prefix bounds the question, not the content. Offsets, grapheme
    boundaries and token slicing are untouched, and the finished row is still
    measured as the row it became and refused if it does not fit.

What must stay true

  • Bound the request, not the document. Nothing is truncated to make an
    engine accept it — checked by S7 and S6, which recover every logical line's
    exact text from the rows it was cut into.
  • Every width is an engine answer. One row is still a line the engine
    measured as fitting; a line held back from that question is cut rather than
    assumed to fit.
  • Answers come back one per candidate, in order. Checked by S6's row-order
    and per-row run assertions.
  • Renderer ownership is unchanged. renderer.ts is not in this diff: two
    engines, only the drawing one owning committed display state, and only a
    capacity refusal replacing an instance.
  • Retained content is not rewritten. Nothing writes to the journal; the
    reading is a projection of what was recorded.

How to verify it

Row S7 a reading with more words than one measuring render is still measured
whole
, both arms through the real entryReading output path, the real fitter
and a real engine:

deno task test packages/cli/tests/repl-source-reading.test.ts \
  --filter "S7 a reading with more words than one measuring render is still measured whole"
  • One recorded output line of 20,000 parts proves a single line past the text
    budget is still measured whole, and fails on the pre-fix implementation with
    the reported trace frame for frame. It would catch a fix that bounded only the
    batch.
  • 1,400 distinct ordinary lines of 21 parts proves a batch inside the element
    budget stays inside the text budget, and fails pre-fix at the pass that
    measures every finished row. The lines are deliberately distinct: the engine
    caches one entry per exact text, so identical lines cost it one line's parts
    and prove nothing — an earlier draft of this arm passed pre-fix for exactly
    that reason.

Both arms also check that no row is wider than its region and that each row's
runs are exactly its text, so a width answered for the wrong candidate shows up.

Measured against the real retained record the reported crash came from —
9,842,113 characters, 4,029 lines, longest line 90,294 parts:

  • on the pre-fix implementation: the reported capacity refusal;
  • on this commit: prepared whole as 120,650 rows, every logical line recovered
    exactly
    , widest row 127 in a 127-column content region.

That journal was opened for reading only. One record was copied out for the
probe; nothing was written back, no entry was replayed and no provider work was
resent.

Also passing: the whole repl-source-reading suite (4 passed, 47 steps) and
repl-presentation-style + repl-layout-admission + repl-terminal (38
passed, 132 steps).

Scope

Included

  • A second render budget in fitting.ts, and a bounded per-line candidate.
  • Row S7, two arms, both failing on the pre-fix implementation.
  • The architecture paragraph that states the width-measurement invariant.

Intentionally unchanged

  • renderer.ts. Its two-engine split and its capacity recovery are correct and
    were not the defect; the recovery is what the error message reports reaching
    its limit.
  • Navigation, selection, scrolling, admission and geometry.
  • Journal storage and anything that writes to it.
  • How much of a reading a viewport prepares. Deferring part of a very large
    reading would be a product decision, not a repair of this crash.

New abstractions

  • wordsIn() and windowOf() are two module-private helpers in fitting.ts,
    each with one purpose and callers in this file only. They sit beside the
    existing WIDTHS_PER_RENDER bound and introduce no public surface.
  • Each new abstraction has multiple concrete uses or a clear justification.
  • No speculative functionality is included.

Risks and limitations

  • Preparing a very large reading is slow. The 9.84 MB record above takes 96
    seconds and 48 engine rebuilds for its first preparation. It is correct and
    complete, and ReplFitStore holds the answer afterwards, but the first frame
    of a reading that large is slow. This is a known non-blocking limitation of
    this PR, not deferred work it needs: bounding what a reading prepares at all
    is a separate product question.
  • A line of degenerate zero-width text could be cut shorter than maximally. The
    rows stay correct and complete; only the wrap point moves, and the previous
    behavior for such a line was the crash this fixes.

Delivery status

Required CI and branch protection remain the merge gate and have not run yet for
this head. Lint, typecheck, JSR publishability and the three runtime shard
matrices are owed, and green with them. The evidence above is the focused
evidence the feedback round ran; it is not a statement that CI passed or that
this is ready to merge.

One affected-test selection is unresolved rather than failed:
deno task test packages/cli/tests/repl-*.test.ts was running against this
exact tree when its owning process was torn down, so it carries no exit status.
It had reported no failure across 56,717 bytes of log at the point it was cut
off. It was deliberately not re-run — CI's shard matrices own the exhaustive
pass. Recorded in .reviewer/render-capacity-20261025/logs/41-repl-suite-disposition.txt.

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.

The REPL's width measurement bounded each render by element count only. The
engine has a second budget — every space-separated part of every text in a
render is kept in a cache it refuses from, and that cache holds 16,384 of them
however few elements carry them. Rebuilding the instance gives it the same
cache, so this is the refusal the renderer's recovery cannot absorb, and it
reached the screen as the error that ends it.

A long Output did it two ways: one recorded line held more parts than any batch
size bounds, and a batch inside the element budget still asked for more parts
than the cache holds. Both now bound the request rather than the document — a
batch is bounded by parts as well as elements, and a line is measured as far as
the region could still hold rather than to its end.

Measured on the retained record the reported crash came from: 9.84 MB, 4,029
lines, longest line 90,294 parts, now prepared whole as 120,650 rows with every
logical line recovered exactly and no row wider than its region.
@github-actions

Copy link
Copy Markdown

PR #990: Keep long REPL output readable

3 files, +183 / -7

Scope

✅ PR scope looks good.

Structural

✅ No structural bloat detected.

Slop

✅ Slop indicators look low.

Static Analysis

Oxlint: 6 diagnostics across 1 file (1 rule)
Density: 0.033 violations/added-line

no-shadow (6): packages/cli/src/repl/fitting.ts

Correctness

No extraneous code patterns detected.

@taras
taras marked this pull request as ready for review October 25, 2026 19:05
@taras
taras merged commit 7e2d824 into main Oct 25, 2026
44 checks passed
@taras
taras deleted the agent/repl-render-capacity-opus-20261025 branch October 25, 2026 19:13
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