fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback - #1130

Merged
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-toggle-preserves-scrollback
Jul 16, 2026
Merged

fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback#1130
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-toggle-preserves-scrollback

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes#1097.

A global Ctrl+O / Ctrl+T toggle resized every tool/thinking block, including ones scrolled above the live viewport. Terminal scrollback cannot be rewritten, so pi-tui's differential renderer fell back to a full redraw (firstChanged < viewportTop) that emits ESC[2J ESC[H ESC[3J, clearing pre-Maka scrollback and resetting the user's scroll position.

Since scrollback is immutable, the only correct behaviors for an off-screen block are "clear and re-emit" (the bug), "append a duplicate transcript" (worse), or "leave it untouched". This PR implements the third:

  • Expansion becomes per-entry state (entry.expanded), stamped from the expandAllTools / expandAllThinking defaults at creation; render and the memo cache read only the entry.
  • The toggles retarget entries whose first rendered line sits inside the live viewport and flip the default for future entries; entries above the viewport keep their frozen rendering.
  • renderMakaPiTranscript records each entry's first line; MakaPiLayoutComponent records the viewport top in transcript coordinates (the transcript is the first composed child). The viewport top is a monotonic max, mirroring pi-tui's private previousViewportTop, which never scrolls back up short of a full redraw — overestimating only makes the toggles more conservative. compositeOverlays never lengthens an over-viewport buffer, so overlays cannot break the estimate.

The runner's "Known limit" comment is replaced by the new invariant. A follow-up upstream PR to pi-tui exposing a viewport getter can later replace the local computation with the exact value; it is not on this fix's critical path.

Verification

  • packages/cli: tsc --noEmit clean, npm test 458/458 (9 new), root npm run lint clean.
  • New unit tests assert the lines above the viewport stay byte-identical across a toggle, off-screen entries keep their state, all-off-screen toggles report nothing to do, and entries created after an expand-all start expanded.
  • New integration test drives the real pi-tui renderer through runMakaPiTui with a 24-row fake terminal: an early tool card scrolled above the viewport, Ctrl+O expands the visible card, and the whole output stream contains no ESC[3J. Reverting the viewport anchoring makes this test fail, confirming it locks the regression.

Review hardening

An external review pass surfaced a real regression in the first commit: the viewport-top estimate was a pure monotonic max, but pi-tui re-anchors its real viewport whenever it full-redraws, so after /new, a session switch, a deep rewind, or a terminal resize the estimate stayed high and the toggles found nothing to target. MakaPiLayoutComponent now shadows pi-tui's exact full-redraw rule (size change, changed line above the top, shrink below the top → re-anchor to the document tail; anything else → monotonic), with pi-tui-layout.test.ts locking all four geometry behaviors including the shallow-truncation case where pi-tui keeps its viewport and the estimate must too.

…les never clear scrollback
Expansion becomes per-entry state stamped from the expandAll defaults at
creation. The global toggles retarget only entries whose first rendered
line sits inside pi-tui's live viewport: entries above it live in terminal
scrollback, which cannot be rewritten, so resizing one forced pi-tui's
differential renderer into a full redraw that emitted ESC[3J, clearing
pre-Maka scrollback and resetting the scroll position.
renderMakaPiTranscript records each entry's first line and
MakaPiLayoutComponent records the viewport top (monotonic max, mirroring
pi-tui's previousViewportTop, which never scrolls back up short of a full
redraw), so overestimation only makes the toggles more conservative.
Fixes#1097
…e-anchors
The monotonic-max viewport estimate never fell after pi-tui re-anchored its
real viewport in a full redraw, so after /new, a session switch, a deep
rewind, or a terminal resize every entry of the shorter document sat
permanently "above the viewport" and Ctrl+O/Ctrl+T found nothing to toggle.
MakaPiLayoutComponent now mirrors pi-tui's own rule: reset the estimate to
the document tail exactly when pi-tui full-redraws (width or height change,
a changed line above the current top, or a shrink below it) and keep it
monotonic otherwise, so shallow truncations still hold the old top just
like pi-tui's deleted-lines path does.
Found by external review of #1130.
Four gaps between the shadow diff and pi-tui's real doRender, all found by
a focused external re-review of 992be01:
- After a wholesale transcript replacement, entry positions are unknown
until the next render; a toggle in that window could rewrite lines above
the still-scrolled real viewport. entryFirstLine is now undefined in that
state and the toggles stay inert until a render records positions.
- Under Termux pi-tui does not full-redraw on height changes; it keeps the
buffer and recomputes its top from it. Re-anchoring to the document tail
there could fall below the real viewport after a shallow truncation. The
shadow now mirrors the buffer-derived formula when TERMUX_VERSION is set.
- pi-tui diffs normalized lines (Thai/Lao AM decomposition); the shadow
compared raw lines and could false-reset below a viewport pi-tui kept.
The shadow now stores and compares normalized lines.
- The deep-truncation boundary was off by one (< vs <=, pi-tui redraws at
targetRow < top), and a pure truncation clearing more than a screenful
also full-redraws (extraLines > height); both now re-anchor.
The geometry tests now open a top-above-tail gap first so each rule is
pinned independently, plus Termux, exact-boundary, and replacement-window
cases.
@Astro-Han
Astro-Han merged commit 1426853 into mainJul 16, 2026
3 checks passed
Astro-Han added a commit that referenced this pull request Jul 16, 2026
…ing redraws (#1139)
* fix(cli): freeze off-screen entry renders to prevent scrollback-clearing redraws (#1135)
Off-screen transcript entries live in terminal scrollback, which is
immutable. Five event paths mutated their rendered lines in place,
forcing pi-tui's differential renderer into a full redraw
(ESC[2J ESC[H ESC[3J) that clears pre-Maka scrollback and resets the
scroll position:
- refreshRunningShellRunElapsed: 1s ticker rewrote off-screen running
cards every second
- shell-run settle/ownership: applied status/result changes to off-screen
Bash cards
- setThinking: late thinking_complete replaced streamed text in place
- tool_result poll-card splice: removed an off-screen entry, shifting
subsequent line numbers
- tool_progress/tool_output_delta: appended to expanded off-screen cards
The fix introduces a single render-layer freeze: when an entry's first
line sits above the live viewport (already recorded by #1130's
renderGeometry), renderTranscriptEntryMemoized serves the cached render
matching the current width instead of re-rendering. The underlying entry
state still updates — only the visual output is frozen, so session
switch, resume, and re-expand stay correct.
The splice path gets a separate entryInLiveViewport guard: splicing an
off-screen entry shifts line numbers above the viewport, which the freeze
alone cannot prevent. The stale entry stays in place until the next
session switch.
The ticker skips off-screen running cards entirely, so
refreshRunningShellRunElapsed returns false when no visible card needs an
update and the 1s interval stops.
* fix(cli): address review findings — freeze only fully off-screen entries
Codex review found three issues in the initial freeze approach:
P1: Freezing an entry whose first line is in scrollback but whose tail
is still visible stopped streaming text from appearing. The invariant
is that scrollback lines are immutable, not that the entire entry is
frozen. Now only entries sitting entirely above the viewport (firstLine
+ height <= viewportTop) are frozen; straddling entries re-render
normally — append-only changes fall inside the viewport, so pi-tui's
firstChanged never touches scrollback.
P2: The ticker skip stopped the 1s interval when all running cards were
off-screen, but a resize making them visible again never restarted it.
Removed the skip: the render freeze already prevents visual changes for
off-screen cards, and the extra render cycle is negligible.
P2: The splice guard left off-screen poll cards as stale 'running'
entries. A width-change full redraw would re-render them as duplicate
running cards. Now the guard also updates the entry's status to 'done'
with the folded result, so a future full redraw renders it correctly.
Added a streaming-past-viewport test that verifies a long assistant
reply keeps appending visible content after crossing the viewport
boundary, with no ESC[3J.
* fix(cli): handle zero-height cache and hidden poll cards from second review
Two findings from the second Codex review:
1. Zero-height cache gap: a blank thinking_delta renders zero lines; when
pushed off-screen and then thinking_complete writes non-empty text, the
entryHeight=0 check prevented the freeze, inserting new lines into
scrollback. Now entries whose first line is above the viewport are
frozen regardless of cached height.
2. Poll card duplication: off-screen Read/StopBackgroundTask poll cards
that were kept in place (to avoid line-number shifts) but marked done
would re-render as duplicate cards on a width-change full redraw. Now
they get a hidden flag that makes them contribute zero lines, so a
future full redraw skips them entirely. They are cleaned on the next
session switch.
@Astro-Han
Astro-Han deleted the fix/cli-offscreen-toggle-preserves-scrollback branch July 24, 2026 09:05
Sign up for freeto 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.

fix(cli): expanding an off-screen block triggers a full redraw that clears pre-Maka scrollback

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback - #1130

Merged
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-toggle-preserves-scrollback
Jul 16, 2026
Merged

fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback#1130
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-toggle-preserves-scrollback

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes#1097.

A global Ctrl+O / Ctrl+T toggle resized every tool/thinking block, including ones scrolled above the live viewport. Terminal scrollback cannot be rewritten, so pi-tui's differential renderer fell back to a full redraw (firstChanged < viewportTop) that emits ESC[2J ESC[H ESC[3J, clearing pre-Maka scrollback and resetting the user's scroll position.

Since scrollback is immutable, the only correct behaviors for an off-screen block are "clear and re-emit" (the bug), "append a duplicate transcript" (worse), or "leave it untouched". This PR implements the third:

  • Expansion becomes per-entry state (entry.expanded), stamped from the expandAllTools / expandAllThinking defaults at creation; render and the memo cache read only the entry.
  • The toggles retarget entries whose first rendered line sits inside the live viewport and flip the default for future entries; entries above the viewport keep their frozen rendering.
  • renderMakaPiTranscript records each entry's first line; MakaPiLayoutComponent records the viewport top in transcript coordinates (the transcript is the first composed child). The viewport top is a monotonic max, mirroring pi-tui's private previousViewportTop, which never scrolls back up short of a full redraw — overestimating only makes the toggles more conservative. compositeOverlays never lengthens an over-viewport buffer, so overlays cannot break the estimate.

The runner's "Known limit" comment is replaced by the new invariant. A follow-up upstream PR to pi-tui exposing a viewport getter can later replace the local computation with the exact value; it is not on this fix's critical path.

Verification

  • packages/cli: tsc --noEmit clean, npm test 458/458 (9 new), root npm run lint clean.
  • New unit tests assert the lines above the viewport stay byte-identical across a toggle, off-screen entries keep their state, all-off-screen toggles report nothing to do, and entries created after an expand-all start expanded.
  • New integration test drives the real pi-tui renderer through runMakaPiTui with a 24-row fake terminal: an early tool card scrolled above the viewport, Ctrl+O expands the visible card, and the whole output stream contains no ESC[3J. Reverting the viewport anchoring makes this test fail, confirming it locks the regression.

Review hardening

An external review pass surfaced a real regression in the first commit: the viewport-top estimate was a pure monotonic max, but pi-tui re-anchors its real viewport whenever it full-redraws, so after /new, a session switch, a deep rewind, or a terminal resize the estimate stayed high and the toggles found nothing to target. MakaPiLayoutComponent now shadows pi-tui's exact full-redraw rule (size change, changed line above the top, shrink below the top → re-anchor to the document tail; anything else → monotonic), with pi-tui-layout.test.ts locking all four geometry behaviors including the shallow-truncation case where pi-tui keeps its viewport and the estimate must too.

…les never clear scrollback
Expansion becomes per-entry state stamped from the expandAll defaults at
creation. The global toggles retarget only entries whose first rendered
line sits inside pi-tui's live viewport: entries above it live in terminal
scrollback, which cannot be rewritten, so resizing one forced pi-tui's
differential renderer into a full redraw that emitted ESC[3J, clearing
pre-Maka scrollback and resetting the scroll position.
renderMakaPiTranscript records each entry's first line and
MakaPiLayoutComponent records the viewport top (monotonic max, mirroring
pi-tui's previousViewportTop, which never scrolls back up short of a full
redraw), so overestimation only makes the toggles more conservative.
Fixes#1097
…e-anchors
The monotonic-max viewport estimate never fell after pi-tui re-anchored its
real viewport in a full redraw, so after /new, a session switch, a deep
rewind, or a terminal resize every entry of the shorter document sat
permanently "above the viewport" and Ctrl+O/Ctrl+T found nothing to toggle.
MakaPiLayoutComponent now mirrors pi-tui's own rule: reset the estimate to
the document tail exactly when pi-tui full-redraws (width or height change,
a changed line above the current top, or a shrink below it) and keep it
monotonic otherwise, so shallow truncations still hold the old top just
like pi-tui's deleted-lines path does.
Found by external review of #1130.
Four gaps between the shadow diff and pi-tui's real doRender, all found by
a focused external re-review of 992be01:
- After a wholesale transcript replacement, entry positions are unknown
until the next render; a toggle in that window could rewrite lines above
the still-scrolled real viewport. entryFirstLine is now undefined in that
state and the toggles stay inert until a render records positions.
- Under Termux pi-tui does not full-redraw on height changes; it keeps the
buffer and recomputes its top from it. Re-anchoring to the document tail
there could fall below the real viewport after a shallow truncation. The
shadow now mirrors the buffer-derived formula when TERMUX_VERSION is set.
- pi-tui diffs normalized lines (Thai/Lao AM decomposition); the shadow
compared raw lines and could false-reset below a viewport pi-tui kept.
The shadow now stores and compares normalized lines.
- The deep-truncation boundary was off by one (< vs <=, pi-tui redraws at
targetRow < top), and a pure truncation clearing more than a screenful
also full-redraws (extraLines > height); both now re-anchor.
The geometry tests now open a top-above-tail gap first so each rule is
pinned independently, plus Termux, exact-boundary, and replacement-window
cases.
@Astro-Han
Astro-Han merged commit 1426853 into mainJul 16, 2026
3 checks passed
Astro-Han added a commit that referenced this pull request Jul 16, 2026
…ing redraws (#1139)
* fix(cli): freeze off-screen entry renders to prevent scrollback-clearing redraws (#1135)
Off-screen transcript entries live in terminal scrollback, which is
immutable. Five event paths mutated their rendered lines in place,
forcing pi-tui's differential renderer into a full redraw
(ESC[2J ESC[H ESC[3J) that clears pre-Maka scrollback and resets the
scroll position:
- refreshRunningShellRunElapsed: 1s ticker rewrote off-screen running
cards every second
- shell-run settle/ownership: applied status/result changes to off-screen
Bash cards
- setThinking: late thinking_complete replaced streamed text in place
- tool_result poll-card splice: removed an off-screen entry, shifting
subsequent line numbers
- tool_progress/tool_output_delta: appended to expanded off-screen cards
The fix introduces a single render-layer freeze: when an entry's first
line sits above the live viewport (already recorded by #1130's
renderGeometry), renderTranscriptEntryMemoized serves the cached render
matching the current width instead of re-rendering. The underlying entry
state still updates — only the visual output is frozen, so session
switch, resume, and re-expand stay correct.
The splice path gets a separate entryInLiveViewport guard: splicing an
off-screen entry shifts line numbers above the viewport, which the freeze
alone cannot prevent. The stale entry stays in place until the next
session switch.
The ticker skips off-screen running cards entirely, so
refreshRunningShellRunElapsed returns false when no visible card needs an
update and the 1s interval stops.
* fix(cli): address review findings — freeze only fully off-screen entries
Codex review found three issues in the initial freeze approach:
P1: Freezing an entry whose first line is in scrollback but whose tail
is still visible stopped streaming text from appearing. The invariant
is that scrollback lines are immutable, not that the entire entry is
frozen. Now only entries sitting entirely above the viewport (firstLine
+ height <= viewportTop) are frozen; straddling entries re-render
normally — append-only changes fall inside the viewport, so pi-tui's
firstChanged never touches scrollback.
P2: The ticker skip stopped the 1s interval when all running cards were
off-screen, but a resize making them visible again never restarted it.
Removed the skip: the render freeze already prevents visual changes for
off-screen cards, and the extra render cycle is negligible.
P2: The splice guard left off-screen poll cards as stale 'running'
entries. A width-change full redraw would re-render them as duplicate
running cards. Now the guard also updates the entry's status to 'done'
with the folded result, so a future full redraw renders it correctly.
Added a streaming-past-viewport test that verifies a long assistant
reply keeps appending visible content after crossing the viewport
boundary, with no ESC[3J.
* fix(cli): handle zero-height cache and hidden poll cards from second review
Two findings from the second Codex review:
1. Zero-height cache gap: a blank thinking_delta renders zero lines; when
pushed off-screen and then thinking_complete writes non-empty text, the
entryHeight=0 check prevented the freeze, inserting new lines into
scrollback. Now entries whose first line is above the viewport are
frozen regardless of cached height.
2. Poll card duplication: off-screen Read/StopBackgroundTask poll cards
that were kept in place (to avoid line-number shifts) but marked done
would re-render as duplicate cards on a width-change full redraw. Now
they get a hidden flag that makes them contribute zero lines, so a
future full redraw skips them entirely. They are cleaned on the next
session switch.
@Astro-Han
Astro-Han deleted the fix/cli-offscreen-toggle-preserves-scrollback branch July 24, 2026 09:05
Sign up for freeto 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.

fix(cli): expanding an off-screen block triggers a full redraw that clears pre-Maka scrollback

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback - #1130

Merged
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-toggle-preserves-scrollback
Jul 16, 2026
Merged

fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback#1130
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-toggle-preserves-scrollback

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes#1097.

A global Ctrl+O / Ctrl+T toggle resized every tool/thinking block, including ones scrolled above the live viewport. Terminal scrollback cannot be rewritten, so pi-tui's differential renderer fell back to a full redraw (firstChanged < viewportTop) that emits ESC[2J ESC[H ESC[3J, clearing pre-Maka scrollback and resetting the user's scroll position.

Since scrollback is immutable, the only correct behaviors for an off-screen block are "clear and re-emit" (the bug), "append a duplicate transcript" (worse), or "leave it untouched". This PR implements the third:

  • Expansion becomes per-entry state (entry.expanded), stamped from the expandAllTools / expandAllThinking defaults at creation; render and the memo cache read only the entry.
  • The toggles retarget entries whose first rendered line sits inside the live viewport and flip the default for future entries; entries above the viewport keep their frozen rendering.
  • renderMakaPiTranscript records each entry's first line; MakaPiLayoutComponent records the viewport top in transcript coordinates (the transcript is the first composed child). The viewport top is a monotonic max, mirroring pi-tui's private previousViewportTop, which never scrolls back up short of a full redraw — overestimating only makes the toggles more conservative. compositeOverlays never lengthens an over-viewport buffer, so overlays cannot break the estimate.

The runner's "Known limit" comment is replaced by the new invariant. A follow-up upstream PR to pi-tui exposing a viewport getter can later replace the local computation with the exact value; it is not on this fix's critical path.

Verification

  • packages/cli: tsc --noEmit clean, npm test 458/458 (9 new), root npm run lint clean.
  • New unit tests assert the lines above the viewport stay byte-identical across a toggle, off-screen entries keep their state, all-off-screen toggles report nothing to do, and entries created after an expand-all start expanded.
  • New integration test drives the real pi-tui renderer through runMakaPiTui with a 24-row fake terminal: an early tool card scrolled above the viewport, Ctrl+O expands the visible card, and the whole output stream contains no ESC[3J. Reverting the viewport anchoring makes this test fail, confirming it locks the regression.

Review hardening

An external review pass surfaced a real regression in the first commit: the viewport-top estimate was a pure monotonic max, but pi-tui re-anchors its real viewport whenever it full-redraws, so after /new, a session switch, a deep rewind, or a terminal resize the estimate stayed high and the toggles found nothing to target. MakaPiLayoutComponent now shadows pi-tui's exact full-redraw rule (size change, changed line above the top, shrink below the top → re-anchor to the document tail; anything else → monotonic), with pi-tui-layout.test.ts locking all four geometry behaviors including the shallow-truncation case where pi-tui keeps its viewport and the estimate must too.

…les never clear scrollback
Expansion becomes per-entry state stamped from the expandAll defaults at
creation. The global toggles retarget only entries whose first rendered
line sits inside pi-tui's live viewport: entries above it live in terminal
scrollback, which cannot be rewritten, so resizing one forced pi-tui's
differential renderer into a full redraw that emitted ESC[3J, clearing
pre-Maka scrollback and resetting the scroll position.
renderMakaPiTranscript records each entry's first line and
MakaPiLayoutComponent records the viewport top (monotonic max, mirroring
pi-tui's previousViewportTop, which never scrolls back up short of a full
redraw), so overestimation only makes the toggles more conservative.
Fixes#1097
…e-anchors
The monotonic-max viewport estimate never fell after pi-tui re-anchored its
real viewport in a full redraw, so after /new, a session switch, a deep
rewind, or a terminal resize every entry of the shorter document sat
permanently "above the viewport" and Ctrl+O/Ctrl+T found nothing to toggle.
MakaPiLayoutComponent now mirrors pi-tui's own rule: reset the estimate to
the document tail exactly when pi-tui full-redraws (width or height change,
a changed line above the current top, or a shrink below it) and keep it
monotonic otherwise, so shallow truncations still hold the old top just
like pi-tui's deleted-lines path does.
Found by external review of #1130.
Four gaps between the shadow diff and pi-tui's real doRender, all found by
a focused external re-review of 992be01:
- After a wholesale transcript replacement, entry positions are unknown
until the next render; a toggle in that window could rewrite lines above
the still-scrolled real viewport. entryFirstLine is now undefined in that
state and the toggles stay inert until a render records positions.
- Under Termux pi-tui does not full-redraw on height changes; it keeps the
buffer and recomputes its top from it. Re-anchoring to the document tail
there could fall below the real viewport after a shallow truncation. The
shadow now mirrors the buffer-derived formula when TERMUX_VERSION is set.
- pi-tui diffs normalized lines (Thai/Lao AM decomposition); the shadow
compared raw lines and could false-reset below a viewport pi-tui kept.
The shadow now stores and compares normalized lines.
- The deep-truncation boundary was off by one (< vs <=, pi-tui redraws at
targetRow < top), and a pure truncation clearing more than a screenful
also full-redraws (extraLines > height); both now re-anchor.
The geometry tests now open a top-above-tail gap first so each rule is
pinned independently, plus Termux, exact-boundary, and replacement-window
cases.
@Astro-Han
Astro-Han merged commit 1426853 into mainJul 16, 2026
3 checks passed
Astro-Han added a commit that referenced this pull request Jul 16, 2026
…ing redraws (#1139)
* fix(cli): freeze off-screen entry renders to prevent scrollback-clearing redraws (#1135)
Off-screen transcript entries live in terminal scrollback, which is
immutable. Five event paths mutated their rendered lines in place,
forcing pi-tui's differential renderer into a full redraw
(ESC[2J ESC[H ESC[3J) that clears pre-Maka scrollback and resets the
scroll position:
- refreshRunningShellRunElapsed: 1s ticker rewrote off-screen running
cards every second
- shell-run settle/ownership: applied status/result changes to off-screen
Bash cards
- setThinking: late thinking_complete replaced streamed text in place
- tool_result poll-card splice: removed an off-screen entry, shifting
subsequent line numbers
- tool_progress/tool_output_delta: appended to expanded off-screen cards
The fix introduces a single render-layer freeze: when an entry's first
line sits above the live viewport (already recorded by #1130's
renderGeometry), renderTranscriptEntryMemoized serves the cached render
matching the current width instead of re-rendering. The underlying entry
state still updates — only the visual output is frozen, so session
switch, resume, and re-expand stay correct.
The splice path gets a separate entryInLiveViewport guard: splicing an
off-screen entry shifts line numbers above the viewport, which the freeze
alone cannot prevent. The stale entry stays in place until the next
session switch.
The ticker skips off-screen running cards entirely, so
refreshRunningShellRunElapsed returns false when no visible card needs an
update and the 1s interval stops.
* fix(cli): address review findings — freeze only fully off-screen entries
Codex review found three issues in the initial freeze approach:
P1: Freezing an entry whose first line is in scrollback but whose tail
is still visible stopped streaming text from appearing. The invariant
is that scrollback lines are immutable, not that the entire entry is
frozen. Now only entries sitting entirely above the viewport (firstLine
+ height <= viewportTop) are frozen; straddling entries re-render
normally — append-only changes fall inside the viewport, so pi-tui's
firstChanged never touches scrollback.
P2: The ticker skip stopped the 1s interval when all running cards were
off-screen, but a resize making them visible again never restarted it.
Removed the skip: the render freeze already prevents visual changes for
off-screen cards, and the extra render cycle is negligible.
P2: The splice guard left off-screen poll cards as stale 'running'
entries. A width-change full redraw would re-render them as duplicate
running cards. Now the guard also updates the entry's status to 'done'
with the folded result, so a future full redraw renders it correctly.
Added a streaming-past-viewport test that verifies a long assistant
reply keeps appending visible content after crossing the viewport
boundary, with no ESC[3J.
* fix(cli): handle zero-height cache and hidden poll cards from second review
Two findings from the second Codex review:
1. Zero-height cache gap: a blank thinking_delta renders zero lines; when
pushed off-screen and then thinking_complete writes non-empty text, the
entryHeight=0 check prevented the freeze, inserting new lines into
scrollback. Now entries whose first line is above the viewport are
frozen regardless of cached height.
2. Poll card duplication: off-screen Read/StopBackgroundTask poll cards
that were kept in place (to avoid line-number shifts) but marked done
would re-render as duplicate cards on a width-change full redraw. Now
they get a hidden flag that makes them contribute zero lines, so a
future full redraw skips them entirely. They are cleaned on the next
session switch.
@Astro-Han
Astro-Han deleted the fix/cli-offscreen-toggle-preserves-scrollback branch July 24, 2026 09:05
Sign up for freeto 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.

fix(cli): expanding an off-screen block triggers a full redraw that clears pre-Maka scrollback

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback - #1130

Merged
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-toggle-preserves-scrollback
Jul 16, 2026
Merged

fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback#1130
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-toggle-preserves-scrollback

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes#1097.

A global Ctrl+O / Ctrl+T toggle resized every tool/thinking block, including ones scrolled above the live viewport. Terminal scrollback cannot be rewritten, so pi-tui's differential renderer fell back to a full redraw (firstChanged < viewportTop) that emits ESC[2J ESC[H ESC[3J, clearing pre-Maka scrollback and resetting the user's scroll position.

Since scrollback is immutable, the only correct behaviors for an off-screen block are "clear and re-emit" (the bug), "append a duplicate transcript" (worse), or "leave it untouched". This PR implements the third:

  • Expansion becomes per-entry state (entry.expanded), stamped from the expandAllTools / expandAllThinking defaults at creation; render and the memo cache read only the entry.
  • The toggles retarget entries whose first rendered line sits inside the live viewport and flip the default for future entries; entries above the viewport keep their frozen rendering.
  • renderMakaPiTranscript records each entry's first line; MakaPiLayoutComponent records the viewport top in transcript coordinates (the transcript is the first composed child). The viewport top is a monotonic max, mirroring pi-tui's private previousViewportTop, which never scrolls back up short of a full redraw — overestimating only makes the toggles more conservative. compositeOverlays never lengthens an over-viewport buffer, so overlays cannot break the estimate.

The runner's "Known limit" comment is replaced by the new invariant. A follow-up upstream PR to pi-tui exposing a viewport getter can later replace the local computation with the exact value; it is not on this fix's critical path.

Verification

  • packages/cli: tsc --noEmit clean, npm test 458/458 (9 new), root npm run lint clean.
  • New unit tests assert the lines above the viewport stay byte-identical across a toggle, off-screen entries keep their state, all-off-screen toggles report nothing to do, and entries created after an expand-all start expanded.
  • New integration test drives the real pi-tui renderer through runMakaPiTui with a 24-row fake terminal: an early tool card scrolled above the viewport, Ctrl+O expands the visible card, and the whole output stream contains no ESC[3J. Reverting the viewport anchoring makes this test fail, confirming it locks the regression.

Review hardening

An external review pass surfaced a real regression in the first commit: the viewport-top estimate was a pure monotonic max, but pi-tui re-anchors its real viewport whenever it full-redraws, so after /new, a session switch, a deep rewind, or a terminal resize the estimate stayed high and the toggles found nothing to target. MakaPiLayoutComponent now shadows pi-tui's exact full-redraw rule (size change, changed line above the top, shrink below the top → re-anchor to the document tail; anything else → monotonic), with pi-tui-layout.test.ts locking all four geometry behaviors including the shallow-truncation case where pi-tui keeps its viewport and the estimate must too.

…les never clear scrollback
Expansion becomes per-entry state stamped from the expandAll defaults at
creation. The global toggles retarget only entries whose first rendered
line sits inside pi-tui's live viewport: entries above it live in terminal
scrollback, which cannot be rewritten, so resizing one forced pi-tui's
differential renderer into a full redraw that emitted ESC[3J, clearing
pre-Maka scrollback and resetting the scroll position.
renderMakaPiTranscript records each entry's first line and
MakaPiLayoutComponent records the viewport top (monotonic max, mirroring
pi-tui's previousViewportTop, which never scrolls back up short of a full
redraw), so overestimation only makes the toggles more conservative.
Fixes#1097
…e-anchors
The monotonic-max viewport estimate never fell after pi-tui re-anchored its
real viewport in a full redraw, so after /new, a session switch, a deep
rewind, or a terminal resize every entry of the shorter document sat
permanently "above the viewport" and Ctrl+O/Ctrl+T found nothing to toggle.
MakaPiLayoutComponent now mirrors pi-tui's own rule: reset the estimate to
the document tail exactly when pi-tui full-redraws (width or height change,
a changed line above the current top, or a shrink below it) and keep it
monotonic otherwise, so shallow truncations still hold the old top just
like pi-tui's deleted-lines path does.
Found by external review of #1130.
Four gaps between the shadow diff and pi-tui's real doRender, all found by
a focused external re-review of 992be01:
- After a wholesale transcript replacement, entry positions are unknown
until the next render; a toggle in that window could rewrite lines above
the still-scrolled real viewport. entryFirstLine is now undefined in that
state and the toggles stay inert until a render records positions.
- Under Termux pi-tui does not full-redraw on height changes; it keeps the
buffer and recomputes its top from it. Re-anchoring to the document tail
there could fall below the real viewport after a shallow truncation. The
shadow now mirrors the buffer-derived formula when TERMUX_VERSION is set.
- pi-tui diffs normalized lines (Thai/Lao AM decomposition); the shadow
compared raw lines and could false-reset below a viewport pi-tui kept.
The shadow now stores and compares normalized lines.
- The deep-truncation boundary was off by one (< vs <=, pi-tui redraws at
targetRow < top), and a pure truncation clearing more than a screenful
also full-redraws (extraLines > height); both now re-anchor.
The geometry tests now open a top-above-tail gap first so each rule is
pinned independently, plus Termux, exact-boundary, and replacement-window
cases.
@Astro-Han
Astro-Han merged commit 1426853 into mainJul 16, 2026
3 checks passed
Astro-Han added a commit that referenced this pull request Jul 16, 2026
…ing redraws (#1139)
* fix(cli): freeze off-screen entry renders to prevent scrollback-clearing redraws (#1135)
Off-screen transcript entries live in terminal scrollback, which is
immutable. Five event paths mutated their rendered lines in place,
forcing pi-tui's differential renderer into a full redraw
(ESC[2J ESC[H ESC[3J) that clears pre-Maka scrollback and resets the
scroll position:
- refreshRunningShellRunElapsed: 1s ticker rewrote off-screen running
cards every second
- shell-run settle/ownership: applied status/result changes to off-screen
Bash cards
- setThinking: late thinking_complete replaced streamed text in place
- tool_result poll-card splice: removed an off-screen entry, shifting
subsequent line numbers
- tool_progress/tool_output_delta: appended to expanded off-screen cards
The fix introduces a single render-layer freeze: when an entry's first
line sits above the live viewport (already recorded by #1130's
renderGeometry), renderTranscriptEntryMemoized serves the cached render
matching the current width instead of re-rendering. The underlying entry
state still updates — only the visual output is frozen, so session
switch, resume, and re-expand stay correct.
The splice path gets a separate entryInLiveViewport guard: splicing an
off-screen entry shifts line numbers above the viewport, which the freeze
alone cannot prevent. The stale entry stays in place until the next
session switch.
The ticker skips off-screen running cards entirely, so
refreshRunningShellRunElapsed returns false when no visible card needs an
update and the 1s interval stops.
* fix(cli): address review findings — freeze only fully off-screen entries
Codex review found three issues in the initial freeze approach:
P1: Freezing an entry whose first line is in scrollback but whose tail
is still visible stopped streaming text from appearing. The invariant
is that scrollback lines are immutable, not that the entire entry is
frozen. Now only entries sitting entirely above the viewport (firstLine
+ height <= viewportTop) are frozen; straddling entries re-render
normally — append-only changes fall inside the viewport, so pi-tui's
firstChanged never touches scrollback.
P2: The ticker skip stopped the 1s interval when all running cards were
off-screen, but a resize making them visible again never restarted it.
Removed the skip: the render freeze already prevents visual changes for
off-screen cards, and the extra render cycle is negligible.
P2: The splice guard left off-screen poll cards as stale 'running'
entries. A width-change full redraw would re-render them as duplicate
running cards. Now the guard also updates the entry's status to 'done'
with the folded result, so a future full redraw renders it correctly.
Added a streaming-past-viewport test that verifies a long assistant
reply keeps appending visible content after crossing the viewport
boundary, with no ESC[3J.
* fix(cli): handle zero-height cache and hidden poll cards from second review
Two findings from the second Codex review:
1. Zero-height cache gap: a blank thinking_delta renders zero lines; when
pushed off-screen and then thinking_complete writes non-empty text, the
entryHeight=0 check prevented the freeze, inserting new lines into
scrollback. Now entries whose first line is above the viewport are
frozen regardless of cached height.
2. Poll card duplication: off-screen Read/StopBackgroundTask poll cards
that were kept in place (to avoid line-number shifts) but marked done
would re-render as duplicate cards on a width-change full redraw. Now
they get a hidden flag that makes them contribute zero lines, so a
future full redraw skips them entirely. They are cleaned on the next
session switch.
@Astro-Han
Astro-Han deleted the fix/cli-offscreen-toggle-preserves-scrollback branch July 24, 2026 09:05
Sign up for freeto 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.

fix(cli): expanding an off-screen block triggers a full redraw that clears pre-Maka scrollback

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback - #1130

Merged
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-toggle-preserves-scrollback
Jul 16, 2026
Merged

fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback#1130
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-toggle-preserves-scrollback

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes#1097.

A global Ctrl+O / Ctrl+T toggle resized every tool/thinking block, including ones scrolled above the live viewport. Terminal scrollback cannot be rewritten, so pi-tui's differential renderer fell back to a full redraw (firstChanged < viewportTop) that emits ESC[2J ESC[H ESC[3J, clearing pre-Maka scrollback and resetting the user's scroll position.

Since scrollback is immutable, the only correct behaviors for an off-screen block are "clear and re-emit" (the bug), "append a duplicate transcript" (worse), or "leave it untouched". This PR implements the third:

  • Expansion becomes per-entry state (entry.expanded), stamped from the expandAllTools / expandAllThinking defaults at creation; render and the memo cache read only the entry.
  • The toggles retarget entries whose first rendered line sits inside the live viewport and flip the default for future entries; entries above the viewport keep their frozen rendering.
  • renderMakaPiTranscript records each entry's first line; MakaPiLayoutComponent records the viewport top in transcript coordinates (the transcript is the first composed child). The viewport top is a monotonic max, mirroring pi-tui's private previousViewportTop, which never scrolls back up short of a full redraw — overestimating only makes the toggles more conservative. compositeOverlays never lengthens an over-viewport buffer, so overlays cannot break the estimate.

The runner's "Known limit" comment is replaced by the new invariant. A follow-up upstream PR to pi-tui exposing a viewport getter can later replace the local computation with the exact value; it is not on this fix's critical path.

Verification

  • packages/cli: tsc --noEmit clean, npm test 458/458 (9 new), root npm run lint clean.
  • New unit tests assert the lines above the viewport stay byte-identical across a toggle, off-screen entries keep their state, all-off-screen toggles report nothing to do, and entries created after an expand-all start expanded.
  • New integration test drives the real pi-tui renderer through runMakaPiTui with a 24-row fake terminal: an early tool card scrolled above the viewport, Ctrl+O expands the visible card, and the whole output stream contains no ESC[3J. Reverting the viewport anchoring makes this test fail, confirming it locks the regression.

Review hardening

An external review pass surfaced a real regression in the first commit: the viewport-top estimate was a pure monotonic max, but pi-tui re-anchors its real viewport whenever it full-redraws, so after /new, a session switch, a deep rewind, or a terminal resize the estimate stayed high and the toggles found nothing to target. MakaPiLayoutComponent now shadows pi-tui's exact full-redraw rule (size change, changed line above the top, shrink below the top → re-anchor to the document tail; anything else → monotonic), with pi-tui-layout.test.ts locking all four geometry behaviors including the shallow-truncation case where pi-tui keeps its viewport and the estimate must too.

…les never clear scrollback
Expansion becomes per-entry state stamped from the expandAll defaults at
creation. The global toggles retarget only entries whose first rendered
line sits inside pi-tui's live viewport: entries above it live in terminal
scrollback, which cannot be rewritten, so resizing one forced pi-tui's
differential renderer into a full redraw that emitted ESC[3J, clearing
pre-Maka scrollback and resetting the scroll position.
renderMakaPiTranscript records each entry's first line and
MakaPiLayoutComponent records the viewport top (monotonic max, mirroring
pi-tui's previousViewportTop, which never scrolls back up short of a full
redraw), so overestimation only makes the toggles more conservative.
Fixes#1097
…e-anchors
The monotonic-max viewport estimate never fell after pi-tui re-anchored its
real viewport in a full redraw, so after /new, a session switch, a deep
rewind, or a terminal resize every entry of the shorter document sat
permanently "above the viewport" and Ctrl+O/Ctrl+T found nothing to toggle.
MakaPiLayoutComponent now mirrors pi-tui's own rule: reset the estimate to
the document tail exactly when pi-tui full-redraws (width or height change,
a changed line above the current top, or a shrink below it) and keep it
monotonic otherwise, so shallow truncations still hold the old top just
like pi-tui's deleted-lines path does.
Found by external review of #1130.
Four gaps between the shadow diff and pi-tui's real doRender, all found by
a focused external re-review of 992be01:
- After a wholesale transcript replacement, entry positions are unknown
until the next render; a toggle in that window could rewrite lines above
the still-scrolled real viewport. entryFirstLine is now undefined in that
state and the toggles stay inert until a render records positions.
- Under Termux pi-tui does not full-redraw on height changes; it keeps the
buffer and recomputes its top from it. Re-anchoring to the document tail
there could fall below the real viewport after a shallow truncation. The
shadow now mirrors the buffer-derived formula when TERMUX_VERSION is set.
- pi-tui diffs normalized lines (Thai/Lao AM decomposition); the shadow
compared raw lines and could false-reset below a viewport pi-tui kept.
The shadow now stores and compares normalized lines.
- The deep-truncation boundary was off by one (< vs <=, pi-tui redraws at
targetRow < top), and a pure truncation clearing more than a screenful
also full-redraws (extraLines > height); both now re-anchor.
The geometry tests now open a top-above-tail gap first so each rule is
pinned independently, plus Termux, exact-boundary, and replacement-window
cases.
@Astro-Han
Astro-Han merged commit 1426853 into mainJul 16, 2026
3 checks passed
Astro-Han added a commit that referenced this pull request Jul 16, 2026
…ing redraws (#1139)
* fix(cli): freeze off-screen entry renders to prevent scrollback-clearing redraws (#1135)
Off-screen transcript entries live in terminal scrollback, which is
immutable. Five event paths mutated their rendered lines in place,
forcing pi-tui's differential renderer into a full redraw
(ESC[2J ESC[H ESC[3J) that clears pre-Maka scrollback and resets the
scroll position:
- refreshRunningShellRunElapsed: 1s ticker rewrote off-screen running
cards every second
- shell-run settle/ownership: applied status/result changes to off-screen
Bash cards
- setThinking: late thinking_complete replaced streamed text in place
- tool_result poll-card splice: removed an off-screen entry, shifting
subsequent line numbers
- tool_progress/tool_output_delta: appended to expanded off-screen cards
The fix introduces a single render-layer freeze: when an entry's first
line sits above the live viewport (already recorded by #1130's
renderGeometry), renderTranscriptEntryMemoized serves the cached render
matching the current width instead of re-rendering. The underlying entry
state still updates — only the visual output is frozen, so session
switch, resume, and re-expand stay correct.
The splice path gets a separate entryInLiveViewport guard: splicing an
off-screen entry shifts line numbers above the viewport, which the freeze
alone cannot prevent. The stale entry stays in place until the next
session switch.
The ticker skips off-screen running cards entirely, so
refreshRunningShellRunElapsed returns false when no visible card needs an
update and the 1s interval stops.
* fix(cli): address review findings — freeze only fully off-screen entries
Codex review found three issues in the initial freeze approach:
P1: Freezing an entry whose first line is in scrollback but whose tail
is still visible stopped streaming text from appearing. The invariant
is that scrollback lines are immutable, not that the entire entry is
frozen. Now only entries sitting entirely above the viewport (firstLine
+ height <= viewportTop) are frozen; straddling entries re-render
normally — append-only changes fall inside the viewport, so pi-tui's
firstChanged never touches scrollback.
P2: The ticker skip stopped the 1s interval when all running cards were
off-screen, but a resize making them visible again never restarted it.
Removed the skip: the render freeze already prevents visual changes for
off-screen cards, and the extra render cycle is negligible.
P2: The splice guard left off-screen poll cards as stale 'running'
entries. A width-change full redraw would re-render them as duplicate
running cards. Now the guard also updates the entry's status to 'done'
with the folded result, so a future full redraw renders it correctly.
Added a streaming-past-viewport test that verifies a long assistant
reply keeps appending visible content after crossing the viewport
boundary, with no ESC[3J.
* fix(cli): handle zero-height cache and hidden poll cards from second review
Two findings from the second Codex review:
1. Zero-height cache gap: a blank thinking_delta renders zero lines; when
pushed off-screen and then thinking_complete writes non-empty text, the
entryHeight=0 check prevented the freeze, inserting new lines into
scrollback. Now entries whose first line is above the viewport are
frozen regardless of cached height.
2. Poll card duplication: off-screen Read/StopBackgroundTask poll cards
that were kept in place (to avoid line-number shifts) but marked done
would re-render as duplicate cards on a width-change full redraw. Now
they get a hidden flag that makes them contribute zero lines, so a
future full redraw skips them entirely. They are cleaned on the next
session switch.
@Astro-Han
Astro-Han deleted the fix/cli-offscreen-toggle-preserves-scrollback branch July 24, 2026 09:05
Sign up for freeto 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.

fix(cli): expanding an off-screen block triggers a full redraw that clears pre-Maka scrollback

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback - #1130

Merged
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-toggle-preserves-scrollback
Jul 16, 2026
Merged

fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback#1130
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-toggle-preserves-scrollback

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes#1097.

A global Ctrl+O / Ctrl+T toggle resized every tool/thinking block, including ones scrolled above the live viewport. Terminal scrollback cannot be rewritten, so pi-tui's differential renderer fell back to a full redraw (firstChanged < viewportTop) that emits ESC[2J ESC[H ESC[3J, clearing pre-Maka scrollback and resetting the user's scroll position.

Since scrollback is immutable, the only correct behaviors for an off-screen block are "clear and re-emit" (the bug), "append a duplicate transcript" (worse), or "leave it untouched". This PR implements the third:

  • Expansion becomes per-entry state (entry.expanded), stamped from the expandAllTools / expandAllThinking defaults at creation; render and the memo cache read only the entry.
  • The toggles retarget entries whose first rendered line sits inside the live viewport and flip the default for future entries; entries above the viewport keep their frozen rendering.
  • renderMakaPiTranscript records each entry's first line; MakaPiLayoutComponent records the viewport top in transcript coordinates (the transcript is the first composed child). The viewport top is a monotonic max, mirroring pi-tui's private previousViewportTop, which never scrolls back up short of a full redraw — overestimating only makes the toggles more conservative. compositeOverlays never lengthens an over-viewport buffer, so overlays cannot break the estimate.

The runner's "Known limit" comment is replaced by the new invariant. A follow-up upstream PR to pi-tui exposing a viewport getter can later replace the local computation with the exact value; it is not on this fix's critical path.

Verification

  • packages/cli: tsc --noEmit clean, npm test 458/458 (9 new), root npm run lint clean.
  • New unit tests assert the lines above the viewport stay byte-identical across a toggle, off-screen entries keep their state, all-off-screen toggles report nothing to do, and entries created after an expand-all start expanded.
  • New integration test drives the real pi-tui renderer through runMakaPiTui with a 24-row fake terminal: an early tool card scrolled above the viewport, Ctrl+O expands the visible card, and the whole output stream contains no ESC[3J. Reverting the viewport anchoring makes this test fail, confirming it locks the regression.

Review hardening

An external review pass surfaced a real regression in the first commit: the viewport-top estimate was a pure monotonic max, but pi-tui re-anchors its real viewport whenever it full-redraws, so after /new, a session switch, a deep rewind, or a terminal resize the estimate stayed high and the toggles found nothing to target. MakaPiLayoutComponent now shadows pi-tui's exact full-redraw rule (size change, changed line above the top, shrink below the top → re-anchor to the document tail; anything else → monotonic), with pi-tui-layout.test.ts locking all four geometry behaviors including the shallow-truncation case where pi-tui keeps its viewport and the estimate must too.

…les never clear scrollback
Expansion becomes per-entry state stamped from the expandAll defaults at
creation. The global toggles retarget only entries whose first rendered
line sits inside pi-tui's live viewport: entries above it live in terminal
scrollback, which cannot be rewritten, so resizing one forced pi-tui's
differential renderer into a full redraw that emitted ESC[3J, clearing
pre-Maka scrollback and resetting the scroll position.
renderMakaPiTranscript records each entry's first line and
MakaPiLayoutComponent records the viewport top (monotonic max, mirroring
pi-tui's previousViewportTop, which never scrolls back up short of a full
redraw), so overestimation only makes the toggles more conservative.
Fixes#1097
…e-anchors
The monotonic-max viewport estimate never fell after pi-tui re-anchored its
real viewport in a full redraw, so after /new, a session switch, a deep
rewind, or a terminal resize every entry of the shorter document sat
permanently "above the viewport" and Ctrl+O/Ctrl+T found nothing to toggle.
MakaPiLayoutComponent now mirrors pi-tui's own rule: reset the estimate to
the document tail exactly when pi-tui full-redraws (width or height change,
a changed line above the current top, or a shrink below it) and keep it
monotonic otherwise, so shallow truncations still hold the old top just
like pi-tui's deleted-lines path does.
Found by external review of #1130.
Four gaps between the shadow diff and pi-tui's real doRender, all found by
a focused external re-review of 992be01:
- After a wholesale transcript replacement, entry positions are unknown
until the next render; a toggle in that window could rewrite lines above
the still-scrolled real viewport. entryFirstLine is now undefined in that
state and the toggles stay inert until a render records positions.
- Under Termux pi-tui does not full-redraw on height changes; it keeps the
buffer and recomputes its top from it. Re-anchoring to the document tail
there could fall below the real viewport after a shallow truncation. The
shadow now mirrors the buffer-derived formula when TERMUX_VERSION is set.
- pi-tui diffs normalized lines (Thai/Lao AM decomposition); the shadow
compared raw lines and could false-reset below a viewport pi-tui kept.
The shadow now stores and compares normalized lines.
- The deep-truncation boundary was off by one (< vs <=, pi-tui redraws at
targetRow < top), and a pure truncation clearing more than a screenful
also full-redraws (extraLines > height); both now re-anchor.
The geometry tests now open a top-above-tail gap first so each rule is
pinned independently, plus Termux, exact-boundary, and replacement-window
cases.
@Astro-Han
Astro-Han merged commit 1426853 into mainJul 16, 2026
3 checks passed
Astro-Han added a commit that referenced this pull request Jul 16, 2026
…ing redraws (#1139)
* fix(cli): freeze off-screen entry renders to prevent scrollback-clearing redraws (#1135)
Off-screen transcript entries live in terminal scrollback, which is
immutable. Five event paths mutated their rendered lines in place,
forcing pi-tui's differential renderer into a full redraw
(ESC[2J ESC[H ESC[3J) that clears pre-Maka scrollback and resets the
scroll position:
- refreshRunningShellRunElapsed: 1s ticker rewrote off-screen running
cards every second
- shell-run settle/ownership: applied status/result changes to off-screen
Bash cards
- setThinking: late thinking_complete replaced streamed text in place
- tool_result poll-card splice: removed an off-screen entry, shifting
subsequent line numbers
- tool_progress/tool_output_delta: appended to expanded off-screen cards
The fix introduces a single render-layer freeze: when an entry's first
line sits above the live viewport (already recorded by #1130's
renderGeometry), renderTranscriptEntryMemoized serves the cached render
matching the current width instead of re-rendering. The underlying entry
state still updates — only the visual output is frozen, so session
switch, resume, and re-expand stay correct.
The splice path gets a separate entryInLiveViewport guard: splicing an
off-screen entry shifts line numbers above the viewport, which the freeze
alone cannot prevent. The stale entry stays in place until the next
session switch.
The ticker skips off-screen running cards entirely, so
refreshRunningShellRunElapsed returns false when no visible card needs an
update and the 1s interval stops.
* fix(cli): address review findings — freeze only fully off-screen entries
Codex review found three issues in the initial freeze approach:
P1: Freezing an entry whose first line is in scrollback but whose tail
is still visible stopped streaming text from appearing. The invariant
is that scrollback lines are immutable, not that the entire entry is
frozen. Now only entries sitting entirely above the viewport (firstLine
+ height <= viewportTop) are frozen; straddling entries re-render
normally — append-only changes fall inside the viewport, so pi-tui's
firstChanged never touches scrollback.
P2: The ticker skip stopped the 1s interval when all running cards were
off-screen, but a resize making them visible again never restarted it.
Removed the skip: the render freeze already prevents visual changes for
off-screen cards, and the extra render cycle is negligible.
P2: The splice guard left off-screen poll cards as stale 'running'
entries. A width-change full redraw would re-render them as duplicate
running cards. Now the guard also updates the entry's status to 'done'
with the folded result, so a future full redraw renders it correctly.
Added a streaming-past-viewport test that verifies a long assistant
reply keeps appending visible content after crossing the viewport
boundary, with no ESC[3J.
* fix(cli): handle zero-height cache and hidden poll cards from second review
Two findings from the second Codex review:
1. Zero-height cache gap: a blank thinking_delta renders zero lines; when
pushed off-screen and then thinking_complete writes non-empty text, the
entryHeight=0 check prevented the freeze, inserting new lines into
scrollback. Now entries whose first line is above the viewport are
frozen regardless of cached height.
2. Poll card duplication: off-screen Read/StopBackgroundTask poll cards
that were kept in place (to avoid line-number shifts) but marked done
would re-render as duplicate cards on a width-change full redraw. Now
they get a hidden flag that makes them contribute zero lines, so a
future full redraw skips them entirely. They are cleaned on the next
session switch.
@Astro-Han
Astro-Han deleted the fix/cli-offscreen-toggle-preserves-scrollback branch July 24, 2026 09:05
Sign up for freeto 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.

fix(cli): expanding an off-screen block triggers a full redraw that clears pre-Maka scrollback

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback - #1130

Merged
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-toggle-preserves-scrollback
Jul 16, 2026
Merged

fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback#1130
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-toggle-preserves-scrollback

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes#1097.

A global Ctrl+O / Ctrl+T toggle resized every tool/thinking block, including ones scrolled above the live viewport. Terminal scrollback cannot be rewritten, so pi-tui's differential renderer fell back to a full redraw (firstChanged < viewportTop) that emits ESC[2J ESC[H ESC[3J, clearing pre-Maka scrollback and resetting the user's scroll position.

Since scrollback is immutable, the only correct behaviors for an off-screen block are "clear and re-emit" (the bug), "append a duplicate transcript" (worse), or "leave it untouched". This PR implements the third:

  • Expansion becomes per-entry state (entry.expanded), stamped from the expandAllTools / expandAllThinking defaults at creation; render and the memo cache read only the entry.
  • The toggles retarget entries whose first rendered line sits inside the live viewport and flip the default for future entries; entries above the viewport keep their frozen rendering.
  • renderMakaPiTranscript records each entry's first line; MakaPiLayoutComponent records the viewport top in transcript coordinates (the transcript is the first composed child). The viewport top is a monotonic max, mirroring pi-tui's private previousViewportTop, which never scrolls back up short of a full redraw — overestimating only makes the toggles more conservative. compositeOverlays never lengthens an over-viewport buffer, so overlays cannot break the estimate.

The runner's "Known limit" comment is replaced by the new invariant. A follow-up upstream PR to pi-tui exposing a viewport getter can later replace the local computation with the exact value; it is not on this fix's critical path.

Verification

  • packages/cli: tsc --noEmit clean, npm test 458/458 (9 new), root npm run lint clean.
  • New unit tests assert the lines above the viewport stay byte-identical across a toggle, off-screen entries keep their state, all-off-screen toggles report nothing to do, and entries created after an expand-all start expanded.
  • New integration test drives the real pi-tui renderer through runMakaPiTui with a 24-row fake terminal: an early tool card scrolled above the viewport, Ctrl+O expands the visible card, and the whole output stream contains no ESC[3J. Reverting the viewport anchoring makes this test fail, confirming it locks the regression.

Review hardening

An external review pass surfaced a real regression in the first commit: the viewport-top estimate was a pure monotonic max, but pi-tui re-anchors its real viewport whenever it full-redraws, so after /new, a session switch, a deep rewind, or a terminal resize the estimate stayed high and the toggles found nothing to target. MakaPiLayoutComponent now shadows pi-tui's exact full-redraw rule (size change, changed line above the top, shrink below the top → re-anchor to the document tail; anything else → monotonic), with pi-tui-layout.test.ts locking all four geometry behaviors including the shallow-truncation case where pi-tui keeps its viewport and the estimate must too.

…les never clear scrollback
Expansion becomes per-entry state stamped from the expandAll defaults at
creation. The global toggles retarget only entries whose first rendered
line sits inside pi-tui's live viewport: entries above it live in terminal
scrollback, which cannot be rewritten, so resizing one forced pi-tui's
differential renderer into a full redraw that emitted ESC[3J, clearing
pre-Maka scrollback and resetting the scroll position.
renderMakaPiTranscript records each entry's first line and
MakaPiLayoutComponent records the viewport top (monotonic max, mirroring
pi-tui's previousViewportTop, which never scrolls back up short of a full
redraw), so overestimation only makes the toggles more conservative.
Fixes#1097
…e-anchors
The monotonic-max viewport estimate never fell after pi-tui re-anchored its
real viewport in a full redraw, so after /new, a session switch, a deep
rewind, or a terminal resize every entry of the shorter document sat
permanently "above the viewport" and Ctrl+O/Ctrl+T found nothing to toggle.
MakaPiLayoutComponent now mirrors pi-tui's own rule: reset the estimate to
the document tail exactly when pi-tui full-redraws (width or height change,
a changed line above the current top, or a shrink below it) and keep it
monotonic otherwise, so shallow truncations still hold the old top just
like pi-tui's deleted-lines path does.
Found by external review of #1130.
Four gaps between the shadow diff and pi-tui's real doRender, all found by
a focused external re-review of 992be01:
- After a wholesale transcript replacement, entry positions are unknown
until the next render; a toggle in that window could rewrite lines above
the still-scrolled real viewport. entryFirstLine is now undefined in that
state and the toggles stay inert until a render records positions.
- Under Termux pi-tui does not full-redraw on height changes; it keeps the
buffer and recomputes its top from it. Re-anchoring to the document tail
there could fall below the real viewport after a shallow truncation. The
shadow now mirrors the buffer-derived formula when TERMUX_VERSION is set.
- pi-tui diffs normalized lines (Thai/Lao AM decomposition); the shadow
compared raw lines and could false-reset below a viewport pi-tui kept.
The shadow now stores and compares normalized lines.
- The deep-truncation boundary was off by one (< vs <=, pi-tui redraws at
targetRow < top), and a pure truncation clearing more than a screenful
also full-redraws (extraLines > height); both now re-anchor.
The geometry tests now open a top-above-tail gap first so each rule is
pinned independently, plus Termux, exact-boundary, and replacement-window
cases.
@Astro-Han
Astro-Han merged commit 1426853 into mainJul 16, 2026
3 checks passed
Astro-Han added a commit that referenced this pull request Jul 16, 2026
…ing redraws (#1139)
* fix(cli): freeze off-screen entry renders to prevent scrollback-clearing redraws (#1135)
Off-screen transcript entries live in terminal scrollback, which is
immutable. Five event paths mutated their rendered lines in place,
forcing pi-tui's differential renderer into a full redraw
(ESC[2J ESC[H ESC[3J) that clears pre-Maka scrollback and resets the
scroll position:
- refreshRunningShellRunElapsed: 1s ticker rewrote off-screen running
cards every second
- shell-run settle/ownership: applied status/result changes to off-screen
Bash cards
- setThinking: late thinking_complete replaced streamed text in place
- tool_result poll-card splice: removed an off-screen entry, shifting
subsequent line numbers
- tool_progress/tool_output_delta: appended to expanded off-screen cards
The fix introduces a single render-layer freeze: when an entry's first
line sits above the live viewport (already recorded by #1130's
renderGeometry), renderTranscriptEntryMemoized serves the cached render
matching the current width instead of re-rendering. The underlying entry
state still updates — only the visual output is frozen, so session
switch, resume, and re-expand stay correct.
The splice path gets a separate entryInLiveViewport guard: splicing an
off-screen entry shifts line numbers above the viewport, which the freeze
alone cannot prevent. The stale entry stays in place until the next
session switch.
The ticker skips off-screen running cards entirely, so
refreshRunningShellRunElapsed returns false when no visible card needs an
update and the 1s interval stops.
* fix(cli): address review findings — freeze only fully off-screen entries
Codex review found three issues in the initial freeze approach:
P1: Freezing an entry whose first line is in scrollback but whose tail
is still visible stopped streaming text from appearing. The invariant
is that scrollback lines are immutable, not that the entire entry is
frozen. Now only entries sitting entirely above the viewport (firstLine
+ height <= viewportTop) are frozen; straddling entries re-render
normally — append-only changes fall inside the viewport, so pi-tui's
firstChanged never touches scrollback.
P2: The ticker skip stopped the 1s interval when all running cards were
off-screen, but a resize making them visible again never restarted it.
Removed the skip: the render freeze already prevents visual changes for
off-screen cards, and the extra render cycle is negligible.
P2: The splice guard left off-screen poll cards as stale 'running'
entries. A width-change full redraw would re-render them as duplicate
running cards. Now the guard also updates the entry's status to 'done'
with the folded result, so a future full redraw renders it correctly.
Added a streaming-past-viewport test that verifies a long assistant
reply keeps appending visible content after crossing the viewport
boundary, with no ESC[3J.
* fix(cli): handle zero-height cache and hidden poll cards from second review
Two findings from the second Codex review:
1. Zero-height cache gap: a blank thinking_delta renders zero lines; when
pushed off-screen and then thinking_complete writes non-empty text, the
entryHeight=0 check prevented the freeze, inserting new lines into
scrollback. Now entries whose first line is above the viewport are
frozen regardless of cached height.
2. Poll card duplication: off-screen Read/StopBackgroundTask poll cards
that were kept in place (to avoid line-number shifts) but marked done
would re-render as duplicate cards on a width-change full redraw. Now
they get a hidden flag that makes them contribute zero lines, so a
future full redraw skips them entirely. They are cleaned on the next
session switch.
@Astro-Han
Astro-Han deleted the fix/cli-offscreen-toggle-preserves-scrollback branch July 24, 2026 09:05
Sign up for freeto 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.

fix(cli): expanding an off-screen block triggers a full redraw that clears pre-Maka scrollback

1 participant

@Astro-Han
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback - #1130

Merged
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-toggle-preserves-scrollback
Jul 16, 2026
Merged

fix(cli): anchor Ctrl+O/Ctrl+T expansion to the live viewport so toggles never clear scrollback#1130
Astro-Han merged 3 commits into
mainfrom
fix/cli-offscreen-toggle-preserves-scrollback

Conversation

@Astro-Han

@Astro-HanAstro-Han commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes#1097.

A global Ctrl+O / Ctrl+T toggle resized every tool/thinking block, including ones scrolled above the live viewport. Terminal scrollback cannot be rewritten, so pi-tui's differential renderer fell back to a full redraw (firstChanged < viewportTop) that emits ESC[2J ESC[H ESC[3J, clearing pre-Maka scrollback and resetting the user's scroll position.

Since scrollback is immutable, the only correct behaviors for an off-screen block are "clear and re-emit" (the bug), "append a duplicate transcript" (worse), or "leave it untouched". This PR implements the third:

  • Expansion becomes per-entry state (entry.expanded), stamped from the expandAllTools / expandAllThinking defaults at creation; render and the memo cache read only the entry.
  • The toggles retarget entries whose first rendered line sits inside the live viewport and flip the default for future entries; entries above the viewport keep their frozen rendering.
  • renderMakaPiTranscript records each entry's first line; MakaPiLayoutComponent records the viewport top in transcript coordinates (the transcript is the first composed child). The viewport top is a monotonic max, mirroring pi-tui's private previousViewportTop, which never scrolls back up short of a full redraw — overestimating only makes the toggles more conservative. compositeOverlays never lengthens an over-viewport buffer, so overlays cannot break the estimate.

The runner's "Known limit" comment is replaced by the new invariant. A follow-up upstream PR to pi-tui exposing a viewport getter can later replace the local computation with the exact value; it is not on this fix's critical path.

Verification

  • packages/cli: tsc --noEmit clean, npm test 458/458 (9 new), root npm run lint clean.
  • New unit tests assert the lines above the viewport stay byte-identical across a toggle, off-screen entries keep their state, all-off-screen toggles report nothing to do, and entries created after an expand-all start expanded.
  • New integration test drives the real pi-tui renderer through runMakaPiTui with a 24-row fake terminal: an early tool card scrolled above the viewport, Ctrl+O expands the visible card, and the whole output stream contains no ESC[3J. Reverting the viewport anchoring makes this test fail, confirming it locks the regression.

Review hardening

An external review pass surfaced a real regression in the first commit: the viewport-top estimate was a pure monotonic max, but pi-tui re-anchors its real viewport whenever it full-redraws, so after /new, a session switch, a deep rewind, or a terminal resize the estimate stayed high and the toggles found nothing to target. MakaPiLayoutComponent now shadows pi-tui's exact full-redraw rule (size change, changed line above the top, shrink below the top → re-anchor to the document tail; anything else → monotonic), with pi-tui-layout.test.ts locking all four geometry behaviors including the shallow-truncation case where pi-tui keeps its viewport and the estimate must too.

…les never clear scrollback
Expansion becomes per-entry state stamped from the expandAll defaults at
creation. The global toggles retarget only entries whose first rendered
line sits inside pi-tui's live viewport: entries above it live in terminal
scrollback, which cannot be rewritten, so resizing one forced pi-tui's
differential renderer into a full redraw that emitted ESC[3J, clearing
pre-Maka scrollback and resetting the scroll position.
renderMakaPiTranscript records each entry's first line and
MakaPiLayoutComponent records the viewport top (monotonic max, mirroring
pi-tui's previousViewportTop, which never scrolls back up short of a full
redraw), so overestimation only makes the toggles more conservative.
Fixes#1097
…e-anchors
The monotonic-max viewport estimate never fell after pi-tui re-anchored its
real viewport in a full redraw, so after /new, a session switch, a deep
rewind, or a terminal resize every entry of the shorter document sat
permanently "above the viewport" and Ctrl+O/Ctrl+T found nothing to toggle.
MakaPiLayoutComponent now mirrors pi-tui's own rule: reset the estimate to
the document tail exactly when pi-tui full-redraws (width or height change,
a changed line above the current top, or a shrink below it) and keep it
monotonic otherwise, so shallow truncations still hold the old top just
like pi-tui's deleted-lines path does.
Found by external review of #1130.
Four gaps between the shadow diff and pi-tui's real doRender, all found by
a focused external re-review of 992be01:
- After a wholesale transcript replacement, entry positions are unknown
until the next render; a toggle in that window could rewrite lines above
the still-scrolled real viewport. entryFirstLine is now undefined in that
state and the toggles stay inert until a render records positions.
- Under Termux pi-tui does not full-redraw on height changes; it keeps the
buffer and recomputes its top from it. Re-anchoring to the document tail
there could fall below the real viewport after a shallow truncation. The
shadow now mirrors the buffer-derived formula when TERMUX_VERSION is set.
- pi-tui diffs normalized lines (Thai/Lao AM decomposition); the shadow
compared raw lines and could false-reset below a viewport pi-tui kept.
The shadow now stores and compares normalized lines.
- The deep-truncation boundary was off by one (< vs <=, pi-tui redraws at
targetRow < top), and a pure truncation clearing more than a screenful
also full-redraws (extraLines > height); both now re-anchor.
The geometry tests now open a top-above-tail gap first so each rule is
pinned independently, plus Termux, exact-boundary, and replacement-window
cases.
@Astro-Han
Astro-Han merged commit 1426853 into mainJul 16, 2026
3 checks passed
Astro-Han added a commit that referenced this pull request Jul 16, 2026
…ing redraws (#1139)
* fix(cli): freeze off-screen entry renders to prevent scrollback-clearing redraws (#1135)
Off-screen transcript entries live in terminal scrollback, which is
immutable. Five event paths mutated their rendered lines in place,
forcing pi-tui's differential renderer into a full redraw
(ESC[2J ESC[H ESC[3J) that clears pre-Maka scrollback and resets the
scroll position:
- refreshRunningShellRunElapsed: 1s ticker rewrote off-screen running
cards every second
- shell-run settle/ownership: applied status/result changes to off-screen
Bash cards
- setThinking: late thinking_complete replaced streamed text in place
- tool_result poll-card splice: removed an off-screen entry, shifting
subsequent line numbers
- tool_progress/tool_output_delta: appended to expanded off-screen cards
The fix introduces a single render-layer freeze: when an entry's first
line sits above the live viewport (already recorded by #1130's
renderGeometry), renderTranscriptEntryMemoized serves the cached render
matching the current width instead of re-rendering. The underlying entry
state still updates — only the visual output is frozen, so session
switch, resume, and re-expand stay correct.
The splice path gets a separate entryInLiveViewport guard: splicing an
off-screen entry shifts line numbers above the viewport, which the freeze
alone cannot prevent. The stale entry stays in place until the next
session switch.
The ticker skips off-screen running cards entirely, so
refreshRunningShellRunElapsed returns false when no visible card needs an
update and the 1s interval stops.
* fix(cli): address review findings — freeze only fully off-screen entries
Codex review found three issues in the initial freeze approach:
P1: Freezing an entry whose first line is in scrollback but whose tail
is still visible stopped streaming text from appearing. The invariant
is that scrollback lines are immutable, not that the entire entry is
frozen. Now only entries sitting entirely above the viewport (firstLine
+ height <= viewportTop) are frozen; straddling entries re-render
normally — append-only changes fall inside the viewport, so pi-tui's
firstChanged never touches scrollback.
P2: The ticker skip stopped the 1s interval when all running cards were
off-screen, but a resize making them visible again never restarted it.
Removed the skip: the render freeze already prevents visual changes for
off-screen cards, and the extra render cycle is negligible.
P2: The splice guard left off-screen poll cards as stale 'running'
entries. A width-change full redraw would re-render them as duplicate
running cards. Now the guard also updates the entry's status to 'done'
with the folded result, so a future full redraw renders it correctly.
Added a streaming-past-viewport test that verifies a long assistant
reply keeps appending visible content after crossing the viewport
boundary, with no ESC[3J.
* fix(cli): handle zero-height cache and hidden poll cards from second review
Two findings from the second Codex review:
1. Zero-height cache gap: a blank thinking_delta renders zero lines; when
pushed off-screen and then thinking_complete writes non-empty text, the
entryHeight=0 check prevented the freeze, inserting new lines into
scrollback. Now entries whose first line is above the viewport are
frozen regardless of cached height.
2. Poll card duplication: off-screen Read/StopBackgroundTask poll cards
that were kept in place (to avoid line-number shifts) but marked done
would re-render as duplicate cards on a width-change full redraw. Now
they get a hidden flag that makes them contribute zero lines, so a
future full redraw skips them entirely. They are cleaned on the next
session switch.
@Astro-Han
Astro-Han deleted the fix/cli-offscreen-toggle-preserves-scrollback branch July 24, 2026 09:05
Sign up for freeto 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.

fix(cli): expanding an off-screen block triggers a full redraw that clears pre-Maka scrollback

1 participant

@Astro-Han